375 lines
14 KiB
Python
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
|