2019-06-29 03:48:53 +00:00
|
|
|
"""Support for Fronius devices."""
|
2021-03-17 22:49:01 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2019-06-29 03:48:53 +00:00
|
|
|
import copy
|
2019-08-01 20:18:52 +00:00
|
|
|
from datetime import timedelta
|
2019-06-29 03:48:53 +00:00
|
|
|
import logging
|
|
|
|
|
|
|
|
from pyfronius import Fronius
|
2019-12-09 13:14:40 +00:00
|
|
|
import voluptuous as vol
|
2019-06-29 03:48:53 +00:00
|
|
|
|
2021-07-30 06:34:03 +00:00
|
|
|
from homeassistant.components.sensor import (
|
|
|
|
PLATFORM_SCHEMA,
|
|
|
|
STATE_CLASS_MEASUREMENT,
|
|
|
|
SensorEntity,
|
|
|
|
)
|
2019-07-31 19:25:30 +00:00
|
|
|
from homeassistant.const import (
|
|
|
|
CONF_DEVICE,
|
|
|
|
CONF_MONITORED_CONDITIONS,
|
2019-12-09 13:14:40 +00:00
|
|
|
CONF_RESOURCE,
|
2019-08-01 20:18:52 +00:00
|
|
|
CONF_SCAN_INTERVAL,
|
2019-12-09 13:14:40 +00:00
|
|
|
CONF_SENSOR_TYPE,
|
2021-08-11 20:40:04 +00:00
|
|
|
DEVICE_CLASS_BATTERY,
|
|
|
|
DEVICE_CLASS_CURRENT,
|
2021-07-30 06:34:03 +00:00
|
|
|
DEVICE_CLASS_ENERGY,
|
|
|
|
DEVICE_CLASS_POWER,
|
2021-08-11 20:40:04 +00:00
|
|
|
DEVICE_CLASS_POWER_FACTOR,
|
|
|
|
DEVICE_CLASS_TEMPERATURE,
|
|
|
|
DEVICE_CLASS_TIMESTAMP,
|
|
|
|
DEVICE_CLASS_VOLTAGE,
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2021-07-30 06:34:03 +00:00
|
|
|
from homeassistant.core import callback
|
2019-06-29 03:48:53 +00:00
|
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
|
|
import homeassistant.helpers.config_validation as cv
|
2019-08-01 20:18:52 +00:00
|
|
|
from homeassistant.helpers.event import async_track_time_interval
|
2021-07-30 06:34:03 +00:00
|
|
|
from homeassistant.util import dt
|
2019-08-01 20:18:52 +00:00
|
|
|
|
2019-06-29 03:48:53 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
CONF_SCOPE = "scope"
|
2019-06-29 03:48:53 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
TYPE_INVERTER = "inverter"
|
|
|
|
TYPE_STORAGE = "storage"
|
|
|
|
TYPE_METER = "meter"
|
|
|
|
TYPE_POWER_FLOW = "power_flow"
|
|
|
|
SCOPE_DEVICE = "device"
|
|
|
|
SCOPE_SYSTEM = "system"
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
DEFAULT_SCOPE = SCOPE_DEVICE
|
|
|
|
DEFAULT_DEVICE = 0
|
|
|
|
DEFAULT_INVERTER = 1
|
2019-08-01 20:18:52 +00:00
|
|
|
DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
SENSOR_TYPES = [TYPE_INVERTER, TYPE_STORAGE, TYPE_METER, TYPE_POWER_FLOW]
|
|
|
|
SCOPE_TYPES = [SCOPE_DEVICE, SCOPE_SYSTEM]
|
|
|
|
|
2021-08-11 20:40:04 +00:00
|
|
|
PREFIX_DEVICE_CLASS_MAPPING = [
|
|
|
|
("state_of_charge", DEVICE_CLASS_BATTERY),
|
|
|
|
("temperature", DEVICE_CLASS_TEMPERATURE),
|
|
|
|
("power_factor", DEVICE_CLASS_POWER_FACTOR),
|
|
|
|
("power", DEVICE_CLASS_POWER),
|
|
|
|
("energy", DEVICE_CLASS_ENERGY),
|
|
|
|
("current", DEVICE_CLASS_CURRENT),
|
|
|
|
("timestamp", DEVICE_CLASS_TIMESTAMP),
|
|
|
|
("voltage", DEVICE_CLASS_VOLTAGE),
|
|
|
|
]
|
|
|
|
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
def _device_id_validator(config):
|
|
|
|
"""Ensure that inverters have default id 1 and other devices 0."""
|
|
|
|
config = copy.deepcopy(config)
|
|
|
|
for cond in config[CONF_MONITORED_CONDITIONS]:
|
|
|
|
if CONF_DEVICE not in cond:
|
|
|
|
if cond[CONF_SENSOR_TYPE] == TYPE_INVERTER:
|
|
|
|
cond[CONF_DEVICE] = DEFAULT_INVERTER
|
|
|
|
else:
|
|
|
|
cond[CONF_DEVICE] = DEFAULT_DEVICE
|
|
|
|
return config
|
|
|
|
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
PLATFORM_SCHEMA = vol.Schema(
|
|
|
|
vol.All(
|
|
|
|
PLATFORM_SCHEMA.extend(
|
|
|
|
{
|
|
|
|
vol.Required(CONF_RESOURCE): cv.url,
|
|
|
|
vol.Required(CONF_MONITORED_CONDITIONS): vol.All(
|
|
|
|
cv.ensure_list,
|
|
|
|
[
|
|
|
|
{
|
|
|
|
vol.Required(CONF_SENSOR_TYPE): vol.In(SENSOR_TYPES),
|
|
|
|
vol.Optional(CONF_SCOPE, default=DEFAULT_SCOPE): vol.In(
|
|
|
|
SCOPE_TYPES
|
|
|
|
),
|
2020-10-11 20:04:49 +00:00
|
|
|
vol.Optional(CONF_DEVICE): cv.positive_int,
|
2019-07-31 19:25:30 +00:00
|
|
|
}
|
|
|
|
],
|
|
|
|
),
|
|
|
|
}
|
|
|
|
),
|
|
|
|
_device_id_validator,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
2019-06-29 03:48:53 +00:00
|
|
|
"""Set up of Fronius platform."""
|
|
|
|
session = async_get_clientsession(hass)
|
|
|
|
fronius = Fronius(session, config[CONF_RESOURCE])
|
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
|
|
|
adapters = []
|
|
|
|
# Creates all adapters for monitored conditions
|
2019-06-29 03:48:53 +00:00
|
|
|
for condition in config[CONF_MONITORED_CONDITIONS]:
|
|
|
|
|
|
|
|
device = condition[CONF_DEVICE]
|
2019-08-01 20:18:52 +00:00
|
|
|
sensor_type = condition[CONF_SENSOR_TYPE]
|
|
|
|
scope = condition[CONF_SCOPE]
|
2020-02-25 01:54:20 +00:00
|
|
|
name = f"Fronius {condition[CONF_SENSOR_TYPE].replace('_', ' ').capitalize()} {device if scope == SCOPE_DEVICE else SCOPE_SYSTEM} {config[CONF_RESOURCE]}"
|
2019-06-29 03:48:53 +00:00
|
|
|
if sensor_type == TYPE_INVERTER:
|
|
|
|
if scope == SCOPE_SYSTEM:
|
2019-08-01 20:18:52 +00:00
|
|
|
adapter_cls = FroniusInverterSystem
|
2019-06-29 03:48:53 +00:00
|
|
|
else:
|
2019-08-01 20:18:52 +00:00
|
|
|
adapter_cls = FroniusInverterDevice
|
2019-06-29 03:48:53 +00:00
|
|
|
elif sensor_type == TYPE_METER:
|
|
|
|
if scope == SCOPE_SYSTEM:
|
2019-08-01 20:18:52 +00:00
|
|
|
adapter_cls = FroniusMeterSystem
|
2019-06-29 03:48:53 +00:00
|
|
|
else:
|
2019-08-01 20:18:52 +00:00
|
|
|
adapter_cls = FroniusMeterDevice
|
2019-06-29 03:48:53 +00:00
|
|
|
elif sensor_type == TYPE_POWER_FLOW:
|
2019-08-01 20:18:52 +00:00
|
|
|
adapter_cls = FroniusPowerFlow
|
2019-06-29 03:48:53 +00:00
|
|
|
else:
|
2019-08-01 20:18:52 +00:00
|
|
|
adapter_cls = FroniusStorage
|
2019-06-29 03:48:53 +00:00
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
adapters.append(adapter_cls(fronius, name, device, async_add_entities))
|
2019-06-29 03:48:53 +00:00
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
# Creates a lamdba that fetches an update when called
|
|
|
|
def adapter_data_fetcher(data_adapter):
|
|
|
|
async def fetch_data(*_):
|
|
|
|
await data_adapter.async_update()
|
2019-06-29 03:48:53 +00:00
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
return fetch_data
|
2019-06-29 03:48:53 +00:00
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
# Set up the fetching in a fixed interval for each adapter
|
|
|
|
for adapter in adapters:
|
|
|
|
fetch = adapter_data_fetcher(adapter)
|
|
|
|
# fetch data once at set-up
|
|
|
|
await fetch()
|
|
|
|
async_track_time_interval(hass, fetch, scan_interval)
|
2019-06-29 03:48:53 +00:00
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
|
|
|
|
class FroniusAdapter:
|
|
|
|
"""The Fronius sensor fetching component."""
|
|
|
|
|
|
|
|
def __init__(self, bridge, name, device, add_entities):
|
2019-06-29 03:48:53 +00:00
|
|
|
"""Initialize the sensor."""
|
2019-08-01 20:18:52 +00:00
|
|
|
self.bridge = bridge
|
2019-06-29 03:48:53 +00:00
|
|
|
self._name = name
|
|
|
|
self._device = device
|
2019-08-01 20:18:52 +00:00
|
|
|
self._fetched = {}
|
2021-02-15 17:28:28 +00:00
|
|
|
self._available = True
|
2019-08-01 20:18:52 +00:00
|
|
|
|
|
|
|
self.sensors = set()
|
|
|
|
self._registered_sensors = set()
|
|
|
|
self._add_entities = add_entities
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def name(self):
|
|
|
|
"""Return the name of the sensor."""
|
|
|
|
return self._name
|
|
|
|
|
|
|
|
@property
|
2019-08-01 20:18:52 +00:00
|
|
|
def data(self):
|
2019-06-29 03:48:53 +00:00
|
|
|
"""Return the state attributes."""
|
2019-08-01 20:18:52 +00:00
|
|
|
return self._fetched
|
2019-06-29 03:48:53 +00:00
|
|
|
|
2021-02-15 17:28:28 +00:00
|
|
|
@property
|
|
|
|
def available(self):
|
|
|
|
"""Whether the fronius device is active."""
|
|
|
|
return self._available
|
|
|
|
|
2019-06-29 03:48:53 +00:00
|
|
|
async def async_update(self):
|
|
|
|
"""Retrieve and update latest state."""
|
|
|
|
try:
|
|
|
|
values = await self._update()
|
|
|
|
except ConnectionError:
|
2021-02-15 17:28:28 +00:00
|
|
|
# fronius devices are often powered by self-produced solar energy
|
|
|
|
# and henced turned off at night.
|
|
|
|
# Therefore we will not print multiple errors when connection fails
|
|
|
|
if self._available:
|
|
|
|
self._available = False
|
|
|
|
_LOGGER.error("Failed to update: connection error")
|
|
|
|
return
|
2019-06-29 03:48:53 +00:00
|
|
|
except ValueError:
|
2019-07-31 19:25:30 +00:00
|
|
|
_LOGGER.error(
|
|
|
|
"Failed to update: invalid response returned."
|
|
|
|
"Maybe the configured device is not supported"
|
|
|
|
)
|
2019-08-01 20:18:52 +00:00
|
|
|
return
|
2021-02-15 17:28:28 +00:00
|
|
|
|
|
|
|
self._available = True # reset connection failure
|
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
attributes = self._fetched
|
|
|
|
# Copy data of current fronius device
|
|
|
|
for key, entry in values.items():
|
|
|
|
# If the data is directly a sensor
|
|
|
|
if "value" in entry:
|
|
|
|
attributes[key] = entry
|
|
|
|
self._fetched = attributes
|
|
|
|
|
|
|
|
# Add discovered value fields as sensors
|
|
|
|
# because some fields are only sent temporarily
|
|
|
|
new_sensors = []
|
|
|
|
for key in attributes:
|
|
|
|
if key not in self.sensors:
|
|
|
|
self.sensors.add(key)
|
|
|
|
_LOGGER.info("Discovered %s, adding as sensor", key)
|
|
|
|
new_sensors.append(FroniusTemplateSensor(self, key))
|
|
|
|
self._add_entities(new_sensors, True)
|
|
|
|
|
|
|
|
# Schedule an update for all included sensors
|
|
|
|
for sensor in self._registered_sensors:
|
|
|
|
sensor.async_schedule_update_ha_state(True)
|
2019-06-29 03:48:53 +00:00
|
|
|
|
2021-03-17 22:49:01 +00:00
|
|
|
async def _update(self) -> dict:
|
2019-06-29 03:48:53 +00:00
|
|
|
"""Return values of interest."""
|
|
|
|
|
2021-07-30 06:34:03 +00:00
|
|
|
@callback
|
|
|
|
def register(self, sensor):
|
2019-08-01 20:18:52 +00:00
|
|
|
"""Register child sensor for update subscriptions."""
|
|
|
|
self._registered_sensors.add(sensor)
|
2021-07-30 06:34:03 +00:00
|
|
|
return lambda: self._registered_sensors.remove(sensor)
|
2019-06-29 03:48:53 +00:00
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
|
|
|
|
class FroniusInverterSystem(FroniusAdapter):
|
|
|
|
"""Adapter for the fronius inverter with system scope."""
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
async def _update(self):
|
|
|
|
"""Get the values for the current state."""
|
2019-08-01 20:18:52 +00:00
|
|
|
return await self.bridge.current_system_inverter_data()
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
class FroniusInverterDevice(FroniusAdapter):
|
|
|
|
"""Adapter for the fronius inverter with device scope."""
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
async def _update(self):
|
|
|
|
"""Get the values for the current state."""
|
2019-08-01 20:18:52 +00:00
|
|
|
return await self.bridge.current_inverter_data(self._device)
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
class FroniusStorage(FroniusAdapter):
|
|
|
|
"""Adapter for the fronius battery storage."""
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
async def _update(self):
|
|
|
|
"""Get the values for the current state."""
|
2019-08-01 20:18:52 +00:00
|
|
|
return await self.bridge.current_storage_data(self._device)
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
class FroniusMeterSystem(FroniusAdapter):
|
|
|
|
"""Adapter for the fronius meter with system scope."""
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
async def _update(self):
|
|
|
|
"""Get the values for the current state."""
|
2019-08-01 20:18:52 +00:00
|
|
|
return await self.bridge.current_system_meter_data()
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
class FroniusMeterDevice(FroniusAdapter):
|
|
|
|
"""Adapter for the fronius meter with device scope."""
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
async def _update(self):
|
|
|
|
"""Get the values for the current state."""
|
2019-08-01 20:18:52 +00:00
|
|
|
return await self.bridge.current_meter_data(self._device)
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
class FroniusPowerFlow(FroniusAdapter):
|
|
|
|
"""Adapter for the fronius power flow."""
|
2019-06-29 03:48:53 +00:00
|
|
|
|
|
|
|
async def _update(self):
|
|
|
|
"""Get the values for the current state."""
|
2019-08-01 20:18:52 +00:00
|
|
|
return await self.bridge.current_power_flow()
|
|
|
|
|
|
|
|
|
2021-03-22 18:45:17 +00:00
|
|
|
class FroniusTemplateSensor(SensorEntity):
|
2019-08-01 20:18:52 +00:00
|
|
|
"""Sensor for the single values (e.g. pv power, ac power)."""
|
|
|
|
|
2021-08-11 20:40:04 +00:00
|
|
|
_attr_state_class = STATE_CLASS_MEASUREMENT
|
|
|
|
|
|
|
|
def __init__(self, parent: FroniusAdapter, key: str) -> None:
|
2019-08-01 20:18:52 +00:00
|
|
|
"""Initialize a singular value sensor."""
|
2021-07-30 06:34:03 +00:00
|
|
|
self._key = key
|
|
|
|
self._attr_name = f"{key.replace('_', ' ').capitalize()} {parent.name}"
|
|
|
|
self._parent = parent
|
2021-08-11 20:40:04 +00:00
|
|
|
for prefix, device_class in PREFIX_DEVICE_CLASS_MAPPING:
|
|
|
|
if self._key.startswith(prefix):
|
|
|
|
self._attr_device_class = device_class
|
|
|
|
break
|
2019-08-01 20:18:52 +00:00
|
|
|
|
|
|
|
@property
|
|
|
|
def should_poll(self):
|
|
|
|
"""Device should not be polled, returns False."""
|
|
|
|
return False
|
|
|
|
|
2021-02-15 17:28:28 +00:00
|
|
|
@property
|
|
|
|
def available(self):
|
|
|
|
"""Whether the fronius device is active."""
|
2021-07-30 06:34:03 +00:00
|
|
|
return self._parent.available
|
2021-02-15 17:28:28 +00:00
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
async def async_update(self):
|
|
|
|
"""Update the internal state."""
|
2021-07-30 06:34:03 +00:00
|
|
|
state = self._parent.data.get(self._key)
|
|
|
|
self._attr_state = state.get("value")
|
|
|
|
if isinstance(self._attr_state, float):
|
2021-08-12 12:23:56 +00:00
|
|
|
self._attr_native_value = round(self._attr_state, 2)
|
|
|
|
self._attr_native_unit_of_measurement = state.get("unit")
|
2019-08-01 20:18:52 +00:00
|
|
|
|
2021-08-11 20:40:04 +00:00
|
|
|
@property
|
|
|
|
def last_reset(self) -> dt.dt.datetime | None:
|
|
|
|
"""Return the time when the sensor was last reset, if it is a meter."""
|
|
|
|
if self._key.endswith("day"):
|
|
|
|
return dt.start_of_local_day()
|
|
|
|
if self._key.endswith("year"):
|
|
|
|
return dt.start_of_local_day(dt.dt.date(dt.now().year, 1, 1))
|
|
|
|
if self._key.endswith("total") or self._key.startswith("energy_real"):
|
|
|
|
return dt.utc_from_timestamp(0)
|
|
|
|
return None
|
|
|
|
|
2019-08-01 20:18:52 +00:00
|
|
|
async def async_added_to_hass(self):
|
|
|
|
"""Register at parent component for updates."""
|
2021-07-30 06:34:03 +00:00
|
|
|
self.async_on_remove(self._parent.register(self))
|
2019-08-01 20:18:52 +00:00
|
|
|
|
|
|
|
def __hash__(self):
|
|
|
|
"""Hash sensor by hashing its name."""
|
|
|
|
return hash(self.name)
|