Skip to content

Commit

Permalink
Merge pull request #1 from tijsverkoyen/config-flow
Browse files Browse the repository at this point in the history
Config flow
  • Loading branch information
tijsverkoyen authored Oct 20, 2022
2 parents e3c9752 + b1f9152 commit 5933fad
Show file tree
Hide file tree
Showing 17 changed files with 518 additions and 374 deletions.
66 changes: 9 additions & 57 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,74 +1,26 @@
# Home Assistant FusionSolar Integration

[![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](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)
26 changes: 26 additions & 0 deletions custom_components/fusion_solar/__init__.py
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
86 changes: 86 additions & 0 deletions custom_components/fusion_solar/config_flow.py
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,
)
Original file line number Diff line number Diff line change
@@ -1,25 +1,12 @@
"""Constants for FusionSolar Kiosk."""
"""Constants for FusionSolar."""
# Base constants
DOMAIN = 'fusion_solar_kiosk'

DOMAIN = 'fusion_solar'

# Configuration
CONF_KIOSKS = 'kiosks'
CONF_KIOSK_URL = 'url'


# Fusion Solar Kiosk API response attributes
ATTR_DATA = 'data'
ATTR_FAIL_CODE = 'failCode'
ATTR_SUCCESS = 'success'
ATTR_DATA_REALKPI = 'realKpi'
# 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'

CONF_TYPE = 'type'
CONF_TYPE_KIOSK = 'kiosk'
CONF_TYPE_OPENAPI = 'openapi'

# Possible ID suffixes
ID_REALTIME_POWER = 'realtime_power'
Expand All @@ -28,7 +15,6 @@
ID_TOTAL_CURRENT_YEAR_ENERGY = 'total_current_year_energy'
ID_TOTAL_LIFETIME_ENERGY = 'total_lifetime_energy'


# Possible Name suffixes
NAME_REALTIME_POWER = 'Realtime Power'
NAME_TOTAL_CURRENT_DAY_ENERGY = 'Total Current Day Energy'
Expand Down
12 changes: 12 additions & 0 deletions custom_components/fusion_solar/fusion_solar/const.py
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 custom_components/fusion_solar/fusion_solar/energy_sensor.py
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
24 changes: 24 additions & 0 deletions custom_components/fusion_solar/fusion_solar/kiosk.py
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
Loading

0 comments on commit 5933fad

Please sign in to comment.