Files
Home-Assistant/custom_components/intelligent_heating_pilot/sensor.py
T
2026-07-08 10:43:39 -04:00

640 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Sensor platform for Intelligent Heating Pilot."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import (
ATTR_ANTICIPATED_START_TIME,
ATTR_LEARNED_HEATING_SLOPE,
ATTR_NEXT_SCHEDULE_TIME,
ATTR_NEXT_TARGET_TEMP,
CONF_NAME,
DOMAIN,
EVENT_DEAD_TIME_UPDATED,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Intelligent Heating Pilot sensors."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
name = config_entry.data.get(CONF_NAME, "Intelligent Heating Pilot")
sensors = [
IntelligentHeatingPilotAnticipationTimeSensor(coordinator, config_entry, name),
IntelligentHeatingPilotNextScheduleSensor(coordinator, config_entry, name),
# HMS-only display companions
IntelligentHeatingPilotAnticipationTimeHmsSensor(coordinator, config_entry, name),
IntelligentHeatingPilotNextScheduleHmsSensor(coordinator, config_entry, name),
# Metrics - LHS sensors (Global + Contextual)
IntelligentHeatingPilotGlobalLearnedSlopeSensor(coordinator, config_entry, name),
IntelligentHeatingPilotContextualLearnedSlopeSensor(coordinator, config_entry, name),
IntelligentHeatingPilotPredictionConfidenceSensor(
coordinator, config_entry, name
), # Phase 4: New
# Dead time learning sensor
IntelligentHeatingPilotDeadTimeSensor(coordinator, config_entry, name),
]
async_add_entities(sensors, True)
class IntelligentHeatingPilotSensorBase(SensorEntity):
"""Base class for Intelligent Heating Pilot sensors."""
_attr_has_entity_name = True
def __init__(self, coordinator: Any, config_entry: ConfigEntry, name: str) -> None:
"""Initialize the sensor."""
self.coordinator = coordinator
self._config_entry = config_entry
self._attr_device_info = {
"identifiers": {(DOMAIN, config_entry.entry_id)},
"name": name,
"manufacturer": "Intelligent Heating Pilot",
"model": "Intelligent Preheating with ML",
}
async def async_added_to_hass(self) -> None:
"""Register callbacks when entity is added."""
await super().async_added_to_hass()
@callback
def handle_anticipation_event(event):
"""Handle anticipation calculated event."""
data = event.data or {}
# Filter events to only those coming from our own config entry
if data.get("entry_id") != self._config_entry.entry_id:
return
old_value = self.native_value
self._handle_anticipation_result(data)
# Only write HA state when the sensor value actually changed to
# avoid unnecessary state-machine writes and log noise.
if self.native_value != old_value:
self.async_write_ha_state()
self.async_on_remove(
self.hass.bus.async_listen(
f"{DOMAIN}_anticipation_calculated", handle_anticipation_event
)
)
def _handle_anticipation_result(self, data: dict) -> None:
"""Handle new anticipation result. Override in subclasses."""
pass
class IntelligentHeatingPilotAnticipationTimeSensor(IntelligentHeatingPilotSensorBase):
"""Sensor for anticipated start time."""
_attr_name = "Anticipated Start Time"
_attr_icon = "mdi:clock-start"
_attr_device_class = SensorDeviceClass.TIMESTAMP
def __init__(self, coordinator: Any, config_entry: ConfigEntry, name: str) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, config_entry, name)
self._attr_unique_id = f"{config_entry.entry_id}_anticipated_start_time"
self._anticipated_start: datetime | None = None
self._attributes: dict[str, Any] = {}
@property
def native_value(self) -> datetime | None:
"""Return the state of the sensor."""
return self._anticipated_start
@property
def available(self) -> bool:
"""Return True if entity is available."""
return True
@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes."""
return self._attributes
def _handle_anticipation_result(self, data: dict) -> None:
"""Handle new anticipation result."""
# Clear sensor values when scheduler is disabled or no timeslot is available
# This sets the sensor to 'unknown' state and clears all attributes
anticipated_start = data.get(ATTR_ANTICIPATED_START_TIME)
if anticipated_start is None:
self._anticipated_start = None
self._attributes = {}
_LOGGER.info("Anticipated start time cleared (scheduler disabled or no timeslot)")
return
if anticipated_start:
# Event carries ISO string; accept datetime too
if isinstance(anticipated_start, str):
# Parse with HA helper to preserve timezone correctly
parsed = dt_util.parse_datetime(anticipated_start)
if parsed is None:
try:
parsed = datetime.fromisoformat(anticipated_start)
except ValueError:
parsed = None
self._anticipated_start = parsed
else:
self._anticipated_start = anticipated_start
# Store attributes - keep next_schedule_time as ISO string for proper serialization
next_sched = data.get(ATTR_NEXT_SCHEDULE_TIME)
if isinstance(next_sched, str):
next_sched_attr = next_sched # Already ISO string
elif isinstance(next_sched, datetime):
next_sched_attr = next_sched.isoformat()
else:
next_sched_attr = None
self._attributes = {
ATTR_NEXT_SCHEDULE_TIME: next_sched_attr,
ATTR_NEXT_TARGET_TEMP: data.get(ATTR_NEXT_TARGET_TEMP),
"anticipation_minutes": data.get("anticipation_minutes"),
"current_temp": data.get("current_temp"),
"scheduler_entity": data.get("scheduler_entity"),
ATTR_LEARNED_HEATING_SLOPE: data.get(ATTR_LEARNED_HEATING_SLOPE),
"confidence_level": data.get("confidence_level"), # Phase 4: New from domain
}
_LOGGER.info(
"Anticipated start time updated: %s (confidence: %.2f)",
self._anticipated_start,
data.get("confidence_level", 0.0),
)
class IntelligentHeatingPilotAnticipationTimeHmsSensor(IntelligentHeatingPilotSensorBase):
"""Companion sensor showing only HH:MM:SS for anticipated start time."""
_attr_name = "Anticipated Start Time (HMS)"
_attr_icon = "mdi:clock-outline"
def __init__(self, coordinator: Any, config_entry: ConfigEntry, name: str) -> None:
super().__init__(coordinator, config_entry, name)
self._attr_unique_id = f"{config_entry.entry_id}_anticipated_start_time_hms"
self._time_str: str | None = None
self._attributes: dict[str, Any] = {}
@property
def native_value(self) -> str | None:
return self._time_str
@property
def available(self) -> bool:
return True
@property
def extra_state_attributes(self) -> dict:
return self._attributes
def _handle_anticipation_result(self, data: dict) -> None:
# Check if anticipated start time is available
value = data.get(ATTR_ANTICIPATED_START_TIME)
if value is None:
self._time_str = None
self._attributes = {}
return
dt_val: datetime | None = None
if isinstance(value, str):
dt_val = dt_util.parse_datetime(value) or None
if dt_val is None:
try:
dt_val = datetime.fromisoformat(value)
except ValueError:
dt_val = None
elif isinstance(value, datetime):
dt_val = value
if dt_val is not None:
# Ensure local timezone and format HH:MM:SS
local_dt = dt_util.as_local(dt_val)
self._time_str = local_dt.strftime("%H:%M:%S")
# Provide raw timestamp as attribute for completeness
self._attributes = {
"timestamp": local_dt.isoformat(),
}
class IntelligentHeatingPilotGlobalLearnedSlopeSensor(IntelligentHeatingPilotSensorBase):
"""Sensor for global learned heating slope (LHS)."""
_attr_name = "Global Learned Heating Slope"
_attr_native_unit_of_measurement = "°C/h"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:chart-line"
def __init__(self, coordinator: Any, config_entry: ConfigEntry, name: str) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, config_entry, name)
self._attr_unique_id = f"{config_entry.entry_id}_global_learned_heating_slope"
# Tracks the last value written to HA state for threshold comparison.
self._last_lhs_displayed: float | None = None
@property
def native_value(self) -> float | None:
"""Return the global LHS from coordinator cache."""
value = self.coordinator.get_learned_heating_slope()
# Round to 2 decimal places for cleaner display
return round(value, 2) if value is not None else None
@property
def available(self) -> bool:
"""Return True if entity is available."""
return True
@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes."""
return {
"description": "Average heating slope across all extracted cycles",
}
def _handle_anticipation_result(self, data: dict) -> None:
"""Refresh state only when global LHS changed by >= 0.1 °C/h.
The coordinator is updated before this event fires, so we compare the
current native_value against the last value written to HA state.
Availability transitions (None ↔ value) always trigger a state write.
"""
current = self.native_value
prev = self._last_lhs_displayed
if (prev is None) != (current is None) or (
prev is not None and current is not None and abs(current - prev) >= 0.1
):
self._last_lhs_displayed = current
self.async_write_ha_state()
class IntelligentHeatingPilotContextualLearnedSlopeSensor(IntelligentHeatingPilotSensorBase):
"""Sensor for contextual learned heating slope (for current hour)."""
_attr_name = "Contextual Learned Heating Slope"
_attr_native_unit_of_measurement = "°C/h"
_attr_icon = "mdi:chart-line"
def __init__(self, coordinator: Any, config_entry: ConfigEntry, name: str) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, config_entry, name)
self._attr_unique_id = f"{config_entry.entry_id}_contextual_learned_heating_slope"
self._next_schedule_time: datetime | None = None
# Tracks the last value written to HA state for threshold comparison.
self._last_contextual_lhs_displayed: float | None = None
@property
def native_value(self) -> float | None:
"""Return the contextual LHS for next scheduled event hour."""
# If no next schedule time, cannot provide contextual LHS
if not self._next_schedule_time:
return None
try:
schedule_hour = self._next_schedule_time.hour
except AttributeError:
return None
# Try to get contextual LHS from coordinator cache for scheduled hour
contextual_lhs = self.coordinator.get_contextual_learned_heating_slope(schedule_hour)
if contextual_lhs is None or contextual_lhs == 2.0: # Default value means no data
return None
# Round to 2 decimal places for cleaner display and return as float
return float(round(contextual_lhs, 2))
@property
def available(self) -> bool:
"""Return True if entity is available."""
return True
@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes."""
if not self._next_schedule_time:
return {
"description": "Average heating slope for scheduled event hour (no event scheduled)",
"scheduled_hour": "",
}
try:
scheduled_hour = self._next_schedule_time.hour
except AttributeError:
return {
"description": "Average heating slope for scheduled event hour (parse error)",
"scheduled_hour": "",
}
return {
"description": "Average heating slope for scheduled event hour",
"scheduled_hour": f"{scheduled_hour:02d}:00",
}
def _handle_anticipation_result(self, data: dict) -> None:
"""Refresh state only when contextual LHS changed by >= 0.1 °C/h.
Captures the current schedule hour BEFORE updating _next_schedule_time so
we can detect a context change (different scheduled hour) and always write
state when the LHS context changes, regardless of the value delta.
Availability transitions (None ↔ value) always trigger a state write.
"""
old_schedule_hour = self._next_schedule_time.hour if self._next_schedule_time else None
# Update next_schedule_time from event
next_schedule = data.get(ATTR_NEXT_SCHEDULE_TIME)
if next_schedule is None:
self._next_schedule_time = None
else:
# Event carries ISO string; accept datetime too
if isinstance(next_schedule, str):
parsed = dt_util.parse_datetime(next_schedule)
if parsed is None:
try:
parsed = datetime.fromisoformat(next_schedule)
except ValueError:
parsed = None
self._next_schedule_time = parsed
else:
self._next_schedule_time = next_schedule
new_schedule_hour = self._next_schedule_time.hour if self._next_schedule_time else None
schedule_context_changed = old_schedule_hour != new_schedule_hour
new_value = self.native_value
prev = self._last_contextual_lhs_displayed
if (
schedule_context_changed
or (prev is None) != (new_value is None)
or (prev is not None and new_value is not None and abs(new_value - prev) >= 0.1)
):
self._last_contextual_lhs_displayed = new_value
self.async_write_ha_state()
class IntelligentHeatingPilotNextScheduleSensor(IntelligentHeatingPilotSensorBase):
"""Sensor for next schedule time."""
_attr_name = "Next Schedule Time"
_attr_icon = "mdi:calendar-clock"
_attr_device_class = SensorDeviceClass.TIMESTAMP
def __init__(self, coordinator: Any, config_entry: ConfigEntry, name: str) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, config_entry, name)
self._attr_unique_id = f"{config_entry.entry_id}_next_schedule_time"
self._next_schedule: datetime | None = None
self._attributes: dict[str, Any] = {}
@property
def native_value(self) -> datetime | None:
"""Return the state of the sensor."""
return self._next_schedule
@property
def available(self) -> bool:
"""Return True if entity is available."""
return True
@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes."""
return self._attributes
def _handle_anticipation_result(self, data: dict) -> None:
"""Handle new anticipation result."""
# Check if scheduler is disabled or no timeslot is available
next_schedule = data.get(ATTR_NEXT_SCHEDULE_TIME)
if next_schedule is None:
self._next_schedule = None
self._attributes = {}
_LOGGER.info("Next schedule time cleared (scheduler disabled or no timeslot)")
return
if next_schedule:
# Event carries ISO string; accept datetime too
if isinstance(next_schedule, str):
# Parse with HA helper to preserve timezone correctly
parsed = dt_util.parse_datetime(next_schedule)
if parsed is None:
try:
parsed = datetime.fromisoformat(next_schedule)
except ValueError:
parsed = None
self._next_schedule = parsed
else:
self._next_schedule = next_schedule
self._attributes = {
ATTR_NEXT_TARGET_TEMP: data.get(ATTR_NEXT_TARGET_TEMP),
"scheduler_entity": data.get("scheduler_entity"),
}
_LOGGER.debug("Next schedule time updated: %s", self._next_schedule)
class IntelligentHeatingPilotNextScheduleHmsSensor(IntelligentHeatingPilotSensorBase):
"""Companion sensor showing only HH:MM:SS for next schedule time."""
_attr_name = "Next Schedule Time (HMS)"
_attr_icon = "mdi:clock-time-three-outline"
def __init__(self, coordinator: Any, config_entry: ConfigEntry, name: str) -> None:
super().__init__(coordinator, config_entry, name)
self._attr_unique_id = f"{config_entry.entry_id}_next_schedule_time_hms"
self._time_str: str | None = None
self._attributes: dict[str, Any] = {}
@property
def native_value(self) -> str | None:
return self._time_str
@property
def available(self) -> bool:
return True
@property
def extra_state_attributes(self) -> dict:
return self._attributes
def _handle_anticipation_result(self, data: dict) -> None:
# Clear when no next schedule time is available
value = data.get(ATTR_NEXT_SCHEDULE_TIME)
if value is None:
self._time_str = None
self._attributes = {}
return
dt_val: datetime | None = None
if isinstance(value, str):
dt_val = dt_util.parse_datetime(value) or None
if dt_val is None:
try:
dt_val = datetime.fromisoformat(value)
except ValueError:
dt_val = None
elif isinstance(value, datetime):
dt_val = value
if dt_val is not None:
local_dt = dt_util.as_local(dt_val)
self._time_str = local_dt.strftime("%H:%M:%S")
self._attributes = {
"timestamp": local_dt.isoformat(),
}
class IntelligentHeatingPilotPredictionConfidenceSensor(IntelligentHeatingPilotSensorBase):
"""Sensor for prediction confidence level.
Phase 4: New sensor to display domain prediction confidence (0.0-1.0).
This reflects the quality of the prediction based on learned data and
available environmental sensors.
"""
_attr_name = "Prediction Confidence"
_attr_icon = "mdi:percent"
_attr_native_unit_of_measurement = "%"
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, coordinator: Any, config_entry: ConfigEntry, name: str) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, config_entry, name)
self._attr_unique_id = f"{config_entry.entry_id}_prediction_confidence"
self._confidence: float | None = None
self._attributes: dict[str, Any] = {}
@property
def native_value(self) -> float | None:
"""Return the state of the sensor as percentage (0-100)."""
if self._confidence is not None:
return round(self._confidence * 100, 1)
return None
@property
def available(self) -> bool:
"""Return True if entity is available."""
return True
@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes."""
return self._attributes
def _handle_anticipation_result(self, data: dict) -> None:
"""Handle new anticipation result."""
# Clear when no confidence level is available
confidence = data.get("confidence_level")
if confidence is None:
self._confidence = None
self._attributes = {}
_LOGGER.info("Prediction confidence cleared (scheduler disabled or no timeslot)")
return
confidence_value = float(confidence)
if confidence_value > 1.0:
confidence_value = confidence_value / 100.0
self._confidence = confidence_value
self._attributes = {
ATTR_LEARNED_HEATING_SLOPE: data.get(ATTR_LEARNED_HEATING_SLOPE),
"anticipation_minutes": data.get("anticipation_minutes"),
"environmental_data_available": {
"humidity": data.get("humidity") is not None,
"cloud_coverage": data.get("cloud_coverage") is not None,
},
}
_LOGGER.info("Prediction confidence updated: %.1f%%", self._confidence * 100)
class IntelligentHeatingPilotDeadTimeSensor(SensorEntity):
"""Sensor for displaying learned dead time value.
Shows the dead time calculated from heating cycles.
Dead time is the delay between heating start and first measurable temperature rise (in minutes).
"""
_attr_name = "Dead Time"
_attr_icon = "mdi:timer-sand"
_attr_device_class = SensorDeviceClass.DURATION
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = "min"
_attr_has_entity_name = True
def __init__(
self,
coordinator: Any,
config_entry: ConfigEntry,
name: str,
) -> None:
"""Initialize the sensor."""
self._coordinator = coordinator
self._config_entry = config_entry
self._attr_unique_id = f"{config_entry.entry_id}_dead_time"
# Device info
self._attr_device_info = {
"identifiers": {("intelligent_heating_pilot", config_entry.entry_id)},
"name": f"Intelligent Heating Pilot {name}",
"manufacturer": "Intelligent Heating Pilot",
"model": "IHP",
}
self._dead_time: float | None = None
@property
def native_value(self) -> float | None:
"""Return the current dead time value in minutes."""
return self._dead_time
@property
def available(self) -> bool:
"""Return True if sensor is available."""
return True
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional attributes."""
return {
"auto_learning": self._coordinator.is_auto_learning_enabled(),
}
async def async_added_to_hass(self) -> None:
"""When entity is added to Home Assistant."""
await super().async_added_to_hass()
await self._async_refresh_dead_time()
@callback
def handle_dead_time_updated(event) -> None:
"""Handle dead time update events."""
data = event.data or {}
if data.get("entry_id") != self._config_entry.entry_id:
return
if self.hass is None:
return
self.hass.async_create_task(self._async_refresh_dead_time())
if self.hass is not None:
self.async_on_remove(
self.hass.bus.async_listen(EVENT_DEAD_TIME_UPDATED, handle_dead_time_updated)
)
async def _async_refresh_dead_time(self) -> None:
"""Refresh effective dead time from coordinator."""
dead_time = await self._coordinator.get_effective_dead_time()
self._dead_time = dead_time if dead_time is not None else None
if self.hass is not None:
self.async_write_ha_state()