Skip to content

Commit

Permalink
Merge pull request #29 from nilrog/async-io
Browse files Browse the repository at this point in the history
Improvment to Async I/O to fix warning in HA 0.109 and performance improvements
  • Loading branch information
safepay committed May 5, 2020
2 parents 9ced175 + 3099434 commit 65ffd89
Show file tree
Hide file tree
Showing 2 changed files with 168 additions and 93 deletions.
81 changes: 77 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This component simplifies the integration of a Fronius inverter and optional Pow
* creates up to 22 individual sensors for easy display or use in automations
* converts Wh to kWh
* rounds values to 2 decimal places
* converts daily, yearly and total energy data to kWh or MWh (user-configurable)
* converts yearly and total energy data to kWh or MWh (user-configurable)
* optionally sums values if you have more than one inverter

If you have a SmartMeter installed this component:
Expand Down Expand Up @@ -88,6 +88,7 @@ variable | required | type | default | description
-------- | -------- | ---- | ------- | -----------
``ip_address`` | yes | string | | The local IP address of your Fronius Inverter.
``name`` | no | string | ``Fronius`` | The preferred name of your Fronius Inverter.
``scan_interval`` | no | string | 60 | The interval to query the Fronius Inverter for data.
``powerflow`` | no | boolean | ``False`` | Set to ``True`` if you have a PowerFlow meter (SmartMeter) to add ``grid_usage``, ``house_load``, ``panel_status``, ``rel_autonomy`` and ``rel_selfconsumption`` sensors.
``smartmeter`` | no | boolean | ``False`` | Set to ``True`` if you have a SmartMeter to add ``smartmeter_current_ac_phase_one``, ``smartmeter_current_ac_phase_two``, ``smartmeter_current_ac_phase_three``, ``smartmeter_voltage_ac_phase_one``, ``smartmeter_voltage_ac_phase_two``, ``smartmeter_voltage_ac_phase_three``, ``smartmeter_energy_ac_consumed`` and ``smartmeter_energy_ac_sold`` sensors.
``smartmeter_device_id`` | no | string | ``0`` | The Device ID of your Fronius SmartMeter.
Expand All @@ -102,18 +103,90 @@ variable | required | type | default | description

Follow the instructions for installation on [Github](https://github.com/gurbyz/power-wheel-card/tree/master)

Add the following to the top of your Lovelace config in the Raw Config Editor:
Add the following to the Lovelace resource config in the Raw Config Editor:
```yaml
resources:
- type: module
url: /local/custom_ui/power-wheel-card.js?v=1
```
Then add and configure a basic custom card:
Then add and configure a basic custom card for displaying the power view:
```yaml
type: 'custom:power-wheel-card'
title: Solar Power
production_is_positive: false
solar_power_entity: sensor.fronius_panel_status
grid_power_entity: sensor.fronius_grid_usage
home_energy_entity: sensor.fronius_house_load
```

If you also want to have an energy view in the Power Wheel you need three more sensors. And these sensors
will be different depending on if your smart meter is installed in the feed-in-path or consumption-path.

This is the configuration you need to add to your Power Wheel config in Lovelace. This will be the same
regardless of where your smart meter is installed.
```yaml
solar_energy_entity: sensor.fronius_day_energy
grid_energy_consumption_entity: sensor.grid_consumed_energy_day
grid_energy_production_entity: sensor.grid_sold_energy_day
```

Next you need to create two new sensors for grid energy consumption and production. And this is what
will differ depending on your smart meter installation.

1. **Feed-in path.** This is the simplest setup. With the smart meter in the feed-in path (next to your main
electricity meter) it already knows what you are consuming and producing. But it counts the accumulative
values. And we need daily vaules, in kWh, to match the sensor.fronius_day_energy.

Create the two sensors for daily consumption and production.
Note: if smart meter energy sensors are not in kWh you need to convert those two to kWh using template sensors.
```yaml
utility_meter:
# calculate daily energy consumed from grid (input must be in kWh)
grid_consumed_energy_day:
source: sensor.fronius_smartmeter_energy_ac_consumed
cycle: daily
# calculate daily energy sold to grid (input must be in kWh)
grid_sold_energy_day:
source: sensor.fronius_smartmeter_energy_ac_sold
cycle: daily
```

2. **Consumption path.** With the smart meter in the consumption path (between the inverter and your consumers)
it cannot know how much you are consuming or producing from/to the grid. So the only sensor that will have
a value is the sensor.fronius_smartmeter_energy_ac_consumed. But it will not show what is consumed from the
grid. It will show how much your house has consumed. So we need to create sensors that will give us what
the Power Wheel needs.
```yaml
utility_meter:
# convert consumed energy to daily energy (this is what the house consumes)
house_energy_day:
source: sensor.fronius_smartmeter_energy_ac_consumed
cycle: daily
```
```yaml
sensor:
- platform: template
sensors:
# calculate grid energy (negative will be to grid, positive from grid)
grid_energy_day:
friendly_name: 'Grid energy'
unit_of_measurement: 'kWh'
value_template: '{{ (states("sensor.fronius_day_energy") | float - states("sensor.house_energy_day") | float) * -1 }}'
# calculate energy consumed from grid
grid_consumed_energy_day:
unit_of_measurement: 'kWh'
value_template: >
{% if states("sensor.grid_energy_day") | float > 0 -%}
{{ states("sensor.grid_energy_day") | float }}
{%- else -%}
{{ 0 | float }}
{%- endif %}
# calculate energy produced to grid
grid_sold_energy_day:
unit_of_measurement: 'kWh'
value_template: >
{% if states("sensor.grid_energy_day") | float < 0 -%}
{{ states("sensor.grid_energy_day") | float * -1 }}
{%- else -%}
{{ 0 | float }}
{%- endif %}
```
180 changes: 91 additions & 89 deletions custom_components/fronius_inverter/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
import requests
import voluptuous as vol
import json
import aiohttp

from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
CONF_MONITORED_CONDITIONS, CONF_NAME, ATTR_ATTRIBUTION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL, ATTR_ATTRIBUTION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
)
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
from homeassistant.util.dt import utcnow as dt_utcnow, as_local
from homeassistant.helpers.sun import get_astral_event_date

Expand All @@ -35,12 +37,12 @@
CONF_SMARTMETER = 'smartmeter'
CONF_SMARTMETER_DEVICE_ID = 'smartmeter_device_id'

DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)

SCOPE_TYPES = ['Device', 'System']
UNIT_TYPES = ['Wh', 'kWh', 'MWh']
POWER_UNIT_TYPES = ['W', 'kW', 'MW']

MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)

# Key: ['device', 'system', 'json_key', 'name', 'unit', 'convert_units', 'icon']
SENSOR_TYPES = {
'year_energy': ['inverter', True, 'YEAR_ENERGY', 'Year Energy', 'MWh', 'energy', 'mdi:solar-power'],
Expand Down Expand Up @@ -87,6 +89,7 @@
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Fronius inverter sensor."""

session = async_get_clientsession(hass)
ip_address = config[CONF_IP_ADDRESS]
device_id = config.get(CONF_DEVICE_ID)
scope = config.get(CONF_SCOPE)
Expand All @@ -96,31 +99,27 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
powerflow = config.get(CONF_POWERFLOW)
smartmeter = config.get(CONF_SMARTMETER)
smartmeter_device_id = config.get(CONF_SMARTMETER_DEVICE_ID)
scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)

inverter_data = InverterData(ip_address, device_id, scope)

try:
await inverter_data.async_update()
except ValueError as err:
_LOGGER.error("Received data error from Fronius inverter: %s", err)
return

fetchers = []
inverter_data = InverterData(session, ip_address, device_id, scope)
fetchers.append(inverter_data)
if powerflow:
powerflow_data = PowerflowData(ip_address)
try:
await powerflow_data.async_update()
except ValueError as err:
_LOGGER.error("Received data error from Fronius Powerflow: %s", err)
return

powerflow_data = PowerflowData(session, ip_address, None, None)
fetchers.append(powerflow_data)
if smartmeter:
smartmeter_data = SmartMeterData(ip_address, smartmeter_device_id)
try:
await smartmeter_data.async_update()
except ValueError as err:
_LOGGER.error("Received data error from Fronius SmartMeter: %s", err)
return
smartmeter_data = SmartMeterData(session, ip_address, smartmeter_device_id, "Device")
fetchers.append(smartmeter_data)

def fetch_executor(fetcher):
async def fetch_data(*_):
await fetcher.async_update()
return fetch_data

for fetcher in fetchers:
fetch = fetch_executor(fetcher)
await fetch()
async_track_time_interval(hass, fetch, scan_interval)

dev = []
for variable in config[CONF_MONITORED_CONDITIONS]:
Expand Down Expand Up @@ -220,15 +219,14 @@ def icon(self):
"""Icon to use in the frontend, if any."""
return self._icon

@property
def should_poll(self):
"""Device should not be polled, returns False."""
return False

async def async_update(self, utcnow=None):
"""Get the latest data from inverter and update the states."""

# Prevent errors when data not present at night but retain long term states
await self._data.async_update()
if not self._data:
_LOGGER.error("Didn't receive data from the inverter")
return

state = None
if self._data.latest_data and (self._json_key in self._data.latest_data):
_LOGGER.debug("Device: {}".format(self._device))
Expand Down Expand Up @@ -285,6 +283,14 @@ async def async_update(self, utcnow=None):
_LOGGER.debug("Latest data: {}".format(self._data.latest_data))
_LOGGER.debug("State converted ({})".format(self._state))

async def async_added_to_hass(self):
"""Register at data provider for updates."""
await self._data.register(self)

def __hash__(self):
"""Hash sensor by hashing its name."""
return hash(self.name)

def find_start_time(self, now):
"""Return sunrise or start_time if given."""
sunrise = get_astral_event_date(self.hass, SUN_EVENT_SUNRISE, now.date())
Expand All @@ -295,20 +301,45 @@ def find_stop_time(self, now):
sunset = get_astral_event_date(self.hass, SUN_EVENT_SUNSET, now.date())
return sunset

class InverterData:
"""Handle Fronius API object and limit updates."""
class FroniusFetcher:
"""Handle Fronius API requests."""

def __init__(self, ip_address, device_id, scope):
def __init__(self, session, ip_address, device_id, scope):
"""Initialize the data object."""
self._session = session
self._ip_address = ip_address
self._device_id = device_id
self._scope = scope
self._data = None
self._sensors = set()

def _build_url(self):
"""Build the URL for the requests."""
url = _INVERTERRT.format(self._ip_address, self._scope, self._device_id)
_LOGGER.debug("Fronius Inverter URL: %s", url)
return url
async def async_update(self):
"""Retrieve and update latest state."""
try:
await self._update()
except aiohttp.ClientConnectionError:
_LOGGER.error("Failed to update: connection error")
except asyncio.TimeoutError:
_LOGGER.error("Failed to update: request timeout")
except ValueError:
_LOGGER.error("Failed to update: invalid response received")

# Schedule an update for all included sensors
for sensor in self._sensors:
sensor.async_schedule_update_ha_state(True)

async def fetch_data(self, url):
"""Retrieve data from inverter in async manner."""
_LOGGER.debug("Requesting data from URL: %s", url)
try:
response = await self._session.get(url, timeout=10)
if response.status != 200:
raise ValueError
json_response = await response.json()
_LOGGER.debug("Got data from URL: %s\n%s", url, json_response)
return json_response
except aiohttp.ClientResponseError:
raise ValueError

@property
def latest_data(self):
Expand All @@ -317,77 +348,48 @@ def latest_data(self):
return self._data
return None

@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self):
async def register(self, sensor):
"""Register child sensor for update subscriptions."""
self._sensors.add(sensor)

class InverterData(FroniusFetcher):
"""Handle Fronius API object and limit updates."""

def _build_url(self):
"""Build the URL for the requests."""
url = _INVERTERRT.format(self._ip_address, self._scope, self._device_id)
_LOGGER.debug("Fronius Inverter URL: %s", url)
return url

async def _update(self):
"""Get the latest data from inverter."""
_LOGGER.debug("Requesting inverter data")
try:
result = requests.get(self._build_url(), timeout=10).json()
self._data = result['Body']['Data']
except (requests.exceptions.RequestException) as error:
_LOGGER.error("Unable to connect to Fronius: %s", error)
self._data = None
self._data = (await self.fetch_data(self._build_url()))['Body']['Data']

class PowerflowData:
class PowerflowData(FroniusFetcher):
"""Handle Fronius API object and limit updates."""

def __init__(self, ip_address):
"""Initialize the data object."""
self._ip_address = ip_address

def _build_url(self):
"""Build the URL for the requests."""
url = _POWERFLOW_URL.format(self._ip_address)
_LOGGER.debug("Fronius Powerflow URL: %s", url)
return url

@property
def latest_data(self):
"""Return the latest data object."""
if self._data:
return self._data
return None

@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self):
async def _update(self):
"""Get the latest data from inverter."""
_LOGGER.debug("Requesting powerflow data")
try:
result = requests.get(self._build_url(), timeout=10).json()
self._data = result['Body']['Data']['Site']
except (requests.exceptions.RequestException) as error:
_LOGGER.error("Unable to connect to Powerflow: %s", error)
self._data = None
self._data = (await self.fetch_data(self._build_url()))['Body']['Data']['Site']

class SmartMeterData:
class SmartMeterData(FroniusFetcher):
"""Handle Fronius API object and limit updates."""

def __init__(self, ip_address, device_id):
"""Initialize the data object."""
self._ip_address = ip_address
self._device_id = device_id
self._scope = 'Device'

def _build_url(self):
"""Build the URL for the requests."""
url = _METER_URL.format(self._ip_address, self._scope, self._device_id)
_LOGGER.debug("Fronius SmartMeter URL: %s", url)
return url

@property
def latest_data(self):
"""Return the latest data object."""
if self._data:
return self._data
return None

@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self):
async def _update(self):
"""Get the latest data from inverter."""
_LOGGER.debug("Requesting smartmeter data")
try:
result = requests.get(self._build_url(), timeout=10).json()
self._data = result['Body']['Data']
except (requests.exceptions.RequestException) as error:
_LOGGER.error("Unable to connect to Meter: %s", error)
self._data = None
self._data = (await self.fetch_data(self._build_url()))['Body']['Data']

0 comments on commit 65ffd89

Please sign in to comment.