"""Support for Fronius devices.""" from __future__ import annotations import copy from datetime import timedelta import logging from pyfronius import Fronius import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, SensorEntity, ) from homeassistant.const import ( CONF_DEVICE, CONF_MONITORED_CONDITIONS, CONF_RESOURCE, CONF_SCAN_INTERVAL, CONF_SENSOR_TYPE, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, ) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import dt _LOGGER = logging.getLogger(__name__) CONF_SCOPE = "scope" TYPE_INVERTER = "inverter" TYPE_STORAGE = "storage" TYPE_METER = "meter" TYPE_POWER_FLOW = "power_flow" SCOPE_DEVICE = "device" SCOPE_SYSTEM = "system" DEFAULT_SCOPE = SCOPE_DEVICE DEFAULT_DEVICE = 0 DEFAULT_INVERTER = 1 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) SENSOR_TYPES = [TYPE_INVERTER, TYPE_STORAGE, TYPE_METER, TYPE_POWER_FLOW] SCOPE_TYPES = [SCOPE_DEVICE, SCOPE_SYSTEM] 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), ] 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 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 ), vol.Optional(CONF_DEVICE): cv.positive_int, } ], ), } ), _device_id_validator, ) ) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up of Fronius platform.""" session = async_get_clientsession(hass) fronius = Fronius(session, config[CONF_RESOURCE]) scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) adapters = [] # Creates all adapters for monitored conditions for condition in config[CONF_MONITORED_CONDITIONS]: device = condition[CONF_DEVICE] sensor_type = condition[CONF_SENSOR_TYPE] scope = condition[CONF_SCOPE] name = f"Fronius {condition[CONF_SENSOR_TYPE].replace('_', ' ').capitalize()} {device if scope == SCOPE_DEVICE else SCOPE_SYSTEM} {config[CONF_RESOURCE]}" if sensor_type == TYPE_INVERTER: if scope == SCOPE_SYSTEM: adapter_cls = FroniusInverterSystem else: adapter_cls = FroniusInverterDevice elif sensor_type == TYPE_METER: if scope == SCOPE_SYSTEM: adapter_cls = FroniusMeterSystem else: adapter_cls = FroniusMeterDevice elif sensor_type == TYPE_POWER_FLOW: adapter_cls = FroniusPowerFlow else: adapter_cls = FroniusStorage adapters.append(adapter_cls(fronius, name, device, async_add_entities)) # Creates a lamdba that fetches an update when called def adapter_data_fetcher(data_adapter): async def fetch_data(*_): await data_adapter.async_update() return fetch_data # 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) class FroniusAdapter: """The Fronius sensor fetching component.""" def __init__(self, bridge, name, device, add_entities): """Initialize the sensor.""" self.bridge = bridge self._name = name self._device = device self._fetched = {} self._available = True self.sensors = set() self._registered_sensors = set() self._add_entities = add_entities @property def name(self): """Return the name of the sensor.""" return self._name @property def data(self): """Return the state attributes.""" return self._fetched @property def available(self): """Whether the fronius device is active.""" return self._available async def async_update(self): """Retrieve and update latest state.""" try: values = await self._update() except ConnectionError: # 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 except ValueError: _LOGGER.error( "Failed to update: invalid response returned." "Maybe the configured device is not supported" ) return self._available = True # reset connection failure 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) async def _update(self) -> dict: """Return values of interest.""" @callback def register(self, sensor): """Register child sensor for update subscriptions.""" self._registered_sensors.add(sensor) return lambda: self._registered_sensors.remove(sensor) class FroniusInverterSystem(FroniusAdapter): """Adapter for the fronius inverter with system scope.""" async def _update(self): """Get the values for the current state.""" return await self.bridge.current_system_inverter_data() class FroniusInverterDevice(FroniusAdapter): """Adapter for the fronius inverter with device scope.""" async def _update(self): """Get the values for the current state.""" return await self.bridge.current_inverter_data(self._device) class FroniusStorage(FroniusAdapter): """Adapter for the fronius battery storage.""" async def _update(self): """Get the values for the current state.""" return await self.bridge.current_storage_data(self._device) class FroniusMeterSystem(FroniusAdapter): """Adapter for the fronius meter with system scope.""" async def _update(self): """Get the values for the current state.""" return await self.bridge.current_system_meter_data() class FroniusMeterDevice(FroniusAdapter): """Adapter for the fronius meter with device scope.""" async def _update(self): """Get the values for the current state.""" return await self.bridge.current_meter_data(self._device) class FroniusPowerFlow(FroniusAdapter): """Adapter for the fronius power flow.""" async def _update(self): """Get the values for the current state.""" return await self.bridge.current_power_flow() class FroniusTemplateSensor(SensorEntity): """Sensor for the single values (e.g. pv power, ac power).""" _attr_state_class = STATE_CLASS_MEASUREMENT def __init__(self, parent: FroniusAdapter, key: str) -> None: """Initialize a singular value sensor.""" self._key = key self._attr_name = f"{key.replace('_', ' ').capitalize()} {parent.name}" self._parent = parent for prefix, device_class in PREFIX_DEVICE_CLASS_MAPPING: if self._key.startswith(prefix): self._attr_device_class = device_class break @property def should_poll(self): """Device should not be polled, returns False.""" return False @property def available(self): """Whether the fronius device is active.""" return self._parent.available async def async_update(self): """Update the internal state.""" state = self._parent.data.get(self._key) self._attr_native_value = state.get("value") if isinstance(self._attr_native_value, float): self._attr_native_value = round(self._attr_native_value, 2) self._attr_native_unit_of_measurement = state.get("unit") @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 async def async_added_to_hass(self): """Register at parent component for updates.""" self.async_on_remove(self._parent.register(self)) def __hash__(self): """Hash sensor by hashing its name.""" return hash(self.name)