From dad8497e09e81d4c1832fb5f9d28f5c026f0caf2 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Fri, 21 Oct 2022 11:18:44 +0200 Subject: [PATCH 01/11] Moved kiosk stuff into own folder --- .../fusion_solar/fusion_solar/{ => kiosk}/kiosk.py | 0 .../fusion_solar/fusion_solar/{ => kiosk}/kiosk_api.py | 2 +- custom_components/fusion_solar/sensor.py | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) rename custom_components/fusion_solar/fusion_solar/{ => kiosk}/kiosk.py (100%) rename custom_components/fusion_solar/fusion_solar/{ => kiosk}/kiosk_api.py (98%) diff --git a/custom_components/fusion_solar/fusion_solar/kiosk.py b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk.py similarity index 100% rename from custom_components/fusion_solar/fusion_solar/kiosk.py rename to custom_components/fusion_solar/fusion_solar/kiosk/kiosk.py diff --git a/custom_components/fusion_solar/fusion_solar/kiosk_api.py b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py similarity index 98% rename from custom_components/fusion_solar/fusion_solar/kiosk_api.py rename to custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py index f38051e..fe78b67 100644 --- a/custom_components/fusion_solar/fusion_solar/kiosk_api.py +++ b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py @@ -3,7 +3,7 @@ import html import json -from .const import ( +from ..const import ( ATTR_DATA, ATTR_FAIL_CODE, ATTR_SUCCESS, diff --git a/custom_components/fusion_solar/sensor.py b/custom_components/fusion_solar/sensor.py index ae302c4..fdd7a0c 100644 --- a/custom_components/fusion_solar/sensor.py +++ b/custom_components/fusion_solar/sensor.py @@ -11,8 +11,8 @@ from .fusion_solar.const import ATTR_DATA_REALKPI, ATTR_REALTIME_POWER, ATTR_TOTAL_CURRENT_DAY_ENERGY, \ ATTR_TOTAL_CURRENT_MONTH_ENERGY, ATTR_TOTAL_CURRENT_YEAR_ENERGY, ATTR_TOTAL_LIFETIME_ENERGY -from .fusion_solar.kiosk import Kiosk -from .fusion_solar.kiosk_api import FusionSolarKioksApi, FusionSolarKioskApiError +from .fusion_solar.kiosk.kiosk import Kiosk +from .fusion_solar.kiosk.kiosk_api import FusionSolarKioksApi from .fusion_solar.energy_sensor import FusionSolarEnergySensorTotalCurrentDay, \ FusionSolarEnergySensorTotalCurrentMonth, FusionSolarEnergySensorTotalCurrentYear, \ FusionSolarEnergySensorTotalLifetime From 36fea3e6652a6c1ded8d902bb83e140d920c008b Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Fri, 21 Oct 2022 11:20:17 +0200 Subject: [PATCH 02/11] Fixed typo --- .../fusion_solar/fusion_solar/kiosk/kiosk_api.py | 2 +- custom_components/fusion_solar/sensor.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py index fe78b67..4307763 100644 --- a/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py +++ b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -class FusionSolarKioksApi: +class FusionSolarKioskApi: def __init__(self, host): self._host = host diff --git a/custom_components/fusion_solar/sensor.py b/custom_components/fusion_solar/sensor.py index fdd7a0c..25c725b 100644 --- a/custom_components/fusion_solar/sensor.py +++ b/custom_components/fusion_solar/sensor.py @@ -12,7 +12,7 @@ from .fusion_solar.const import ATTR_DATA_REALKPI, ATTR_REALTIME_POWER, ATTR_TOTAL_CURRENT_DAY_ENERGY, \ ATTR_TOTAL_CURRENT_MONTH_ENERGY, ATTR_TOTAL_CURRENT_YEAR_ENERGY, ATTR_TOTAL_LIFETIME_ENERGY from .fusion_solar.kiosk.kiosk import Kiosk -from .fusion_solar.kiosk.kiosk_api import FusionSolarKioksApi +from .fusion_solar.kiosk.kiosk_api import FusionSolarKioskApi from .fusion_solar.energy_sensor import FusionSolarEnergySensorTotalCurrentDay, \ FusionSolarEnergySensorTotalCurrentMonth, FusionSolarEnergySensorTotalCurrentYear, \ FusionSolarEnergySensorTotalLifetime @@ -46,7 +46,7 @@ async def add_entities_for_kiosk(hass, async_add_entities, kiosk: Kiosk): async def async_update_data(): """Fetch data""" data = {} - api = FusionSolarKioksApi(kiosk.apiUrl()) + api = FusionSolarKioskApi(kiosk.apiUrl()) data[f'{DOMAIN}-{kiosk.id}'] = { ATTR_DATA_REALKPI: await hass.async_add_executor_job(api.getRealTimeKpi, kiosk.id) } From 0a04556d241162d91a7ba95c885cf1228b46abf6 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Fri, 21 Oct 2022 13:22:16 +0200 Subject: [PATCH 03/11] Initial API client for FusionSolar OpenAPI --- .../fusion_solar/openapi/openapi_api.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py diff --git a/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py new file mode 100644 index 0000000..4ae0fd5 --- /dev/null +++ b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py @@ -0,0 +1,40 @@ +"""API client for FusionSolar OpenAPI.""" +import logging + +from requests import post + +_LOGGER = logging.getLogger(__name__) + + +class FusionSolarOpenApi: + def __init__(self, host: str): + self._host = host + self._token = None + + def login(self, username: str, password) -> str: + url = self._host + '/thirdData/login' + headers = { + 'accept': 'application/json', + } + json = { + 'userName': username, + 'systemCode': password, + } + + try: + response = post(url, headers=headers, json=json) + response.raise_for_status() + + if 'xsrf-token' in response.headers: + self._token = response.headers['xsrf-token'] + return response.headers.get("xsrf-token") + + _LOGGER.debug(response.json()) + _LOGGER.debug(response.headers()) + raise FusionSolarOpenApiError(f'Could not login with given credentials') + except Exception as error: + raise FusionSolarOpenApiError(f'Could not login with given credentials') + + +class FusionSolarOpenApiError(Exception): + pass From 4f85de5813ac08f8221d862c642e657aadd241d6 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Fri, 21 Oct 2022 13:23:10 +0200 Subject: [PATCH 04/11] Config flow for OpenAPI --- custom_components/fusion_solar/config_flow.py | 33 +++++++++++++++++-- custom_components/fusion_solar/const.py | 1 + custom_components/fusion_solar/strings.json | 10 +++++- .../fusion_solar/translations/en.json | 10 +++++- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/custom_components/fusion_solar/config_flow.py b/custom_components/fusion_solar/config_flow.py index 4eb7be5..d37f5fb 100644 --- a/custom_components/fusion_solar/config_flow.py +++ b/custom_components/fusion_solar/config_flow.py @@ -1,8 +1,10 @@ from typing import Any, Dict, Optional from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_URL -from .const import DOMAIN, CONF_KIOSKS, CONF_TYPE, CONF_TYPE_KIOSK, CONF_TYPE_OPENAPI +from homeassistant.const import CONF_NAME, CONF_URL, CONF_HOST, CONF_USERNAME, CONF_PASSWORD + +from .const import DOMAIN, CONF_KIOSKS, CONF_TYPE, CONF_TYPE_KIOSK, CONF_TYPE_OPENAPI, CONF_OPENAPI_CREDENTIALS +from .fusion_solar.openapi.openapi_api import FusionSolarOpenApi, FusionSolarOpenApiError import voluptuous as vol import logging @@ -13,6 +15,7 @@ class FusionSolarConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data: Optional[Dict[str, Any]] = { CONF_KIOSKS: [], + CONF_OPENAPI_CREDENTIALS: {} } async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): @@ -76,11 +79,35 @@ async def async_step_openapi(self, user_input: Optional[Dict[str, Any]] = None): _LOGGER.debug(f'async_step_openapi: {user_input}') errors: Dict[str, str] = {} - errors['base'] = 'not_implemented' + if user_input is not None: + try: + api = FusionSolarOpenApi(user_input[CONF_HOST]) + response = await self.hass.async_add_executor_job( + api.login, + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD] + ) + + self.data[CONF_OPENAPI_CREDENTIALS] = { + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + + return self.async_create_entry( + title="Fusion Solar", + data=self.data, + ) + + except FusionSolarOpenApiError as error: + _LOGGER.debug(error) + errors["base"] = "invalid_credentials" return self.async_show_form( step_id="openapi", data_schema=vol.Schema({ + vol.Required(CONF_HOST, default='https://eu5.fusionsolar.huawei.com'): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, }), errors=errors, ) diff --git a/custom_components/fusion_solar/const.py b/custom_components/fusion_solar/const.py index 33465b0..c24be82 100644 --- a/custom_components/fusion_solar/const.py +++ b/custom_components/fusion_solar/const.py @@ -4,6 +4,7 @@ # Configuration CONF_KIOSKS = 'kiosks' +CONF_OPENAPI_CREDENTIALS = 'credentials' CONF_TYPE = 'type' CONF_TYPE_KIOSK = 'kiosk' CONF_TYPE_OPENAPI = 'openapi' diff --git a/custom_components/fusion_solar/strings.json b/custom_components/fusion_solar/strings.json index 7980457..be125ef 100644 --- a/custom_components/fusion_solar/strings.json +++ b/custom_components/fusion_solar/strings.json @@ -3,7 +3,7 @@ "config": { "error": { "invalid_type": "Invalid type, only kiosk or openapi are allowed.", - "not_implemented": "OpenApi is not implemented yet." + "invalid_credentials": "Could not authenticate with the provided credentials." }, "step": { "choose_type": { @@ -19,6 +19,14 @@ "url": "Kiosk URL", "add_another": "Add another kiosk URL" } + }, + "openapi": { + "description": "Enter the OpenAPI credentials your received from Huawei FusionSolar below.", + "data": { + "host": "Host", + "username": "Username", + "password": "Password" + } } } } diff --git a/custom_components/fusion_solar/translations/en.json b/custom_components/fusion_solar/translations/en.json index 7980457..be125ef 100644 --- a/custom_components/fusion_solar/translations/en.json +++ b/custom_components/fusion_solar/translations/en.json @@ -3,7 +3,7 @@ "config": { "error": { "invalid_type": "Invalid type, only kiosk or openapi are allowed.", - "not_implemented": "OpenApi is not implemented yet." + "invalid_credentials": "Could not authenticate with the provided credentials." }, "step": { "choose_type": { @@ -19,6 +19,14 @@ "url": "Kiosk URL", "add_another": "Add another kiosk URL" } + }, + "openapi": { + "description": "Enter the OpenAPI credentials your received from Huawei FusionSolar below.", + "data": { + "host": "Host", + "username": "Username", + "password": "Password" + } } } } From 3624eeec03f622fe7acb495534dc00b5f8a50fd2 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Fri, 21 Oct 2022 15:29:37 +0200 Subject: [PATCH 05/11] WIP: openapi implementation --- custom_components/fusion_solar/config_flow.py | 7 +- .../fusion_solar/fusion_solar/const.py | 8 ++ .../fusion_solar/energy_sensor.py | 10 ++- .../fusion_solar/openapi/openapi_api.py | 85 +++++++++++++++++- .../fusion_solar/openapi/station.py | 17 ++++ custom_components/fusion_solar/sensor.py | 87 ++++++++++++++++++- 6 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 custom_components/fusion_solar/fusion_solar/openapi/station.py diff --git a/custom_components/fusion_solar/config_flow.py b/custom_components/fusion_solar/config_flow.py index d37f5fb..15f5b91 100644 --- a/custom_components/fusion_solar/config_flow.py +++ b/custom_components/fusion_solar/config_flow.py @@ -81,14 +81,15 @@ async def async_step_openapi(self, user_input: Optional[Dict[str, Any]] = None): if user_input is not None: try: - api = FusionSolarOpenApi(user_input[CONF_HOST]) - response = await self.hass.async_add_executor_job( - api.login, + api = FusionSolarOpenApi( + user_input[CONF_HOST], user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) + response = await self.hass.async_add_executor_job(api.login) self.data[CONF_OPENAPI_CREDENTIALS] = { + CONF_HOST: user_input[CONF_HOST], CONF_USERNAME: user_input[CONF_USERNAME], CONF_PASSWORD: user_input[CONF_PASSWORD], } diff --git a/custom_components/fusion_solar/fusion_solar/const.py b/custom_components/fusion_solar/fusion_solar/const.py index 02ca457..cf33df3 100644 --- a/custom_components/fusion_solar/fusion_solar/const.py +++ b/custom_components/fusion_solar/fusion_solar/const.py @@ -2,7 +2,10 @@ ATTR_DATA = 'data' ATTR_DATA_REALKPI = 'realKpi' ATTR_FAIL_CODE = 'failCode' +ATTR_MESSAGE = 'message' ATTR_SUCCESS = 'success' +ATTR_STATION_CODE = 'stationCode' +ATTR_STATION_NAME = 'stationName' # Data attributes ATTR_REALTIME_POWER = 'realTimePower' @@ -10,3 +13,8 @@ ATTR_TOTAL_CURRENT_MONTH_ENERGY = 'monthEnergy' ATTR_TOTAL_CURRENT_YEAR_ENERGY = 'yearEnergy' ATTR_TOTAL_LIFETIME_ENERGY = 'cumulativeEnergy' + +ATTR_STATION_REAL_KPI_DATA_ITEM_MAP = 'dataItemMap' +ATTR_STATION_REAL_KPI_TOTAL_CURRENT_DAY_ENERGY = 'day_power' +ATTR_STATION_REAL_KPI_TOTAL_CURRENT_MONTH_ENERGY = 'month_power' +ATTR_STATION_REAL_KPI_TOTAL_LIFETIME_ENERGY = 'total_power' diff --git a/custom_components/fusion_solar/fusion_solar/energy_sensor.py b/custom_components/fusion_solar/fusion_solar/energy_sensor.py index 35316e9..45eb599 100644 --- a/custom_components/fusion_solar/fusion_solar/energy_sensor.py +++ b/custom_components/fusion_solar/fusion_solar/energy_sensor.py @@ -26,7 +26,8 @@ def __init__( unique_id, name, attribute, - data_name + data_name, + device_info ): """Initialize the entity""" super().__init__(coordinator) @@ -34,6 +35,7 @@ def __init__( self._name = name self._attribute = attribute self._data_name = data_name + self._device_info = device_info @property def device_class(self) -> str: @@ -96,6 +98,12 @@ def native_value(self) -> str: def native_unit_of_measurement(self) -> str: return self.unit_of_measurement + @property + def device_info(self) -> dict: + _LOGGER.debug(f'device info: {self._device_info}') + device_info = self._device_info + return device_info + class FusionSolarEnergySensorTotalCurrentDay(FusionSolarEnergySensor): pass diff --git a/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py index 4ae0fd5..c060ff2 100644 --- a/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py +++ b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py @@ -3,22 +3,27 @@ from requests import post +from ..const import ATTR_SUCCESS, ATTR_DATA, ATTR_FAIL_CODE, ATTR_MESSAGE, ATTR_STATION_CODE, ATTR_STATION_NAME +from .station import FusionSolarStation + _LOGGER = logging.getLogger(__name__) class FusionSolarOpenApi: - def __init__(self, host: str): + def __init__(self, host: str, username: str, password: str): self._host = host + self._username = username + self._password = password self._token = None - def login(self, username: str, password) -> str: + def login(self) -> str: url = self._host + '/thirdData/login' headers = { 'accept': 'application/json', } json = { - 'userName': username, - 'systemCode': password, + 'userName': self._username, + 'systemCode': self._password, } try: @@ -35,6 +40,78 @@ def login(self, username: str, password) -> str: except Exception as error: raise FusionSolarOpenApiError(f'Could not login with given credentials') + def get_station_list(self): + if self._token is None: + self.login() + + url = self._host + '/thirdData/getStationList' + headers = { + 'accept': 'application/json', + 'xsrf-token': self._token, + } + json = {} + + try: + response = post(url, headers=headers, json=json) + response.raise_for_status() + jsonData = response.json() + _LOGGER.debug(jsonData) + + if ATTR_SUCCESS in jsonData and not jsonData[ATTR_SUCCESS]: + raise FusionSolarOpenApiError( + f'Retrieving the data failed with failCode: {jsonData[ATTR_FAIL_CODE]}, message: {jsonData[ATTR_MESSAGE]}' + ) + + # convert encoded html string to JSON + _LOGGER.debug('Received data for getStationList:') + _LOGGER.debug(jsonData[ATTR_DATA]) + + data = [] + for station in jsonData[ATTR_DATA]: + data.append( + FusionSolarStation(station[ATTR_STATION_CODE], station[ATTR_STATION_NAME]) + ) + + return data + + except KeyError as error: + _LOGGER.error(error) + _LOGGER.error(response.text) + + def get_station_real_kpi(self, station_codes: list): + if self._token is None: + self.login() + + url = self._host + '/thirdData/getStationRealKpi' + headers = { + 'accept': 'application/json', + 'xsrf-token': self._token, + } + json = { + 'stationCodes': ','.join(station_codes), + } + + try: + response = post(url, headers=headers, json=json) + response.raise_for_status() + jsonData = response.json() + _LOGGER.debug(jsonData) + + if ATTR_SUCCESS in jsonData and not jsonData[ATTR_SUCCESS]: + raise FusionSolarOpenApiError( + f'Retrieving the data failed with failCode: {jsonData[ATTR_FAIL_CODE]}, message: {jsonData[ATTR_MESSAGE]}' + ) + + # convert encoded html string to JSON + _LOGGER.debug('Received data for getStationRealKpi:') + _LOGGER.debug(jsonData[ATTR_DATA]) + + return jsonData[ATTR_DATA] + + except KeyError as error: + _LOGGER.error(error) + _LOGGER.error(response.text) + class FusionSolarOpenApiError(Exception): pass diff --git a/custom_components/fusion_solar/fusion_solar/openapi/station.py b/custom_components/fusion_solar/fusion_solar/openapi/station.py new file mode 100644 index 0000000..b8d7e8d --- /dev/null +++ b/custom_components/fusion_solar/fusion_solar/openapi/station.py @@ -0,0 +1,17 @@ +from ...const import DOMAIN + + +class FusionSolarStation: + def __init__(self, code: str, name: str): + self.code = code + self.name = name + + def device_info(self): + return { + 'identifiers': { + (DOMAIN, self.code) + }, + 'name': self.name, + 'manufacturer': 'Huawei FusionSolar', + 'model': 'Station' + } diff --git a/custom_components/fusion_solar/sensor.py b/custom_components/fusion_solar/sensor.py index 25c725b..73e2deb 100644 --- a/custom_components/fusion_solar/sensor.py +++ b/custom_components/fusion_solar/sensor.py @@ -6,19 +6,22 @@ from datetime import timedelta from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, CONF_URL +from homeassistant.const import CONF_NAME, CONF_URL, CONF_HOST, CONF_USERNAME, CONF_PASSWORD from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .fusion_solar.const import ATTR_DATA_REALKPI, ATTR_REALTIME_POWER, ATTR_TOTAL_CURRENT_DAY_ENERGY, \ - ATTR_TOTAL_CURRENT_MONTH_ENERGY, ATTR_TOTAL_CURRENT_YEAR_ENERGY, ATTR_TOTAL_LIFETIME_ENERGY + ATTR_TOTAL_CURRENT_MONTH_ENERGY, ATTR_TOTAL_CURRENT_YEAR_ENERGY, ATTR_TOTAL_LIFETIME_ENERGY, \ + ATTR_STATION_CODE, ATTR_STATION_REAL_KPI_DATA_ITEM_MAP, ATTR_STATION_REAL_KPI_TOTAL_CURRENT_DAY_ENERGY, \ + ATTR_STATION_REAL_KPI_TOTAL_CURRENT_MONTH_ENERGY, ATTR_STATION_REAL_KPI_TOTAL_LIFETIME_ENERGY from .fusion_solar.kiosk.kiosk import Kiosk from .fusion_solar.kiosk.kiosk_api import FusionSolarKioskApi +from .fusion_solar.openapi.openapi_api import FusionSolarOpenApi from .fusion_solar.energy_sensor import FusionSolarEnergySensorTotalCurrentDay, \ FusionSolarEnergySensorTotalCurrentMonth, FusionSolarEnergySensorTotalCurrentYear, \ FusionSolarEnergySensorTotalLifetime from .fusion_solar.power_entity import FusionSolarPowerEntityRealtime -from .const import CONF_KIOSKS, DOMAIN, ID_REALTIME_POWER, NAME_REALTIME_POWER, \ +from .const import CONF_KIOSKS, CONF_OPENAPI_CREDENTIALS, DOMAIN, ID_REALTIME_POWER, NAME_REALTIME_POWER, \ ID_TOTAL_CURRENT_DAY_ENERGY, NAME_TOTAL_CURRENT_DAY_ENERGY, \ ID_TOTAL_CURRENT_MONTH_ENERGY, NAME_TOTAL_CURRENT_MONTH_ENERGY, \ ID_TOTAL_CURRENT_YEAR_ENERGY, NAME_TOTAL_CURRENT_YEAR_ENERGY, \ @@ -103,6 +106,72 @@ async def async_update_data(): ]) +async def add_entities_for_stations(hass, async_add_entities, stations, api: FusionSolarOpenApi): + _LOGGER.debug(f'Adding entities for stations') + station_codes = [station.code for station in stations] + + async def async_update_data(): + """Fetch data""" + data = {} + response = await hass.async_add_executor_job(api.get_station_real_kpi, station_codes) + _LOGGER.debug(f'response: {response}') + + for data in response: + data[f'{DOMAIN}-{data[ATTR_STATION_CODE]}'] = { + ATTR_DATA_REALKPI: data[ATTR_STATION_REAL_KPI_DATA_ITEM_MAP] + } + + _LOGGER.debug(f'async_update_data: {data}') + + return data + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name='FusionSolarOpenAPIRealKpi', + update_method=async_update_data, + update_interval=timedelta(seconds=300), + ) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + for station in stations: + async_add_entities([ + FusionSolarEnergySensorTotalCurrentDay( + coordinator, + f'{DOMAIN}-{station.code}-{ID_TOTAL_CURRENT_DAY_ENERGY}', + f'{station.name} ({station.code}) - {NAME_TOTAL_CURRENT_DAY_ENERGY}', + ATTR_STATION_REAL_KPI_TOTAL_CURRENT_DAY_ENERGY, + f'{DOMAIN}-{station.code}', + station.device_info() + ), + FusionSolarEnergySensorTotalCurrentMonth( + coordinator, + f'{DOMAIN}-{station.code}-{ID_TOTAL_CURRENT_MONTH_ENERGY}', + f'{station.name} ({station.code}) - {NAME_TOTAL_CURRENT_MONTH_ENERGY}', + ATTR_STATION_REAL_KPI_TOTAL_CURRENT_MONTH_ENERGY, + f'{DOMAIN}-{station.code}', + station.device_info() + ), + # FusionSolarEnergySensorTotalCurrentYear( + # coordinator, + # f'{DOMAIN}-{kiosk.id}-{ID_TOTAL_CURRENT_YEAR_ENERGY}', + # f'{kiosk.name} ({kiosk.id}) - {NAME_TOTAL_CURRENT_YEAR_ENERGY}', + # ATTR_TOTAL_CURRENT_YEAR_ENERGY, + # f'{DOMAIN}-{kiosk.id}', + # ), + FusionSolarEnergySensorTotalLifetime( + coordinator, + f'{DOMAIN}-{station.code}-{ID_TOTAL_LIFETIME_ENERGY}', + f'{station.name} ({station.code}) - {NAME_TOTAL_LIFETIME_ENERGY}', + ATTR_STATION_REAL_KPI_TOTAL_LIFETIME_ENERGY, + f'{DOMAIN}-{station.code}', + station.device_info() + ) + ]) + + async def async_setup_entry(hass, config_entry, async_add_entities): config = hass.data[DOMAIN][config_entry.entry_id] # Update our config to include new repos and remove those that have been removed. @@ -113,6 +182,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): kiosk = Kiosk(kioskConfig[CONF_URL], kioskConfig[CONF_NAME]) await add_entities_for_kiosk(hass, async_add_entities, kiosk) + if config[CONF_OPENAPI_CREDENTIALS]: + # get stations from openapi + api = FusionSolarOpenApi( + config[CONF_OPENAPI_CREDENTIALS][CONF_HOST], + config[CONF_OPENAPI_CREDENTIALS][CONF_USERNAME], + config[CONF_OPENAPI_CREDENTIALS][CONF_PASSWORD], + ) + stations = await hass.async_add_executor_job(api.get_station_list) + await add_entities_for_stations(hass, async_add_entities, stations, api) + + _LOGGER.debug(stations) + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): for kioskConfig in config[CONF_KIOSKS]: From ae578a1ae7ebf4e1b0829e8f95cf9ba8213c55cf Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Mon, 24 Oct 2022 14:35:37 +0200 Subject: [PATCH 06/11] Refactor OpenAPI client to have less duplicate code --- .../fusion_solar/openapi/openapi_api.py | 70 +++++++------------ 1 file changed, 26 insertions(+), 44 deletions(-) diff --git a/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py index c060ff2..b25fdb2 100644 --- a/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py +++ b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py @@ -41,72 +41,54 @@ def login(self) -> str: raise FusionSolarOpenApiError(f'Could not login with given credentials') def get_station_list(self): - if self._token is None: - self.login() - url = self._host + '/thirdData/getStationList' - headers = { - 'accept': 'application/json', - 'xsrf-token': self._token, - } json = {} + response = self._do_call(url, json) - try: - response = post(url, headers=headers, json=json) - response.raise_for_status() - jsonData = response.json() - _LOGGER.debug(jsonData) + data = [] + for station in response[ATTR_DATA]: + data.append( + FusionSolarStation(station[ATTR_STATION_CODE], station[ATTR_STATION_NAME]) + ) - if ATTR_SUCCESS in jsonData and not jsonData[ATTR_SUCCESS]: - raise FusionSolarOpenApiError( - f'Retrieving the data failed with failCode: {jsonData[ATTR_FAIL_CODE]}, message: {jsonData[ATTR_MESSAGE]}' - ) - - # convert encoded html string to JSON - _LOGGER.debug('Received data for getStationList:') - _LOGGER.debug(jsonData[ATTR_DATA]) - - data = [] - for station in jsonData[ATTR_DATA]: - data.append( - FusionSolarStation(station[ATTR_STATION_CODE], station[ATTR_STATION_NAME]) - ) + return data - return data + def get_station_real_kpi(self, station_codes: list): + url = self._host + '/thirdData/getStationRealKpi' + json = { + 'stationCodes': ','.join(station_codes), + } + response = self._do_call(url, json) - except KeyError as error: - _LOGGER.error(error) - _LOGGER.error(response.text) + return response[ATTR_DATA] - def get_station_real_kpi(self, station_codes: list): + def _do_call(self, url: str, json: dict): if self._token is None: self.login() - url = self._host + '/thirdData/getStationRealKpi' headers = { 'accept': 'application/json', 'xsrf-token': self._token, } - json = { - 'stationCodes': ','.join(station_codes), - } try: response = post(url, headers=headers, json=json) response.raise_for_status() - jsonData = response.json() - _LOGGER.debug(jsonData) + json_data = response.json() + _LOGGER.debug(json_data) + + if ATTR_FAIL_CODE in json_data and json_data[ATTR_FAIL_CODE] == 305: + _LOGGER.debug('Token expired, trying to login again') + # token expired + self._token = None + return self._do_call(url, json) - if ATTR_SUCCESS in jsonData and not jsonData[ATTR_SUCCESS]: + if ATTR_SUCCESS in json_data and not json_data[ATTR_SUCCESS]: raise FusionSolarOpenApiError( - f'Retrieving the data failed with failCode: {jsonData[ATTR_FAIL_CODE]}, message: {jsonData[ATTR_MESSAGE]}' + f'Retrieving the data failed with failCode: {json_data[ATTR_FAIL_CODE]}, message: {json_data[ATTR_MESSAGE]}' ) - # convert encoded html string to JSON - _LOGGER.debug('Received data for getStationRealKpi:') - _LOGGER.debug(jsonData[ATTR_DATA]) - - return jsonData[ATTR_DATA] + return json_data except KeyError as error: _LOGGER.error(error) From 0050d94d1e5122fc0f5bc43ed51376ef6254d3e9 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Mon, 24 Oct 2022 17:03:24 +0200 Subject: [PATCH 07/11] Implemented all total yields --- README.md | 4 + .../fusion_solar/fusion_solar/const.py | 5 ++ .../fusion_solar/energy_sensor.py | 11 ++- .../fusion_solar/openapi/openapi_api.py | 26 +++++-- .../fusion_solar/fusion_solar/power_entity.py | 7 +- custom_components/fusion_solar/sensor.py | 77 +++++++++++++------ 6 files changed, 92 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 8aa6e3c..cd3a8da 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,13 @@ The configuration happens in the configuration flow when you add the integration FusionSolar has a kiosk mode. The kiosk is a dashboard that is accessible for everyone that has the url. The integration uses a JSON REST api that is also consumed by the kiosk. +The integration updates the data every 10 minutes. + **In kiosk mode the "realtime" data is not really realtime, it is cached at FusionSolars end for 30 minutes.** If you need more accurate information you should use the OpenAPI mode. ### OpenAPI You will need an OpenAPI account from Huawei for this to work. [More information](https://forum.huawei.com/enterprise/en/communicate-with-fusionsolar-through-an-openapi-account/thread/591478-100027) + +The integration updates the total yields (current day, current month, current year, lifetime) every 10 minutes. diff --git a/custom_components/fusion_solar/fusion_solar/const.py b/custom_components/fusion_solar/fusion_solar/const.py index cf33df3..f687db3 100644 --- a/custom_components/fusion_solar/fusion_solar/const.py +++ b/custom_components/fusion_solar/fusion_solar/const.py @@ -1,11 +1,14 @@ # Fusion Solar Kiosk API response attributes ATTR_DATA = 'data' ATTR_DATA_REALKPI = 'realKpi' +ATTR_DATA_COLLECT_TIME = 'collectTime' ATTR_FAIL_CODE = 'failCode' ATTR_MESSAGE = 'message' ATTR_SUCCESS = 'success' ATTR_STATION_CODE = 'stationCode' ATTR_STATION_NAME = 'stationName' +ATTR_PARAMS = 'params' +ATTR_PARAMS_CURRENT_TIME = 'currentTime' # Data attributes ATTR_REALTIME_POWER = 'realTimePower' @@ -18,3 +21,5 @@ ATTR_STATION_REAL_KPI_TOTAL_CURRENT_DAY_ENERGY = 'day_power' ATTR_STATION_REAL_KPI_TOTAL_CURRENT_MONTH_ENERGY = 'month_power' ATTR_STATION_REAL_KPI_TOTAL_LIFETIME_ENERGY = 'total_power' + +ATTR_KPI_YEAR_INVERTER_POWER = 'inverter_power' \ No newline at end of file diff --git a/custom_components/fusion_solar/fusion_solar/energy_sensor.py b/custom_components/fusion_solar/fusion_solar/energy_sensor.py index 45eb599..e700294 100644 --- a/custom_components/fusion_solar/fusion_solar/energy_sensor.py +++ b/custom_components/fusion_solar/fusion_solar/energy_sensor.py @@ -4,7 +4,7 @@ from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING, SensorEntity from homeassistant.const import DEVICE_CLASS_ENERGY, ENERGY_KILO_WATT_HOUR -from .const import ATTR_TOTAL_LIFETIME_ENERGY, ATTR_DATA_REALKPI +from .const import ATTR_TOTAL_LIFETIME_ENERGY, ATTR_STATION_REAL_KPI_TOTAL_LIFETIME_ENERGY _LOGGER = logging.getLogger(__name__) @@ -59,7 +59,7 @@ def state(self) -> float: if entity is not None: current_value = entity.state - new_value = self.coordinator.data[self._data_name][ATTR_DATA_REALKPI][self._attribute] + new_value = self.coordinator.data[self._data_name][self._attribute] if not isfloat(new_value): _LOGGER.warning(f'{self.entity_id}: new value ({new_value}) is not a float, so not updating.') @@ -74,13 +74,13 @@ def state(self) -> float: f'{self.entity_id}: new value ({new_value}) is smaller then current value ({entity.state}), so not updating.') return float(current_value) - if ATTR_DATA_REALKPI not in self.coordinator.data[self._data_name]: + if self._data_name not in self.coordinator.data: return None - if self._attribute not in self.coordinator.data[self._data_name][ATTR_DATA_REALKPI]: + if self._attribute not in self.coordinator.data[self._data_name]: return None - return float(self.coordinator.data[self._data_name][ATTR_DATA_REALKPI][self._attribute]) + return float(self.coordinator.data[self._data_name][self._attribute]) @property def unit_of_measurement(self) -> str: @@ -100,7 +100,6 @@ def native_unit_of_measurement(self) -> str: @property def device_info(self) -> dict: - _LOGGER.debug(f'device info: {self._device_info}') device_info = self._device_info return device_info diff --git a/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py index b25fdb2..a9e2b6e 100644 --- a/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py +++ b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py @@ -3,7 +3,8 @@ from requests import post -from ..const import ATTR_SUCCESS, ATTR_DATA, ATTR_FAIL_CODE, ATTR_MESSAGE, ATTR_STATION_CODE, ATTR_STATION_NAME +from ..const import ATTR_SUCCESS, ATTR_DATA, ATTR_FAIL_CODE, ATTR_MESSAGE, ATTR_STATION_CODE, \ + ATTR_STATION_NAME, ATTR_PARAMS, ATTR_PARAMS_CURRENT_TIME from .station import FusionSolarStation _LOGGER = logging.getLogger(__name__) @@ -11,10 +12,11 @@ class FusionSolarOpenApi: def __init__(self, host: str, username: str, password: str): + self._token = None + self._last_station_list_current_time = None self._host = host self._username = username self._password = password - self._token = None def login(self) -> str: url = self._host + '/thirdData/login' @@ -34,8 +36,6 @@ def login(self) -> str: self._token = response.headers['xsrf-token'] return response.headers.get("xsrf-token") - _LOGGER.debug(response.json()) - _LOGGER.debug(response.headers()) raise FusionSolarOpenApiError(f'Could not login with given credentials') except Exception as error: raise FusionSolarOpenApiError(f'Could not login with given credentials') @@ -45,6 +45,9 @@ def get_station_list(self): json = {} response = self._do_call(url, json) + if ATTR_PARAMS in response and ATTR_PARAMS_CURRENT_TIME in response[ATTR_PARAMS]: + self._last_station_list_current_time = response[ATTR_PARAMS][ATTR_PARAMS_CURRENT_TIME] + data = [] for station in response[ATTR_DATA]: data.append( @@ -62,6 +65,19 @@ def get_station_real_kpi(self, station_codes: list): return response[ATTR_DATA] + def get_kpi_station_year(self, station_codes: list): + if self._last_station_list_current_time is None: + self.get_station_list() + + url = self._host + '/thirdData/getKpiStationYear' + json = { + 'stationCodes': ','.join(station_codes), + 'collectTime': self._last_station_list_current_time, + } + response = self._do_call(url, json) + + return response[ATTR_DATA] + def _do_call(self, url: str, json: dict): if self._token is None: self.login() @@ -75,7 +91,7 @@ def _do_call(self, url: str, json: dict): response = post(url, headers=headers, json=json) response.raise_for_status() json_data = response.json() - _LOGGER.debug(json_data) + _LOGGER.debug(f'JSON data for {url}: {json_data}') if ATTR_FAIL_CODE in json_data and json_data[ATTR_FAIL_CODE] == 305: _LOGGER.debug('Token expired, trying to login again') diff --git a/custom_components/fusion_solar/fusion_solar/power_entity.py b/custom_components/fusion_solar/fusion_solar/power_entity.py index 1add088..f7984b0 100644 --- a/custom_components/fusion_solar/fusion_solar/power_entity.py +++ b/custom_components/fusion_solar/fusion_solar/power_entity.py @@ -36,13 +36,10 @@ def name(self): @property def state(self): - if ATTR_DATA_REALKPI not in self.coordinator.data[self._data_name]: + if self._attribute not in self.coordinator.data[self._data_name]: return None - if self._attribute not in self.coordinator.data[self._data_name][ATTR_DATA_REALKPI]: - return None - - return float(self.coordinator.data[self._data_name][ATTR_DATA_REALKPI][self._attribute]) + return float(self.coordinator.data[self._data_name][self._attribute]) @property def unit_of_measurement(self): diff --git a/custom_components/fusion_solar/sensor.py b/custom_components/fusion_solar/sensor.py index 73e2deb..5edc953 100644 --- a/custom_components/fusion_solar/sensor.py +++ b/custom_components/fusion_solar/sensor.py @@ -12,7 +12,8 @@ from .fusion_solar.const import ATTR_DATA_REALKPI, ATTR_REALTIME_POWER, ATTR_TOTAL_CURRENT_DAY_ENERGY, \ ATTR_TOTAL_CURRENT_MONTH_ENERGY, ATTR_TOTAL_CURRENT_YEAR_ENERGY, ATTR_TOTAL_LIFETIME_ENERGY, \ ATTR_STATION_CODE, ATTR_STATION_REAL_KPI_DATA_ITEM_MAP, ATTR_STATION_REAL_KPI_TOTAL_CURRENT_DAY_ENERGY, \ - ATTR_STATION_REAL_KPI_TOTAL_CURRENT_MONTH_ENERGY, ATTR_STATION_REAL_KPI_TOTAL_LIFETIME_ENERGY + ATTR_STATION_REAL_KPI_TOTAL_CURRENT_MONTH_ENERGY, ATTR_STATION_REAL_KPI_TOTAL_LIFETIME_ENERGY, \ + ATTR_DATA_COLLECT_TIME, ATTR_KPI_YEAR_INVERTER_POWER from .fusion_solar.kiosk.kiosk import Kiosk from .fusion_solar.kiosk.kiosk_api import FusionSolarKioskApi from .fusion_solar.openapi.openapi_api import FusionSolarOpenApi @@ -51,7 +52,7 @@ async def async_update_data(): data = {} api = FusionSolarKioskApi(kiosk.apiUrl()) data[f'{DOMAIN}-{kiosk.id}'] = { - ATTR_DATA_REALKPI: await hass.async_add_executor_job(api.getRealTimeKpi, kiosk.id) + await hass.async_add_executor_job(api.getRealTimeKpi, kiosk.id) } return data @@ -60,7 +61,7 @@ async def async_update_data(): _LOGGER, name='FusionSolarKiosk', update_method=async_update_data, - update_interval=timedelta(seconds=300), + update_interval=timedelta(seconds=600), ) # Fetch initial data so we have data when entities subscribe @@ -110,27 +111,24 @@ async def add_entities_for_stations(hass, async_add_entities, stations, api: Fus _LOGGER.debug(f'Adding entities for stations') station_codes = [station.code for station in stations] - async def async_update_data(): + async def async_update_station_real_kpi_data(): """Fetch data""" data = {} response = await hass.async_add_executor_job(api.get_station_real_kpi, station_codes) - _LOGGER.debug(f'response: {response}') - for data in response: - data[f'{DOMAIN}-{data[ATTR_STATION_CODE]}'] = { - ATTR_DATA_REALKPI: data[ATTR_STATION_REAL_KPI_DATA_ITEM_MAP] - } + for response_data in response: + data[f'{DOMAIN}-{response_data[ATTR_STATION_CODE]}'] = response_data[ATTR_STATION_REAL_KPI_DATA_ITEM_MAP] - _LOGGER.debug(f'async_update_data: {data}') + _LOGGER.debug(f'async_update_station_real_kpi_data: {data}') return data coordinator = DataUpdateCoordinator( hass, _LOGGER, - name='FusionSolarOpenAPIRealKpi', - update_method=async_update_data, - update_interval=timedelta(seconds=300), + name='FusionSolarOpenAPIStationRealKpi', + update_method=async_update_station_real_kpi_data, + update_interval=timedelta(seconds=600), ) # Fetch initial data so we have data when entities subscribe @@ -154,13 +152,6 @@ async def async_update_data(): f'{DOMAIN}-{station.code}', station.device_info() ), - # FusionSolarEnergySensorTotalCurrentYear( - # coordinator, - # f'{DOMAIN}-{kiosk.id}-{ID_TOTAL_CURRENT_YEAR_ENERGY}', - # f'{kiosk.name} ({kiosk.id}) - {NAME_TOTAL_CURRENT_YEAR_ENERGY}', - # ATTR_TOTAL_CURRENT_YEAR_ENERGY, - # f'{DOMAIN}-{kiosk.id}', - # ), FusionSolarEnergySensorTotalLifetime( coordinator, f'{DOMAIN}-{station.code}-{ID_TOTAL_LIFETIME_ENERGY}', @@ -171,6 +162,50 @@ async def async_update_data(): ) ]) + async def async_update_station_year_kpi_data(): + data = {} + collect_times = {} + response = await hass.async_add_executor_job(api.get_kpi_station_year, station_codes) + + for response_data in response: + key = f'{DOMAIN}-{response_data[ATTR_STATION_CODE]}' + + if key in collect_times and key in data: + # Only update if the collectTime is newer + if response_data[ATTR_DATA_COLLECT_TIME] > collect_times[key]: + data[key] = response_data[ATTR_STATION_REAL_KPI_DATA_ITEM_MAP] + collect_times[key] = response_data[ATTR_DATA_COLLECT_TIME] + else: + data[key] = response_data[ATTR_STATION_REAL_KPI_DATA_ITEM_MAP] + collect_times[key] = response_data[ATTR_DATA_COLLECT_TIME] + + _LOGGER.debug(f'async_update_station_year_kpi_data: {data}') + + return data + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name='FusionSolarOpenAPIStationYearKpi', + update_method=async_update_station_year_kpi_data, + update_interval=timedelta(seconds=600), + ) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + for station in stations: + async_add_entities([ + FusionSolarEnergySensorTotalCurrentYear( + coordinator, + f'{DOMAIN}-{station.code}-{ID_TOTAL_CURRENT_YEAR_ENERGY}', + f'{station.name} ({station.code}) - {NAME_TOTAL_CURRENT_YEAR_ENERGY}', + ATTR_KPI_YEAR_INVERTER_POWER, + f'{DOMAIN}-{station.code}', + station.device_info() + ) + ]) + async def async_setup_entry(hass, config_entry, async_add_entities): config = hass.data[DOMAIN][config_entry.entry_id] @@ -192,8 +227,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): stations = await hass.async_add_executor_job(api.get_station_list) await add_entities_for_stations(hass, async_add_entities, stations, api) - _LOGGER.debug(stations) - async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): for kioskConfig in config[CONF_KIOSKS]: From 9d21edcd9e9bb3d8e9ea11db1450c8a4bfa76b36 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Mon, 24 Oct 2022 17:09:58 +0200 Subject: [PATCH 08/11] Renamed Kiosk to FusionSolarKiosk --- custom_components/fusion_solar/fusion_solar/const.py | 2 +- .../fusion_solar/fusion_solar/kiosk/kiosk.py | 2 +- custom_components/fusion_solar/sensor.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/fusion_solar/fusion_solar/const.py b/custom_components/fusion_solar/fusion_solar/const.py index f687db3..a9a2701 100644 --- a/custom_components/fusion_solar/fusion_solar/const.py +++ b/custom_components/fusion_solar/fusion_solar/const.py @@ -1,4 +1,4 @@ -# Fusion Solar Kiosk API response attributes +# Fusion Solar API response attributes ATTR_DATA = 'data' ATTR_DATA_REALKPI = 'realKpi' ATTR_DATA_COLLECT_TIME = 'collectTime' diff --git a/custom_components/fusion_solar/fusion_solar/kiosk/kiosk.py b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk.py index b50e2db..8fa5d29 100644 --- a/custom_components/fusion_solar/fusion_solar/kiosk/kiosk.py +++ b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk.py @@ -6,7 +6,7 @@ _LOGGER = logging.getLogger(__name__) -class Kiosk: +class FusionSolarKiosk: def __init__(self, url, name): self.url = url self.name = name diff --git a/custom_components/fusion_solar/sensor.py b/custom_components/fusion_solar/sensor.py index 5edc953..0cd4493 100644 --- a/custom_components/fusion_solar/sensor.py +++ b/custom_components/fusion_solar/sensor.py @@ -14,7 +14,7 @@ ATTR_STATION_CODE, ATTR_STATION_REAL_KPI_DATA_ITEM_MAP, ATTR_STATION_REAL_KPI_TOTAL_CURRENT_DAY_ENERGY, \ ATTR_STATION_REAL_KPI_TOTAL_CURRENT_MONTH_ENERGY, ATTR_STATION_REAL_KPI_TOTAL_LIFETIME_ENERGY, \ ATTR_DATA_COLLECT_TIME, ATTR_KPI_YEAR_INVERTER_POWER -from .fusion_solar.kiosk.kiosk import Kiosk +from .fusion_solar.kiosk.kiosk import FusionSolarKiosk from .fusion_solar.kiosk.kiosk_api import FusionSolarKioskApi from .fusion_solar.openapi.openapi_api import FusionSolarOpenApi from .fusion_solar.energy_sensor import FusionSolarEnergySensorTotalCurrentDay, \ @@ -44,7 +44,7 @@ _LOGGER = logging.getLogger(__name__) -async def add_entities_for_kiosk(hass, async_add_entities, kiosk: Kiosk): +async def add_entities_for_kiosk(hass, async_add_entities, kiosk: FusionSolarKiosk): _LOGGER.debug(f'Adding entities for kiosk {kiosk.id}') async def async_update_data(): @@ -214,7 +214,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config.update(config_entry.options) for kioskConfig in config[CONF_KIOSKS]: - kiosk = Kiosk(kioskConfig[CONF_URL], kioskConfig[CONF_NAME]) + kiosk = FusionSolarKiosk(kioskConfig[CONF_URL], kioskConfig[CONF_NAME]) await add_entities_for_kiosk(hass, async_add_entities, kiosk) if config[CONF_OPENAPI_CREDENTIALS]: @@ -230,5 +230,5 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): for kioskConfig in config[CONF_KIOSKS]: - kiosk = Kiosk(kioskConfig[CONF_URL], kioskConfig[CONF_NAME]) + kiosk = FusionSolarKiosk(kioskConfig[CONF_URL], kioskConfig[CONF_NAME]) await add_entities_for_kiosk(hass, async_add_entities, kiosk) From b2b144f9f61a67d6e4b96897ed7959d752bda033 Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Mon, 24 Oct 2022 18:21:02 +0200 Subject: [PATCH 09/11] Realtime power implementation --- README.md | 3 +- .../fusion_solar/fusion_solar/const.py | 15 +++++- .../fusion_solar/energy_sensor.py | 3 +- .../fusion_solar/openapi/device.py | 42 +++++++++++++++ .../fusion_solar/openapi/openapi_api.py | 42 ++++++++++++++- .../fusion_solar/fusion_solar/power_entity.py | 11 +++- custom_components/fusion_solar/sensor.py | 52 +++++++++++++++++-- 7 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 custom_components/fusion_solar/fusion_solar/openapi/device.py diff --git a/README.md b/README.md index cd3a8da..9ff8d3d 100644 --- a/README.md +++ b/README.md @@ -27,4 +27,5 @@ If you need more accurate information you should use the OpenAPI mode. ### OpenAPI You will need an OpenAPI account from Huawei for this to work. [More information](https://forum.huawei.com/enterprise/en/communicate-with-fusionsolar-through-an-openapi-account/thread/591478-100027) -The integration updates the total yields (current day, current month, current year, lifetime) every 10 minutes. +The integration updates the total yields (current day, current month, current year, lifetime) every 10 minutes. +The realtime data is updated every minute. diff --git a/custom_components/fusion_solar/fusion_solar/const.py b/custom_components/fusion_solar/fusion_solar/const.py index a9a2701..ef4db8f 100644 --- a/custom_components/fusion_solar/fusion_solar/const.py +++ b/custom_components/fusion_solar/fusion_solar/const.py @@ -9,6 +9,13 @@ ATTR_STATION_NAME = 'stationName' ATTR_PARAMS = 'params' ATTR_PARAMS_CURRENT_TIME = 'currentTime' +ATTR_DEVICE_ID = 'id' +ATTR_DEVICE_NAME = 'devName' +ATTR_DEVICE_STATION_CODE = 'stationCode' +ATTR_DEVICE_ESN_CODE = 'esnCode' +ATTR_DEVICE_TYPE_ID = 'devTypeId' +ATTR_DEVICE_INVERTER_TYPE = 'invType' +ATTR_DEVICE_SOFTWARE_VERSION = 'softwareVersion' # Data attributes ATTR_REALTIME_POWER = 'realTimePower' @@ -22,4 +29,10 @@ ATTR_STATION_REAL_KPI_TOTAL_CURRENT_MONTH_ENERGY = 'month_power' ATTR_STATION_REAL_KPI_TOTAL_LIFETIME_ENERGY = 'total_power' -ATTR_KPI_YEAR_INVERTER_POWER = 'inverter_power' \ No newline at end of file +ATTR_KPI_YEAR_INVERTER_POWER = 'inverter_power' + +ATTR_DEVICE_REAL_KPI_DEV_ID = 'devId' +ATTR_DEVICE_REAL_KPI_DATA_ITEM_MAP = 'dataItemMap' +ATTR_DEVICE_REAL_KPI_ACTIVE_POWER = 'active_power' + +PARAM_DEVICE_TYPE_ID_RESIDENTIAL_INVERTER = 38 \ No newline at end of file diff --git a/custom_components/fusion_solar/fusion_solar/energy_sensor.py b/custom_components/fusion_solar/fusion_solar/energy_sensor.py index e700294..829723a 100644 --- a/custom_components/fusion_solar/fusion_solar/energy_sensor.py +++ b/custom_components/fusion_solar/fusion_solar/energy_sensor.py @@ -100,8 +100,7 @@ def native_unit_of_measurement(self) -> str: @property def device_info(self) -> dict: - device_info = self._device_info - return device_info + return self._device_info class FusionSolarEnergySensorTotalCurrentDay(FusionSolarEnergySensor): diff --git a/custom_components/fusion_solar/fusion_solar/openapi/device.py b/custom_components/fusion_solar/fusion_solar/openapi/device.py new file mode 100644 index 0000000..5c7b99e --- /dev/null +++ b/custom_components/fusion_solar/fusion_solar/openapi/device.py @@ -0,0 +1,42 @@ +from ...const import DOMAIN + + +class FusionSolarDevice: + def __init__(self, id: str, name: str, station_code: str, esn_code: str, type_id: str, inverter_type, + software_version: str): + self.device_id = id + self.name = name + self.station_code = station_code + self.esn_code = esn_code + self.type_id = type_id + self.inverter_type = inverter_type + self.software_version = software_version + + @property + def model(self) -> str: + if self.type_id == 1: + return 'String inverter' + if self.type_id == 10: + return 'EMI' + if self.type_id == 17: + return 'Grid meter' + if self.type_id == 38: + return f'Residential inverter {self.inverter_type}' + if self.type_id == 39: + return 'Battery' + if self.type_id == 47: + return 'Power Sensor' + + return 'Unknown' + + def device_info(self): + return { + 'identifiers': { + (DOMAIN, self.device_id) + }, + 'name': self.name, + 'manufacturer': 'Huawei FusionSolar', + 'model': self.model, + 'sw_version': self.software_version, + 'via_device': (DOMAIN, self.station_code) + } diff --git a/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py index a9e2b6e..fc81922 100644 --- a/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py +++ b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py @@ -4,8 +4,12 @@ from requests import post from ..const import ATTR_SUCCESS, ATTR_DATA, ATTR_FAIL_CODE, ATTR_MESSAGE, ATTR_STATION_CODE, \ - ATTR_STATION_NAME, ATTR_PARAMS, ATTR_PARAMS_CURRENT_TIME + ATTR_STATION_NAME, ATTR_PARAMS, ATTR_PARAMS_CURRENT_TIME, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, \ + ATTR_DEVICE_STATION_CODE, ATTR_DEVICE_ESN_CODE, ATTR_DEVICE_TYPE_ID, ATTR_DEVICE_INVERTER_TYPE, \ + ATTR_DEVICE_SOFTWARE_VERSION + from .station import FusionSolarStation +from .device import FusionSolarDevice _LOGGER = logging.getLogger(__name__) @@ -78,6 +82,42 @@ def get_kpi_station_year(self, station_codes: list): return response[ATTR_DATA] + def get_dev_list(self, station_codes: list): + url = self._host + '/thirdData/getDevList' + json = { + 'stationCodes': ','.join(station_codes), + } + response = self._do_call(url, json) + + if ATTR_PARAMS in response and ATTR_PARAMS_CURRENT_TIME in response[ATTR_PARAMS]: + self._last_station_list_current_time = response[ATTR_PARAMS][ATTR_PARAMS_CURRENT_TIME] + + data = [] + for device in response[ATTR_DATA]: + data.append( + FusionSolarDevice( + device[ATTR_DEVICE_ID], + device[ATTR_DEVICE_NAME], + device[ATTR_DEVICE_STATION_CODE], + device[ATTR_DEVICE_ESN_CODE], + device[ATTR_DEVICE_TYPE_ID], + device[ATTR_DEVICE_INVERTER_TYPE], + device[ATTR_DEVICE_SOFTWARE_VERSION], + ) + ) + + return data + + def get_dev_real_kpi(self, device_ids: list, type_id: int): + url = self._host + '/thirdData/getDevRealKpi' + json = { + 'devIds': ','.join(device_ids), + 'devTypeId': type_id, + } + response = self._do_call(url, json) + + return response[ATTR_DATA] + def _do_call(self, url: str, json: dict): if self._token is None: self.login() diff --git a/custom_components/fusion_solar/fusion_solar/power_entity.py b/custom_components/fusion_solar/fusion_solar/power_entity.py index f7984b0..2724393 100644 --- a/custom_components/fusion_solar/fusion_solar/power_entity.py +++ b/custom_components/fusion_solar/fusion_solar/power_entity.py @@ -2,7 +2,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.const import DEVICE_CLASS_POWER, POWER_KILO_WATT -from .const import ATTR_DATA_REALKPI class FusionSolarPowerEntity(CoordinatorEntity, Entity): """Base class for all FusionSolarPowerEntity entities.""" @@ -13,7 +12,8 @@ def __init__( unique_id, name, attribute, - data_name + data_name, + device_info ): """Initialize the entity""" super().__init__(coordinator) @@ -21,6 +21,7 @@ def __init__( self._name = name self._attribute = attribute self._data_name = data_name + self._device_info = device_info @property def device_class(self): @@ -36,6 +37,8 @@ def name(self): @property def state(self): + if self._data_name not in self.coordinator.data: + return None if self._attribute not in self.coordinator.data[self._data_name]: return None @@ -45,6 +48,10 @@ def state(self): def unit_of_measurement(self): return POWER_KILO_WATT + @property + def device_info(self) -> dict: + return self._device_info + class FusionSolarPowerEntityRealtime(FusionSolarPowerEntity): pass diff --git a/custom_components/fusion_solar/sensor.py b/custom_components/fusion_solar/sensor.py index 0cd4493..69770c7 100644 --- a/custom_components/fusion_solar/sensor.py +++ b/custom_components/fusion_solar/sensor.py @@ -13,7 +13,8 @@ ATTR_TOTAL_CURRENT_MONTH_ENERGY, ATTR_TOTAL_CURRENT_YEAR_ENERGY, ATTR_TOTAL_LIFETIME_ENERGY, \ ATTR_STATION_CODE, ATTR_STATION_REAL_KPI_DATA_ITEM_MAP, ATTR_STATION_REAL_KPI_TOTAL_CURRENT_DAY_ENERGY, \ ATTR_STATION_REAL_KPI_TOTAL_CURRENT_MONTH_ENERGY, ATTR_STATION_REAL_KPI_TOTAL_LIFETIME_ENERGY, \ - ATTR_DATA_COLLECT_TIME, ATTR_KPI_YEAR_INVERTER_POWER + ATTR_DATA_COLLECT_TIME, ATTR_KPI_YEAR_INVERTER_POWER, ATTR_DEVICE_REAL_KPI_ACTIVE_POWER, \ + PARAM_DEVICE_TYPE_ID_RESIDENTIAL_INVERTER, ATTR_DEVICE_REAL_KPI_DEV_ID, ATTR_DEVICE_REAL_KPI_DATA_ITEM_MAP from .fusion_solar.kiosk.kiosk import FusionSolarKiosk from .fusion_solar.kiosk.kiosk_api import FusionSolarKioskApi from .fusion_solar.openapi.openapi_api import FusionSolarOpenApi @@ -47,7 +48,7 @@ async def add_entities_for_kiosk(hass, async_add_entities, kiosk: FusionSolarKiosk): _LOGGER.debug(f'Adding entities for kiosk {kiosk.id}') - async def async_update_data(): + async def async_update_kiosk_data(): """Fetch data""" data = {} api = FusionSolarKioskApi(kiosk.apiUrl()) @@ -60,7 +61,7 @@ async def async_update_data(): hass, _LOGGER, name='FusionSolarKiosk', - update_method=async_update_data, + update_method=async_update_kiosk_data, update_interval=timedelta(seconds=600), ) @@ -206,6 +207,51 @@ async def async_update_station_year_kpi_data(): ) ]) + devices = await hass.async_add_executor_job(api.get_dev_list, station_codes) + device_ids = [str(device.device_id) for device in devices] + + async def async_update_device_real_kpi_data(): + data = {} + response = await hass.async_add_executor_job( + api.get_dev_real_kpi, + device_ids, + PARAM_DEVICE_TYPE_ID_RESIDENTIAL_INVERTER + ) + + for response_data in response: + key = f'{DOMAIN}-{response_data[ATTR_DEVICE_REAL_KPI_DEV_ID]}' + data[key] = response_data[ATTR_DEVICE_REAL_KPI_DATA_ITEM_MAP]; + + _LOGGER.debug(f'async_update_device_real_kpi_data: {data}') + + return data + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name='FusionSolarOpenAPIDeviceRealKpi', + update_method=async_update_device_real_kpi_data, + update_interval=timedelta(seconds=60), + ) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + for device in devices: + if device.type_id != PARAM_DEVICE_TYPE_ID_RESIDENTIAL_INVERTER: + continue + + async_add_entities([ + FusionSolarPowerEntityRealtime( + coordinator, + f'{DOMAIN}-{device.esn_code}-{ID_REALTIME_POWER}', + f'{device.name} ({device.esn_code}) - {NAME_REALTIME_POWER}', + ATTR_DEVICE_REAL_KPI_ACTIVE_POWER, + f'{DOMAIN}-{device.device_id}', + device.device_info() + ) + ]) + async def async_setup_entry(hass, config_entry, async_add_entities): config = hass.data[DOMAIN][config_entry.entry_id] From 3ebfbbce0e5a3930d76691b4e5a9a54ec29fd91e Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Tue, 25 Oct 2022 09:50:14 +0200 Subject: [PATCH 10/11] Removed useless log statement --- custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py index 4307763..fb23bb7 100644 --- a/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py +++ b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py @@ -27,7 +27,6 @@ def getRealTimeKpi(self, id: str): try: response = get(url, headers=headers) - # _LOGGER.debug(response.text) jsonData = response.json() if not jsonData[ATTR_SUCCESS]: From 2561be55682e14565f7694d838fbbb055ca64d1a Mon Sep 17 00:00:00 2001 From: Tijs Verkoyen Date: Tue, 25 Oct 2022 15:39:34 +0200 Subject: [PATCH 11/11] Fixed kiosk --- .../fusion_solar/energy_sensor.py | 2 +- .../fusion_solar/fusion_solar/power_entity.py | 2 +- custom_components/fusion_solar/sensor.py | 23 ++++++++++++++++--- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/custom_components/fusion_solar/fusion_solar/energy_sensor.py b/custom_components/fusion_solar/fusion_solar/energy_sensor.py index 829723a..1d56f70 100644 --- a/custom_components/fusion_solar/fusion_solar/energy_sensor.py +++ b/custom_components/fusion_solar/fusion_solar/energy_sensor.py @@ -27,7 +27,7 @@ def __init__( name, attribute, data_name, - device_info + device_info=None ): """Initialize the entity""" super().__init__(coordinator) diff --git a/custom_components/fusion_solar/fusion_solar/power_entity.py b/custom_components/fusion_solar/fusion_solar/power_entity.py index 2724393..deca5d7 100644 --- a/custom_components/fusion_solar/fusion_solar/power_entity.py +++ b/custom_components/fusion_solar/fusion_solar/power_entity.py @@ -13,7 +13,7 @@ def __init__( name, attribute, data_name, - device_info + device_info=None ): """Initialize the entity""" super().__init__(coordinator) diff --git a/custom_components/fusion_solar/sensor.py b/custom_components/fusion_solar/sensor.py index 69770c7..0f11e53 100644 --- a/custom_components/fusion_solar/sensor.py +++ b/custom_components/fusion_solar/sensor.py @@ -52,9 +52,12 @@ async def async_update_kiosk_data(): """Fetch data""" data = {} api = FusionSolarKioskApi(kiosk.apiUrl()) - data[f'{DOMAIN}-{kiosk.id}'] = { - await hass.async_add_executor_job(api.getRealTimeKpi, kiosk.id) - } + + _LOGGER.debug(DOMAIN) + _LOGGER.debug(kiosk.id) + + data[f'{DOMAIN}-{kiosk.id}'] = await hass.async_add_executor_job(api.getRealTimeKpi, kiosk.id) + return data coordinator = DataUpdateCoordinator( @@ -68,6 +71,15 @@ async def async_update_kiosk_data(): # Fetch initial data so we have data when entities subscribe await coordinator.async_refresh() + device_info = { + 'identifiers': { + (DOMAIN, kiosk.id) + }, + 'name': kiosk.name, + 'manufacturer': 'Huawei FusionSolar', + 'model': 'Kiosk' + } + async_add_entities([ FusionSolarPowerEntityRealtime( coordinator, @@ -75,6 +87,7 @@ async def async_update_kiosk_data(): f'{kiosk.name} ({kiosk.id}) - {NAME_REALTIME_POWER}', ATTR_REALTIME_POWER, f'{DOMAIN}-{kiosk.id}', + device_info ), FusionSolarEnergySensorTotalCurrentDay( @@ -83,6 +96,7 @@ async def async_update_kiosk_data(): f'{kiosk.name} ({kiosk.id}) - {NAME_TOTAL_CURRENT_DAY_ENERGY}', ATTR_TOTAL_CURRENT_DAY_ENERGY, f'{DOMAIN}-{kiosk.id}', + device_info ), FusionSolarEnergySensorTotalCurrentMonth( coordinator, @@ -90,6 +104,7 @@ async def async_update_kiosk_data(): f'{kiosk.name} ({kiosk.id}) - {NAME_TOTAL_CURRENT_MONTH_ENERGY}', ATTR_TOTAL_CURRENT_MONTH_ENERGY, f'{DOMAIN}-{kiosk.id}', + device_info ), FusionSolarEnergySensorTotalCurrentYear( coordinator, @@ -97,6 +112,7 @@ async def async_update_kiosk_data(): f'{kiosk.name} ({kiosk.id}) - {NAME_TOTAL_CURRENT_YEAR_ENERGY}', ATTR_TOTAL_CURRENT_YEAR_ENERGY, f'{DOMAIN}-{kiosk.id}', + device_info ), FusionSolarEnergySensorTotalLifetime( coordinator, @@ -104,6 +120,7 @@ async def async_update_kiosk_data(): f'{kiosk.name} ({kiosk.id}) - {NAME_TOTAL_LIFETIME_ENERGY}', ATTR_TOTAL_LIFETIME_ENERGY, f'{DOMAIN}-{kiosk.id}', + device_info ) ])