from datetime import timedelta from collections import defaultdict from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.sensor import SensorStateClass from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers import entity_registry from homeassistant.exceptions import IntegrationError from .const import ( DOMAIN, _LOGGER, ATTR_PRODUCT_DESCRIPTION, ATTR_DESTINATION_NAME, ATTR_SOURCE_NAME, ATTR_UNIQUE_ID, ATTR_PRODUCT_SERIAL, ATTR_PRODUCT_NAME, ATTR_PRODUCT_VENDOR, ATTR_PRODUCT_BUILD, ATTR_PRODUCT_VERSION, ATTR_PRODUCT_FEATURES, ISSUE_URL_ERROR_MESSAGE, ) from .ecoflow import Ecoflow, AuthenticationFailed # Setting up the adding and updating of sensor entities async def async_setup_entry(hass, config_entry, async_add_entities): # Retrieve the API instance from the config_entry data ecoflow = hass.data[DOMAIN][config_entry.entry_id] device_id = ecoflow.device["serial"] # Call EcoFlow to get access to the API data try: auth_check = await hass.async_add_executor_job(ecoflow.authorize) if not auth_check: # If device returns False or is empty, log an error and return _LOGGER.warning( f"{device_id}: It appears the PowerOcean device is offline or has changed host." + ISSUE_URL_ERROR_MESSAGE ) except AuthenticationFailed as error: _LOGGER.warning(f"{device_id}: Authentication failed: {error}") return try: # Fetch the sensor data from the device data = await hass.async_add_executor_job(ecoflow.fetch_data) if not data: # If data returns False or is empty, log an error and return _LOGGER.warning( f"{device_id}: Failed to fetch sensor data => authentication failed or no data." + ISSUE_URL_ERROR_MESSAGE ) return # Exception if data cannot be fetched except IntegrationError as error: _LOGGER.warning( f"{device_id}: Failed to fetch sensor data: {error}" + ISSUE_URL_ERROR_MESSAGE ) return # Get device id and then reset the device specific list of sensors for updates # to ensure it's empty before adding new entries # Initialize or clear the sensor list for this device hass.data[DOMAIN]["device_specific_sensors"][device_id] = [] # Register entities and add them to the list for schedule updates on each device # which is stored within hass.data for unique_id, endpoint in data.items(): # Get individual sensor entry from API sensor = PowerOceanSensor(ecoflow, endpoint) # Add sensors to the device specific list of sensors to be updated, via hass.data as also used in unload hass.data[DOMAIN]["device_specific_sensors"][device_id].append(sensor) # Register sensor async_add_entities([sensor], False) device_specific_sensors = hass.data[DOMAIN]["device_specific_sensors"] _LOGGER.debug( f"{device_id}: List of device_specific_sensors[device_id]: " f"{device_specific_sensors[device_id]}" ) # Log the number of sensors registered (and added to the update list) _LOGGER.debug( f"{device_id}: All '{len(device_specific_sensors[device_id])}' sensors have registered." ) # Schedule updates async def async_update_data(now): # If device deleted but HASS not restarted, then don't bother continuing if device_id not in hass.data.get(DOMAIN, {}).get( "device_specific_sensors", {} ): return False _LOGGER.debug(f"{device_id}: Preparing to update sensors at {now}") # Fetch the full dataset once from the API try: full_data = await hass.async_add_executor_job(ecoflow.fetch_data) except Exception as e: _LOGGER.error( f"{device_id}: Error fetching data from the device: {e}" + ISSUE_URL_ERROR_MESSAGE ) return # Fetch the registry and check if sensors are enabled registry = entity_registry.async_get(hass) # Set counters to zero counter_updated = 0 # Successfully updated sensors counter_disabled = 0 # Disabled sensors, not to be updated counter_unchanged = 0 # Skipped sensors since value has not changed counter_error = 0 # Skipped sensors due to some error, such as registry not found or no data from API # Get the list of device specific sensors from hass.data if device_id in hass.data.get(DOMAIN, {}).get("device_specific_sensors", {}): device_specific_sensors = hass.data[DOMAIN]["device_specific_sensors"] # ---------------------------------------------- # Now loop through the sensors to be updated # ---------------------------------------------- for sensor in device_specific_sensors[device_id]: entity_id = registry.async_get_entity_id( "sensor", DOMAIN, sensor.unique_id ) if entity_id: entity = registry.entities.get(entity_id) # get entity # entity is enabled if entity and not entity.disabled_by: sensor_data = full_data.get(sensor.unique_id) # _LOGGER.debug(f"{device_id}: Sensor {sensor.name} enabled.") if sensor_data: # _LOGGER.debug( # f"{device_id}: Sensor {sensor.name} has API data to update {sensor_data}" # ) # Check if current state value differs from new API value, # or current state has not initialized if ( str(sensor._state).strip() != str(sensor_data.value).strip() ): # _LOGGER.debug( # f"{device_id}: Sensor {sensor.name} marked for update: current state = " # f"{sensor._state} with new value = {sensor_data.value}" # ) # Now update the sensor with new values # update_status returns 1 for upated, 0 for skipped or error update_status = await sensor.async_update(sensor_data) counter_updated = counter_updated + update_status else: # _LOGGER.debug( # f"{device_id}: Sensor {sensor.name} skipped update! Current value = " # f"{sensor._state}, new value = {sensor_data.value}" # ) counter_unchanged = counter_unchanged + 1 else: _LOGGER.warning( f"{device_id}: Sensor {sensor.name}: found no data for update!" + ISSUE_URL_ERROR_MESSAGE ) counter_error = counter_error + 1 else: # _LOGGER.debug( # f"{device_id}: Sensor {sensor.name} is disabled, skipping update" # ) counter_disabled = counter_disabled + 1 else: _LOGGER.warning( f"{device_id}: Sensor {sensor.name} not found in the registry, skipping update" + ISSUE_URL_ERROR_MESSAGE ) counter_error = counter_error + 1 # Log summary of updates _LOGGER.debug( f"{device_id}: A total of {counter_updated} sensors have been updated. " f"Number of disabled sensors or skipped updates = {counter_disabled} " f"Number of sensors with constant values = {counter_unchanged} " f"Number of sensors with errors = {counter_error}" ) # Device not in list: must have been deleted, will resolve post re-start else: _LOGGER.warning( f"{device_id}: Sensor must have been deleted, re-start of HA recommended." ) # Get the polling interval from the options, defaulting to 5 seconds if not set polling_interval = timedelta( seconds=config_entry.options.get("polling_interval", 5) ) async_track_time_interval(hass, async_update_data, polling_interval) # This is the actual instance of SensorEntity class class PowerOceanSensor(SensorEntity): """Representation of a PowerOcean Sensor.""" def __init__(self, ecoflow: Ecoflow, endpoint): """Initialize the sensor.""" # Make Ecoflow and the endpoint parameters from the Sensor API available self.ecoflow = ecoflow self.endpoint = endpoint # Set Friendly name when sensor is first created self._attr_unique_id = endpoint.name self._attr_has_entity_name = True self._attr_name = endpoint.friendly_name self._name = endpoint.friendly_name # The unique identifier for this sensor within Home Assistant # has nothing to do with the entity_id, it is the internal unique_id of the sensor entity registry self._unique_id = endpoint.internal_unique_id # Set the icon for the sensor based on its unit, ensure the icon_mapper is defined # Default handled in function # self._icon = PowerOceanSensor.icon_mapper.get(endpoint.unit) self._icon = endpoint.icon # The initial state/value of the sensor self._state = endpoint.value # The unit of measurement for the sensor self._unit = endpoint.unit # Set entity category to diagnostic for sensors with no unit if ecoflow.options.get("group_sensors") and not endpoint.unit: self._attr_entity_category = EntityCategory.DIAGNOSTIC # If diagnostics entity then disable sensor by default if ecoflow.options.get("disable_sensors") and not endpoint.unit: self._attr_entity_registry_enabled_default = False @property def should_poll(self): """async_track_time_intervals handles updates.""" return False @property def unique_id(self): """Return the unique ID of the sensor.""" return self._unique_id @property def name(self): """Return the name of the sensor.""" return self._name @property def state(self): """Return the state of the sensor.""" return self._state @property def unit_of_measurement(self): """Return the unit of measurement.""" return self._unit @property def device_class(self): """Return the device class of this entity, if any.""" if self._unit == "°C": return SensorDeviceClass.TEMPERATURE elif self._unit == "%": return SensorDeviceClass.BATTERY elif self._unit in {"Wh", "kWh"}: return SensorDeviceClass.ENERGY elif self._unit == "W": return SensorDeviceClass.POWER elif self._unit == "V": return SensorDeviceClass.VOLTAGE elif self._unit == "A": return SensorDeviceClass.CURRENT else: return None @property def state_class(self): """Return the state class of this entity, if any.""" if self._unit in {"°C", "h", "W", "V", "A"}: return SensorStateClass.MEASUREMENT elif self._unit in {"Wh", "kWh"}: return SensorStateClass.TOTAL_INCREASING else: return None @property def extra_state_attributes(self): """Return the state attributes of this device.""" attr = {} attr[ATTR_PRODUCT_DESCRIPTION] = self.endpoint.description attr[ATTR_UNIQUE_ID] = self.endpoint.internal_unique_id attr[ATTR_PRODUCT_VENDOR] = self.ecoflow.device["vendor"] attr[ATTR_PRODUCT_NAME] = self.ecoflow.device["name"] attr[ATTR_PRODUCT_SERIAL] = self.endpoint.serial attr[ATTR_PRODUCT_VERSION] = self.ecoflow.device["version"] attr[ATTR_PRODUCT_BUILD] = self.ecoflow.device["build"] attr[ATTR_PRODUCT_FEATURES] = self.ecoflow.device["features"] return attr @property def device_info(self): """Return device specific attributes.""" # The unique identifier of the device is the serial number return { "identifiers": {(DOMAIN, self.ecoflow.device["serial"])}, "name": self.ecoflow.device["name"], "manufacturer": "ECOFLOW", } @property def icon(self): """Return the icon of the sensor.""" return self._icon # icon_mapper = defaultdict( # lambda: "mdi:alert-circle", # { # "°C": "mdi:thermometer", # "%": "mdi:flash", # "s": "mdi:timer", # "Wh": "mdi:solar-power-variant-outline", # "h": "mdi:timer-sand", # }, # ) # This is to register the icon settings async def async_added_to_hass(self): """Call when the sensor is added to Home Assistant.""" self.async_write_ha_state() # Update of Sensor values async def async_update(self, sensor_data=None): """Update the sensor with the provided data.""" if sensor_data is None: _LOGGER.warning( f"{self.ecoflow.device['serial']}: No new data provided for sensor '{self.name}' update" + ISSUE_URL_ERROR_MESSAGE ) update_status = 0 return try: self._state = sensor_data.value update_status = 1 self.async_write_ha_state() except Exception as error: _LOGGER.error( f"{self.ecoflow.device['serial']}: Error updating sensor {self.name}: {error}" + ISSUE_URL_ERROR_MESSAGE ) update_status = 0 return update_status