├── .github └── FUNDING.yml ├── hacs.json ├── custom_components └── fusion_solar_kiosk │ ├── manifest.json │ ├── const.py │ ├── fusion_solar_kiosk_api.py │ ├── sensor.py │ └── __init__.py ├── LICENSE └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: tijsverkoyen 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Fusion Solar Kiosk", 3 | "render_readme": true 4 | } 5 | -------------------------------------------------------------------------------- /custom_components/fusion_solar_kiosk/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "fusion_solar_kiosk", 3 | "name": "FusionSolar Kiosk", 4 | "documentation": "https://github.com/tijsverkoyen/Home-Assisant-FusionSolar-Kiosk", 5 | "issue_tracker": "https://github.com/tijsverkoyen/Home-Assisant-FusionSolar-Kiosk/issues", 6 | "dependencies": [], 7 | "config_flow": false, 8 | "codeowners": [ 9 | "@tijsVerkoyen" 10 | ], 11 | "requirements": [], 12 | "version": "3.0.6" 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tijs Verkoyen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom_components/fusion_solar_kiosk/const.py: -------------------------------------------------------------------------------- 1 | """Constants for FusionSolar Kiosk.""" 2 | # Base constants 3 | DOMAIN = 'fusion_solar_kiosk' 4 | 5 | 6 | # Configuration 7 | CONF_KIOSKS = 'kiosks' 8 | CONF_KIOSK_URL = 'url' 9 | 10 | 11 | # Fusion Solar Kiosk API response attributes 12 | ATTR_DATA = 'data' 13 | ATTR_FAIL_CODE = 'failCode' 14 | ATTR_SUCCESS = 'success' 15 | ATTR_DATA_REALKPI = 'realKpi' 16 | # Data attributes 17 | ATTR_REALTIME_POWER = 'realTimePower' 18 | ATTR_TOTAL_CURRENT_DAY_ENERGY = 'dailyEnergy' 19 | ATTR_TOTAL_CURRENT_MONTH_ENERGY = 'monthEnergy' 20 | ATTR_TOTAL_CURRENT_YEAR_ENERGY = 'yearEnergy' 21 | ATTR_TOTAL_LIFETIME_ENERGY = 'cumulativeEnergy' 22 | 23 | 24 | # Possible ID suffixes 25 | ID_REALTIME_POWER = 'realtime_power' 26 | ID_TOTAL_CURRENT_DAY_ENERGY = 'total_current_day_energy' 27 | ID_TOTAL_CURRENT_MONTH_ENERGY = 'total_current_month_energy' 28 | ID_TOTAL_CURRENT_YEAR_ENERGY = 'total_current_year_energy' 29 | ID_TOTAL_LIFETIME_ENERGY = 'total_lifetime_energy' 30 | 31 | 32 | # Possible Name suffixes 33 | NAME_REALTIME_POWER = 'Realtime Power' 34 | NAME_TOTAL_CURRENT_DAY_ENERGY = 'Total Current Day Energy' 35 | NAME_TOTAL_CURRENT_MONTH_ENERGY = 'Total Current Month Energy' 36 | NAME_TOTAL_CURRENT_YEAR_ENERGY = 'Total Current Year Energy' 37 | NAME_TOTAL_LIFETIME_ENERGY = 'Total Lifetime Energy' 38 | -------------------------------------------------------------------------------- /custom_components/fusion_solar_kiosk/fusion_solar_kiosk_api.py: -------------------------------------------------------------------------------- 1 | """API client for FusionSolar Kiosk.""" 2 | import logging 3 | import html 4 | import json 5 | 6 | from .const import ( 7 | ATTR_DATA, 8 | ATTR_FAIL_CODE, 9 | ATTR_SUCCESS, 10 | ATTR_DATA_REALKPI, 11 | ) 12 | 13 | from requests import get 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | class FusionSolarKioksApi: 18 | def __init__(self, host): 19 | self._host = host 20 | 21 | def getRealTimeKpi(self, id: str): 22 | url = self._host + '/rest/pvms/web/kiosk/v1/station-kiosk-file?kk=' + id 23 | headers = { 24 | 'accept': 'application/json', 25 | } 26 | 27 | try: 28 | response = get(url, headers=headers) 29 | # _LOGGER.debug(response.text) 30 | jsonData = response.json() 31 | 32 | if not jsonData[ATTR_SUCCESS]: 33 | raise FusionSolarKioskApiError(f'Retrieving the data failed with failCode: {jsonData[ATTR_FAIL_CODE]}, data: {jsonData[ATTR_DATA]}') 34 | 35 | # convert encoded html string to JSON 36 | jsonData[ATTR_DATA] = json.loads(html.unescape(jsonData[ATTR_DATA])) 37 | _LOGGER.debug('Received data for ' + id + ': ') 38 | _LOGGER.debug(jsonData[ATTR_DATA][ATTR_DATA_REALKPI]) 39 | return jsonData[ATTR_DATA][ATTR_DATA_REALKPI] 40 | 41 | except KeyError as error: 42 | _LOGGER.error(error) 43 | _LOGGER.error(response.text) 44 | 45 | except FusionSolarKioskApiError as error: 46 | _LOGGER.error(error) 47 | _LOGGER.debug(response.text) 48 | 49 | return { 50 | ATTR_SUCCESS: False 51 | } 52 | 53 | class FusionSolarKioskApiError(Exception): 54 | pass 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant FusionSolar Integration 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) 4 | 5 | **IMPORTANT: This integration is not longer maintained**. You can upgrade to the newer [FusionSolar](https://github.com/tijsverkoyen/HomeAssistant-FusionSolar)-integration. 6 | Which not only supports Kiosk but also the use of an OpenAPI account. The later has actually realtime info. 7 | 8 | Integrate FusionSolar into you Home Assistant. 9 | 10 | FusionSolar has a kiosk mode. When this kiosk mode is enabled we can access 11 | data about our plants through a JSON REST api. 12 | 13 | {% if installed %} 14 | {% if version_installed.replace("v", "").replace(".","") | int < 303 %} 15 | ### No longer maintained 16 | **This integration is not longer maintained.** 17 | I strongly suggest to upgrade to the newer [FusionSolar](https://github.com/tijsverkoyen/HomeAssistant-FusionSolar)-integration. 18 | {% endif %} 19 | 20 | {% if version_installed.replace("v", "").replace(".","") | int < 300 %} 21 | ## Breaking Changes 22 | ### Use the full kiosk url (since v3.0.0) 23 | Your current configuration should be updated. Before v3.0.0 we used the kiosk id. 24 | Starting with v3.0.0 the full kiosk url should be used: 25 | 26 | sensor: 27 | - platform: fusion_solar_kiosk 28 | kiosks: 29 | - url: "REPLACE THIS WITH THE KIOSK URL" 30 | name: "A readable name for the plant" 31 | 32 | See the "Configuration" section for more details 33 | {% endif %} 34 | {% endif %} 35 | 36 | ## Remark 37 | **In kiosk mode the "realtime" data is not really realtime, it is cached at FusionSolars end for 30 minutes.** 38 | 39 | If you need more accurate information you can use [Home Assistant FusionSolar OpenAPI Integration](https://github.com/olibos/Home-Assistant-FusionSolar-OpenApi/) by @olibos. This integration requires an OpenAPI account. 40 | 41 | ## Installation 42 | At this point the integration is not part of the default HACS repositories, so 43 | you will need to add this repository as a custom repository in HACS. 44 | 45 | When this is done, just install the repository. 46 | 47 | 48 | ## Configuration 49 | 50 | The configuration of this integration happens in a few steps: 51 | 52 | ### Enable kiosk mode 53 | 1. Sign in on the Huawei FusionSolar portal: [https://eu5.fusionsolar.huawei.com/](https://eu5.fusionsolar.huawei.com/). 54 | 2. Select your plant if needed. 55 | 2. At the top there is a button: "Kiosk", click it. 56 | 3. An overlay will open, and you need to enable the kiosk view by enabling the toggle. 57 | 4. Note down the url that is shown. 58 | 59 | ### Add into configuration 60 | Open your `configuration.yaml`, add the code below: 61 | 62 | sensor: 63 | - platform: fusion_solar_kiosk 64 | kiosks: 65 | - url: "REPLACE THIS WITH THE KIOSK URL" 66 | name: "A readable name for the plant" 67 | 68 | ### Use secrets 69 | I strongly advise to store the unique urls as a secret. The kiosk url is public, 70 | so anybody with the link can access your data. Be careful when sharing this. 71 | 72 | More information on secrets: [Storing secrets](https://www.home-assistant.io/docs/configuration/secrets/). 73 | 74 | ### Multiple plants 75 | You can configure multiple plants: 76 | 77 | sensor: 78 | - platform: fusion_solar_kiosk 79 | kiosks: 80 | - url: "KIOSK URL XXXXX" 81 | name: "A readable name for plant XXXXX" 82 | - url: "KIOSK URL YYYYY" 83 | name: "A readable name for plant YYYYY" 84 | -------------------------------------------------------------------------------- /custom_components/fusion_solar_kiosk/sensor.py: -------------------------------------------------------------------------------- 1 | """FusionSolar Kiosk sensor.""" 2 | from .fusion_solar_kiosk_api import * 3 | import homeassistant.helpers.config_validation as cv 4 | import logging 5 | import voluptuous as vol 6 | 7 | from . import FusionSolarKioskEnergyEntity, FusionSolarKioskPowerEntity 8 | 9 | from datetime import timedelta 10 | from homeassistant.components.sensor import PLATFORM_SCHEMA 11 | from homeassistant.const import ( 12 | CONF_NAME, 13 | ) 14 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 15 | from .const import * 16 | 17 | import re 18 | from urllib.parse import urlparse 19 | 20 | KIOSK_SCHEMA = vol.Schema( 21 | { 22 | vol.Required(CONF_KIOSK_URL): cv.string, 23 | vol.Required(CONF_NAME): cv.string 24 | } 25 | ) 26 | 27 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 28 | { 29 | vol.Required(CONF_KIOSKS): vol.All(cv.ensure_list, [KIOSK_SCHEMA]), 30 | } 31 | ) 32 | 33 | _LOGGER = logging.getLogger(__name__) 34 | 35 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 36 | async def async_update_data(): 37 | """Fetch data""" 38 | data = {} 39 | for kioskConfig in config[CONF_KIOSKS]: 40 | kiosk = Kiosk(kioskConfig['url'], kioskConfig['name']) 41 | api = FusionSolarKioksApi(kiosk.apiUrl()) 42 | data[kiosk.id] = { 43 | ATTR_DATA_REALKPI: await hass.async_add_executor_job(api.getRealTimeKpi, kiosk.id) 44 | } 45 | return data 46 | 47 | coordinator = DataUpdateCoordinator( 48 | hass, 49 | _LOGGER, 50 | name='FusionSolarKiosk', 51 | update_method=async_update_data, 52 | update_interval=timedelta(seconds=300), 53 | ) 54 | 55 | # Fetch initial data so we have data when entities subscribe 56 | await coordinator.async_refresh() 57 | 58 | for kioskConfig in config[CONF_KIOSKS]: 59 | kiosk = Kiosk(kioskConfig['url'], kioskConfig['name']) 60 | 61 | async_add_entities([ 62 | FusionSolarKioskSensorRealtimePower( 63 | coordinator, 64 | kiosk.id, 65 | kiosk.name, 66 | ID_REALTIME_POWER, 67 | NAME_REALTIME_POWER, 68 | ATTR_REALTIME_POWER, 69 | ), 70 | FusionSolarKioskSensorTotalCurrentDayEnergy( 71 | coordinator, 72 | kiosk.id, 73 | kiosk.name, 74 | ID_TOTAL_CURRENT_DAY_ENERGY, 75 | NAME_TOTAL_CURRENT_DAY_ENERGY, 76 | ATTR_TOTAL_CURRENT_DAY_ENERGY, 77 | ), 78 | FusionSolarKioskSensorTotalCurrentMonthEnergy( 79 | coordinator, 80 | kiosk.id, 81 | kiosk.name, 82 | ID_TOTAL_CURRENT_MONTH_ENERGY, 83 | NAME_TOTAL_CURRENT_MONTH_ENERGY, 84 | ATTR_TOTAL_CURRENT_MONTH_ENERGY, 85 | ), 86 | FusionSolarKioskSensorTotalCurrentYearEnergy( 87 | coordinator, 88 | kiosk.id, 89 | kiosk.name, 90 | ID_TOTAL_CURRENT_YEAR_ENERGY, 91 | NAME_TOTAL_CURRENT_YEAR_ENERGY, 92 | ATTR_TOTAL_CURRENT_YEAR_ENERGY, 93 | ), 94 | FusionSolarKioskSensorTotalLifetimeEnergy( 95 | coordinator, 96 | kiosk.id, 97 | kiosk.name, 98 | ID_TOTAL_LIFETIME_ENERGY, 99 | NAME_TOTAL_LIFETIME_ENERGY, 100 | ATTR_TOTAL_LIFETIME_ENERGY, 101 | ) 102 | ]) 103 | 104 | class Kiosk: 105 | def __init__(self, url, name): 106 | self.url = url 107 | self.name = name 108 | self._parseId() 109 | 110 | def _parseId(self): 111 | id = re.search("\?kk=(.*)", self.url).group(1) 112 | _LOGGER.debug('calculated KioskId: ' + id) 113 | self.id = id 114 | 115 | def apiUrl(self): 116 | url = urlparse(self.url) 117 | apiUrl = (url.scheme + "://" + url.netloc) 118 | _LOGGER.debug('calculated API base url for ' + self.id + ': ' + apiUrl) 119 | return apiUrl 120 | 121 | 122 | class FusionSolarKioskSensorRealtimePower(FusionSolarKioskPowerEntity): 123 | pass 124 | 125 | class FusionSolarKioskSensorTotalCurrentDayEnergy(FusionSolarKioskEnergyEntity): 126 | pass 127 | 128 | class FusionSolarKioskSensorTotalCurrentMonthEnergy(FusionSolarKioskEnergyEntity): 129 | pass 130 | 131 | class FusionSolarKioskSensorTotalCurrentYearEnergy(FusionSolarKioskEnergyEntity): 132 | pass 133 | 134 | class FusionSolarKioskSensorTotalLifetimeEnergy(FusionSolarKioskEnergyEntity): 135 | pass 136 | -------------------------------------------------------------------------------- /custom_components/fusion_solar_kiosk/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom integration to integrate FusionSolar Kiosk with Home Assistant. 3 | """ 4 | import logging 5 | 6 | from homeassistant.core import Config, HomeAssistant 7 | from homeassistant.components.sensor import STATE_CLASS_TOTAL_INCREASING, SensorEntity 8 | from homeassistant.const import ( 9 | DEVICE_CLASS_ENERGY, 10 | DEVICE_CLASS_POWER, 11 | ENERGY_KILO_WATT_HOUR, 12 | POWER_KILO_WATT, 13 | ) 14 | from homeassistant.helpers.entity import Entity 15 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 16 | from .const import ( 17 | ATTR_DATA_REALKPI, 18 | ATTR_REALTIME_POWER, 19 | ATTR_TOTAL_LIFETIME_ENERGY, 20 | DOMAIN, 21 | ) 22 | 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | async def async_setup(hass: HomeAssistant, config: Config) -> bool: 28 | """Set up the FusionSolar Kiosk component.""" 29 | return True 30 | 31 | def isfloat(num) -> bool: 32 | try: 33 | float(num) 34 | return True 35 | except ValueError: 36 | return False 37 | 38 | class FusionSolarKioskEnergyEntity(CoordinatorEntity, SensorEntity): 39 | """Base class for all FusionSolarKioskEnergy entities.""" 40 | def __init__( 41 | self, 42 | coordinator, 43 | kioskId, 44 | kioskName, 45 | idSuffix, 46 | nameSuffix, 47 | attribute, 48 | ): 49 | """Initialize the entity""" 50 | super().__init__(coordinator) 51 | self._kioskId = kioskId 52 | self._kioskName = kioskName 53 | self._idSuffix = idSuffix 54 | self._nameSuffix = nameSuffix 55 | self._attribute = attribute 56 | 57 | @property 58 | def device_class(self) -> str: 59 | return DEVICE_CLASS_ENERGY 60 | 61 | @property 62 | def name(self) -> str: 63 | return f'{self._kioskName} ({self._kioskId}) - {self._nameSuffix}' 64 | 65 | @property 66 | def state(self) -> float: 67 | # It seems like Huawei Fusion Solar returns some invalid data for the lifetime energy just before midnight 68 | # Therefore we validate if the new value is higher than the current value 69 | # Data can be invalid in two ways, negative glitch or positive glitch even when no power is reported, disvard if so. 70 | if ATTR_TOTAL_LIFETIME_ENERGY == self._attribute: 71 | # Grab the current data 72 | entity = self.hass.states.get(self.entity_id) 73 | 74 | if entity is not None: 75 | current_value = entity.state 76 | power = self.coordinator.data[self._kioskId][ATTR_DATA_REALKPI][ATTR_REALTIME_POWER] 77 | 78 | if (power) == '0.00': 79 | _LOGGER.debug(f'{self.entity_id}: new energy value is discarded if no power is reported ({entity.state}), so not updating.') 80 | return float(current_value) 81 | 82 | return float(self.coordinator.data[self._kioskId][ATTR_DATA_REALKPI][self._attribute]) if self.coordinator.data[self._kioskId][ATTR_DATA_REALKPI] else None 83 | 84 | @property 85 | def unique_id(self) -> str: 86 | return f'{DOMAIN}-{self._kioskId}-{self._idSuffix}' 87 | 88 | @property 89 | def unit_of_measurement(self) -> str: 90 | return ENERGY_KILO_WATT_HOUR 91 | 92 | @property 93 | def state_class(self) -> str: 94 | return STATE_CLASS_TOTAL_INCREASING 95 | 96 | @property 97 | def native_value(self) -> str: 98 | return self.state if self.state else '' 99 | 100 | @property 101 | def native_unit_of_measurement(self) -> str: 102 | return self.unit_of_measurement 103 | 104 | 105 | class FusionSolarKioskPowerEntity(CoordinatorEntity, Entity): 106 | """Base class for all FusionSolarKioskEnergy entities.""" 107 | def __init__( 108 | self, 109 | coordinator, 110 | kioskId, 111 | kioskName, 112 | idSuffix, 113 | nameSuffix, 114 | attribute, 115 | ): 116 | """Initialize the entity""" 117 | super().__init__(coordinator) 118 | self._kioskId = kioskId 119 | self._kioskName = kioskName 120 | self._idSuffix = idSuffix 121 | self._nameSuffix = nameSuffix 122 | self._attribute = attribute 123 | 124 | @property 125 | def device_class(self): 126 | return DEVICE_CLASS_POWER 127 | 128 | @property 129 | def name(self): 130 | return f'{self._kioskName} ({self._kioskId}) - {self._nameSuffix}' 131 | 132 | @property 133 | def state(self): 134 | return float(self.coordinator.data[self._kioskId][ATTR_DATA_REALKPI][self._attribute]) if self.coordinator.data[self._kioskId][ATTR_DATA_REALKPI] else None 135 | 136 | @property 137 | def unique_id(self) -> str: 138 | return f'{DOMAIN}-{self._kioskId}-{self._idSuffix}' 139 | 140 | @property 141 | def unit_of_measurement(self): 142 | return POWER_KILO_WATT 143 | --------------------------------------------------------------------------------