-
-
Notifications
You must be signed in to change notification settings - Fork 32
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from tijsverkoyen/config-flow
Config flow
- Loading branch information
Showing
17 changed files
with
518 additions
and
374 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,74 +1,26 @@ | ||
# Home Assistant FusionSolar Integration | ||
|
||
[](https://github.com/custom-components/hacs) | ||
|
||
Integrate FusionSolar into you Home Assistant. | ||
|
||
FusionSolar has a kiosk mode. When this kiosk mode is enabled we can access | ||
data about our plants through a JSON REST api. | ||
|
||
{% if installed %} | ||
{% if version_installed.replace("v", "").replace(".","") | int < 300 %} | ||
## Breaking Changes | ||
### Use the full kiosk url (since v3.0.0) | ||
Your current configuration should be updated. Before v3.0.0 we used the kiosk id. | ||
Starting with v3.0.0 the full kiosk url should be used: | ||
|
||
sensor: | ||
- platform: fusion_solar_kiosk | ||
kiosks: | ||
- url: "REPLACE THIS WITH THE KIOSK URL" | ||
name: "A readable name for the plant" | ||
|
||
See the "Configuration" section for more details | ||
{% endif %} | ||
{% endif %} | ||
|
||
## Remark | ||
**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 can use [Home Assistant FusionSolar OpenAPI Integration](https://github.com/olibos/Home-Assistant-FusionSolar-OpenApi/) by @olibos. This integration requires an OpenAPI account. | ||
The integration is able to work with Kiosk mode, or with an OpenAPI account, see below for more details. | ||
|
||
## Installation | ||
At this point the integration is not part of the default HACS repositories, so | ||
you will need to add this repository as a custom repository in HACS. | ||
|
||
When this is done, just install the repository. | ||
|
||
The configuration happens in the configuration flow when you add the integration. | ||
|
||
## Configuration | ||
### Kiosk | ||
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 configuration of this integration happens in a few steps: | ||
|
||
### Enable kiosk mode | ||
1. Sign in on the Huawei FusionSolar portal: [https://eu5.fusionsolar.huawei.com/](https://eu5.fusionsolar.huawei.com/). | ||
2. Select your plant if needed. | ||
2. At the top there is a button: "Kiosk", click it. | ||
3. An overlay will open, and you need to enable the kiosk view by enabling the toggle. | ||
4. Note down the url that is shown. | ||
|
||
### Add into configuration | ||
Open your `configuration.yaml`, add the code below: | ||
|
||
sensor: | ||
- platform: fusion_solar_kiosk | ||
kiosks: | ||
- url: "REPLACE THIS WITH THE KIOSK URL" | ||
name: "A readable name for the plant" | ||
|
||
### Use secrets | ||
I strongly advise to store the unique urls as a secret. The kiosk url is public, | ||
so anybody with the link can access your data. Be careful when sharing this. | ||
|
||
More information on secrets: [Storing secrets](https://www.home-assistant.io/docs/configuration/secrets/). | ||
**In kiosk mode the "realtime" data is not really realtime, it is cached at FusionSolars end for 30 minutes.** | ||
|
||
### Multiple plants | ||
You can configure multiple plants: | ||
If you need more accurate information you should use the OpenAPI mode. | ||
|
||
sensor: | ||
- platform: fusion_solar_kiosk | ||
kiosks: | ||
- url: "KIOSK URL XXXXX" | ||
name: "A readable name for plant XXXXX" | ||
- url: "KIOSK URL YYYYY" | ||
name: "A readable name for plant YYYYY" | ||
### 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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
""" | ||
Custom integration to integrate FusionSolar with Home Assistant. | ||
""" | ||
from homeassistant.core import HomeAssistant, Config | ||
from homeassistant.config_entries import ConfigEntry | ||
|
||
from .const import DOMAIN | ||
|
||
|
||
async def async_setup(hass: HomeAssistant, config: Config) -> bool: | ||
"""Set up the FusionSolar component from yaml configuration.""" | ||
hass.data.setdefault(DOMAIN, {}) | ||
return True | ||
|
||
|
||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Set up the FusionSolar component from a ConfigEntry.""" | ||
hass.data.setdefault(DOMAIN, {}) | ||
|
||
hass.data[DOMAIN][entry.entry_id] = entry.data | ||
|
||
# Forward the setup to the sensor platform. | ||
hass.async_create_task( | ||
hass.config_entries.async_forward_entry_setup(entry, "sensor") | ||
) | ||
return True |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
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 | ||
|
||
import voluptuous as vol | ||
import logging | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
|
||
class FusionSolarConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): | ||
data: Optional[Dict[str, Any]] = { | ||
CONF_KIOSKS: [], | ||
} | ||
|
||
async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None): | ||
"""Invoked when a user initiates a flow via the user interface.""" | ||
return await self.async_step_choose_type(user_input) | ||
|
||
async def async_step_choose_type(self, user_input: Optional[Dict[str, Any]] = None): | ||
_LOGGER.debug(f'async_step_choose_type: {user_input}') | ||
errors: Dict[str, str] = {} | ||
|
||
if user_input is not None: | ||
if user_input[CONF_TYPE] == CONF_TYPE_KIOSK: | ||
return await self.async_step_kiosk() | ||
elif user_input[CONF_TYPE] == CONF_TYPE_OPENAPI: | ||
return await self.async_step_openapi() | ||
else: | ||
errors['base'] = 'invalid_type' | ||
|
||
type_listing = { | ||
CONF_TYPE_KIOSK: 'Kiosk', | ||
CONF_TYPE_OPENAPI: 'OpenAPI', | ||
} | ||
|
||
return self.async_show_form( | ||
step_id="choose_type", | ||
data_schema=vol.Schema({ | ||
vol.Required(CONF_TYPE, default=CONF_TYPE_KIOSK): vol.In(type_listing) | ||
}), | ||
errors=errors, | ||
) | ||
|
||
async def async_step_kiosk(self, user_input: Optional[Dict[str, Any]] = None): | ||
_LOGGER.debug(f'async_step_kiosk: {user_input}') | ||
errors: Dict[str, str] = {} | ||
|
||
if user_input is not None: | ||
self.data[CONF_KIOSKS].append({ | ||
CONF_NAME: user_input[CONF_NAME], | ||
CONF_URL: user_input[CONF_URL], | ||
}) | ||
|
||
if user_input.get("add_another", False): | ||
return await self.async_step_kiosk() | ||
|
||
return self.async_create_entry( | ||
title="Fusion Solar", | ||
data=self.data, | ||
) | ||
|
||
return self.async_show_form( | ||
step_id="kiosk", | ||
data_schema=vol.Schema({ | ||
vol.Required(CONF_NAME): str, | ||
vol.Required(CONF_URL): str, | ||
vol.Optional("add_another"): bool, | ||
}), | ||
errors=errors, | ||
) | ||
|
||
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' | ||
|
||
return self.async_show_form( | ||
step_id="openapi", | ||
data_schema=vol.Schema({ | ||
}), | ||
errors=errors, | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
# Fusion Solar Kiosk API response attributes | ||
ATTR_DATA = 'data' | ||
ATTR_DATA_REALKPI = 'realKpi' | ||
ATTR_FAIL_CODE = 'failCode' | ||
ATTR_SUCCESS = 'success' | ||
|
||
# Data attributes | ||
ATTR_REALTIME_POWER = 'realTimePower' | ||
ATTR_TOTAL_CURRENT_DAY_ENERGY = 'dailyEnergy' | ||
ATTR_TOTAL_CURRENT_MONTH_ENERGY = 'monthEnergy' | ||
ATTR_TOTAL_CURRENT_YEAR_ENERGY = 'yearEnergy' | ||
ATTR_TOTAL_LIFETIME_ENERGY = 'cumulativeEnergy' |
113 changes: 113 additions & 0 deletions
113
custom_components/fusion_solar/fusion_solar/energy_sensor.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
import logging | ||
|
||
from homeassistant.helpers.update_coordinator import CoordinatorEntity | ||
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 | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
|
||
def isfloat(num) -> bool: | ||
try: | ||
float(num) | ||
return True | ||
except ValueError: | ||
return False | ||
|
||
|
||
class FusionSolarEnergySensor(CoordinatorEntity, SensorEntity): | ||
"""Base class for all FusionSolarEnergySensor sensors.""" | ||
|
||
def __init__( | ||
self, | ||
coordinator, | ||
unique_id, | ||
name, | ||
attribute, | ||
data_name | ||
): | ||
"""Initialize the entity""" | ||
super().__init__(coordinator) | ||
self._unique_id = unique_id | ||
self._name = name | ||
self._attribute = attribute | ||
self._data_name = data_name | ||
|
||
@property | ||
def device_class(self) -> str: | ||
return DEVICE_CLASS_ENERGY | ||
|
||
@property | ||
def unique_id(self) -> str: | ||
return self._unique_id | ||
|
||
@property | ||
def name(self) -> str: | ||
return self._name | ||
|
||
@property | ||
def state(self) -> float: | ||
# It seems like Huawei Fusion Solar returns some invalid data for the lifetime energy just before midnight | ||
# Therefore we validate if the new value is higher than the current value | ||
if ATTR_TOTAL_LIFETIME_ENERGY == self._attribute: | ||
# Grab the current data | ||
entity = self.hass.states.get(self.entity_id) | ||
|
||
if entity is not None: | ||
current_value = entity.state | ||
new_value = self.coordinator.data[self._data_name][ATTR_DATA_REALKPI][self._attribute] | ||
|
||
if not isfloat(new_value): | ||
_LOGGER.warning(f'{self.entity_id}: new value ({new_value}) is not a float, so not updating.') | ||
return float(current_value) | ||
|
||
if not isfloat(current_value): | ||
_LOGGER.warning(f'{self.entity_id}: current value ({current_value}) is not a float, send 0.') | ||
return 0 | ||
|
||
if float(new_value) < float(current_value): | ||
_LOGGER.debug( | ||
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]: | ||
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]) | ||
|
||
@property | ||
def unit_of_measurement(self) -> str: | ||
return ENERGY_KILO_WATT_HOUR | ||
|
||
@property | ||
def state_class(self) -> str: | ||
return STATE_CLASS_TOTAL_INCREASING | ||
|
||
@property | ||
def native_value(self) -> str: | ||
return self.state if self.state else '' | ||
|
||
@property | ||
def native_unit_of_measurement(self) -> str: | ||
return self.unit_of_measurement | ||
|
||
|
||
class FusionSolarEnergySensorTotalCurrentDay(FusionSolarEnergySensor): | ||
pass | ||
|
||
|
||
class FusionSolarEnergySensorTotalCurrentMonth(FusionSolarEnergySensor): | ||
pass | ||
|
||
|
||
class FusionSolarEnergySensorTotalCurrentYear(FusionSolarEnergySensor): | ||
pass | ||
|
||
|
||
class FusionSolarEnergySensorTotalLifetime(FusionSolarEnergySensor): | ||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import re | ||
import logging | ||
|
||
from urllib.parse import urlparse | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
|
||
class Kiosk: | ||
def __init__(self, url, name): | ||
self.url = url | ||
self.name = name | ||
self._parseId() | ||
|
||
def _parseId(self): | ||
id = re.search("\?kk=(.*)", self.url).group(1) | ||
_LOGGER.debug('calculated KioskId: ' + id) | ||
self.id = id | ||
|
||
def apiUrl(self): | ||
url = urlparse(self.url) | ||
apiUrl = (url.scheme + "://" + url.netloc) | ||
_LOGGER.debug('calculated API base url for ' + self.id + ': ' + apiUrl) | ||
return apiUrl |
Oops, something went wrong.