diff --git a/README.md b/README.md index 8aa6e3c..9ff8d3d 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,14 @@ 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. +The realtime data is updated every minute. diff --git a/custom_components/fusion_solar/config_flow.py b/custom_components/fusion_solar/config_flow.py index 4eb7be5..15f5b91 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,36 @@ 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], + 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], + } + + 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/fusion_solar/const.py b/custom_components/fusion_solar/fusion_solar/const.py index 02ca457..ef4db8f 100644 --- a/custom_components/fusion_solar/fusion_solar/const.py +++ b/custom_components/fusion_solar/fusion_solar/const.py @@ -1,8 +1,21 @@ -# Fusion Solar Kiosk API response attributes +# Fusion Solar 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' +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' @@ -10,3 +23,16 @@ 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' + +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 35316e9..1d56f70 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__) @@ -26,7 +26,8 @@ def __init__( unique_id, name, attribute, - data_name + data_name, + device_info=None ): """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: @@ -57,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.') @@ -72,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: @@ -96,6 +98,10 @@ def native_value(self) -> str: def native_unit_of_measurement(self) -> str: return self.unit_of_measurement + @property + def device_info(self) -> dict: + return self._device_info + class FusionSolarEnergySensorTotalCurrentDay(FusionSolarEnergySensor): pass diff --git a/custom_components/fusion_solar/fusion_solar/kiosk.py b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk.py similarity index 95% rename from custom_components/fusion_solar/fusion_solar/kiosk.py rename to custom_components/fusion_solar/fusion_solar/kiosk/kiosk.py index b50e2db..8fa5d29 100644 --- a/custom_components/fusion_solar/fusion_solar/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/fusion_solar/kiosk_api.py b/custom_components/fusion_solar/fusion_solar/kiosk/kiosk_api.py similarity index 94% 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..fb23bb7 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, @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -class FusionSolarKioksApi: +class FusionSolarKioskApi: def __init__(self, host): self._host = host @@ -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]: 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 new file mode 100644 index 0000000..fc81922 --- /dev/null +++ b/custom_components/fusion_solar/fusion_solar/openapi/openapi_api.py @@ -0,0 +1,155 @@ +"""API client for FusionSolar OpenAPI.""" +import logging + +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_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__) + + +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 + + def login(self) -> str: + url = self._host + '/thirdData/login' + headers = { + 'accept': 'application/json', + } + json = { + 'userName': self._username, + 'systemCode': self._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") + + raise FusionSolarOpenApiError(f'Could not login with given credentials') + except Exception as error: + raise FusionSolarOpenApiError(f'Could not login with given credentials') + + def get_station_list(self): + url = self._host + '/thirdData/getStationList' + 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( + FusionSolarStation(station[ATTR_STATION_CODE], station[ATTR_STATION_NAME]) + ) + + 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) + + 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 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() + + headers = { + 'accept': 'application/json', + 'xsrf-token': self._token, + } + + try: + response = post(url, headers=headers, json=json) + response.raise_for_status() + json_data = response.json() + _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') + # token expired + self._token = None + return self._do_call(url, json) + + if ATTR_SUCCESS in json_data and not json_data[ATTR_SUCCESS]: + raise FusionSolarOpenApiError( + f'Retrieving the data failed with failCode: {json_data[ATTR_FAIL_CODE]}, message: {json_data[ATTR_MESSAGE]}' + ) + + return json_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/fusion_solar/power_entity.py b/custom_components/fusion_solar/fusion_solar/power_entity.py index 1add088..deca5d7 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=None ): """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,18 +37,21 @@ def name(self): @property def state(self): - 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): 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 ae302c4..0f11e53 100644 --- a/custom_components/fusion_solar/sensor.py +++ b/custom_components/fusion_solar/sensor.py @@ -6,19 +6,24 @@ 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 -from .fusion_solar.kiosk import Kiosk -from .fusion_solar.kiosk_api import FusionSolarKioksApi, FusionSolarKioskApiError + 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_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 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, \ @@ -40,29 +45,41 @@ _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(): + async def async_update_kiosk_data(): """Fetch data""" data = {} - api = FusionSolarKioksApi(kiosk.apiUrl()) - data[f'{DOMAIN}-{kiosk.id}'] = { - ATTR_DATA_REALKPI: await hass.async_add_executor_job(api.getRealTimeKpi, kiosk.id) - } + api = FusionSolarKioskApi(kiosk.apiUrl()) + + _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( hass, _LOGGER, name='FusionSolarKiosk', - update_method=async_update_data, - update_interval=timedelta(seconds=300), + update_method=async_update_kiosk_data, + update_interval=timedelta(seconds=600), ) # 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, @@ -70,6 +87,7 @@ async def async_update_data(): f'{kiosk.name} ({kiosk.id}) - {NAME_REALTIME_POWER}', ATTR_REALTIME_POWER, f'{DOMAIN}-{kiosk.id}', + device_info ), FusionSolarEnergySensorTotalCurrentDay( @@ -78,6 +96,7 @@ async def async_update_data(): f'{kiosk.name} ({kiosk.id}) - {NAME_TOTAL_CURRENT_DAY_ENERGY}', ATTR_TOTAL_CURRENT_DAY_ENERGY, f'{DOMAIN}-{kiosk.id}', + device_info ), FusionSolarEnergySensorTotalCurrentMonth( coordinator, @@ -85,6 +104,7 @@ async def async_update_data(): f'{kiosk.name} ({kiosk.id}) - {NAME_TOTAL_CURRENT_MONTH_ENERGY}', ATTR_TOTAL_CURRENT_MONTH_ENERGY, f'{DOMAIN}-{kiosk.id}', + device_info ), FusionSolarEnergySensorTotalCurrentYear( coordinator, @@ -92,6 +112,7 @@ async def async_update_data(): f'{kiosk.name} ({kiosk.id}) - {NAME_TOTAL_CURRENT_YEAR_ENERGY}', ATTR_TOTAL_CURRENT_YEAR_ENERGY, f'{DOMAIN}-{kiosk.id}', + device_info ), FusionSolarEnergySensorTotalLifetime( coordinator, @@ -99,10 +120,156 @@ async def async_update_data(): f'{kiosk.name} ({kiosk.id}) - {NAME_TOTAL_LIFETIME_ENERGY}', ATTR_TOTAL_LIFETIME_ENERGY, f'{DOMAIN}-{kiosk.id}', + device_info ) ]) +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_station_real_kpi_data(): + """Fetch data""" + data = {} + response = await hass.async_add_executor_job(api.get_station_real_kpi, station_codes) + + 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_station_real_kpi_data: {data}') + + return data + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + 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 + 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() + ), + 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_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() + ) + ]) + + 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] # Update our config to include new repos and remove those that have been removed. @@ -110,11 +277,21 @@ 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]: + # 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) + 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) 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" + } } } }