New apps Added
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
"""Sensor platform for Climate Scheduler."""
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# How many historical samples to keep for derivative calculation
|
||||
SAMPLE_SIZE = 10
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Climate Scheduler sensors from a config entry."""
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
storage = hass.data[DOMAIN]["storage"]
|
||||
coordinator = hass.data[DOMAIN]["coordinator"]
|
||||
|
||||
# Get settings to check if derivative sensors are enabled
|
||||
settings = storage._data.get("settings", {})
|
||||
sensors = []
|
||||
|
||||
# Always create coldest and warmest sensors
|
||||
sensors.append(ColdestEntitySensor(hass, storage, coordinator))
|
||||
sensors.append(WarmestEntitySensor(hass, storage, coordinator))
|
||||
|
||||
if not settings.get("create_derivative_sensors", True):
|
||||
async_add_entities(sensors, True)
|
||||
return
|
||||
|
||||
# Get registries
|
||||
entity_registry = er.async_get(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
# Get all entities from groups (both single and multi-entity groups)
|
||||
all_entity_ids = await storage.async_get_all_entities()
|
||||
|
||||
for entity_id in all_entity_ids:
|
||||
# Skip ignored entities
|
||||
if await storage.async_is_ignored(entity_id):
|
||||
continue
|
||||
|
||||
# Find the device for this climate entity
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
_LOGGER.debug(f"Entity entry for {entity_id}: {entity_entry}")
|
||||
|
||||
device_id = None
|
||||
if entity_entry and entity_entry.device_id:
|
||||
device_id = entity_entry.device_id
|
||||
_LOGGER.debug(f"Found device {device_id} for {entity_id}")
|
||||
|
||||
# Create main temperature rate sensor
|
||||
_LOGGER.debug(f"Creating derivative sensor for {entity_id}")
|
||||
sensors.append(ClimateSchedulerRateSensor(hass, entity_id, entity_id, "current_temperature", device_id))
|
||||
|
||||
if device_id:
|
||||
# Find all sensor entities on the same device
|
||||
device_sensors = [
|
||||
entry for entry in entity_registry.entities.values()
|
||||
if entry.device_id == device_id
|
||||
and entry.domain == "sensor"
|
||||
and "floor" in entry.entity_id.lower() # Look for "floor" in the entity_id
|
||||
]
|
||||
|
||||
_LOGGER.debug(f"Found {len(device_sensors)} floor sensors for {entity_id}: {[s.entity_id for s in device_sensors]}")
|
||||
|
||||
# Create derivative sensors for floor temperature sensors
|
||||
for floor_sensor in device_sensors:
|
||||
_LOGGER.debug(f"Creating floor derivative sensor for {entity_id} tracking {floor_sensor.entity_id}")
|
||||
sensors.append(ClimateSchedulerRateSensor(
|
||||
hass,
|
||||
entity_id, # Associated climate entity
|
||||
floor_sensor.entity_id, # Floor sensor to track
|
||||
"state", # Use state instead of attribute
|
||||
device_id # Link to same device
|
||||
))
|
||||
else:
|
||||
_LOGGER.warning(f"No device found for {entity_id}, skipping floor sensor detection")
|
||||
|
||||
if sensors:
|
||||
async_add_entities(sensors, True)
|
||||
|
||||
|
||||
class ClimateSchedulerRateSensor(SensorEntity):
|
||||
"""Sensor that tracks the rate of temperature change for a climate entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
climate_entity_id: str,
|
||||
source_entity_id: str,
|
||||
temperature_attribute: str = "current_temperature",
|
||||
device_id: str = None
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.hass = hass
|
||||
self._climate_entity_id = climate_entity_id
|
||||
self._source_entity_id = source_entity_id
|
||||
self._temperature_attribute = temperature_attribute
|
||||
self._device_id = device_id # Store device_id for device_info
|
||||
|
||||
# Extract name from entity_id (e.g., climate.bedroom -> bedroom)
|
||||
entity_name = climate_entity_id.split(".")[-1]
|
||||
friendly_name = entity_name.replace("_", " ").title()
|
||||
|
||||
# Determine if this is a floor sensor (tracking a separate sensor entity)
|
||||
is_floor = source_entity_id != climate_entity_id
|
||||
|
||||
if is_floor:
|
||||
# For floor sensors, include the floor sensor name in unique_id to avoid collisions
|
||||
floor_sensor_name = source_entity_id.split(".")[-1]
|
||||
# Strip any previously-created climate_scheduler prefix to avoid recursive names
|
||||
if floor_sensor_name.startswith("climate_scheduler_"):
|
||||
floor_sensor_name = floor_sensor_name[len("climate_scheduler_") :]
|
||||
# Strip trailing _rate if present
|
||||
if floor_sensor_name.endswith("_rate"):
|
||||
floor_sensor_name = floor_sensor_name[: -len("_rate")]
|
||||
|
||||
# Prevent duplicated segments like "front_room_front_room_..."
|
||||
if floor_sensor_name.startswith(f"{entity_name}_"):
|
||||
floor_sensor_suffix = floor_sensor_name[len(entity_name) + 1 :]
|
||||
else:
|
||||
floor_sensor_suffix = floor_sensor_name
|
||||
|
||||
suffix = f"{floor_sensor_suffix.replace('_', ' ').title()} Rate"
|
||||
unique_suffix = f"{floor_sensor_suffix}_rate"
|
||||
else:
|
||||
suffix = "Rate"
|
||||
unique_suffix = "rate"
|
||||
|
||||
_LOGGER.debug(f"Creating sensor climate_scheduler_{entity_name}_{unique_suffix} with device_id: {device_id}")
|
||||
|
||||
self._attr_name = f"Climate Scheduler {friendly_name} {suffix}"
|
||||
self._attr_unique_id = f"climate_scheduler_{entity_name}_{unique_suffix}"
|
||||
# Let Home Assistant manage the `entity_id` from the `unique_id`
|
||||
self._attr_device_class = None # No standard device class for rate
|
||||
self._attr_state_class = SensorStateClass.MEASUREMENT
|
||||
self._attr_native_unit_of_measurement = "°C/h"
|
||||
self._attr_icon = "mdi:thermometer-lines" if not is_floor else "mdi:floor-plan"
|
||||
|
||||
# Storage for temperature samples (timestamp, temperature)
|
||||
self._samples = []
|
||||
self._attr_native_value = 0.0 # Start at 0 instead of None
|
||||
|
||||
# Set device_info during initialization to ensure proper device linking
|
||||
if device_id:
|
||||
device_registry = dr.async_get(hass)
|
||||
device = device_registry.async_get(device_id)
|
||||
|
||||
if device:
|
||||
self._attr_device_info = {
|
||||
"identifiers": device.identifiers,
|
||||
}
|
||||
_LOGGER.debug(f"Linked sensor {self._attr_unique_id} to device {device_id} with identifiers {device.identifiers}")
|
||||
else:
|
||||
_LOGGER.error(f"Could not find device {device_id} in registry for sensor {self._attr_unique_id}")
|
||||
else:
|
||||
_LOGGER.warning(f"No device_id provided for sensor {self._attr_unique_id}")
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state listener when entity is added."""
|
||||
# Listen to state changes of the source entity
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self._source_entity_id],
|
||||
self._async_source_state_changed,
|
||||
)
|
||||
)
|
||||
|
||||
# Get initial state
|
||||
state = self.hass.states.get(self._source_entity_id)
|
||||
if state:
|
||||
# For separate sensor entities, use the state value
|
||||
if self._temperature_attribute == "state":
|
||||
try:
|
||||
temp = float(state.state)
|
||||
now = dt_util.utcnow()
|
||||
self._samples.append((now, temp))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# For climate entity attributes, use the attribute
|
||||
elif state.attributes.get(self._temperature_attribute) is not None:
|
||||
temp = float(state.attributes[self._temperature_attribute])
|
||||
now = dt_util.utcnow()
|
||||
self._samples.append((now, temp))
|
||||
|
||||
@callback
|
||||
def _async_source_state_changed(self, event) -> None:
|
||||
"""Handle source entity state changes."""
|
||||
new_state = event.data.get("new_state")
|
||||
if not new_state:
|
||||
return
|
||||
|
||||
try:
|
||||
# For separate sensor entities, use the state value
|
||||
if self._temperature_attribute == "state":
|
||||
temp = float(new_state.state)
|
||||
# For climate entity attributes, use the attribute
|
||||
else:
|
||||
current_temp = new_state.attributes.get(self._temperature_attribute)
|
||||
if current_temp is None:
|
||||
return
|
||||
temp = float(current_temp)
|
||||
now = dt_util.utcnow()
|
||||
|
||||
# Add new sample
|
||||
self._samples.append((now, temp))
|
||||
|
||||
# Keep only last SAMPLE_SIZE samples
|
||||
if len(self._samples) > SAMPLE_SIZE:
|
||||
self._samples = self._samples[-SAMPLE_SIZE:]
|
||||
|
||||
# Calculate derivative if we have enough samples
|
||||
if len(self._samples) >= 2:
|
||||
self._calculate_rate()
|
||||
|
||||
self.async_write_ha_state()
|
||||
except (ValueError, TypeError) as e:
|
||||
_LOGGER.debug(f"Error processing temperature for {self._climate_entity_id}: {e}")
|
||||
|
||||
def _calculate_rate(self) -> None:
|
||||
"""Calculate the rate of temperature change."""
|
||||
if len(self._samples) < 2:
|
||||
self._attr_native_value = 0.0 # Not enough data, rate is 0
|
||||
return
|
||||
|
||||
# Get oldest and newest samples
|
||||
oldest_time, oldest_temp = self._samples[0]
|
||||
newest_time, newest_temp = self._samples[-1]
|
||||
|
||||
# Calculate time difference in hours
|
||||
time_diff = (newest_time - oldest_time).total_seconds() / 3600
|
||||
|
||||
if time_diff == 0:
|
||||
self._attr_native_value = 0.0
|
||||
return
|
||||
|
||||
# Calculate temperature change rate (°C per hour)
|
||||
temp_diff = newest_temp - oldest_temp
|
||||
rate = temp_diff / time_diff
|
||||
|
||||
# Round to 2 decimal places
|
||||
self._attr_native_value = round(rate, 2)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional state attributes."""
|
||||
return {
|
||||
"climate_entity": self._climate_entity_id,
|
||||
"source_entity": self._source_entity_id,
|
||||
"temperature_attribute": self._temperature_attribute,
|
||||
"sample_count": len(self._samples),
|
||||
"time_window_minutes": 5,
|
||||
}
|
||||
|
||||
|
||||
class ColdestEntitySensor(SensorEntity):
|
||||
"""Sensor that shows the coldest climate entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
storage,
|
||||
coordinator
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.hass = hass
|
||||
self._storage = storage
|
||||
self._coordinator = coordinator
|
||||
|
||||
self._attr_name = "Climate Scheduler Coldest Entity"
|
||||
self._attr_unique_id = "climate_scheduler_coldest_entity"
|
||||
# Let Home Assistant manage the `entity_id` from the `unique_id`
|
||||
self._attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
self._attr_state_class = SensorStateClass.MEASUREMENT
|
||||
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
self._attr_icon = "mdi:snowflake"
|
||||
self._attr_native_value = None
|
||||
self._coldest_entity_id = None
|
||||
self._coldest_friendly_name = None
|
||||
self._remove_listener = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state listener when entity is added."""
|
||||
# Listen to coordinator updates
|
||||
self._remove_listener = self._coordinator.async_add_listener(self._handle_coordinator_update)
|
||||
# Initial update
|
||||
await self._async_update()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister listener when entity is removed."""
|
||||
if self._remove_listener:
|
||||
self._remove_listener()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.hass.async_create_task(self._async_update())
|
||||
|
||||
async def _async_update(self) -> None:
|
||||
"""Update the sensor value."""
|
||||
# Get all entities from groups
|
||||
all_entities = await self._storage.async_get_all_entities()
|
||||
|
||||
coldest_temp = None
|
||||
coldest_entity = None
|
||||
coldest_name = None
|
||||
|
||||
for entity_id in all_entities:
|
||||
# Skip ignored entities
|
||||
if await self._storage.async_is_ignored(entity_id):
|
||||
continue
|
||||
|
||||
state = self.hass.states.get(entity_id)
|
||||
if not state:
|
||||
continue
|
||||
|
||||
# Check if entity has current_temperature attribute
|
||||
current_temp = state.attributes.get("current_temperature")
|
||||
if current_temp is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
temp = float(current_temp)
|
||||
if coldest_temp is None or temp < coldest_temp:
|
||||
coldest_temp = temp
|
||||
coldest_entity = entity_id
|
||||
coldest_name = state.attributes.get("friendly_name", entity_id)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
self._attr_native_value = coldest_temp
|
||||
self._coldest_entity_id = coldest_entity
|
||||
self._coldest_friendly_name = coldest_name
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional state attributes."""
|
||||
return {
|
||||
"entity_id": self._coldest_entity_id,
|
||||
"friendly_name": self._coldest_friendly_name,
|
||||
}
|
||||
|
||||
|
||||
class WarmestEntitySensor(SensorEntity):
|
||||
"""Sensor that shows the warmest climate entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
storage,
|
||||
coordinator
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self.hass = hass
|
||||
self._storage = storage
|
||||
self._coordinator = coordinator
|
||||
|
||||
self._attr_name = "Climate Scheduler Warmest Entity"
|
||||
self._attr_unique_id = "climate_scheduler_warmest_entity"
|
||||
# Let Home Assistant manage the `entity_id` from the `unique_id`
|
||||
self._attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
self._attr_state_class = SensorStateClass.MEASUREMENT
|
||||
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
self._attr_icon = "mdi:fire"
|
||||
self._attr_native_value = None
|
||||
self._warmest_entity_id = None
|
||||
self._warmest_friendly_name = None
|
||||
self._remove_listener = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state listener when entity is added."""
|
||||
# Listen to coordinator updates
|
||||
self._remove_listener = self._coordinator.async_add_listener(self._handle_coordinator_update)
|
||||
# Initial update
|
||||
await self._async_update()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister listener when entity is removed."""
|
||||
if self._remove_listener:
|
||||
self._remove_listener()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.hass.async_create_task(self._async_update())
|
||||
|
||||
async def _async_update(self) -> None:
|
||||
"""Update the sensor value."""
|
||||
# Get all entities from groups
|
||||
all_entities = await self._storage.async_get_all_entities()
|
||||
|
||||
warmest_temp = None
|
||||
warmest_entity = None
|
||||
warmest_name = None
|
||||
|
||||
for entity_id in all_entities:
|
||||
# Skip ignored entities
|
||||
if await self._storage.async_is_ignored(entity_id):
|
||||
continue
|
||||
|
||||
state = self.hass.states.get(entity_id)
|
||||
if not state:
|
||||
continue
|
||||
|
||||
# Check if entity has current_temperature attribute
|
||||
current_temp = state.attributes.get("current_temperature")
|
||||
if current_temp is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
temp = float(current_temp)
|
||||
if warmest_temp is None or temp > warmest_temp:
|
||||
warmest_temp = temp
|
||||
warmest_entity = entity_id
|
||||
warmest_name = state.attributes.get("friendly_name", entity_id)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
self._attr_native_value = warmest_temp
|
||||
self._warmest_entity_id = warmest_entity
|
||||
self._warmest_friendly_name = warmest_name
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return additional state attributes."""
|
||||
return {
|
||||
"entity_id": self._warmest_entity_id,
|
||||
"friendly_name": self._warmest_friendly_name,
|
||||
}
|
||||
Reference in New Issue
Block a user