diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dd07b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +custom_components/solis/__pycache__/* + diff --git a/README.md b/README.md index e9adae7..511ae41 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,56 @@ # ❗Request for maintainers As you might have noticed I'm having trouble to spend enough time on maintaining this integration. For the continuity of this integration it would be great if it could be maintained and further developed by a small team of volunteers. Are you interested and do you have coding experience? [Drop me a line](https://github.com/hultenvp/solis-sensor/discussions/376). +# Control Beta Test + +This repo includes a beta version of device control using the same API as the SolisCloud app. This opeartes slighty differently depending on your HMI firmware version. This should be detected automatically. + +Please report any issues via https://github.com/fboundy/solis-sensor/issues + +## Version 4A00 and Earlier + +The following controls should update the inverter immediately: + +- Energy Storage Control Switch +- Overdischarge SOC +- Force Charge SOC +- Backup SOC + +The timed change controls are all sent to the API using one command and so they won't update untill the Update Charge/Discharge button is pressed. The controls included in this are all three sets of the following (where N is slots 1-3) +- Timed Charge Current N +- Timed Charge Start Time N +- Timed Charge End Time N +- Timed Discharge Current N +- Timed Discharge Start Time N +- Timed Discharge End Time N + +Note that all three slots are sent at the same time + +## Version 4B00 and Later + +Six slots are available and include an SOC limit and a voltage (though the purpose of the voltage is not clear). Only the start and end times for each Charge/Discharge slot need top to be sent to the inverter together so the following are updated immediately (where N is slot 1-6): +- Energy Storage Control Switch (fewer available modes than pre-4B00) +- Overdischarge SOC +- Force Charge SOC +- Backup SOC +- Timed Charge Current N +- Timed Charge SOC N +- Timed Charge Voltage N +- Timed Discharge Current N +- Timed Discharge SOC N +- Timed Discharge Voltage N + +Each pair of start/end times has an associated button pushfor charge there are 6: + +- Timed Charge Start Time N +- Timed Charge End Time N +- Button Update Charge Time N + +And discharge: +- Timed Discharge Start Time N +- Timed Discharge End Time N +- Button Update Discharge Time N + # SolisCloud sensor integration HomeAssistant sensor for SolisCloud portal. Still questions after the readme? Read the [wiki](https://github.com/hultenvp/solis-sensor/wiki) or look at the [discussions page](https://github.com/hultenvp/solis-sensor/discussions) diff --git a/custom_components/solis/__init__.py b/custom_components/solis/__init__.py index b39517d..5f3b6c7 100644 --- a/custom_components/solis/__init__.py +++ b/custom_components/solis/__init__.py @@ -1,4 +1,5 @@ """The Solis Inverter integration.""" + from datetime import datetime, timedelta, timezone import asyncio import logging @@ -31,7 +32,20 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [ + Platform.SENSOR, + Platform.SELECT, + Platform.NUMBER, + Platform.TIME, + Platform.BUTTON, +] + +CONTROL_PLATFORMS = [ + Platform.SELECT, + Platform.NUMBER, + Platform.TIME, + Platform.BUTTON, +] async def async_setup(hass: HomeAssistant, config: ConfigType): @@ -57,9 +71,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): return True -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up platform from a ConfigEntry.""" hass.data.setdefault(DOMAIN, {}) @@ -70,35 +82,38 @@ async def async_setup_entry( portal_plantid = config[CONF_PLANT_ID] portal_username = config[CONF_USERNAME] portal_version = config[CONF_PORTAL_VERSION] + portal_password = config[CONF_PASSWORD] portal_config: PortalConfig | None = None if portal_version == "ginlong_v2": portal_password = config[CONF_PASSWORD] - portal_config = GinlongConfig( - portal_domain, portal_username, portal_password, portal_plantid) + portal_config = GinlongConfig(portal_domain, portal_username, portal_password, portal_plantid) else: portal_key_id = config[CONF_KEY_ID] - portal_secret: bytes = bytes(config[CONF_SECRET], 'utf-8') + portal_secret: bytes = bytes(config[CONF_SECRET], "utf-8") portal_config = SoliscloudConfig( - portal_domain, portal_username, portal_key_id, portal_secret, portal_plantid) + portal_domain, portal_username, portal_key_id, portal_secret, portal_plantid, portal_password + ) # Initialize the Ginlong data service. service: InverterService = InverterService(portal_config, hass) hass.data[DOMAIN][entry.entry_id] = service # Forward the setup to the sensor platform. - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) + # while not service.discovery_complete: + # asyncio.sleep(1) + _LOGGER.debug("Sensor setup complete") + await hass.config_entries.async_forward_entry_setups(entry, CONTROL_PLATFORMS) return True + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = all( await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, component) - for component in PLATFORMS - ] + *[hass.config_entries.async_forward_entry_unload(entry, component) for component in PLATFORMS] ) ) diff --git a/custom_components/solis/button.py b/custom_components/solis/button.py new file mode 100644 index 0000000..a4888de --- /dev/null +++ b/custom_components/solis/button.py @@ -0,0 +1,87 @@ +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.components.button import ButtonEntity + + +import asyncio +import logging +from datetime import datetime + +from .const import ( + DOMAIN, +) + +from .service import ServiceSubscriber, InverterService +from .control_const import SolisBaseControlEntity, RETRIES, RETRY_WAIT, ALL_CONTROLS, SolisButtonEntityDescription + +_LOGGER = logging.getLogger(__name__) +RETRIES = 100 + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + """Setup sensors from a config entry created in the integrations UI.""" + # Prepare the sensor entities. + plant_id = config_entry.data["portal_plant_id"] + _LOGGER.debug(f"config_entry.data: {config_entry.data}") + _LOGGER.debug(f"Domain: {DOMAIN}") + service = hass.data[DOMAIN][config_entry.entry_id] + + _LOGGER.info(f"Waiting for discovery of controls for plant {plant_id}") + await asyncio.sleep(8) + attempts = 0 + while (attempts < RETRIES) and (not service.has_controls): + _LOGGER.debug(f" Attempt {attempts} failed") + await asyncio.sleep(RETRY_WAIT) + attempts += 1 + + if service.has_controls: + entities = [] + _LOGGER.debug(f"Plant ID {plant_id} has controls:") + for inverter_sn in service.controls: + for cid, index, entity, button, intial_value in service.controls[inverter_sn]["button"]: + entities.append( + SolisButtonEntity( + service, + config_entry.data["name"], + inverter_sn, + cid, + entity, + index, + ) + ) + + if len(entities) > 0: + _LOGGER.debug(f"Creating {len(entities)} Button entities") + async_add_entities(entities) + else: + _LOGGER.debug(f"No Button controls found for Plant ID {plant_id}") + + else: + _LOGGER.debug(f"No controls found for Plant ID {plant_id}") + + return True + + +class SolisButtonEntity(SolisBaseControlEntity, ServiceSubscriber, ButtonEntity): + def __init__(self, service: InverterService, config_name, inverter_sn, cid, button_info, index): + super().__init__(service, config_name, inverter_sn, cid, button_info) + self._index = index + self._joiner = button_info.joiner + self._entities = service.subscriptions.get(inverter_sn, {}).get(cid, []) + # Subscribe to the service with the cid as the index + # service.subscribe(self, inverter_sn, str(cid)) + + def do_update(self, value, last_updated): + # When the data from the API changes this method will be called with value as the new value + # return super().do_update(value, last_updated) + pass + + async def async_press(self) -> None: + """Handle the button press.""" + for entity in self._entities: + _LOGGER.debug(f"{entity.name:s} {entity.to_string:s} {entity.index}") + # Sort the entities by their index + items = sorted({entity.index: entity.to_string for entity in self._entities}.items()) + value = self._joiner.join([x[1] for x in items]) + _LOGGER.debug(f"{self._cid} {value}") + await self.write_control_data(value) diff --git a/custom_components/solis/config_flow.py b/custom_components/solis/config_flow.py index 0590fab..0653607 100644 --- a/custom_components/solis/config_flow.py +++ b/custom_components/solis/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Solis integration.""" + import logging import voluptuous as vol @@ -29,6 +30,7 @@ PLATFORMV2 = "ginlong_v2" SOLISCLOUD = "soliscloud" + class SolisConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Solis.""" @@ -47,30 +49,30 @@ async def async_step_user(self, user_input=None): return await self.async_step_credentials_password(user_input) return await self.async_step_credentials_secret(user_input) - data_schema= { + data_schema = { vol.Required(CONF_NAME, default=SENSOR_PREFIX): cv.string, vol.Required(CONF_PORTAL_DOMAIN, default=DEFAULT_DOMAIN): cv.string, } - data_schema[CONF_PORTAL_VERSION] = selector({ - "select": { - "options": [PLATFORMV2, SOLISCLOUD], + data_schema[CONF_PORTAL_VERSION] = selector( + { + "select": { + "options": [PLATFORMV2, SOLISCLOUD], + } } - }) + ) - return self.async_show_form(step_id="user", - data_schema=vol.Schema(data_schema), - errors=errors) + return self.async_show_form(step_id="user", data_schema=vol.Schema(data_schema), errors=errors) async def async_step_credentials_password(self, user_input=None): """Handle username/password based credential settings.""" errors: dict[str, str] = {} if user_input is not None: - url = self._data.get(CONF_PORTAL_DOMAIN) + url = self._data.get(CONF_PORTAL_DOMAIN) plant_id = user_input.get(CONF_PLANT_ID) username = user_input.get(CONF_USERNAME) password = user_input.get(CONF_PASSWORD) - if url[:8] != 'https://': + if url[:8] != "https://": errors["base"] = "invalid_path" else: if username and password and plant_id: @@ -79,57 +81,55 @@ async def async_step_credentials_password(self, user_input=None): api = GinlongAPI(config) if await api.login(async_get_clientsession(self.hass)): await self.async_set_unique_id(plant_id) - return self.async_create_entry(title=f"Plant {api.plant_name}", - data=self._data) + return self.async_create_entry(title=f"Plant {api.plant_name}", data=self._data) errors["base"] = "auth" - data_schema= { - vol.Required(CONF_USERNAME , default=None): cv.string, - vol.Required(CONF_PASSWORD , default=''): cv.string, + data_schema = { + vol.Required(CONF_USERNAME, default=None): cv.string, + vol.Required(CONF_PASSWORD, default=""): cv.string, vol.Required(CONF_PLANT_ID, default=None): cv.positive_int, } - return self.async_show_form(step_id="credentials_password", - data_schema=vol.Schema(data_schema), errors=errors) + return self.async_show_form(step_id="credentials_password", data_schema=vol.Schema(data_schema), errors=errors) async def async_step_credentials_secret(self, user_input=None): """Handle key_id/secret based credential settings.""" errors: dict[str, str] = {} if user_input is not None: - url = self._data.get(CONF_PORTAL_DOMAIN) + url = self._data.get(CONF_PORTAL_DOMAIN) plant_id = user_input.get(CONF_PLANT_ID) username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) key_id = user_input.get(CONF_KEY_ID) - secret: bytes = bytes('', 'utf-8') + secret: bytes = bytes("", "utf-8") try: - secret = bytes(user_input.get(CONF_SECRET), 'utf-8') + secret = bytes(user_input.get(CONF_SECRET), "utf-8") except TypeError: pass - if url[:8] != 'https://': + if url[:8] != "https://": errors["base"] = "invalid_path" else: if username and key_id and secret and plant_id: self._data.update(user_input) - config = SoliscloudConfig(url, username, key_id, secret, plant_id) + config = SoliscloudConfig(url, username, key_id, secret, plant_id, password) api = SoliscloudAPI(config) if await api.login(async_get_clientsession(self.hass)): await self.async_set_unique_id(plant_id) - return self.async_create_entry(title=f"Station {api.plant_name}", - data=self._data) + return self.async_create_entry(title=f"Station {api.plant_name}", data=self._data) errors["base"] = "auth" - data_schema={ - vol.Required(CONF_USERNAME , default=None): cv.string, - vol.Required(CONF_SECRET , default='00'): cv.string, - vol.Required(CONF_KEY_ID , default=''): cv.string, + data_schema = { + vol.Required(CONF_USERNAME, default=None): cv.string, + vol.Required(CONF_PASSWORD, default=""): cv.string, + vol.Required(CONF_SECRET, default="00"): cv.string, + vol.Required(CONF_KEY_ID, default=""): cv.string, vol.Required(CONF_PLANT_ID, default=None): cv.string, } - return self.async_show_form(step_id="credentials_secret", - data_schema=vol.Schema(data_schema), errors=errors) + return self.async_show_form(step_id="credentials_secret", data_schema=vol.Schema(data_schema), errors=errors) async def async_step_import(self, user_input=None): """Import a config entry from configuration.yaml.""" @@ -141,14 +141,14 @@ async def async_step_import(self, user_input=None): _LOGGER.warning(_str) return self.async_abort(reason="already_configured") url = user_input.get(CONF_PORTAL_DOMAIN) - if url[:4] != 'http': + if url[:4] != "http": # Fix URL url = f"https://{url}" user_input[CONF_PORTAL_DOMAIN] = url user_input[CONF_PORTAL_VERSION] = PLATFORMV2 - has_key_id = user_input.get(CONF_KEY_ID) != '' - has_secret: bytes = bytes(user_input.get(CONF_SECRET), 'utf-8') != b'\x00' + has_key_id = user_input.get(CONF_KEY_ID) != "" + has_secret: bytes = bytes(user_input.get(CONF_SECRET), "utf-8") != b"\x00" if has_key_id and has_secret: user_input[CONF_PORTAL_VERSION] = SOLISCLOUD diff --git a/custom_components/solis/const.py b/custom_components/solis/const.py index ccb9f38..d97c273 100644 --- a/custom_components/solis/const.py +++ b/custom_components/solis/const.py @@ -1,6 +1,7 @@ """Constants For more information: https://github.com/hultenvp/solis-sensor/ """ + from homeassistant.components.sensor import ( SensorStateClass, SensorDeviceClass, @@ -15,735 +16,732 @@ UnitOfTemperature, UnitOfFrequency, UnitOfReactivePower, - PERCENTAGE) + PERCENTAGE, +) + +from typing import Any from .ginlong_const import * -CONF_PORTAL_DOMAIN = 'portal_domain' -CONF_PORTAL_VERSION = 'portal_version' -CONF_USERNAME = 'portal_username' -CONF_PASSWORD = 'portal_password' -CONF_SECRET = 'portal_secret' -CONF_KEY_ID = 'portal_key_id' -CONF_PLANT_ID = 'portal_plant_id' + +VERSION = "3.7.1" + +# ATTRIBUTES +LAST_UPDATED = "Last updated" +SERIAL = "Inverter serial" +API_NAME = "API Name" + +EMPTY_ATTR: dict[str, Any] = { + LAST_UPDATED: None, + SERIAL: None, + API_NAME: None, +} + + +CONF_PORTAL_DOMAIN = "portal_domain" +CONF_PORTAL_VERSION = "portal_version" +CONF_USERNAME = "portal_username" +CONF_PASSWORD = "portal_password" +CONF_SECRET = "portal_secret" +CONF_KEY_ID = "portal_key_id" +CONF_PLANT_ID = "portal_plant_id" DOMAIN = "solis" -SENSOR_PREFIX = 'Solis' -DEFAULT_DOMAIN = 'https://www.soliscloud.com:13333' +SENSOR_PREFIX = "Solis" +DEFAULT_DOMAIN = "https://www.soliscloud.com:13333" # Supported sensor types: # Key: ['label', unit, icon, device class, state class, api_attribute_name] SENSOR_TYPES = { - 'inverterpowerstate': [ - 'Power State', + "inverterpowerstate": ["Power State", None, "mdi:power", None, SensorStateClass.MEASUREMENT, INVERTER_POWER_STATE], + "inverterstate": ["State", None, "mdi:state-machine", None, SensorStateClass.MEASUREMENT, INVERTER_STATE], + "timestamponline": [ + "Timestamp Inverter Online", None, - 'mdi:power', + "mdi:calendar-clock", None, SensorStateClass.MEASUREMENT, - INVERTER_POWER_STATE + INVERTER_TIMESTAMP_ONLINE, ], - 'inverterstate': [ - 'State', + "timestampmeasurement": [ + "Timestamp Measurements Received", None, - 'mdi:state-machine', + "mdi:calendar-clock", None, SensorStateClass.MEASUREMENT, - INVERTER_STATE - ], - 'timestamponline': [ - 'Timestamp Inverter Online', - None, - 'mdi:calendar-clock', - None, - SensorStateClass.MEASUREMENT, - INVERTER_TIMESTAMP_ONLINE - ], - 'timestampmeasurement': [ - 'Timestamp Measurements Received', - None, - 'mdi:calendar-clock', - None, - SensorStateClass.MEASUREMENT, - INVERTER_TIMESTAMP_UPDATE - ], - 'status': [ - 'Status', - None, - 'mdi:solar-power', - None, - None, - 'status' + INVERTER_TIMESTAMP_UPDATE, ], - 'temperature': [ - 'Temperature', + "status": ["Status", None, "mdi:solar-power", None, None, "status"], + "temperature": [ + "Temperature", UnitOfTemperature.CELSIUS, - 'mdi:thermometer', + "mdi:thermometer", SensorDeviceClass.TEMPERATURE, SensorStateClass.MEASUREMENT, - INVERTER_TEMPERATURE + INVERTER_TEMPERATURE, ], - 'radiatortemperature1': [ - 'Radiator temperature 1', # Solarman only + "radiatortemperature1": [ + "Radiator temperature 1", # Solarman only UnitOfTemperature.CELSIUS, - 'mdi:thermometer', + "mdi:thermometer", SensorDeviceClass.TEMPERATURE, SensorStateClass.MEASUREMENT, - RADIATOR1_TEMP + RADIATOR1_TEMP, ], - 'dcinputvoltagepv1': [ - 'DC Voltage PV1', + "dcinputvoltagepv1": [ + "DC Voltage PV1", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - STRING1_VOLTAGE + STRING1_VOLTAGE, ], - 'dcinputvoltagepv2': [ - 'DC Voltage PV2', + "dcinputvoltagepv2": [ + "DC Voltage PV2", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - STRING2_VOLTAGE + STRING2_VOLTAGE, ], - 'dcinputvoltagepv3': [ - 'DC Voltage PV3', + "dcinputvoltagepv3": [ + "DC Voltage PV3", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - STRING3_VOLTAGE + STRING3_VOLTAGE, ], - 'dcinputvoltagepv4': [ - 'DC Voltage PV4', + "dcinputvoltagepv4": [ + "DC Voltage PV4", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - STRING4_VOLTAGE + STRING4_VOLTAGE, ], - 'dcinputvoltagepv5': [ - 'DC Voltage PV5', + "dcinputvoltagepv5": [ + "DC Voltage PV5", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - STRING5_VOLTAGE + STRING5_VOLTAGE, ], - 'dcinputvoltagepv6': [ - 'DC Voltage PV6', + "dcinputvoltagepv6": [ + "DC Voltage PV6", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - STRING6_VOLTAGE + STRING6_VOLTAGE, ], - 'dcinputvoltagepv7': [ - 'DC Voltage PV7', + "dcinputvoltagepv7": [ + "DC Voltage PV7", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - STRING7_VOLTAGE + STRING7_VOLTAGE, ], - 'dcinputvoltagepv8': [ - 'DC Voltage PV8', + "dcinputvoltagepv8": [ + "DC Voltage PV8", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - STRING8_VOLTAGE + STRING8_VOLTAGE, ], - 'dcinputcurrentpv1': [ - 'DC Current PV1', + "dcinputcurrentpv1": [ + "DC Current PV1", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - STRING1_CURRENT + STRING1_CURRENT, ], - 'dcinputcurrentpv2': [ - 'DC Current PV2', + "dcinputcurrentpv2": [ + "DC Current PV2", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - STRING2_CURRENT + STRING2_CURRENT, ], - 'dcinputcurrentpv3': [ - 'DC Current PV3', + "dcinputcurrentpv3": [ + "DC Current PV3", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - STRING3_CURRENT + STRING3_CURRENT, ], - 'dcinputcurrentpv4': [ - 'DC Current PV4', + "dcinputcurrentpv4": [ + "DC Current PV4", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - STRING4_CURRENT + STRING4_CURRENT, ], - 'dcinputcurrentpv5': [ - 'DC Current PV5', + "dcinputcurrentpv5": [ + "DC Current PV5", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - STRING5_CURRENT + STRING5_CURRENT, ], - 'dcinputcurrentpv6': [ - 'DC Current PV6', + "dcinputcurrentpv6": [ + "DC Current PV6", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - STRING6_CURRENT + STRING6_CURRENT, ], - 'dcinputcurrentpv7': [ - 'DC Current PV7', + "dcinputcurrentpv7": [ + "DC Current PV7", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - STRING7_CURRENT + STRING7_CURRENT, ], - 'dcinputcurrentpv8': [ - 'DC Current PV8', + "dcinputcurrentpv8": [ + "DC Current PV8", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - STRING8_CURRENT + STRING8_CURRENT, ], - 'dcinputpowerpv1': [ - 'DC Power PV1', + "dcinputpowerpv1": [ + "DC Power PV1", UnitOfPower.WATT, - 'mdi:solar-power', + "mdi:solar-power", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - STRING1_POWER + STRING1_POWER, ], - 'dcinputpowerpv2': [ - 'DC Power PV2', + "dcinputpowerpv2": [ + "DC Power PV2", UnitOfPower.WATT, - 'mdi:solar-power', + "mdi:solar-power", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - STRING2_POWER + STRING2_POWER, ], - 'dcinputpowerpv3': [ - 'DC Power PV3', + "dcinputpowerpv3": [ + "DC Power PV3", UnitOfPower.WATT, - 'mdi:solar-power', + "mdi:solar-power", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - STRING3_POWER + STRING3_POWER, ], - 'dcinputpowerpv4': [ - 'DC Power PV4', + "dcinputpowerpv4": [ + "DC Power PV4", UnitOfPower.WATT, - 'mdi:solar-power', + "mdi:solar-power", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - STRING4_POWER + STRING4_POWER, ], - 'dcinputpowerpv5': [ - 'DC Power PV5', + "dcinputpowerpv5": [ + "DC Power PV5", UnitOfPower.WATT, - 'mdi:solar-power', + "mdi:solar-power", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - STRING5_POWER + STRING5_POWER, ], - 'dcinputpowerpv6': [ - 'DC Power PV6', + "dcinputpowerpv6": [ + "DC Power PV6", UnitOfPower.WATT, - 'mdi:solar-power', + "mdi:solar-power", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - STRING6_POWER + STRING6_POWER, ], - 'dcinputpowerpv7': [ - 'DC Power PV7', + "dcinputpowerpv7": [ + "DC Power PV7", UnitOfPower.WATT, - 'mdi:solar-power', + "mdi:solar-power", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - STRING7_POWER + STRING7_POWER, ], - 'dcinputpowerpv8': [ - 'DC Power PV8', + "dcinputpowerpv8": [ + "DC Power PV8", UnitOfPower.WATT, - 'mdi:solar-power', + "mdi:solar-power", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - STRING8_POWER + STRING8_POWER, ], - 'acoutputvoltage1': [ - 'AC Voltage R', + "acoutputvoltage1": [ + "AC Voltage R", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - PHASE1_VOLTAGE + PHASE1_VOLTAGE, ], - 'acoutputvoltage2': [ - 'AC Voltage S', + "acoutputvoltage2": [ + "AC Voltage S", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - PHASE2_VOLTAGE + PHASE2_VOLTAGE, ], - 'acoutputvoltage3': [ - 'AC Voltage T', + "acoutputvoltage3": [ + "AC Voltage T", UnitOfElectricPotential.VOLT, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - PHASE3_VOLTAGE + PHASE3_VOLTAGE, ], - 'acoutputcurrent1': [ - 'AC Current R', + "acoutputcurrent1": [ + "AC Current R", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - PHASE1_CURRENT + PHASE1_CURRENT, ], - 'acoutputcurrent2': [ - 'AC Current S', + "acoutputcurrent2": [ + "AC Current S", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - PHASE2_CURRENT + PHASE2_CURRENT, ], - 'acoutputcurrent3': [ - 'AC Current T', + "acoutputcurrent3": [ + "AC Current T", UnitOfElectricCurrent.AMPERE, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - PHASE3_CURRENT + PHASE3_CURRENT, ], - 'actualpower': [ - 'AC Output Total Power', + "actualpower": [ + "AC Output Total Power", UnitOfPower.WATT, - 'mdi:solar-power', + "mdi:solar-power", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - INVERTER_ACPOWER + INVERTER_ACPOWER, ], - 'acfrequency': [ - 'AC Frequency', + "acfrequency": [ + "AC Frequency", UnitOfFrequency.HERTZ, - 'mdi:sine-wave', + "mdi:sine-wave", None, SensorStateClass.MEASUREMENT, - INVERTER_ACFREQUENCY + INVERTER_ACFREQUENCY, ], - 'energylastmonth': [ - 'Energy Last Month', + "energylastmonth": [ + "Energy Last Month", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - INVERTER_ENERGY_LAST_MONTH + INVERTER_ENERGY_LAST_MONTH, ], - 'energytoday': [ - 'Energy Today', + "energytoday": [ + "Energy Today", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - INVERTER_ENERGY_TODAY + INVERTER_ENERGY_TODAY, ], - 'energythismonth': [ - 'Energy This Month', + "energythismonth": [ + "Energy This Month", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - INVERTER_ENERGY_THIS_MONTH + INVERTER_ENERGY_THIS_MONTH, ], - 'energythisyear': [ - 'Energy This Year', + "energythisyear": [ + "Energy This Year", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - INVERTER_ENERGY_THIS_YEAR + INVERTER_ENERGY_THIS_YEAR, ], - 'energytotal': [ - 'Energy Total', + "energytotal": [ + "Energy Total", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:flash-outline', + "mdi:flash-outline", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - INVERTER_ENERGY_TOTAL_LIFE + INVERTER_ENERGY_TOTAL_LIFE, ], - 'batpack1capacityremaining': [ - 'Battery pack 1 remaining battery capacity', # Solarman only + "batpack1capacityremaining": [ + "Battery pack 1 remaining battery capacity", # Solarman only PERCENTAGE, - 'mdi:battery', + "mdi:battery", SensorDeviceClass.BATTERY, SensorStateClass.MEASUREMENT, - BAT1_REMAINING_CAPACITY + BAT1_REMAINING_CAPACITY, ], - 'batpower': [ - 'Battery Power', + "batpower": [ + "Battery Power", UnitOfPower.WATT, - 'mdi:battery', + "mdi:battery", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - BAT_POWER + BAT_POWER, ], - 'batvoltage': [ - 'Battery Voltage', + "batvoltage": [ + "Battery Voltage", UnitOfElectricPotential.VOLT, - 'mdi:battery', + "mdi:battery", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - BAT_VOLTAGE + BAT_VOLTAGE, ], - 'batstatus': [ # Key: ['label', unit, icon, device class, state class, api_attribute_name] - 'Battery Status', + "batstatus": [ # Key: ['label', unit, icon, device class, state class, api_attribute_name] + "Battery Status", None, - 'mdi:battery', + "mdi:battery", None, None, - BAT_STATUS + BAT_STATUS, ], - 'batcurrent': [ - 'Battery Current', + "batcurrent": [ + "Battery Current", UnitOfElectricCurrent.AMPERE, - 'mdi:battery', + "mdi:battery", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - BAT_CURRENT + BAT_CURRENT, ], - 'batcapacityremaining': [ - 'Remaining Battery Capacity', + "batcapacityremaining": [ + "Remaining Battery Capacity", PERCENTAGE, - 'mdi:battery', + "mdi:battery", SensorDeviceClass.BATTERY, SensorStateClass.MEASUREMENT, - BAT_REMAINING_CAPACITY + BAT_REMAINING_CAPACITY, ], - 'battotalenergycharged': [ - 'Total Energy Charged', + "battotalenergycharged": [ + "Total Energy Charged", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:battery-plus', + "mdi:battery-plus", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - BAT_TOTAL_ENERGY_CHARGED + BAT_TOTAL_ENERGY_CHARGED, ], - 'battotalenergydischarged': [ - 'Total Energy Discharged', + "battotalenergydischarged": [ + "Total Energy Discharged", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:battery-minus', + "mdi:battery-minus", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - BAT_TOTAL_ENERGY_DISCHARGED + BAT_TOTAL_ENERGY_DISCHARGED, ], - 'batdailyenergycharged': [ - 'Daily Energy Charged', + "batdailyenergycharged": [ + "Daily Energy Charged", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:battery-plus', + "mdi:battery-plus", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - BAT_DAILY_ENERGY_CHARGED + BAT_DAILY_ENERGY_CHARGED, ], - 'batdailyenergydischarged': [ - 'Daily Energy Discharged', + "batdailyenergydischarged": [ + "Daily Energy Discharged", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:battery-minus', + "mdi:battery-minus", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - BAT_DAILY_ENERGY_DISCHARGED + BAT_DAILY_ENERGY_DISCHARGED, ], - 'griddailyongridenergy': [ - 'Daily On-grid Energy', + "griddailyongridenergy": [ + "Daily On-grid Energy", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_DAILY_ON_GRID_ENERGY + GRID_DAILY_ON_GRID_ENERGY, ], - 'griddailyenergypurchased': [ - 'Daily Grid Energy Purchased', + "griddailyenergypurchased": [ + "Daily Grid Energy Purchased", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_DAILY_ENERGY_PURCHASED + GRID_DAILY_ENERGY_PURCHASED, ], - 'griddailyenergyused': [ - 'Daily Grid Energy Used', + "griddailyenergyused": [ + "Daily Grid Energy Used", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_DAILY_ENERGY_USED + GRID_DAILY_ENERGY_USED, ], - 'gridmonthlyenergypurchased': [ - 'Monthly Grid Energy Purchased', + "gridmonthlyenergypurchased": [ + "Monthly Grid Energy Purchased", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_MONTHLY_ENERGY_PURCHASED + GRID_MONTHLY_ENERGY_PURCHASED, ], - 'gridmonthlyenergyused': [ - 'Monthly Grid Energy Used', + "gridmonthlyenergyused": [ + "Monthly Grid Energy Used", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_MONTHLY_ENERGY_USED + GRID_MONTHLY_ENERGY_USED, ], - 'gridmontlyongridenergy': [ - 'Monthly On-grid Energy', + "gridmontlyongridenergy": [ + "Monthly On-grid Energy", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_MONTHLY_ON_GRID_ENERGY + GRID_MONTHLY_ON_GRID_ENERGY, ], - 'gridyearlyenergypurchased': [ - 'Yearly Grid Energy Purchased', + "gridyearlyenergypurchased": [ + "Yearly Grid Energy Purchased", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_YEARLY_ENERGY_PURCHASED + GRID_YEARLY_ENERGY_PURCHASED, ], - 'gridyearlyenergyused': [ - 'Yearly Grid Energy Used', + "gridyearlyenergyused": [ + "Yearly Grid Energy Used", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_YEARLY_ENERGY_USED + GRID_YEARLY_ENERGY_USED, ], - 'gridyearlyongridenergy': [ - 'Yearly On-grid Energy', + "gridyearlyongridenergy": [ + "Yearly On-grid Energy", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_YEARLY_ON_GRID_ENERGY + GRID_YEARLY_ON_GRID_ENERGY, ], - 'gridtotalongridenergy': [ - 'Total On-grid Energy', + "gridtotalongridenergy": [ + "Total On-grid Energy", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_TOTAL_ON_GRID_ENERGY + GRID_TOTAL_ON_GRID_ENERGY, ], - 'gridtotalconsumptionenergy':[ - 'Total Consumption Energy', + "gridtotalconsumptionenergy": [ + "Total Consumption Energy", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_TOTAL_CONSUMPTION_ENERGY + GRID_TOTAL_CONSUMPTION_ENERGY, ], - 'gridpowergridtotalpower': [ - 'Power Grid total power', + "gridpowergridtotalpower": [ + "Power Grid total power", UnitOfPower.WATT, - 'mdi:home-export-outline', + "mdi:home-export-outline", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - GRID_TOTAL_POWER + GRID_TOTAL_POWER, ], - 'gridtotalconsumptionpower': [ - 'Total Consumption power', + "gridtotalconsumptionpower": [ + "Total Consumption power", UnitOfPower.WATT, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - GRID_TOTAL_CONSUMPTION_POWER + GRID_TOTAL_CONSUMPTION_POWER, ], - 'gridtotalenergypurchased': [ - 'Total Energy Purchased', + "gridtotalenergypurchased": [ + "Total Energy Purchased", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_TOTAL_ENERGY_PURCHASED + GRID_TOTAL_ENERGY_PURCHASED, ], - 'gridtotalenergyused': [ - 'Total Energy Used', + "gridtotalenergyused": [ + "Total Energy Used", UnitOfEnergy.KILO_WATT_HOUR, - 'mdi:transmission-tower', + "mdi:transmission-tower", SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, - GRID_TOTAL_ENERGY_USED + GRID_TOTAL_ENERGY_USED, ], - 'gridphase1power': [ - 'Grid Phase1 Power', + "gridphase1power": [ + "Grid Phase1 Power", UnitOfPower.WATT, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - GRID_PHASE1_POWER + GRID_PHASE1_POWER, ], - 'gridphase2power': [ - 'Grid Phase2 Power', + "gridphase2power": [ + "Grid Phase2 Power", UnitOfPower.WATT, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - GRID_PHASE2_POWER + GRID_PHASE2_POWER, ], - 'gridphase3power': [ - 'Grid Phase3 Power', + "gridphase3power": [ + "Grid Phase3 Power", UnitOfPower.WATT, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - GRID_PHASE3_POWER + GRID_PHASE3_POWER, ], - 'gridapparentphase1power': [ - 'Grid Phase1 Apparent Power', + "gridapparentphase1power": [ + "Grid Phase1 Apparent Power", UnitOfApparentPower.VOLT_AMPERE, - 'mdi:home-import-outline', + "mdi:home-import-outline", None, SensorStateClass.MEASUREMENT, - GRID_APPARENT_PHASE1_POWER + GRID_APPARENT_PHASE1_POWER, ], - 'gridapparentphase2power': [ - 'Grid Phase2 Apparent Power', + "gridapparentphase2power": [ + "Grid Phase2 Apparent Power", UnitOfApparentPower.VOLT_AMPERE, - 'mdi:home-import-outline', + "mdi:home-import-outline", None, SensorStateClass.MEASUREMENT, - GRID_APPARENT_PHASE2_POWER + GRID_APPARENT_PHASE2_POWER, ], - 'gridapparentphase3power': [ - 'Grid Phase3 Apparent Power', + "gridapparentphase3power": [ + "Grid Phase3 Apparent Power", UnitOfApparentPower.VOLT_AMPERE, - 'mdi:home-import-outline', + "mdi:home-import-outline", None, SensorStateClass.MEASUREMENT, - GRID_APPARENT_PHASE3_POWER + GRID_APPARENT_PHASE3_POWER, ], - 'gridreactivephase1power': [ - 'Grid Phase1 Reactive Power', + "gridreactivephase1power": [ + "Grid Phase1 Reactive Power", UnitOfReactivePower.VOLT_AMPERE_REACTIVE, - 'mdi:home-import-outline', + "mdi:home-import-outline", None, SensorStateClass.MEASUREMENT, - GRID_REACTIVE_PHASE1_POWER + GRID_REACTIVE_PHASE1_POWER, ], - 'gridreactivephase2power': [ - 'Grid Phase2 Reactive Power', + "gridreactivephase2power": [ + "Grid Phase2 Reactive Power", UnitOfReactivePower.VOLT_AMPERE_REACTIVE, - 'mdi:home-import-outline', + "mdi:home-import-outline", None, SensorStateClass.MEASUREMENT, - GRID_REACTIVE_PHASE2_POWER + GRID_REACTIVE_PHASE2_POWER, ], - 'gridreactivephase3power': [ - 'Grid Phase3 Reactive Power', + "gridreactivephase3power": [ + "Grid Phase3 Reactive Power", UnitOfReactivePower.VOLT_AMPERE_REACTIVE, - 'mdi:home-import-outline', + "mdi:home-import-outline", None, SensorStateClass.MEASUREMENT, - GRID_REACTIVE_PHASE3_POWER + GRID_REACTIVE_PHASE3_POWER, ], - 'planttotalconsumptionpower': [ - 'Plant Total Consumption power', + "planttotalconsumptionpower": [ + "Plant Total Consumption power", UnitOfPower.WATT, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - PLANT_TOTAL_CONSUMPTION_POWER + PLANT_TOTAL_CONSUMPTION_POWER, ], - 'batstateofhealth': [ - 'Battery State Of Health', + "batstateofhealth": [ + "Battery State Of Health", PERCENTAGE, - 'mdi:battery', + "mdi:battery", SensorDeviceClass.BATTERY, SensorStateClass.MEASUREMENT, - BAT_STATE_OF_HEALTH + BAT_STATE_OF_HEALTH, ], - 'socChargingSet': [ - 'Force Charge SOC', + "socChargingSet": [ + "Force Charge SOC", PERCENTAGE, - 'mdi:battery', + "mdi:battery", SensorDeviceClass.BATTERY, SensorStateClass.MEASUREMENT, - SOC_CHARGING_SET + SOC_CHARGING_SET, ], - 'socDischargeSet': [ - 'Force Discharge SOC', + "socDischargeSet": [ + "Force Discharge SOC", PERCENTAGE, - 'mdi:battery', + "mdi:battery", SensorDeviceClass.BATTERY, SensorStateClass.MEASUREMENT, - SOC_DISCHARGE_SET + SOC_DISCHARGE_SET, ], - 'bypassloadpower': [ - 'Backup Load Power', + "bypassloadpower": [ + "Backup Load Power", UnitOfPower.WATT, - 'mdi:battery-charging', + "mdi:battery-charging", SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, - BYPASS_LOAD_POWER + BYPASS_LOAD_POWER, ], - 'meterItemACurrent': [ - 'Meter item A current', + "meterItemACurrent": [ + "Meter item A current", UnitOfElectricCurrent.AMPERE, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - METER_ITEM_A_CURRENT + METER_ITEM_A_CURRENT, ], - 'meterItemAVoltage': [ - 'Meter item A volt', + "meterItemAVoltage": [ + "Meter item A volt", UnitOfElectricPotential.VOLT, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - METER_ITEM_A_VOLTAGE + METER_ITEM_A_VOLTAGE, ], - 'meterItemBCurrent': [ - 'Meter item B current', + "meterItemBCurrent": [ + "Meter item B current", UnitOfElectricCurrent.AMPERE, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - METER_ITEM_B_CURRENT + METER_ITEM_B_CURRENT, ], - 'meterItemBVoltage': [ - 'Meter item B volt', + "meterItemBVoltage": [ + "Meter item B volt", UnitOfElectricPotential.VOLT, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - METER_ITEM_B_VOLTAGE + METER_ITEM_B_VOLTAGE, ], - 'meterItemCCurrent': [ - 'Meter item C current', + "meterItemCCurrent": [ + "Meter item C current", UnitOfElectricCurrent.AMPERE, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.CURRENT, SensorStateClass.MEASUREMENT, - METER_ITEM_C_CURRENT + METER_ITEM_C_CURRENT, ], - 'meterItemCVoltage': [ - 'Meter item C volt', + "meterItemCVoltage": [ + "Meter item C volt", UnitOfElectricPotential.VOLT, - 'mdi:home-import-outline', + "mdi:home-import-outline", SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, - METER_ITEM_C_VOLTAGE + METER_ITEM_C_VOLTAGE, ], } diff --git a/custom_components/solis/control_const.py b/custom_components/solis/control_const.py new file mode 100644 index 0000000..d66dd0d --- /dev/null +++ b/custom_components/solis/control_const.py @@ -0,0 +1,1031 @@ +from homeassistant.components.select import SelectEntityDescription +from homeassistant.components.number import NumberEntityDescription +from homeassistant.components.number import NumberDeviceClass +from homeassistant.components.time import TimeEntityDescription +from homeassistant.components.button import ButtonEntityDescription +from homeassistant.const import PERCENTAGE, UnitOfElectricCurrent +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.helpers.entity import DeviceInfo +from dataclasses import dataclass +from datetime import datetime +import logging + +from .const import ( + DOMAIN, + SERIAL, + API_NAME, + EMPTY_ATTR, +) + +RETRIES = 1000 +RETRY_WAIT = 10 + +HMI_CID = "6798" +_LOGGER = logging.getLogger(__name__) + + +class SolisBaseControlEntity: + def __init__(self, service, config_name, inverter_sn, cid, info): + self._measured: datetime | None = None + self._entity_type = "control" + self._attributes = dict(EMPTY_ATTR) + self._attributes[SERIAL] = inverter_sn + self._attributes[API_NAME] = service.api_name + self._api = service.api + self._platform_name = config_name + self._name = f"{config_name.title()} {info.name}" + self._key = f"{config_name}_{info.key}" + self._inverter_sn = inverter_sn + self._cid = cid + self._splitter = () + self._index = 0 + self._joiner = "," + + @property + def unique_id(self) -> str: + return f"{self._platform_name}_{self._key}" + + @property + def name(self) -> str: + return self._name + + @property + def cid(self) -> int: + return int(self._cid) + + @property + def index(self) -> int: + return self._index + + @property + def device_info(self) -> DeviceInfo | None: + """Return a device description for device registry.""" + return DeviceInfo( + identifiers={(DOMAIN, f"{self._attributes[SERIAL]}_{DOMAIN, self._attributes[API_NAME]}")}, + manufacturer=f"Solis {self._attributes[API_NAME]}", + name=f"Solis_Inverter_{self._attributes[SERIAL]}", + ) + + async def write_control_data(self, value: str) -> bool: + data = await self._api.write_control_data(self._attributes[SERIAL], self.cid, value) + return data + + def split(self, value): + if len(self._splitter) > 0: + # if there's more than one split string then replace all of the later ones with the first before we split + for x in self._splitter[1:]: + value = value.replace(x, self._splitter[0]) + values = value.split(self._splitter[0]) + + if self._index <= len(values): + return values[self._index] + else: + _LOGGER.warning(f"Unable to retrieve item {self._index:d} from {value} for {self._key}") + else: + return value + + +@dataclass +class SolisSelectEntityDescription(SelectEntityDescription): + option_dict: dict = None + unit: type = float + + +@dataclass +class SolisNumberEntityDescription(NumberEntityDescription): + splitter: tuple = () + + +@dataclass +class SolisTimeEntityDescription(TimeEntityDescription): + splitter: tuple = () + + +@dataclass +class SolisButtonEntityDescription(ButtonEntityDescription): + joiner: str = "," + + +# Control types dict[bool: dict] where key is HMI_4B00 flag + +CONTROL_TYPES = { + "time": SolisTimeEntityDescription, + "number": SolisNumberEntityDescription, + "select": SolisSelectEntityDescription, + "button": SolisButtonEntityDescription, +} + +ALL_CONTROLS = { + True: { + # 103 is still available with 4B00 but it doesn't do anything included here for testing only + # "103": [ + # SolisNumberEntityDescription( + # name="Timed Charge Current 1", + # key="timed_charge_current_1", + # native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + # device_class=SensorDeviceClass.CURRENT, + # icon="mdi:current-dc", + # native_min_value=0, + # native_max_value=100, + # native_step=1, + # splitter=(",", "-"), + # ), + # SolisNumberEntityDescription( + # name="Timed Discharge Current 1", + # key="timed_discharge_current_1", + # native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + # device_class=SensorDeviceClass.CURRENT, + # icon="mdi:current-dc", + # native_min_value=0, + # native_max_value=100, + # native_step=1, + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Charge Start 1", + # key="timed_charge_start_1", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Charge End 1", + # key="timed_charge_end_1", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Discharge Start 1", + # key="timed_discharge_start_1", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Discharge End 1", + # key="timed_discharge_end_1", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisNumberEntityDescription( + # name="Timed Charge Current 2", + # key="timed_charge_current_2", + # native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + # device_class=SensorDeviceClass.CURRENT, + # icon="mdi:current-dc", + # native_min_value=0, + # native_max_value=100, + # native_step=1, + # splitter=(",", "-"), + # ), + # SolisNumberEntityDescription( + # name="Timed Discharge Current 2", + # key="timed_discharge_current_2", + # native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + # device_class=SensorDeviceClass.CURRENT, + # icon="mdi:current-dc", + # native_min_value=0, + # native_max_value=100, + # native_step=1, + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Charge Start 2", + # key="timed_charge_start_2", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Charge End 2", + # key="timed_charge_end_2", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Discharge Start 2", + # key="timed_discharge_start_2", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Discharge End 2", + # key="timed_discharge_end_2", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisNumberEntityDescription( + # name="Timed Charge Current 3", + # key="timed_charge_current_3", + # native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + # device_class=SensorDeviceClass.CURRENT, + # icon="mdi:current-dc", + # native_min_value=0, + # native_max_value=100, + # native_step=1, + # splitter=(",", "-"), + # ), + # SolisNumberEntityDescription( + # name="Timed Discharge Current 3", + # key="timed_discharge_current_3", + # native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + # device_class=SensorDeviceClass.CURRENT, + # icon="mdi:current-dc", + # native_min_value=0, + # native_max_value=100, + # native_step=1, + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Charge Start 3", + # key="timed_charge_start_3", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Charge End 3", + # key="timed_charge_end_3", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Discharge Start 3", + # key="timed_discharge_start_3", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisTimeEntityDescription( + # name="Timed Discharge End 3", + # key="timed_discharge_end_3", + # icon="mdi:clock", + # splitter=(",", "-"), + # ), + # SolisButtonEntityDescription( + # name="Update Timed Charge/Discharge", + # key="update_timed_charge_discharge", + # ), + # ], + "157": [ + SolisNumberEntityDescription( + name="Backup SOC", + key="backup_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "158": [ + SolisNumberEntityDescription( + name="Overdischarge SOC", + key="overdischarge_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "160": [ + SolisNumberEntityDescription( + name="Force Charge SOC", + key="force_charge_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "636": [ + SolisSelectEntityDescription( + name="Energy Storage Control Switch", + key="energy_storage_control_switch", + option_dict={ + "1": "Self-Use - No Grid Charging", + "5": "Off-Grid Mode", + "9": "Battery Awaken - No Grid Charging", + "33": "Self-Use", + "41": "Battery Awaken", + "49": "Backup/Reserve", + "64": "Feed-in priority", + }, + icon="mdi:dip-switch", + ) + ], + "5928": [ + SolisNumberEntityDescription( + name="Timed Charge SOC 1", + key="timed_charge_soc_1", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5948": [ + SolisNumberEntityDescription( + name="Timed Charge Current 1", + key="timed_charge_current_1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5946": [ + SolisTimeEntityDescription( + name="Timed Charge Start 1", + key="timed_charge_start_1", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Charge End 1", + key="timed_charge_end_1", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Charge 1", + key="update_timed_charge_1", + ), + ], + "5965": [ + SolisNumberEntityDescription( + name="Timed Discharge SOC 1", + key="timed_discharge_soc_1", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5967": [ + SolisNumberEntityDescription( + name="Timed Discharge Current 1", + key="timed_discharge_current_1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5964": [ + SolisTimeEntityDescription( + name="Timed Discharge Start 1", + key="timed_discharge_start_1", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge End 1", + key="timed_discharge_end_1", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Discharge 1", + key="update_timed_discharge_1", + ), + ], + # ======================= Slot 2 ================================= + "5929": [ + SolisNumberEntityDescription( + name="Timed Charge SOC 2", + key="timed_charge_soc_2", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5951": [ + SolisNumberEntityDescription( + name="Timed Charge Current 2", + key="timed_charge_current_2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5949": [ + SolisTimeEntityDescription( + name="Timed Charge Start 2", + key="timed_charge_start_2", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Charge End 2", + key="timed_charge_end_2", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Charge 2", + key="update_timed_charge_2", + ), + ], + "5969": [ + SolisNumberEntityDescription( + name="Timed Discharge SOC 2", + key="timed_discharge_soc_2", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5971": [ + SolisNumberEntityDescription( + name="Timed Discharge Current", + key="timed_discharge_current_2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5968": [ + SolisTimeEntityDescription( + name="Timed Discharge Start 2", + key="timed_discharge_start_2", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge End 2", + key="timed_discharge_end_2", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Discharge 2", + key="update_timed_discharge_2", + ), + ], + # ======================= Slot 3 ================================= + "5930": [ + SolisNumberEntityDescription( + name="Timed Charge SOC 3", + key="timed_charge_soc_3", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5954": [ + SolisNumberEntityDescription( + name="Timed Charge Current 3", + key="timed_charge_current_3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5952": [ + SolisTimeEntityDescription( + name="Timed Charge Start 3", + key="timed_charge_start_3", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Charge End 3", + key="timed_charge_end_3", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Charge 3", + key="update_timed_charge_3", + ), + ], + "5973": [ + SolisNumberEntityDescription( + name="Timed Discharge SOC 3", + key="timed_discharge_soc_3", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5975": [ + SolisNumberEntityDescription( + name="Timed Discharge Current 3", + key="timed_discharge_current_3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5972": [ + SolisTimeEntityDescription( + name="Timed Discharge Start 3", + key="timed_discharge_start_3", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge End 3", + key="timed_discharge_end_3", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Discharge 3", + key="update_timed_discharge_3", + ), + ], + # ======================= Slot 4 ================================= + "5931": [ + SolisNumberEntityDescription( + name="Timed Charge SOC 4", + key="timed_charge_soc_4", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5957": [ + SolisNumberEntityDescription( + name="Timed Charge Current 4", + key="timed_charge_current_4", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5955": [ + SolisTimeEntityDescription( + name="Timed Charge Start 4", + key="timed_charge_start_4", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Charge End 4", + key="timed_charge_end_4", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Charge 4", + key="update_timed_charge_4", + ), + ], + "5977": [ + SolisNumberEntityDescription( + name="Timed Discharge SOC 4", + key="timed_discharge_soc_4", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5979": [ + SolisNumberEntityDescription( + name="Timed Discharge Current 4", + key="timed_discharge_current_4", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5976": [ + SolisTimeEntityDescription( + name="Timed Discharge Start 4", + key="timed_discharge_start_4", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge End 4", + key="timed_discharge_end_4", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Discharge 4", + key="update_timed_discharge_4", + ), + ], + # ======================= Slot 5 ================================= + "5932": [ + SolisNumberEntityDescription( + name="Timed Charge SOC 5", + key="timed_charge_soc_5", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5960": [ + SolisNumberEntityDescription( + name="Timed Charge Current 5", + key="timed_charge_current_5", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5958": [ + SolisTimeEntityDescription( + name="Timed Charge Start 5", + key="timed_charge_start_5", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Charge End 5", + key="timed_charge_end_5", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Charge 5", + key="update_timed_charge_5", + ), + ], + "5981": [ + SolisNumberEntityDescription( + name="Timed Discharge SOC 5", + key="timed_discharge_soc_5", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5983": [ + SolisNumberEntityDescription( + name="Timed Discharge Current 5", + key="timed_discharge_current_5", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5980": [ + SolisTimeEntityDescription( + name="Timed Discharge Start 5", + key="timed_discharge_start_5", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge End 5", + key="timed_discharge_end_5", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Discharge 5", + key="update_timed_discharge_5", + ), + ], + # ======================= Slot 6 ================================= + "5933": [ + SolisNumberEntityDescription( + name="Timed Charge SOC 6", + key="timed_charge_soc_6", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5963": [ + SolisNumberEntityDescription( + name="Timed Charge Current 6", + key="timed_charge_current_6", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5961": [ + SolisTimeEntityDescription( + name="Timed Charge Start 6", + key="timed_charge_start_6", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Charge End 6", + key="timed_charge_end_6", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Charge 6", + key="update_timed_charge_6", + ), + ], + "5984": [ + SolisNumberEntityDescription( + name="Timed Discharge SOC 6", + key="timed_discharge_soc_6", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "5986": [ + SolisNumberEntityDescription( + name="Timed Discharge Current 6", + key="timed_discharge_current_6", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:battery-sync", + native_min_value=0, + native_max_value=100, + native_step=1, + ) + ], + "5987": [ + SolisTimeEntityDescription( + name="Timed Discharge Start 6", + key="timed_discharge_start_6", + icon="mdi:clock", + splitter=("-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge End 6", + key="timed_discharge_end_6", + icon="mdi:clock", + splitter=("-"), + ), + SolisButtonEntityDescription( + name="Update Timed Discharge 6", + key="update_timed_discharge_6", + ), + ], + }, + False: { + "103": [ + SolisNumberEntityDescription( + name="Timed Charge Current 1", + key="timed_charge_current_1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:current-dc", + native_min_value=0, + native_max_value=100, + native_step=1, + splitter=(",", "-"), + ), + SolisNumberEntityDescription( + name="Timed Discharge Current 1", + key="timed_discharge_current_1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:current-dc", + native_min_value=0, + native_max_value=100, + native_step=1, + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Charge Start 1", + key="timed_charge_start_1", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Charge End 1", + key="timed_charge_end_1", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge Start 1", + key="timed_discharge_start_1", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge End 1", + key="timed_discharge_end_1", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisNumberEntityDescription( + name="Timed Charge Current 2", + key="timed_charge_current_2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:current-dc", + native_min_value=0, + native_max_value=100, + native_step=1, + splitter=(",", "-"), + ), + SolisNumberEntityDescription( + name="Timed Discharge Current 2", + key="timed_discharge_current_2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:current-dc", + native_min_value=0, + native_max_value=100, + native_step=1, + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Charge Start 2", + key="timed_charge_start_2", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Charge End 2", + key="timed_charge_end_2", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge Start 2", + key="timed_discharge_start_2", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge End 2", + key="timed_discharge_end_2", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisNumberEntityDescription( + name="Timed Charge Current 3", + key="timed_charge_current_3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:current-dc", + native_min_value=0, + native_max_value=100, + native_step=1, + splitter=(",", "-"), + ), + SolisNumberEntityDescription( + name="Timed Discharge Current 3", + key="timed_discharge_current_3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + icon="mdi:current-dc", + native_min_value=0, + native_max_value=100, + native_step=1, + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Charge Start 3", + key="timed_charge_start_3", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Charge End 3", + key="timed_charge_end_3", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge Start 3", + key="timed_discharge_start_3", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisTimeEntityDescription( + name="Timed Discharge End 3", + key="timed_discharge_end_3", + icon="mdi:clock", + splitter=(",", "-"), + ), + SolisButtonEntityDescription( + name="Update Timed Charge/Discharge", + key="update_timed_charge_discharge", + ), + ], + "157": [ + SolisNumberEntityDescription( + name="Backup SOC", + key="backup_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "158": [ + SolisNumberEntityDescription( + name="Overdischarge SOC", + key="overdischarge_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "160": [ + SolisNumberEntityDescription( + name="Force Charge SOC", + key="force_charge_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + icon="mdi:battery-sync", + native_min_value=10, + native_max_value=100, + native_step=1, + ) + ], + "636": [ + SolisSelectEntityDescription( + name="Energy Storage Control Switch", + key="energy_storage_control_switch", + option_dict={ + 1: "Self-Use - No Grid Charging", + 3: "Timed Charge/Discharge - No Grid Charging", + 17: "Backup/Reserve - No Grid Charging", + 33: "Self-Use - No Timed Charge/Discharge", + 35: "Self-Use", + 37: "Off-Grid Mode", + 41: "Battery Awaken", + 43: "Battery Awaken + Timed Charge/Discharge", + 49: "Backup/Reserve - No Timed Charge/Discharge", + 51: "Backup/Reserve", + 64: "Feed-in priority - No Grid Charging", + 96: "Feed-in priority - No Timed Charge/Discharge", + 98: "Feed-in priority", + }, + icon="mdi:dip-switch", + ) + ], + }, +} diff --git a/custom_components/solis/control_utils.py b/custom_components/solis/control_utils.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/solis/ginlong_base.py b/custom_components/solis/ginlong_base.py index 4909071..8809e91 100644 --- a/custom_components/solis/ginlong_base.py +++ b/custom_components/solis/ginlong_base.py @@ -1,12 +1,14 @@ """Ginlong base classes For more information: https://github.com/hultenvp/solis-sensor/ """ + from __future__ import annotations import logging from abc import ABC, abstractmethod -#from typing import final + +# from typing import final from aiohttp import ClientSession from .ginlong_const import INVERTER_STATE @@ -14,43 +16,40 @@ _LOGGER = logging.getLogger(__name__) # VERSION -VERSION = '0.1.1' +VERSION = "0.1.1" + class PortalConfig(ABC): - """ Portal configuration data """ + """Portal configuration data""" - def __init__(self, - portal_domain: str, - portal_username: str, - portal_plant_id: str - ) -> None: + def __init__(self, portal_domain: str, portal_username: str, portal_plant_id: str) -> None: self._domain: str = portal_domain.rstrip("/") self._username: str = portal_username self._plant_id: str = portal_plant_id @property def domain(self) -> str: - """ Configured portal domain name.""" + """Configured portal domain name.""" return self._domain @property def username(self) -> str: - """ Username.""" + """Username.""" return self._username @property def plant_id(self) -> str: - """ Configured plant ID.""" + """Configured plant ID.""" return self._plant_id -class GinlongData(): - """ Representing data measurement for one inverter from Ginlong API """ + +class GinlongData: + """Representing data measurement for one inverter from Ginlong API""" def __init__(self, data: dict[str, str | int | float]) -> None: - """ Initialize the data object """ + """Initialize the data object""" self._data = dict(data) - def get_inverter_data(self) -> dict[str, str | int | float]: """Return all available measurements in a dict.""" return self._data @@ -72,8 +71,13 @@ def __getattr__(self, name): _LOGGER.debug("AttributeError, %s does not exist", name) raise AttributeError(name) from key_error + def __str__(self): + return "\n".join([f"{key:s}: {str(self._data[key]):s}" for key in self._data]) + + class BaseAPI(ABC): - """ API Base class.""" + """API Base class.""" + def __init__(self, config: PortalConfig) -> None: self._config: PortalConfig = config self._session: ClientSession | None = None @@ -82,45 +86,45 @@ def __init__(self, config: PortalConfig) -> None: @property def config(self) -> PortalConfig: - """ Config this for this API instance.""" + """Config this for this API instance.""" return self._config @property def inverters(self) -> dict[str, str] | None: - """ Return the list of inverters for plant ID when logged in.""" + """Return the list of inverters for plant ID when logged in.""" return self._inverter_list @property def plant_name(self) -> str | None: - """ Return plant name for this API instance.""" + """Return plant name for this API instance.""" return self._plant_name @property def plant_id(self) -> str | None: - """ Return plant ID for this API instance.""" + """Return plant ID for this API instance.""" return self._config.plant_id @property @abstractmethod def api_name(self) -> str: - """ Return name of the API.""" + """Return name of the API.""" @property @abstractmethod def is_online(self) -> bool: - """ Returns if we are logged in.""" + """Returns if we are logged in.""" @abstractmethod async def login(self, session: ClientSession) -> bool: - """ Login to service.""" + """Login to service.""" @abstractmethod async def logout(self) -> None: - """ Close session.""" + """Close session.""" @abstractmethod async def fetch_inverter_list(self, plant_id: str) -> dict[str, str]: - """ Retrieve inverter list from service.""" + """Retrieve inverter list from service.""" @abstractmethod async def fetch_inverter_data(self, inverter_serial: str) -> GinlongData | None: diff --git a/custom_components/solis/number.py b/custom_components/solis/number.py new file mode 100644 index 0000000..0d9b0d5 --- /dev/null +++ b/custom_components/solis/number.py @@ -0,0 +1,126 @@ +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.components.number import NumberEntity + + +import asyncio +import logging +from datetime import datetime + +from .const import ( + DOMAIN, + LAST_UPDATED, +) + +from .service import ServiceSubscriber, InverterService +from .control_const import SolisBaseControlEntity, RETRIES, RETRY_WAIT, ALL_CONTROLS, SolisNumberEntityDescription + +_LOGGER = logging.getLogger(__name__) +RETRIES = 100 + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + """Setup sensors from a config entry created in the integrations UI.""" + # Prepare the sensor entities. + plant_id = config_entry.data["portal_plant_id"] + _LOGGER.debug(f"config_entry.data: {config_entry.data}") + _LOGGER.debug(f"Domain: {DOMAIN}") + service = hass.data[DOMAIN][config_entry.entry_id] + + _LOGGER.info(f"Waiting for discovery of controls for plant {plant_id}") + await asyncio.sleep(8) + attempts = 0 + while (attempts < RETRIES) and (not service.has_controls): + _LOGGER.debug(f" Attempt {attempts} failed") + await asyncio.sleep(RETRY_WAIT) + attempts += 1 + + if service.has_controls: + entities = [] + _LOGGER.debug(f"Plant ID {plant_id} has controls:") + for inverter_sn in service.controls: + for cid, index, entity, button, initial_value in service.controls[inverter_sn]["number"]: + entities.append( + SolisNumberEntity( + service, + config_entry.data["name"], + inverter_sn, + cid, + entity, + index, + button, + initial_value, + ) + ) + + if len(entities) > 0: + _LOGGER.debug(f"Creating {len(entities)} number entities") + async_add_entities(entities) + else: + _LOGGER.debug(f"No number controls found for Plant ID {plant_id}") + + else: + _LOGGER.debug(f"No controls found for Plant ID {plant_id}") + + return True + + +class SolisNumberEntity(SolisBaseControlEntity, ServiceSubscriber, NumberEntity): + def __init__( + self, + service: InverterService, + config_name, + inverter_sn: str, + cid: str, + number_info, + index: int, + button: bool, + initial_value, + ): + super().__init__(service, config_name, inverter_sn, cid, number_info) + self._attr_native_value = 0 + self._attr_native_max_value = number_info.native_max_value + self._attr_native_min_value = number_info.native_min_value + self._attr_native_step = number_info.native_step + self._attr_native_unit_of_measurement = number_info.native_unit_of_measurement + self._icon = number_info.icon + self._splitter = number_info.splitter + self._index = index + self._button = button + if initial_value is not None: + self.do_update(initial_value, datetime.now()) + # Subscribe to the service with the cid as the index + service.subscribe(self, inverter_sn, str(cid)) + + def do_update(self, value, last_updated): + # When the data from the API changes this method will be called with value as the new value + # return super().do_update(value, last_updated) + _LOGGER.debug(f"Update state for {self._name}") + + value = self.split(value) + + if self.hass and self._attr_native_value != value: + self._attr_native_value = value + self._attributes[LAST_UPDATED] = last_updated + self.async_write_ha_state() + return True + return False + + @property + def to_string(self): + if self._attr_native_step >= 1: + return f"{int(self._attr_native_value):d}" + elif self._attr_native_step >= 0.1: + return f"{self._attr_native_value:0.1f}" + elif self._attr_native_step >= 0.01: + return f"{self._attr_native_value:0.2f}" + else: + return f"{self._attr_native_value:f}" + + async def async_set_native_value(self, value: float) -> None: + _LOGGER.debug(f"async_set_native_value for {self._name}") + self._attr_native_value = value + self._attributes[LAST_UPDATED] = datetime.now() + self.async_write_ha_state() + if not self._button: + await self.write_control_data(str(value)) diff --git a/custom_components/solis/select.py b/custom_components/solis/select.py new file mode 100644 index 0000000..8ad620c --- /dev/null +++ b/custom_components/solis/select.py @@ -0,0 +1,111 @@ +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.components.select import SelectEntity + +import asyncio +import logging + +from datetime import datetime + +from .const import ( + DOMAIN, + LAST_UPDATED, +) + +from .service import ServiceSubscriber, InverterService +from .control_const import SolisBaseControlEntity, RETRIES, RETRY_WAIT, ALL_CONTROLS, SolisSelectEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + """Setup sensors from a config entry created in the integrations UI.""" + # Prepare the sensor entities. + plant_id = config_entry.data["portal_plant_id"] + # _LOGGER.debug(f"config_entry.data: {config_entry.data}") + service = hass.data[DOMAIN][config_entry.entry_id] + + _LOGGER.info(f"Waiting for discovery of controls for plant {plant_id}") + await asyncio.sleep(8) + attempts = 0 + while (attempts < RETRIES) and (not service.has_controls): + _LOGGER.debug(f" Attempt {attempts} failed") + await asyncio.sleep(RETRY_WAIT) + attempts += 1 + + if service.has_controls: + entities = [] + _LOGGER.debug(f"Plant ID {plant_id} has controls:") + for inverter_sn in service.controls: + for cid, index, entity, button, initial_value in service.controls[inverter_sn]["select"]: + entities.append( + SolisSelectEntity( + service, + config_entry.data["name"], + inverter_sn, + cid, + entity, + index, + initial_value, + ) + ) + + if len(entities) > 0: + _LOGGER.debug(f"Creating {len(entities)} sensor entities") + async_add_entities(entities) + else: + _LOGGER.debug(f"No select controls found for Plant ID {plant_id}") + + else: + _LOGGER.debug(f"No controls found for Plant ID {plant_id}") + + return True + + +class SolisSelectEntity(SolisBaseControlEntity, ServiceSubscriber, SelectEntity): + def __init__( + self, + service: InverterService, + config_name, + inverter_sn, + cid, + select_info, + index, + initial_value, + ): + super().__init__(service, config_name, inverter_sn, cid, select_info) + self._option_dict = select_info.option_dict + self._reverse_dict = {self._option_dict[k]: str(k) for k in self._option_dict} + self._icon = select_info.icon + self._attr_options = list(select_info.option_dict.values()) + self._attr_current_option = None + self._index = index + if initial_value is not None: + self.do_update(initial_value, datetime.now()) + # Subscribe to the service with the cid as the index + service.subscribe(self, inverter_sn, cid) + + def do_update(self, value, last_updated): + # When the data from the API chnages this method will be called with value as the new value + # return super().do_update(value, last_updated) + _LOGGER.debug(f">>> Update for {self.name}") + _LOGGER.debug(f">>> Current option: {self._attr_current_option}") + _LOGGER.debug(f">>> Value: {value}") + _LOGGER.debug(f">>> Lookup: {self._option_dict.get(value,None)}") + _LOGGER.debug(f">>> {LAST_UPDATED}: {last_updated}") + + if self.hass and self._attr_current_option != self._option_dict.get(value, self._attr_current_option): + self._attr_current_option = self._option_dict.get(value, self._attr_current_option) + self._attributes[LAST_UPDATED] = last_updated + self.async_write_ha_state() + return True + return False + + async def async_select_option(self, option: str) -> None: + _LOGGER.debug(f"select_option for {self._name}") + self._attr_current_option = self._option_dict.get(option, self._attr_current_option) + self._attributes[LAST_UPDATED] = datetime.now() + self.async_write_ha_state() + value = self._reverse_dict.get(option, None) + if value is not None: + await self.write_control_data(str(value)) diff --git a/custom_components/solis/sensor.py b/custom_components/solis/sensor.py index aa31c74..873f07b 100644 --- a/custom_components/solis/sensor.py +++ b/custom_components/solis/sensor.py @@ -4,6 +4,7 @@ For more information: https://github.com/hultenvp/solis-sensor/ """ + from __future__ import annotations from datetime import datetime, timedelta @@ -16,9 +17,8 @@ PLATFORM_SCHEMA, SensorEntity, ) -from homeassistant.const import ( - CONF_NAME, -) +from homeassistant.const import CONF_NAME + from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo @@ -35,75 +35,76 @@ SENSOR_PREFIX, DEFAULT_DOMAIN, SENSOR_TYPES, -# SERVICE, + VERSION, + LAST_UPDATED, + SERIAL, + API_NAME, + EMPTY_ATTR, ) -from .service import (ServiceSubscriber, InverterService) +from .service import ServiceSubscriber, InverterService _LOGGER = logging.getLogger(__name__) -# VERSION -VERSION = '3.7.1' - -# ATTRIBUTES -LAST_UPDATED = 'Last updated' -SERIAL = 'Inverter serial' -API_NAME = 'API Name' - -EMPTY_ATTR: dict[str, Any] = { - LAST_UPDATED: None, - SERIAL: None, - API_NAME: None, -} def _check_config_schema(config: ConfigType): # Check input configuration. portal_domain = config.get(CONF_PORTAL_DOMAIN) if portal_domain is None: - raise vol.Invalid('configuration parameter [portal_domain] does not have a value') - if portal_domain[:4] != 'http': - portal_domain=f"https://{portal_domain}" + raise vol.Invalid("configuration parameter [portal_domain] does not have a value") + if portal_domain[:4] != "http": + portal_domain = f"https://{portal_domain}" if config.get(CONF_USERNAME) is None: - raise vol.Invalid('configuration parameter [portal_username] does not have a value') + raise vol.Invalid("configuration parameter [portal_username] does not have a value") if config.get(CONF_PLANT_ID) is None: - raise vol.Invalid('Configuration parameter [portal_plantid] does not have a value') - has_password: bool = config.get(CONF_PASSWORD) != '' - has_key_id: bool = config.get(CONF_KEY_ID) != '' - has_secret: bool = bytes(config.get(CONF_SECRET), 'utf-8') != b'\x00' + raise vol.Invalid("Configuration parameter [portal_plantid] does not have a value") + has_password: bool = config.get(CONF_PASSWORD) != "" + has_key_id: bool = config.get(CONF_KEY_ID) != "" + has_secret: bool = bytes(config.get(CONF_SECRET), "utf-8") != b"\x00" if not has_password ^ (has_key_id and has_secret): - raise vol.Invalid('Please specify either[portal_password] or [portal_key_id] \ - & [portal_secret]') + raise vol.Invalid( + "Please specify either[portal_password] or [portal_key_id] \ + & [portal_secret]" + ) return config -PLATFORM_SCHEMA = vol.All(PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=SENSOR_PREFIX): cv.string, - vol.Optional(CONF_PORTAL_DOMAIN, default=DEFAULT_DOMAIN): cv.string, - vol.Required(CONF_USERNAME , default=None): cv.string, - vol.Optional(CONF_PASSWORD , default=''): cv.string, - vol.Optional(CONF_SECRET , default='00'): cv.string, - vol.Optional(CONF_KEY_ID , default=''): cv.string, - vol.Required(CONF_PLANT_ID, default=None): cv.positive_int, -}, extra=vol.PREVENT_EXTRA), _check_config_schema) - -def create_sensors(sensors: dict[str, list[str]], - inverter_service: InverterService, - inverter_name: str - ) -> list[SolisSensor]: - """ Create the sensors.""" + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=SENSOR_PREFIX): cv.string, + vol.Optional(CONF_PORTAL_DOMAIN, default=DEFAULT_DOMAIN): cv.string, + vol.Required(CONF_USERNAME, default=None): cv.string, + vol.Optional(CONF_PASSWORD, default=""): cv.string, + vol.Optional(CONF_SECRET, default="00"): cv.string, + vol.Optional(CONF_KEY_ID, default=""): cv.string, + vol.Required(CONF_PLANT_ID, default=None): cv.positive_int, + }, + extra=vol.PREVENT_EXTRA, + ), + _check_config_schema, +) + + +def create_sensors( + sensors: dict[str, list[str]], inverter_service: InverterService, inverter_name: str +) -> list[SolisSensor]: + """Create the sensors.""" hass_sensors = [] for inverter_sn in sensors: for sensor_type in sensors[inverter_sn]: - _LOGGER.debug("Creating %s (%s)", sensor_type, inverter_sn) - hass_sensors.append(SolisSensor(inverter_service, inverter_name, - inverter_sn, sensor_type)) + # _LOGGER.debug("Creating %s (%s)", sensor_type, inverter_sn) + hass_sensors.append(SolisSensor(inverter_service, inverter_name, inverter_sn, sensor_type)) return hass_sensors + async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback | None = None, - discovery_info: DiscoveryInfoType | None = None) -> None: + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback | None = None, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up Solis platform.""" hass.async_create_task( hass.config_entries.flow.async_init( @@ -113,28 +114,22 @@ async def async_setup_platform( ) ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): """Setup sensors from a config entry created in the integrations UI.""" # Prepare the sensor entities. inverter_name = config_entry.data[CONF_NAME] service = hass.data[DOMAIN][config_entry.entry_id] - cookie: dict[str, Any] = { - 'name': inverter_name, - 'service': service, - 'async_add_entities' : async_add_entities - } + cookie: dict[str, Any] = {"name": inverter_name, "service": service, "async_add_entities": async_add_entities} # Will retry endlessly to discover _LOGGER.info("Scheduling discovery") service.schedule_discovery(on_discovered, cookie, 1) + @callback def on_discovered(capabilities, cookie): - """ Callback when discovery was successful.""" - discovered_sensors: dict[str, list]= {} + """Callback when discovery was successful.""" + discovered_sensors: dict[str, list] = {} for inverter_sn in capabilities: for sensor in SENSOR_TYPES.keys(): if SENSOR_TYPES[sensor][5] in capabilities[inverter_sn]: @@ -145,28 +140,25 @@ def on_discovered(capabilities, cookie): _LOGGER.warning("No sensors detected, nothing to register") # Create the sensors - hass_sensors = create_sensors(discovered_sensors, cookie['service'], cookie['name']) - cookie['async_add_entities'](hass_sensors) + hass_sensors = create_sensors(discovered_sensors, cookie["service"], cookie["name"]) + cookie["async_add_entities"](hass_sensors) # schedule the first update in 1 minute from now: - cookie['service'].schedule_update(timedelta(minutes=1)) + cookie["service"].schedule_update(timedelta(minutes=1)) + class SolisSensor(ServiceSubscriber, SensorEntity): - """ Representation of a Solis sensor. """ - - def __init__(self, - ginlong_service: InverterService, - inverter_name: str, - inverter_sn: str, - sensor_type: str - ): + """Representation of a Solis sensor.""" + + def __init__(self, ginlong_service: InverterService, inverter_name: str, inverter_sn: str, sensor_type: str): # Initialize the sensor. self._measured: datetime | None = None + self._entity_type = "sensor" self._attributes = dict(EMPTY_ATTR) self._attributes[SERIAL] = inverter_sn self._attributes[API_NAME] = ginlong_service.api_name # Properties self._icon = SENSOR_TYPES[sensor_type][2] - self._name = inverter_name + ' ' + SENSOR_TYPES[sensor_type][0] + self._name = inverter_name + " " + SENSOR_TYPES[sensor_type][0] self._attr_native_value = None self._attr_native_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._attr_device_class = SENSOR_TYPES[sensor_type][3] @@ -175,7 +167,7 @@ def __init__(self, ginlong_service.subscribe(self, inverter_sn, SENSOR_TYPES[sensor_type][5]) def do_update(self, value: Any, last_updated: datetime) -> bool: - """ Update the sensor.""" + """Update the sensor.""" if self.hass and self._attr_native_value != value: self._attr_native_value = value self._attributes[LAST_UPDATED] = last_updated @@ -185,12 +177,12 @@ def do_update(self, value: Any, last_updated: datetime) -> bool: @property def icon(self): - """ Return the icon of the sensor. """ + """Return the icon of the sensor.""" return self._icon @property def name(self): - """ Return the name of the sensor. """ + """Return the name of the sensor.""" return self._name @property @@ -207,14 +199,12 @@ def extra_state_attributes(self): def device_info(self) -> DeviceInfo | None: """Return a device description for device registry.""" return DeviceInfo( -# config_entry_id=config.entry_id, - # connections={(dr.CONNECTION_NETWORK_MAC, config.mac)}, - identifiers={ - (DOMAIN, f"{self._attributes[SERIAL]}_{DOMAIN, self._attributes[API_NAME]}") - }, + # config_entry_id=config.entry_id, + # connections={(dr.CONNECTION_NETWORK_MAC, config.mac)}, + identifiers={(DOMAIN, f"{self._attributes[SERIAL]}_{DOMAIN, self._attributes[API_NAME]}")}, manufacturer=f"Solis {self._attributes[API_NAME]}", name=f"Solis_Inverter_{self._attributes[SERIAL]}", -# model=config.modelid, -# sw_version=config.swversion, -# hw_version=config.hwversion, + # model=config.modelid, + # sw_version=config.swversion, + # hw_version=config.hwversion, ) diff --git a/custom_components/solis/service.py b/custom_components/solis/service.py index 7bfb825..5ed0ed1 100644 --- a/custom_components/solis/service.py +++ b/custom_components/solis/service.py @@ -3,6 +3,7 @@ For more information: https://github.com/hultenvp/solis-sensor/ """ + from __future__ import annotations import logging @@ -24,35 +25,42 @@ INVERTER_ACPOWER, INVERTER_SERIAL, INVERTER_STATE, - INVERTER_TIMESTAMP_UPDATE + INVERTER_TIMESTAMP_UPDATE, ) +from .control_const import HMI_CID, ALL_CONTROLS, CONTROL_TYPES + # REFRESH CONSTANTS # Match up with the default SolisCloud API resolution of 5 minutes -SCHEDULE_OK = 5 +SCHEDULE_OK = 300 # Attempt retries every 1 minute if we fail to talk to the API, though -SCHEDULE_NOK = 1 +SCHEDULE_NOK = 60 +# If we have controls then update more frequently because they can be changed externtally +SCHEDULE_CONTROLS = 30 _LOGGER = logging.getLogger(__name__) # VERSION -VERSION = '1.0.3' +VERSION = "1.0.3" # Don't login every time HRS_BETWEEN_LOGIN = timedelta(hours=2) -#Autodiscover +# Autodiscover RETRY_DELAY_SECONDS = 60 MAX_RETRY_DELAY_SECONDS = 900 # Status constants -ONLINE = 'Online' -OFFLINE = 'Offline' +ONLINE = "Online" +OFFLINE = "Offline" + class ServiceSubscriber(ABC): """Subscriber base class.""" + def __init__(self) -> None: self._measured: datetime | None = None + self._entity_type: str = "" @final def data_updated(self, value: Any, last_updated: datetime) -> None: @@ -61,6 +69,10 @@ def data_updated(self, value: Any, last_updated: datetime) -> None: if self.do_update(value, last_updated): self._measured = last_updated + @property + def entity_type(self): + return self._entity_type + @property def measured(self) -> datetime | None: """Return timestamp last measurement.""" @@ -70,7 +82,8 @@ def measured(self) -> datetime | None: def do_update(self, value: Any, last_updated: datetime) -> bool: """Implement actual update of attribute.""" -class InverterService(): + +class InverterService: """Serves all plantId's and inverters on a Ginlong account""" def __init__(self, portal_config: PortalConfig, hass: HomeAssistant) -> None: @@ -80,22 +93,62 @@ def __init__(self, portal_config: PortalConfig, hass: HomeAssistant) -> None: self._hass: HomeAssistant = hass self._discovery_callback = None self._discovery_cookie: dict[str, Any] = {} + self._discovery_complete: bool = False self._retry_delay_seconds = 0 + self._controllable: bool = False + self._controls: dict[str, dict[str, list[tuple]]] = {} + # self._active_times: dict[str, dict] = {} if isinstance(portal_config, GinlongConfig): self._api: BaseAPI = GinlongAPI(portal_config) elif isinstance(portal_config, SoliscloudConfig): self._api = SoliscloudAPI(portal_config) else: _LOGGER.error("Failed to initialize service, incompatible config") + @property def api_name(self) -> str: """Return name of the API.""" return self._api.api_name + @property + def subscriptions(self) -> dict[str, dict[str, ServiceSubscriber]]: + return self._subscriptions + + @property + def api(self): + return self._api + + @property + def has_controls(self) -> bool: + return self._controllable & (len(self._controls) > 0) + + @property + def controllable(self) -> bool: + return self._controllable + + @property + def controls(self) -> dict: + return self._controls + + @property + def discovery_complete(self) -> bool: + return self._discovery_complete + + # def set_active_times(self, inverter_sn, cid, index, times: tuple): + # if inverter_sn not in self._active_times: + # self._active_times[inverter_sn]={} + + # if cid not in self._active_times[inverter_sn]: + # self._active_times[inverter_sn][cid]={} + + # self._active_times[inverter_sn][cid][id]= times + async def _login(self) -> bool: if not self._api.is_online: if await self._api.login(async_get_clientsession(self._hass)): self._logintime = datetime.now() + if isinstance(self._api, SoliscloudAPI): + self._controllable = self._api._token != "" return self._api.is_online async def _logout(self) -> None: @@ -103,21 +156,48 @@ async def _logout(self) -> None: self._logintime = None async def async_discover(self, *_) -> None: - """ Try to discover and retry if needed.""" + """Try to discover and retry if needed.""" capabilities: dict[str, list[str]] = {} capabilities = await self._do_discover() + if capabilities: + if self.controllable: + inverter_serials = list(capabilities.keys()) + await self._discover_controls(inverter_serials) + if self._discovery_callback and self._discovery_cookie: self._discovery_callback(capabilities, self._discovery_cookie) self._retry_delay_seconds = 0 + self._dicovery_complete = True else: - self._retry_delay_seconds = min(MAX_RETRY_DELAY_SECONDS, \ - self._retry_delay_seconds + RETRY_DELAY_SECONDS) - _LOGGER.warning("Failed to discover, scheduling retry in %s seconds.", \ - self._retry_delay_seconds) + self._retry_delay_seconds = min(MAX_RETRY_DELAY_SECONDS, self._retry_delay_seconds + RETRY_DELAY_SECONDS) + _LOGGER.warning("Failed to discover, scheduling retry in %s seconds.", self._retry_delay_seconds) await self._logout() - self.schedule_discovery(self._discovery_callback, self._discovery_cookie, \ - self._retry_delay_seconds) + self.schedule_discovery(self._discovery_callback, self._discovery_cookie, self._retry_delay_seconds) + + async def _discover_controls(self, inverter_serials: list[str]): + _LOGGER.debug(f"Starting controls discovery") + controls = {} + control_lookup = {CONTROL_TYPES[platform]: platform for platform in CONTROL_TYPES} + for inverter_sn in inverter_serials: + controls[inverter_sn] = {platform: [] for platform in CONTROL_TYPES} + await self._api.get_control_data(inverter_sn, HMI_CID) + hmi_flag = self._api.hmi_fb00(inverter_sn) + _LOGGER.debug(f"Inverter SN {inverter_sn} HMI status {hmi_flag}") + control_desciptions = ALL_CONTROLS[hmi_flag] + for cid in control_desciptions: + button = len(control_desciptions[cid]) > 1 + initial_value = await self._api.get_control_data(inverter_sn, cid) + initial_value = initial_value.get(cid, None) + for index, entity_description in enumerate(control_desciptions[cid]): + entity_type = control_lookup[type(entity_description)] + controls[inverter_sn][entity_type].append((cid, index, entity_description, button, initial_value)) + _LOGGER.debug( + f"Adding {entity_type:s} entity {entity_description.name:s} for inverter Sn {inverter_sn:s} cid {cid:s} with index {index:d}" + ) + + self._controls = controls + _LOGGER.debug(f"Controls discovery complete") async def _do_discover(self) -> dict[str, list[str]]: """Discover for all inverters the attributes it supports""" @@ -128,24 +208,26 @@ async def _do_discover(self) -> dict[str, list[str]]: if inverters is None: return capabilities for inverter_serial in inverters: - data = await self._api.fetch_inverter_data(inverter_serial) + data = await self._api.fetch_inverter_data(inverter_serial, controls=False) if data is not None: capabilities[inverter_serial] = data.keys() return capabilities - - - def subscribe(self, subscriber: ServiceSubscriber, serial: str, attribute: str - ) -> None: - """ Subscribe to changes in 'attribute' from inverter 'serial'.""" - _LOGGER.info("Subscribing sensor to attribute %s for inverter %s", - attribute, serial) + def subscribe(self, subscriber: ServiceSubscriber, serial: str, attribute: str) -> None: + """Subscribe to changes in 'attribute' from inverter 'serial'.""" + if subscriber.entity_type != "sensor": + _LOGGER.info(f"Subscribing {subscriber.entity_type} to attribute {attribute:s} for inverter {serial:s}") if serial not in self._subscriptions: self._subscriptions[serial] = {} - self._subscriptions[serial][attribute] = subscriber + + # Multiple controls can be subscribed to one attribute so make this a list + if attribute not in self._subscriptions[serial]: + self._subscriptions[serial][attribute] = [subscriber] + else: + self._subscriptions[serial][attribute].append(subscriber) async def update_devices(self, data: GinlongData) -> None: - """ Update all registered sensors. """ + """Update all registered sensors.""" try: serial = getattr(data, INVERTER_SERIAL) except AttributeError: @@ -160,9 +242,9 @@ async def update_devices(self, data: GinlongData) -> None: # Overriding stale AC Power value when inverter is offline value = 0 elif attribute == INVERTER_ENERGY_TODAY: - # Energy_today is not reset at midnight, but in the - # morning at sunrise when the inverter switches back on. This - # messes up the energy dashboard. Return 0 while inverter is + # Energy_today is not reset at midnight, but in the + # morning at sunrise when the inverter switches back on. This + # messes up the energy dashboard. Return 0 while inverter is # still off. is_am = datetime.now().hour < 12 if getattr(data, INVERTER_STATE) == 2: @@ -173,8 +255,7 @@ async def update_devices(self, data: GinlongData) -> None: elif getattr(data, INVERTER_STATE) == 1: last_updated_state = None try: - last_updated_state = \ - self._subscriptions[serial][INVERTER_STATE].measured + last_updated_state = self._subscriptions[serial][INVERTER_STATE][0].measured except KeyError: pass if last_updated_state is not None: @@ -189,13 +270,14 @@ async def update_devices(self, data: GinlongData) -> None: continue else: if value == 0: - # SC sometimes produces zeros in the evening, ignore + # SC sometimes produces zeros in the evening, ignore continue - (self._subscriptions[serial][attribute]).data_updated(value, self.last_updated) + for subscriber in self._subscriptions[serial][attribute]: + subscriber.data_updated(value, self.last_updated) async def async_update(self, *_) -> None: """Update the data from Ginlong portal.""" - update = timedelta(minutes=SCHEDULE_NOK) + update = timedelta(seconds=SCHEDULE_NOK) # Login using username and password, but only every HRS_BETWEEN_LOGIN hours if await self._login(): inverters = self._api.inverters @@ -203,10 +285,14 @@ async def async_update(self, *_) -> None: return for inverter_serial in inverters: data = await self._api.fetch_inverter_data(inverter_serial) + if data is not None: # And finally get the inverter details - # default to updating after SCHEDULE_OK minutes; - update = timedelta(minutes=SCHEDULE_OK) + # default to updating after SCHEDULE_OK seconds; + if self.controllable: + update = timedelta(seconds=SCHEDULE_CONTROLS) + else: + update = timedelta(seconds=SCHEDULE_OK) # ...but try to figure out a better next-update time based on when the API last received its data try: ts = getattr(data, INVERTER_TIMESTAMP_UPDATE) @@ -214,11 +300,11 @@ async def async_update(self, *_) -> None: if nxt > dt_util.utcnow(): update = nxt - dt_util.utcnow() except AttributeError: - pass # no last_update found, so keep just using SCHEDULE_OK as a safe default + pass # no last_update found, so keep just using SCHEDULE_OK as a safe default self._last_updated = datetime.now() await self.update_devices(data) else: - update = timedelta(minutes=SCHEDULE_NOK) + update = timedelta(seconds=SCHEDULE_NOK) # Reset session and try to login again next time await self._logout() @@ -230,13 +316,13 @@ async def async_update(self, *_) -> None: await self._logout() def schedule_update(self, td: timedelta) -> None: - """ Schedule an update after td time. """ + """Schedule an update after td time.""" nxt = dt_util.utcnow() + td _LOGGER.debug("Scheduling next update in %s, at %s", str(td), nxt) async_track_point_in_utc_time(self._hass, self.async_update, nxt) def schedule_discovery(self, callback, cookie: dict[str, Any], seconds: int = 1): - """ Schedule a discovery after seconds seconds. """ + """Schedule a discovery after seconds seconds.""" _LOGGER.debug("Scheduling discovery in %s seconds.", seconds) self._discovery_callback = callback self._discovery_cookie = cookie @@ -244,15 +330,15 @@ def schedule_discovery(self, callback, cookie: dict[str, Any], seconds: int = 1) async_track_point_in_utc_time(self._hass, self.async_discover, nxt) async def shutdown(self): - """ Shutdown the service """ + """Shutdown the service""" await self._logout() @property def status(self): - """ Return status of service.""" + """Return status of service.""" return ONLINE if self._api.is_online else OFFLINE @property def last_updated(self): - """ Return when service last checked for updates.""" + """Return when service last checked for updates.""" return self._last_updated diff --git a/custom_components/solis/soliscloud_api.py b/custom_components/solis/soliscloud_api.py index c842ecb..49b756d 100644 --- a/custom_components/solis/soliscloud_api.py +++ b/custom_components/solis/soliscloud_api.py @@ -4,10 +4,12 @@ For more information: https://github.com/hultenvp/solis-sensor/ """ + from __future__ import annotations import hashlib -#from hashlib import sha1 + +# from hashlib import sha1 import hmac import base64 import asyncio @@ -27,177 +29,196 @@ from .ginlong_const import * from .soliscloud_const import * +from time import sleep + _LOGGER = logging.getLogger(__name__) # VERSION -VERSION = '0.5.4' +VERSION = "0.5.4" # API NAME -API_NAME = 'SolisCloud' +API_NAME = "SolisCloud" # Response constants -SUCCESS = 'Success' -CONTENT = 'Content' -STATUS_CODE = 'StatusCode' -MESSAGE = 'Message' +SUCCESS = "Success" +CONTENT = "Content" +STATUS_CODE = "StatusCode" +MESSAGE = "Message" + +CONTROL_DELAY = 0.1 +CONTROL_RETRIES = 5 -#VALUE_RECORD = '_from_record' -#VALUE_ELEMENT = '' +# VALUE_RECORD = '_from_record' +# VALUE_ELEMENT = '' VERB = "POST" -INVERTER_DETAIL_LIST = '/v1/api/inverterDetailList' -PLANT_DETAIL = '/v1/api/stationDetail' -PLANT_LIST = '/v1/api/userStationList' +INVERTER_DETAIL_LIST = "/v1/api/inverterDetailList" +PLANT_DETAIL = "/v1/api/stationDetail" +PLANT_LIST = "/v1/api/userStationList" +AUTHENTICATE = "/v2/api/login" +CONTROL = "/v2/api/control" +AT_READ = "/v2/api/atRead" + +from .control_const import HMI_CID, ALL_CONTROLS + InverterDataType = dict[str, dict[str, list]] """{endpoint: [payload type, {key type, decimal precision}]}""" INVERTER_DATA: InverterDataType = { INVERTER_DETAIL_LIST: { - INVERTER_SERIAL: ['sn', str, None], - INVERTER_PLANT_ID: ['stationId', str, None], - INVERTER_DEVICE_ID: ['id', str, None], - INVERTER_DATALOGGER_SERIAL: ['collectorId', str, None], + INVERTER_SERIAL: ["sn", str, None], + INVERTER_PLANT_ID: ["stationId", str, None], + INVERTER_DEVICE_ID: ["id", str, None], + INVERTER_DATALOGGER_SERIAL: ["collectorId", str, None], # Timestamp of measurement - INVERTER_TIMESTAMP_UPDATE: ['dataTimestamp', int, None], - INVERTER_STATE: ['state', int, None], - INVERTER_TEMPERATURE: ['inverterTemperature', float, 1], - INVERTER_POWER_STATE: ['currentState', int, None], - INVERTER_ACPOWER: ['pac', float, 3], - INVERTER_ACPOWER_STR: ['pacStr', str, None], - INVERTER_ACFREQUENCY: ['fac', float, 2], - INVERTER_ENERGY_TODAY: ['eToday', float, 3], # Default - INVERTER_ENERGY_THIS_MONTH: ['eMonth', float, 3], - INVERTER_ENERGY_THIS_MONTH_STR: ['eMonthStr', str, None], - INVERTER_ENERGY_THIS_YEAR: ['eYear', float, 3], - INVERTER_ENERGY_THIS_YEAR_STR: ['eYearStr', str, None], - INVERTER_ENERGY_TOTAL_LIFE: ['eTotal', float, 3], - INVERTER_ENERGY_TOTAL_LIFE_STR: ['eTotalStr', str, None], - STRING_COUNT: ['dcInputtype', int, None], - STRING1_VOLTAGE: ['uPv1', float, 2], - STRING2_VOLTAGE: ['uPv2', float, 2], - STRING3_VOLTAGE: ['uPv3', float, 2], - STRING4_VOLTAGE: ['uPv4', float, 2], - STRING5_VOLTAGE: ['uPv5', float, 2], - STRING6_VOLTAGE: ['uPv6', float, 2], - STRING7_VOLTAGE: ['uPv7', float, 2], - STRING8_VOLTAGE: ['uPv8', float, 2], - STRING1_CURRENT: ['iPv1', float, 2], - STRING2_CURRENT: ['iPv2', float, 2], - STRING3_CURRENT: ['iPv3', float, 2], - STRING4_CURRENT: ['iPv4', float, 2], - STRING5_CURRENT: ['iPv5', float, 2], - STRING6_CURRENT: ['iPv6', float, 2], - STRING7_CURRENT: ['iPv7', float, 2], - STRING8_CURRENT: ['iPv8', float, 2], - STRING1_POWER: ['pow1', float, 2], - STRING2_POWER: ['pow2', float, 2], - STRING3_POWER: ['pow3', float, 2], - STRING4_POWER: ['pow4', float, 2], - STRING5_POWER: ['pow5', float, 2], - STRING6_POWER: ['pow6', float, 2], - STRING7_POWER: ['pow7', float, 2], - STRING8_POWER: ['pow8', float, 2], - PHASE1_VOLTAGE: ['uAc1', float, 2], - PHASE2_VOLTAGE: ['uAc2', float, 2], - PHASE3_VOLTAGE: ['uAc3', float, 2], - PHASE1_CURRENT: ['iAc1', float, 2], - PHASE2_CURRENT: ['iAc2', float, 2], - PHASE3_CURRENT: ['iAc3', float, 2], - BAT_POWER: ['batteryPower', float, 3], - BAT_POWER_STR: ['batteryPowerStr', str, None], - BAT_REMAINING_CAPACITY: ['batteryCapacitySoc', float, 2], - BAT_STATE_OF_HEALTH: ['batteryHealthSoh', float, 2], - BAT_CURRENT: ['storageBatteryCurrent', float, 2], - BAT_CURRENT_STR: ['storageBatteryCurrentStr', str, None], - BAT_VOLTAGE: ['storageBatteryVoltage', float, 2], - BAT_VOLTAGE_STR: ['storageBatteryVoltageStr', str, None], - BAT_TOTAL_ENERGY_CHARGED: ['batteryTotalChargeEnergy', float, 3], - BAT_TOTAL_ENERGY_CHARGED_STR: ['batteryTotalChargeEnergyStr', str, None], - BAT_TOTAL_ENERGY_DISCHARGED: ['batteryTotalDischargeEnergy', float, 3], - BAT_TOTAL_ENERGY_DISCHARGED_STR: ['batteryTotalDischargeEnergyStr', str, None], - BAT_DAILY_ENERGY_CHARGED: ['batteryTodayChargeEnergy', float, 3], - BAT_DAILY_ENERGY_DISCHARGED: ['batteryTodayDischargeEnergy', float, 3], - #GRID_DAILY_ON_GRID_ENERGY: ['gridSellTodayEnergy', float, 2], #On Plant detail - #GRID_DAILY_ON_GRID_ENERGY_STR: ['gridSellTodayEnergyStr', str, None], #On Plant detail - #GRID_DAILY_ENERGY_PURCHASED: ['gridPurchasedTodayEnergy', float, 2], #On Plant detail - #GRID_DAILY_ENERGY_USED: ['homeLoadTodayEnergy', float, 2], #On Plant detail - #GRID_MONTHLY_ENERGY_PURCHASED: ['gridPurchasedMonthEnergy', float, 2], #On Plant detail - #GRID_YEARLY_ENERGY_PURCHASED: ['gridPurchasedYearEnergy', float, 2], #On Plant detail - GRID_TOTAL_ENERGY_PURCHASED: ['gridPurchasedTotalEnergy', float, 3], - GRID_TOTAL_ENERGY_PURCHASED_STR: ['gridPurchasedTotalEnergyStr', str, None], - GRID_TOTAL_ON_GRID_ENERGY: ['gridSellTotalEnergy', float, 3], - GRID_TOTAL_ON_GRID_ENERGY_STR: ['gridSellTotalEnergyStr', str, None], - GRID_TOTAL_POWER: ['psum', float, 3], - GRID_TOTAL_POWER_STR: ['psumStr', str, None], - GRID_TOTAL_ENERGY_USED: ['homeLoadTotalEnergy', float, 3], - GRID_TOTAL_ENERGY_USED_STR: ['homeLoadTotalEnergyStr', str, None], - GRID_PHASE1_POWER: ['pA', float, 3], - GRID_PHASE2_POWER: ['pB', float, 3], - GRID_PHASE3_POWER: ['pC', float, 3], - GRID_APPARENT_PHASE1_POWER: ['aLookedPower', float, 3], - GRID_APPARENT_PHASE2_POWER: ['bLookedPower', float, 3], - GRID_APPARENT_PHASE3_POWER: ['cLookedPower', float, 3], - GRID_REACTIVE_PHASE1_POWER: ['aReactivePower', float, 3], - GRID_REACTIVE_PHASE2_POWER: ['bReactivePower', float, 3], - GRID_REACTIVE_PHASE3_POWER: ['cReactivePower', float, 3], - GRID_TOTAL_CONSUMPTION_POWER: ['familyLoadPower', float, 3], - GRID_TOTAL_CONSUMPTION_POWER_STR: ['familyLoadPowerStr', str, None], - SOC_CHARGING_SET: ['socChargingSet', float, 0], - SOC_DISCHARGE_SET: ['socDischargeSet', float, 0], - BYPASS_LOAD_POWER: ['bypassLoadPower', float, 3], - BYPASS_LOAD_POWER_STR: ['bypassLoadPowerStr', str, None], - METER_ITEM_A_CURRENT: ['iA', float, 3], - METER_ITEM_A_VOLTAGE: ['uA', float, 3], - METER_ITEM_B_CURRENT: ['iB', float, 3], - METER_ITEM_B_VOLTAGE: ['uB', float, 3], - METER_ITEM_C_CURRENT: ['iC', float, 3], - METER_ITEM_C_VOLTAGE: ['uC', float, 3], + INVERTER_TIMESTAMP_UPDATE: ["dataTimestamp", int, None], + INVERTER_STATE: ["state", int, None], + INVERTER_TEMPERATURE: ["inverterTemperature", float, 1], + INVERTER_POWER_STATE: ["currentState", int, None], + INVERTER_ACPOWER: ["pac", float, 3], + INVERTER_ACPOWER_STR: ["pacStr", str, None], + INVERTER_ACFREQUENCY: ["fac", float, 2], + INVERTER_ENERGY_TODAY: ["eToday", float, 3], # Default + INVERTER_ENERGY_THIS_MONTH: ["eMonth", float, 3], + INVERTER_ENERGY_THIS_MONTH_STR: ["eMonthStr", str, None], + INVERTER_ENERGY_THIS_YEAR: ["eYear", float, 3], + INVERTER_ENERGY_THIS_YEAR_STR: ["eYearStr", str, None], + INVERTER_ENERGY_TOTAL_LIFE: ["eTotal", float, 3], + INVERTER_ENERGY_TOTAL_LIFE_STR: ["eTotalStr", str, None], + STRING_COUNT: ["dcInputtype", int, None], + STRING1_VOLTAGE: ["uPv1", float, 2], + STRING2_VOLTAGE: ["uPv2", float, 2], + STRING3_VOLTAGE: ["uPv3", float, 2], + STRING4_VOLTAGE: ["uPv4", float, 2], + STRING5_VOLTAGE: ["uPv5", float, 2], + STRING6_VOLTAGE: ["uPv6", float, 2], + STRING7_VOLTAGE: ["uPv7", float, 2], + STRING8_VOLTAGE: ["uPv8", float, 2], + STRING1_CURRENT: ["iPv1", float, 2], + STRING2_CURRENT: ["iPv2", float, 2], + STRING3_CURRENT: ["iPv3", float, 2], + STRING4_CURRENT: ["iPv4", float, 2], + STRING5_CURRENT: ["iPv5", float, 2], + STRING6_CURRENT: ["iPv6", float, 2], + STRING7_CURRENT: ["iPv7", float, 2], + STRING8_CURRENT: ["iPv8", float, 2], + STRING1_POWER: ["pow1", float, 2], + STRING2_POWER: ["pow2", float, 2], + STRING3_POWER: ["pow3", float, 2], + STRING4_POWER: ["pow4", float, 2], + STRING5_POWER: ["pow5", float, 2], + STRING6_POWER: ["pow6", float, 2], + STRING7_POWER: ["pow7", float, 2], + STRING8_POWER: ["pow8", float, 2], + PHASE1_VOLTAGE: ["uAc1", float, 2], + PHASE2_VOLTAGE: ["uAc2", float, 2], + PHASE3_VOLTAGE: ["uAc3", float, 2], + PHASE1_CURRENT: ["iAc1", float, 2], + PHASE2_CURRENT: ["iAc2", float, 2], + PHASE3_CURRENT: ["iAc3", float, 2], + BAT_POWER: ["batteryPower", float, 3], + BAT_POWER_STR: ["batteryPowerStr", str, None], + BAT_REMAINING_CAPACITY: ["batteryCapacitySoc", float, 2], + BAT_STATE_OF_HEALTH: ["batteryHealthSoh", float, 2], + BAT_CURRENT: ["storageBatteryCurrent", float, 2], + BAT_CURRENT_STR: ["storageBatteryCurrentStr", str, None], + BAT_VOLTAGE: ["storageBatteryVoltage", float, 2], + BAT_VOLTAGE_STR: ["storageBatteryVoltageStr", str, None], + BAT_TOTAL_ENERGY_CHARGED: ["batteryTotalChargeEnergy", float, 3], + BAT_TOTAL_ENERGY_CHARGED_STR: ["batteryTotalChargeEnergyStr", str, None], + BAT_TOTAL_ENERGY_DISCHARGED: ["batteryTotalDischargeEnergy", float, 3], + BAT_TOTAL_ENERGY_DISCHARGED_STR: ["batteryTotalDischargeEnergyStr", str, None], + BAT_DAILY_ENERGY_CHARGED: ["batteryTodayChargeEnergy", float, 3], + BAT_DAILY_ENERGY_DISCHARGED: ["batteryTodayDischargeEnergy", float, 3], + # GRID_DAILY_ON_GRID_ENERGY: ['gridSellTodayEnergy', float, 2], #On Plant detail + # GRID_DAILY_ON_GRID_ENERGY_STR: ['gridSellTodayEnergyStr', str, None], #On Plant detail + # GRID_DAILY_ENERGY_PURCHASED: ['gridPurchasedTodayEnergy', float, 2], #On Plant detail + # GRID_DAILY_ENERGY_USED: ['homeLoadTodayEnergy', float, 2], #On Plant detail + # GRID_MONTHLY_ENERGY_PURCHASED: ['gridPurchasedMonthEnergy', float, 2], #On Plant detail + # GRID_YEARLY_ENERGY_PURCHASED: ['gridPurchasedYearEnergy', float, 2], #On Plant detail + GRID_TOTAL_ENERGY_PURCHASED: ["gridPurchasedTotalEnergy", float, 3], + GRID_TOTAL_ENERGY_PURCHASED_STR: ["gridPurchasedTotalEnergyStr", str, None], + GRID_TOTAL_ON_GRID_ENERGY: ["gridSellTotalEnergy", float, 3], + GRID_TOTAL_ON_GRID_ENERGY_STR: ["gridSellTotalEnergyStr", str, None], + GRID_TOTAL_POWER: ["psum", float, 3], + GRID_TOTAL_POWER_STR: ["psumStr", str, None], + GRID_TOTAL_ENERGY_USED: ["homeLoadTotalEnergy", float, 3], + GRID_TOTAL_ENERGY_USED_STR: ["homeLoadTotalEnergyStr", str, None], + GRID_PHASE1_POWER: ["pA", float, 3], + GRID_PHASE2_POWER: ["pB", float, 3], + GRID_PHASE3_POWER: ["pC", float, 3], + GRID_APPARENT_PHASE1_POWER: ["aLookedPower", float, 3], + GRID_APPARENT_PHASE2_POWER: ["bLookedPower", float, 3], + GRID_APPARENT_PHASE3_POWER: ["cLookedPower", float, 3], + GRID_REACTIVE_PHASE1_POWER: ["aReactivePower", float, 3], + GRID_REACTIVE_PHASE2_POWER: ["bReactivePower", float, 3], + GRID_REACTIVE_PHASE3_POWER: ["cReactivePower", float, 3], + GRID_TOTAL_CONSUMPTION_POWER: ["familyLoadPower", float, 3], + GRID_TOTAL_CONSUMPTION_POWER_STR: ["familyLoadPowerStr", str, None], + SOC_CHARGING_SET: ["socChargingSet", float, 0], + SOC_DISCHARGE_SET: ["socDischargeSet", float, 0], + BYPASS_LOAD_POWER: ["bypassLoadPower", float, 3], + BYPASS_LOAD_POWER_STR: ["bypassLoadPowerStr", str, None], + METER_ITEM_A_CURRENT: ["iA", float, 3], + METER_ITEM_A_VOLTAGE: ["uA", float, 3], + METER_ITEM_B_CURRENT: ["iB", float, 3], + METER_ITEM_B_VOLTAGE: ["uB", float, 3], + METER_ITEM_C_CURRENT: ["iC", float, 3], + METER_ITEM_C_VOLTAGE: ["uC", float, 3], }, PLANT_DETAIL: { - INVERTER_PLANT_NAME: ['sno', str, None], #stationName no longer available? - INVERTER_LAT: ['latitude', float, 7], - INVERTER_LON: ['longitude', float, 7], - INVERTER_ADDRESS: ['cityStr', str, None], - INVERTER_ENERGY_TODAY: ['dayEnergy', float, 3], #If override set - GRID_DAILY_ENERGY_PURCHASED: ['gridPurchasedDayEnergy', float, 3], - GRID_DAILY_ENERGY_PURCHASED_STR: ['gridPurchasedDayEnergyStr', str, None], - GRID_MONTHLY_ENERGY_PURCHASED: ['gridPurchasedMonthEnergy', float, 3], - GRID_MONTHLY_ENERGY_PURCHASED_STR: ['gridPurchasedMonthEnergyStr', str, None], - GRID_MONTHLY_ON_GRID_ENERGY: ['gridSellMonthEnergy', float, 3], - GRID_MONTHLY_ON_GRID_ENERGY_STR: ['gridSellMonthEnergyStr', str, None], - GRID_YEARLY_ENERGY_PURCHASED: ['gridPurchasedYearEnergy', float, 3], - GRID_YEARLY_ENERGY_PURCHASED_STR: ['gridPurchasedYearEnergyStr', str, None], - GRID_YEARLY_ON_GRID_ENERGY: ['gridSellYearEnergy', float, 3], - GRID_YEARLY_ON_GRID_ENERGY_STR: ['gridSellYearEnergyStr', str, None], - GRID_DAILY_ON_GRID_ENERGY: ['gridSellDayEnergy', float, 3], - GRID_DAILY_ON_GRID_ENERGY_STR: ['gridSellDayEnergyStr', str, None], - GRID_DAILY_ENERGY_USED: ['homeLoadEnergy', float, 3], - GRID_DAILY_ENERGY_USED_STR: ['homeLoadEnergyStr', str, None], - PLANT_TOTAL_CONSUMPTION_POWER: ['familyLoadPower', float, 3], - PLANT_TOTAL_CONSUMPTION_POWER_STR: ['familyLoadPowerStr', str, None] + INVERTER_PLANT_NAME: ["sno", str, None], # stationName no longer available? + INVERTER_LAT: ["latitude", float, 7], + INVERTER_LON: ["longitude", float, 7], + INVERTER_ADDRESS: ["cityStr", str, None], + INVERTER_ENERGY_TODAY: ["dayEnergy", float, 3], # If override set + GRID_DAILY_ENERGY_PURCHASED: ["gridPurchasedDayEnergy", float, 3], + GRID_DAILY_ENERGY_PURCHASED_STR: ["gridPurchasedDayEnergyStr", str, None], + GRID_MONTHLY_ENERGY_PURCHASED: ["gridPurchasedMonthEnergy", float, 3], + GRID_MONTHLY_ENERGY_PURCHASED_STR: ["gridPurchasedMonthEnergyStr", str, None], + GRID_MONTHLY_ON_GRID_ENERGY: ["gridSellMonthEnergy", float, 3], + GRID_MONTHLY_ON_GRID_ENERGY_STR: ["gridSellMonthEnergyStr", str, None], + GRID_YEARLY_ENERGY_PURCHASED: ["gridPurchasedYearEnergy", float, 3], + GRID_YEARLY_ENERGY_PURCHASED_STR: ["gridPurchasedYearEnergyStr", str, None], + GRID_YEARLY_ON_GRID_ENERGY: ["gridSellYearEnergy", float, 3], + GRID_YEARLY_ON_GRID_ENERGY_STR: ["gridSellYearEnergyStr", str, None], + GRID_DAILY_ON_GRID_ENERGY: ["gridSellDayEnergy", float, 3], + GRID_DAILY_ON_GRID_ENERGY_STR: ["gridSellDayEnergyStr", str, None], + GRID_DAILY_ENERGY_USED: ["homeLoadEnergy", float, 3], + GRID_DAILY_ENERGY_USED_STR: ["homeLoadEnergyStr", str, None], + PLANT_TOTAL_CONSUMPTION_POWER: ["familyLoadPower", float, 3], + PLANT_TOTAL_CONSUMPTION_POWER_STR: ["familyLoadPowerStr", str, None], }, } + class SoliscloudConfig(PortalConfig): - """ Portal configuration data """ + """Portal configuration data""" - def __init__(self, + def __init__( + self, portal_domain: str, portal_username: str, portal_key_id: str, portal_secret: bytes, - portal_plantid: str + portal_plantid: str, + portal_password: str, ) -> None: - super().__init__(portal_domain, portal_username, portal_plantid) + super().__init__( + portal_domain, + portal_username, + portal_plantid, + ) self._key_id: str = portal_key_id self._secret: bytes = portal_secret self._workarounds = {} + self._password: str = portal_password async def load_workarounds(self): try: - async with aiofiles.open('/config/custom_components/solis/workarounds.yaml', 'r') as file: + async with aiofiles.open("/config/custom_components/solis/workarounds.yaml", "r") as file: content = await file.read() self._workarounds = yaml.safe_load(content) _LOGGER.debug("workarounds: %s", self._workarounds) @@ -206,19 +227,20 @@ async def load_workarounds(self): @property def key_id(self) -> str: - """ Key ID.""" + """Key ID.""" return self._key_id @property def secret(self) -> bytes: - """ API Key.""" + """API Key.""" return self._secret @property def workarounds(self) -> dict[str, Any]: - """ Return all workaround settings """ + """Return all workaround settings""" return self._workarounds + class SoliscloudAPI(BaseAPI): """Class with functions for reading data from the Soliscloud Portal.""" @@ -228,20 +250,25 @@ def __init__(self, config: SoliscloudConfig) -> None: self._is_online: bool = False self._data: dict[str, str | int | float] = {} self._inverter_list: dict[str, str] | None = None + self._token = "" + self._hmi_fb00 = {} @property def api_name(self) -> str: - """ Return name of the API.""" + """Return name of the API.""" return API_NAME @property def config(self) -> SoliscloudConfig: - """ Config this for this API instance.""" + """Config this for this API instance.""" return self._config + def hmi_fb00(self, inverter_sn): + return self._hmi_fb00.get(inverter_sn, None) + @property def is_online(self) -> bool: - """ Returns if we are logged in.""" + """Returns if we are logged in.""" return self._is_online async def login(self, session: ClientSession) -> bool: @@ -254,7 +281,7 @@ async def login(self, session: ClientSession) -> bool: # Request inverter list self._inverter_list = await self.fetch_inverter_list(self.config.plant_id) - if len(self._inverter_list)==0: + if len(self._inverter_list) == 0: _LOGGER.warning("No inverters found") self._is_online = False else: @@ -267,10 +294,20 @@ async def login(self, session: ClientSession) -> bool: except AttributeError: _LOGGER.info("Failed to acquire plant name, login failed") self._is_online = False + try: + token = await self._fetch_token(self.config.username, self.config._password) + self._token = token + if token == "": + _LOGGER.info("Failed to acquire CSRF token") + else: + _LOGGER.debug("CSRF token acquired") + except: + _LOGGER.info("Failed to acquire CSRF token") + return self.is_online async def logout(self) -> None: - """Hand back session """ + """Hand back session""" self._session = None self._is_online = False self._inverter_list = None @@ -282,38 +319,40 @@ async def fetch_inverter_list(self, plant_id: str) -> dict[str, str]: device_ids = {} - params = { - 'stationId': plant_id - } - result = await self._post_data_json('/v1/api/inverterList', params) + params = {"stationId": plant_id} + result = await self._post_data_json("/v1/api/inverterList", params) if result[SUCCESS] is True: result_json: dict = result[CONTENT] - if result_json['code'] != '0': - _LOGGER.info("%s responded with error: %s:%s",INVERTER_DETAIL_LIST, \ - result_json['code'], result_json['msg']) + if result_json["code"] != "0": + _LOGGER.info( + "%s responded with error: %s:%s", INVERTER_DETAIL_LIST, result_json["code"], result_json["msg"] + ) return device_ids try: - for record in result_json['data']['page']['records']: - serial = record.get('sn') - device_id = record.get('id') + for record in result_json["data"]["page"]["records"]: + serial = record.get("sn") + device_id = record.get("id") device_ids[serial] = device_id except TypeError: _LOGGER.debug("Response contains unexpected data: %s", result_json) elif result[STATUS_CODE] == 408: now = datetime.now().strftime("%d-%m-%Y %H:%M GMT") - _LOGGER.warning("Your system time must be set correctly for this integration \ - to work, your time is %s", now) + _LOGGER.warning( + "Your system time must be set correctly for this integration \ + to work, your time is %s", + now, + ) return device_ids - async def fetch_inverter_data(self, inverter_serial: str) -> GinlongData | None: + async def fetch_inverter_data(self, inverter_serial: str, controls=True) -> GinlongData | None: """ Fetch data for given inverter. Collect available data from payload and store as GinlongData object """ - _LOGGER.debug("Fetching data for serial: %s", inverter_serial) self._data = {} + control_data = {} if self.is_online: if self._inverter_list is not None and inverter_serial in self._inverter_list: device_id = self._inverter_list[inverter_serial] @@ -323,30 +362,31 @@ async def fetch_inverter_data(self, inverter_serial: str) -> GinlongData | None: await asyncio.sleep(1) payload_detail = await self._get_station_details(self.config.plant_id) if payload is not None: - #_LOGGER.debug("%s", payload) + # _LOGGER.debug("%s", payload) self._collect_inverter_data(payload) - #if payload2 is not None: + # if payload2 is not None: # self._collect_station_list_data(payload2) + if (self._token != "") and controls: + _LOGGER.debug(f"Fetching control data for SN:{inverter_serial}") + control_data = await self.get_control_data(inverter_serial) + if payload_detail is not None: self._collect_plant_data(payload_detail) + if self._data is not None and INVERTER_SERIAL in self._data: self._post_process() - return GinlongData(self._data) + return GinlongData(self._data | control_data) + _LOGGER.debug("Unexpected response from server: %s", payload) return None - - async def _get_inverter_details(self, - device_id: str, - device_serial: str - ) -> dict[str, Any] | None: + async def _get_inverter_details(self, device_id: str, device_serial: str) -> dict[str, Any] | None: """ Update inverter details """ # Get inverter details - params = { - } + params = {} result = await self._post_data_json(INVERTER_DETAIL_LIST, params) @@ -354,27 +394,25 @@ async def _get_inverter_details(self, record = None if result[SUCCESS] is True: jsondata = result[CONTENT] - if jsondata['code'] != '0': - _LOGGER.info("%s responded with error: %s:%s",INVERTER_DETAIL_LIST, \ - jsondata['code'], jsondata['msg']) + if jsondata["code"] != "0": + _LOGGER.info("%s responded with error: %s:%s", INVERTER_DETAIL_LIST, jsondata["code"], jsondata["msg"]) return None try: - for record in jsondata['data']['records']: - if record.get('sn') == device_serial and record.get('id') == device_id: + for record in jsondata["data"]["records"]: + if record.get("sn") == device_serial and record.get("id") == device_id: return record except TypeError: _LOGGER.debug("Response contains unexpected data: %s", jsondata) else: - _LOGGER.info('Unable to fetch details for device with ID: %s', device_id) + _LOGGER.info("Unable to fetch details for device with ID: %s", device_id) return record def _collect_inverter_data(self, payload: dict[str, Any]) -> None: - """ Fetch dynamic properties """ + """Fetch dynamic properties""" attributes = INVERTER_DATA[INVERTER_DETAIL_LIST] collect_energy_today = True try: - collect_energy_today = \ - not self.config.workarounds['use_energy_today_from_plant'] + collect_energy_today = not self.config.workarounds["use_energy_today_from_plant"] except KeyError: pass if collect_energy_today: @@ -391,35 +429,88 @@ def _collect_inverter_data(self, payload: dict[str, Any]) -> None: if value is not None: self._data[dictkey] = value + async def get_control_data(self, device_serial: str, cid="") -> dict[str, Any] | None: + control_data = {} + + if device_serial not in self._hmi_fb00: + _LOGGER.debug(f"No firmware version found for Inverter SN {device_serial}") + params = { + "inverterSn": str(device_serial), + "cid": HMI_CID, + } + result = await self._post_data_json(AT_READ, params, csrf=True) + if result[SUCCESS] is True: + jsondata = result[CONTENT] + if jsondata["code"] == "0": + try: + hmi_flag = jsondata.get("data", {}).get("msg", "") + self._hmi_fb00[device_serial] = hex(int(hmi_flag)) == "0xaa55" + if self._hmi_fb00[device_serial]: + _LOGGER.debug(f"HMI firmware version >=4B00 for Inverter SN {device_serial} ") + else: + _LOGGER.debug(f"HMI firmware version <4B00 for Inverter SN {device_serial} ") + + except: + _LOGGER.debug(f"Unable to determine HMI firmware version for Inverter SN {device_serial}") + else: + _LOGGER.debug(f"Unable to determine HMI firmware version for Inverter SN {device_serial}") + else: + _LOGGER.debug(f"Unable to determine HMI firmware version for Inverter SN {device_serial}") + + if device_serial in self._hmi_fb00: + if cid == "": + controls = ALL_CONTROLS[self._hmi_fb00[device_serial]] + else: + controls = [cid] + for cid in controls: + params = {"inverterSn": str(device_serial), "cid": str(cid)} + attempts = 0 + valid = False + while (attempts < CONTROL_RETRIES) and not valid: + attempts += 1 + result = await self._post_data_json(AT_READ, params, csrf=True) + if result[SUCCESS] is True: + jsondata = result[CONTENT] + if jsondata["code"] == "0": + _LOGGER.debug(f" cid: {str(cid):5s} - {jsondata.get('data',{}).get('msg','')}") + control_data[str(cid)] = jsondata.get("data", {}).get("msg", "") + valid = True + else: + error = f" cid: {str(cid):5s} - {AT_READ} responded with error: {jsondata['code']}:{jsondata['msg']}" + + else: + error = f" cid: {str(cid):5s} - {AT_READ} responded with error: {result[MESSAGE]}" + + if not valid: + _LOGGER.info(error) + + return control_data + async def _get_station_details(self, plant_id: str) -> dict[str, str] | None: """ Fetch Station Details """ - params = { - 'id': plant_id - } + params = {"id": plant_id} result = await self._post_data_json(PLANT_DETAIL, params) if result[SUCCESS] is True: - jsondata : dict[str, str] = result[CONTENT] - if jsondata['code'] == '0': + jsondata: dict[str, str] = result[CONTENT] + if jsondata["code"] == "0": return jsondata else: - _LOGGER.info("%s responded with error: %s:%s",PLANT_DETAIL, \ - jsondata['code'], jsondata['msg']) + _LOGGER.info("%s responded with error: %s:%s", PLANT_DETAIL, jsondata["code"], jsondata["msg"]) else: - _LOGGER.info('Unable to fetch details for Station with ID: %s', plant_id) + _LOGGER.info("Unable to fetch details for Station with ID: %s", plant_id) return None def _collect_station_list_data(self, payload: dict[str, Any]) -> None: - """ Fetch dynamic properties """ + """Fetch dynamic properties""" jsondata = payload attributes = INVERTER_DATA[PLANT_LIST] collect_energy_today = False try: - collect_energy_today = \ - self.config.workarounds['use_energy_today_from_plant'] + collect_energy_today = self.config.workarounds["use_energy_today_from_plant"] except KeyError: pass if collect_energy_today: @@ -437,13 +528,12 @@ def _collect_station_list_data(self, payload: dict[str, Any]) -> None: self._data[dictkey] = value def _collect_plant_data(self, payload: dict[str, Any]) -> None: - """ Fetch dynamic properties """ - jsondata = payload['data'] + """Fetch dynamic properties""" + jsondata = payload["data"] attributes = INVERTER_DATA[PLANT_DETAIL] collect_energy_today = False try: - collect_energy_today = \ - self.config.workarounds['use_energy_today_from_plant'] + collect_energy_today = self.config.workarounds["use_energy_today_from_plant"] except KeyError: pass if collect_energy_today: @@ -461,12 +551,11 @@ def _collect_plant_data(self, payload: dict[str, Any]) -> None: self._data[dictkey] = value def _post_process(self) -> None: - """ Cleanup received data. """ + """Cleanup received data.""" if self._data: # Fix timestamps try: - self._data[INVERTER_TIMESTAMP_UPDATE] = \ - float(self._data[INVERTER_TIMESTAMP_UPDATE])/1000 + self._data[INVERTER_TIMESTAMP_UPDATE] = float(self._data[INVERTER_TIMESTAMP_UPDATE]) / 1000 except KeyError: pass @@ -497,15 +586,14 @@ def _post_process(self) -> None: # Just temporary till SolisCloud is fixed try: - if self.config.workarounds['correct_daily_on_grid_energy_enabled']: - self._data[GRID_DAILY_ON_GRID_ENERGY] = \ - float(self._data[GRID_DAILY_ON_GRID_ENERGY])*10 + if self.config.workarounds["correct_daily_on_grid_energy_enabled"]: + self._data[GRID_DAILY_ON_GRID_ENERGY] = float(self._data[GRID_DAILY_ON_GRID_ENERGY]) * 10 except KeyError: pass # turn batteryPower negative when discharging (fix for https://github.com/hultenvp/solis-sensor/issues/158) try: - self._data[BAT_POWER] = math.copysign(self._data[BAT_POWER],self._data[BAT_CURRENT]) + self._data[BAT_POWER] = math.copysign(self._data[BAT_POWER], self._data[BAT_CURRENT]) except KeyError: pass @@ -529,18 +617,18 @@ def _post_process(self) -> None: pass def _fix_units(self, num_key: str, units_key: str) -> None: - """ Convert numeric values according to the units reported by the API. """ + """Convert numeric values according to the units reported by the API.""" try: if self._data[units_key] == "kW": - self._data[num_key] = float(self._data[num_key])*1000 + self._data[num_key] = float(self._data[num_key]) * 1000 self._data[units_key] = "W" elif self._data[units_key] == "MWh": - self._data[num_key] = float(self._data[num_key])*1000 + self._data[num_key] = float(self._data[num_key]) * 1000 self._data[units_key] = "kWh" elif self._data[units_key] == "GWh": - self._data[num_key] = float(self._data[num_key])*1000*1000 + self._data[num_key] = float(self._data[num_key]) * 1000 * 1000 self._data[units_key] = "kWh" except KeyError: @@ -556,11 +644,9 @@ def _purge_if_unused(self, value: Any, *elements: str) -> None: for element in elements: self._data.pop(element) - def _get_value(self, - data: dict[str, Any], key: str, type_: type, precision: int = 2 - ) -> str | int | float | None: - """ Retrieve 'key' from 'data' as type 'type_' with precision 'precision' """ - result : str | int | float | None = None + def _get_value(self, data: dict[str, Any], key: str, type_: type, precision: int = 2) -> str | int | float | None: + """Retrieve 'key' from 'data' as type 'type_' with precision 'precision'""" + result: str | int | float | None = None data_raw = data.get(key) if data_raw is not None: @@ -571,17 +657,13 @@ def _get_value(self, result = type_(data_raw) # Round to specified precision if type_ is float: - result = round(float(result), precision) # type: ignore + result = round(float(result), precision) # type: ignore except ValueError: - _LOGGER.debug("Failed to convert %s to type %s, raw value = %s", \ - key, type_, data_raw) + _LOGGER.debug("Failed to convert %s to type %s, raw value = %s", key, type_, data_raw) return result - async def _get_data(self, - url: str, - params: dict[str, Any] - ) -> dict[str, Any]: - """ Http-get data from specified url. """ + async def _get_data(self, url: str, params: dict[str, Any]) -> dict[str, Any]: + """Http-get data from specified url.""" result: dict[str, Any] = {SUCCESS: False, MESSAGE: None, STATUS_CODE: None} resp = None @@ -609,43 +691,37 @@ async def _get_data(self, def _prepare_header(self, body: dict[str, str], canonicalized_resource: str) -> dict[str, str]: content_md5 = base64.b64encode( - hashlib.md5(json.dumps(body,separators=(",", ":")).encode('utf-8')).digest() - ).decode('utf-8') + hashlib.md5(json.dumps(body, separators=(",", ":")).encode("utf-8")).digest() + ).decode("utf-8") content_type = "application/json" now = datetime.now(timezone.utc) date = now.strftime("%a, %d %b %Y %H:%M:%S GMT") - encrypt_str = (VERB + "\n" - + content_md5 + "\n" - + content_type + "\n" - + date + "\n" - + canonicalized_resource - ) - hmac_obj = hmac.new( - self.config.secret, - msg=encrypt_str.encode('utf-8'), - digestmod=hashlib.sha1 - ) + encrypt_str = VERB + "\n" + content_md5 + "\n" + content_type + "\n" + date + "\n" + canonicalized_resource + hmac_obj = hmac.new(self.config.secret, msg=encrypt_str.encode("utf-8"), digestmod=hashlib.sha1) sign = base64.b64encode(hmac_obj.digest()) - authorization = "API " + self.config.key_id + ":" + sign.decode('utf-8') - - header: dict [str, str] = { - "Content-MD5":content_md5, - "Content-Type":content_type, - "Date":date, - "Authorization":authorization + authorization = "API " + self.config.key_id + ":" + sign.decode("utf-8") + + header: dict[str, str] = { + "Content-MD5": content_md5, + "Content-Type": content_type, + "Date": date, + "Authorization": authorization, } return header - - async def _post_data_json(self, - canonicalized_resource: str, - params: dict[str, Any]) -> dict[str, Any]: - """ Http-post data to specified domain/canonicalized_resource. """ + async def _post_data_json( + self, canonicalized_resource: str, params: dict[str, Any], csrf: bool = False + ) -> dict[str, Any]: + """Http-post data to specified domain/canonicalized_resource.""" header: dict[str, str] = self._prepare_header(params, canonicalized_resource) + if csrf and self._token != "": + header["token"] = self._token + + # _LOGGER.debug(f"header: {header}") result: dict[str, Any] = {SUCCESS: False, MESSAGE: None, STATUS_CODE: None} resp = None if self._session is None: @@ -669,3 +745,50 @@ async def _post_data_json(self, if resp is not None: await resp.release() return result + + async def _fetch_token(self, username: str, password: str) -> str: + """ + Fetch Station Details + """ + + params = { + "username": username, + "password": hashlib.md5(password.encode("utf-8")).hexdigest(), + } + result = await self._post_data_json(AUTHENTICATE, params) + + if result[SUCCESS] is True: + jsondata: dict[str, str] = result[CONTENT] + if "csrfToken" in jsondata: + return jsondata["csrfToken"] + else: + _LOGGER.info(f"({AUTHENTICATE:s} responded with error: {jsondata}") + else: + _LOGGER.info("Unable to fetch authenticate with username and password") + return "" + + async def write_control_data(self, device_serial: str, cid: str, value: str): + _LOGGER.debug(f">>> Writing value {value} for cid {cid} to inverter {device_serial}") + + params = {"inverterSn": str(device_serial), "cid": str(cid), "value": value} + result = await self._post_data_json(CONTROL, params, csrf=True) + + if result[SUCCESS] is True: + jsondata = result[CONTENT] + if jsondata["code"] == "0": + jsondata = jsondata["data"][0] + if jsondata["code"] == "0": + _LOGGER.debug(f"Set code returned OK. Reading code back.") + await asyncio.sleep(CONTROL_DELAY) + control_data = await self.get_control_data(device_serial, cid=str(cid)) + _LOGGER.debug(f"Data read back: {control_data.get(str(cid), None)}") + else: + _LOGGER.info( + f"cid: {str(cid):5s} - {CONTROL} responded with error: {jsondata['code']}:{jsondata.get('msg',None)}" + ) + else: + _LOGGER.info( + f"cid: {str(cid):5s} - {CONTROL} responded with error: {jsondata['code']}:{jsondata.get('msg',None)}" + ) + else: + _LOGGER.info(f" cid: {str(cid):5s} - {CONTROL} responded with error: {result[MESSAGE]}") diff --git a/custom_components/solis/soliscloud_const.py b/custom_components/solis/soliscloud_const.py index 60641cb..6157a87 100644 --- a/custom_components/solis/soliscloud_const.py +++ b/custom_components/solis/soliscloud_const.py @@ -4,42 +4,44 @@ For more information: https://github.com/hultenvp/solis-sensor/ """ + from .ginlong_const import * + # VERSION -VERSION = '0.1.6' +VERSION = "0.1.6" -STRING_COUNT = 'dcStringCount' +STRING_COUNT = "dcStringCount" STRING_LISTS = [ - [STRING1_CURRENT,STRING1_VOLTAGE,STRING1_POWER], - [STRING2_CURRENT,STRING2_VOLTAGE,STRING2_POWER], - [STRING3_CURRENT,STRING3_VOLTAGE,STRING3_POWER], - [STRING4_CURRENT,STRING4_VOLTAGE,STRING4_POWER], - [STRING5_CURRENT,STRING5_VOLTAGE,STRING5_POWER], - [STRING6_CURRENT,STRING6_VOLTAGE,STRING6_POWER], - [STRING7_CURRENT,STRING7_VOLTAGE,STRING7_POWER], - [STRING8_CURRENT,STRING8_VOLTAGE,STRING8_POWER], + [STRING1_CURRENT, STRING1_VOLTAGE, STRING1_POWER], + [STRING2_CURRENT, STRING2_VOLTAGE, STRING2_POWER], + [STRING3_CURRENT, STRING3_VOLTAGE, STRING3_POWER], + [STRING4_CURRENT, STRING4_VOLTAGE, STRING4_POWER], + [STRING5_CURRENT, STRING5_VOLTAGE, STRING5_POWER], + [STRING6_CURRENT, STRING6_VOLTAGE, STRING6_POWER], + [STRING7_CURRENT, STRING7_VOLTAGE, STRING7_POWER], + [STRING8_CURRENT, STRING8_VOLTAGE, STRING8_POWER], ] -GRID_TOTAL_POWER_STR = 'gridTotalPowerUnit' -GRID_TOTAL_CONSUMPTION_POWER_STR = 'gridTotalConsumptionPowerUnit' -INVERTER_ACPOWER_STR = 'pacUnit' -GRID_TOTAL_ENERGY_USED_STR = 'homeLoadTotalEnergyUnit' -INVERTER_ENERGY_THIS_MONTH_STR = 'energyThisMonthUnit' -INVERTER_ENERGY_THIS_YEAR_STR = 'energyThisYearUnit' -INVERTER_ENERGY_TOTAL_LIFE_STR = 'energyTotalLifeUnit' -BAT_POWER_STR = 'batteryPowerUnit' -BAT_TOTAL_ENERGY_CHARGED_STR = 'batteryTotalChargeEnergyUnit' -BAT_TOTAL_ENERGY_DISCHARGED_STR = 'batteryTotalDischargeEnergyUnit' -BAT_CURRENT_STR = 'batteryCurrentUnit' -BAT_VOLTAGE_STR = 'batteryVoltageUnit' -GRID_TOTAL_ENERGY_PURCHASED_STR = 'totalEnergyPurchasedUnit' -GRID_DAILY_ON_GRID_ENERGY_STR = 'dailyOnGridEnergyUnit' -GRID_MONTHLY_ON_GRID_ENERGY_STR = 'monthlyOnGridEnergyUnit' -GRID_YEARLY_ON_GRID_ENERGY_STR = 'yearlyOnGridEnergyUnit' -GRID_TOTAL_ON_GRID_ENERGY_STR = 'totalOnGridEnergyUnit' -GRID_DAILY_ENERGY_PURCHASED_STR = 'dailyEnergyPurchasedUnit' -GRID_MONTHLY_ENERGY_PURCHASED_STR = 'monthlyEnergyPurchasedUnit' -GRID_YEARLY_ENERGY_PURCHASED_STR = 'yearlyEnergyPurchasedUnit' -GRID_DAILY_ENERGY_USED_STR = 'dailyEnergyUsedUnit' -BYPASS_LOAD_POWER_STR = 'bypassLoadPowerUnit' -PLANT_TOTAL_CONSUMPTION_POWER_STR = 'plantTotalConsumptionPowerUnit' +GRID_TOTAL_POWER_STR = "gridTotalPowerUnit" +GRID_TOTAL_CONSUMPTION_POWER_STR = "gridTotalConsumptionPowerUnit" +INVERTER_ACPOWER_STR = "pacUnit" +GRID_TOTAL_ENERGY_USED_STR = "homeLoadTotalEnergyUnit" +INVERTER_ENERGY_THIS_MONTH_STR = "energyThisMonthUnit" +INVERTER_ENERGY_THIS_YEAR_STR = "energyThisYearUnit" +INVERTER_ENERGY_TOTAL_LIFE_STR = "energyTotalLifeUnit" +BAT_POWER_STR = "batteryPowerUnit" +BAT_TOTAL_ENERGY_CHARGED_STR = "batteryTotalChargeEnergyUnit" +BAT_TOTAL_ENERGY_DISCHARGED_STR = "batteryTotalDischargeEnergyUnit" +BAT_CURRENT_STR = "batteryCurrentUnit" +BAT_VOLTAGE_STR = "batteryVoltageUnit" +GRID_TOTAL_ENERGY_PURCHASED_STR = "totalEnergyPurchasedUnit" +GRID_DAILY_ON_GRID_ENERGY_STR = "dailyOnGridEnergyUnit" +GRID_MONTHLY_ON_GRID_ENERGY_STR = "monthlyOnGridEnergyUnit" +GRID_YEARLY_ON_GRID_ENERGY_STR = "yearlyOnGridEnergyUnit" +GRID_TOTAL_ON_GRID_ENERGY_STR = "totalOnGridEnergyUnit" +GRID_DAILY_ENERGY_PURCHASED_STR = "dailyEnergyPurchasedUnit" +GRID_MONTHLY_ENERGY_PURCHASED_STR = "monthlyEnergyPurchasedUnit" +GRID_YEARLY_ENERGY_PURCHASED_STR = "yearlyEnergyPurchasedUnit" +GRID_DAILY_ENERGY_USED_STR = "dailyEnergyUsedUnit" +BYPASS_LOAD_POWER_STR = "bypassLoadPowerUnit" +PLANT_TOTAL_CONSUMPTION_POWER_STR = "plantTotalConsumptionPowerUnit" diff --git a/custom_components/solis/strings.json b/custom_components/solis/strings.json index a6bc2e9..2622bad 100644 --- a/custom_components/solis/strings.json +++ b/custom_components/solis/strings.json @@ -27,6 +27,7 @@ "credentials_secret": { "data": { "portal_username": "Portal username or email address", + "portal_password": "Portal password", "portal_key_id": "API Key ID provided by SolisCloud", "portal_secret": "API Secret provided by SolisCloud", "portal_plant_id": "Station ID as found on portal website" diff --git a/custom_components/solis/time.py b/custom_components/solis/time.py new file mode 100644 index 0000000..800d5ea --- /dev/null +++ b/custom_components/solis/time.py @@ -0,0 +1,130 @@ +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.components.time import TimeEntity + + +import asyncio +import logging +from datetime import datetime + +from .const import ( + DOMAIN, + LAST_UPDATED, +) + +from .service import ServiceSubscriber, InverterService +from .control_const import SolisBaseControlEntity, RETRIES, RETRY_WAIT, ALL_CONTROLS, SolisTimeEntityDescription + +_LOGGER = logging.getLogger(__name__) +RETRIES = 100 +YEAR = 2024 +MONTH = 1 +DAY = 1 + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + """Setup sensors from a config entry created in the integrations UI.""" + # Prepare the sensor entities. + plant_id = config_entry.data["portal_plant_id"] + _LOGGER.debug(f"config_entry.data: {config_entry.data}") + _LOGGER.debug(f"Domain: {DOMAIN}") + service = hass.data[DOMAIN][config_entry.entry_id] + + _LOGGER.info(f"Waiting for discovery of controls for plant {plant_id}") + await asyncio.sleep(8) + attempts = 0 + while (attempts < RETRIES) and (not service.has_controls): + _LOGGER.debug(f" Attempt {attempts} failed") + await asyncio.sleep(RETRY_WAIT) + attempts += 1 + + if service.has_controls: + entities = [] + _LOGGER.debug(f"Plant ID {plant_id} has controls:") + for inverter_sn in service.controls: + for cid, index, entity, button, initial_value in service.controls[inverter_sn]["time"]: + # service.set_active_times(inverter_sn, cid, index, (None, None) + entities.append( + SolisTimeEntity( + service, + config_entry.data["name"], + inverter_sn, + cid, + entity, + index, + initial_value, + ) + ) + + if len(entities) > 0: + _LOGGER.debug(f"Creating {len(entities)} time entities") + async_add_entities(entities) + else: + _LOGGER.debug(f"No time controls found for Plant ID {plant_id}") + + else: + _LOGGER.debug(f"No time found for Plant ID {plant_id}") + + return True + + +class SolisTimeEntity(SolisBaseControlEntity, ServiceSubscriber, TimeEntity): + def __init__(self, service: InverterService, config_name, inverter_sn, cid, time_info, index, initial_value): + super().__init__(service, config_name, inverter_sn, cid, time_info) + self._attr_native_value = datetime( + year=YEAR, + month=MONTH, + day=DAY, + hour=0, + minute=0, + ).time() + self._icon = time_info.icon + self._splitter = time_info.splitter + self._index = index + # self._active_times = service.active_times[inverter_sn] + # Subscribe to the service with the cid as the index + if initial_value is not None: + self.do_update(initial_value, datetime.now()) + service.subscribe(self, inverter_sn, str(cid)) + + def do_update(self, value, last_updated): + # When the data from the API changes this method will be called with value as the new value + # return super().do_update(value, last_updated) + _LOGGER.debug(f"Update state for {self._name}") + _LOGGER.debug(f">>> Initial value: {value}") + values = self.split(value).split(":") + _LOGGER.debug(f">>> Split value: {values}") + time_value = None + try: + time_value = datetime( + year=YEAR, + month=MONTH, + day=DAY, + hour=int(values[0]), + minute=int(values[1]), + ).time() + _LOGGER.debug(f">>> Datetime value: {time_value}") + except: + _LOGGER.debug("Unable to convert {value} to time") + + # Check if this time overlaps with any others + + if time_value is None: + return False + elif self.hass and self._attr_native_value != time_value: + self._attr_native_value = time_value + self._attributes[LAST_UPDATED] = last_updated + self.async_write_ha_state() + return True + return False + + @property + def to_string(self): + return self._attr_native_value.strftime("%-H,%-M") + + async def async_set_value(self, value: float) -> None: + _LOGGER.debug(f"async_set_value to {value} for {self._name}") + self._attr_native_value = value + self._attributes[LAST_UPDATED] = datetime.now() + self.async_write_ha_state() + # await self.write_control_data(str(value)) diff --git a/custom_components/solis/translations/en.json b/custom_components/solis/translations/en.json index a6bc2e9..5d9d51d 100644 --- a/custom_components/solis/translations/en.json +++ b/custom_components/solis/translations/en.json @@ -28,6 +28,7 @@ "data": { "portal_username": "Portal username or email address", "portal_key_id": "API Key ID provided by SolisCloud", + "portal_password": "Portal password", "portal_secret": "API Secret provided by SolisCloud", "portal_plant_id": "Station ID as found on portal website" },