powerocean/custom_components/powerocean/sensor.py
niltrip 3d657ce512 feature special icons
disable some debug messages
2024-08-22 23:29:56 +02:00

375 lines
14 KiB
Python

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