New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,457 @@
"""Home Assistant event bridge - translates HA events to orchestrator calls.
This infrastructure component listens to HA entity state changes and delegates
to the orchestrator.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from typing import TYPE_CHECKING
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util import dt as dt_util
from .vtherm_compat import get_vtherm_attribute
if TYPE_CHECKING:
from datetime import datetime
from ..application import HeatingOrchestrator
_LOGGER = logging.getLogger(__name__)
# Minimum change in monitored sensor value (humidity %, cloud coverage %) to
# trigger a recalculation. Small fluctuations below this threshold are ignored.
_MONITORED_ENTITY_CHANGE_THRESHOLD = 3.0
# Tolerance in seconds for anticipated_start_time comparison. Changes smaller
# than this are not considered meaningful enough to re-publish the event.
_ANTICIPATION_TIME_TOLERANCE_SECONDS = 60
class HAEventBridge:
"""Bridges Home Assistant events to application service.
This infrastructure component:
- Listens to relevant HA entity state changes
- Translates events to application service calls
- Manages state change listeners lifecycle
NO business logic - pure event routing.
"""
def __init__(
self,
hass: HomeAssistant,
orchestrator: HeatingOrchestrator,
vtherm_entity_id: str,
scheduler_entity_ids: list[str],
monitored_entity_ids: list[str] | None = None,
entry_id: str | None = None,
get_ihp_enabled_func: Callable[[], bool] | None = None,
) -> None:
"""Initialize the event bridge.
Args:
hass: Home Assistant instance
orchestrator: Orchestrator to delegate to
vtherm_entity_id: VTherm entity to monitor for slopes
scheduler_entity_ids: Scheduler entities to monitor
monitored_entity_ids: Additional entities to monitor (humidity, etc.)
entry_id: Config entry ID for event filtering
get_ihp_enabled_func: Callback function to get current IHP enabled state
"""
self._hass = hass
self._orchestrator = orchestrator
self._vtherm_entity_id = vtherm_entity_id
self._scheduler_entity_ids = scheduler_entity_ids
self._monitored_entity_ids = monitored_entity_ids or []
self._get_ihp_enabled = get_ihp_enabled_func or (lambda: True)
self._entry_id = entry_id
# Track all entities that should trigger updates
self._tracked_entities = (
[vtherm_entity_id] + scheduler_entity_ids + self._monitored_entity_ids
)
# Listener cleanup callbacks
self._listeners: list = []
# Debouncing state
self._ignore_vtherm_until: datetime | None = None
# Deduplication: remember the last event data that was published so we
# can skip firing when nothing meaningful has changed.
self._last_published_data: dict | None = None
# Track active tasks for proper shutdown
self._active_tasks: set = set()
self._is_shutting_down = False
self._recalculate_task: asyncio.Task | None = None
self._recalculate_pending = False
def setup_listeners(self) -> None:
"""Setup all event listeners."""
@callback
def _on_entity_changed(event: Event[EventStateChangedData]) -> None:
"""Handle entity state change events.
All listened entities trigger _recalculate_and_publish(), which routes
to appropriate orchestrator method based on event source.
"""
entity_id = event.data.get("entity_id")
if entity_id not in self._tracked_entities:
return
# VTherm-specific handling for slope learning
if entity_id == self._vtherm_entity_id:
self._handle_vtherm_change(event)
elif entity_id in self._scheduler_entity_ids:
# Only trigger for meaningful scheduler state changes
self._trigger_recalculate_if_meaningful(
self._has_meaningful_scheduler_change(event), entity_id
)
else:
# Monitored entity (humidity, cloud cover) apply threshold filter
self._trigger_recalculate_if_meaningful(
self._has_meaningful_monitored_change(event), entity_id
)
# Register state change listener
unsub = async_track_state_change_event(
self._hass, self._tracked_entities, _on_entity_changed
)
self._listeners.append(unsub)
_LOGGER.debug("Event bridge tracking %d entities", len(self._tracked_entities))
# ------------------------------------------------------------------
# Smart filtering helpers
# ------------------------------------------------------------------
def _trigger_recalculate_if_meaningful(self, meaningful: bool, entity_id: str) -> None:
"""Trigger recalculation when the entity change is meaningful.
Logs the outcome at DEBUG level to aid diagnostics without noise.
"""
if meaningful and not self._is_shutting_down:
_LOGGER.debug("Entity %s changed meaningfully, triggering update", entity_id)
self._request_recalculate()
elif not meaningful:
_LOGGER.debug("Entity %s change not actionable, skipping recalculation", entity_id)
def _request_recalculate(self) -> None:
"""Request a recalculation with coalescing.
If a recalculation is already running, mark one pending pass instead of
spawning additional concurrent tasks.
"""
if self._is_shutting_down:
return
if self._recalculate_task is not None and not self._recalculate_task.done():
self._recalculate_pending = True
_LOGGER.debug("Recalculation already running, coalescing trigger")
return
task = self._hass.async_create_task(self._run_recalculate_loop())
self._recalculate_task = task
self._active_tasks.add(task)
task.add_done_callback(self._on_recalculate_task_done)
def _on_recalculate_task_done(self, task: asyncio.Task) -> None:
"""Track recalculation task completion and cleanup references."""
self._active_tasks.discard(task)
if self._recalculate_task is task:
self._recalculate_task = None
async def _run_recalculate_loop(self) -> None:
"""Run recalculation and process one coalesced pending request if needed."""
while not self._is_shutting_down:
try:
await self._recalculate_and_publish()
except Exception as exc: # noqa: BLE001
_LOGGER.error("Error during recalculation workflow: %s", exc, exc_info=True)
if self._recalculate_pending:
self._recalculate_pending = False
_LOGGER.debug("Processing coalesced recalculation trigger")
continue
break
def _has_meaningful_scheduler_change(self, event: Event[EventStateChangedData]) -> bool:
"""Return True only when a scheduler state change is actionable for IHP.
Ignores attribute-only updates that do not affect the IHP schedule
(e.g., internal counters, last_triggered, etc.). Only the following
changes are considered meaningful:
- The enabled / disabled state (on / off) toggled.
- The ``next_trigger`` timestamp changed (different upcoming slot).
- The ``actions`` attribute changed (different target temperature / preset).
"""
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if not old_state or not new_state:
return True
# Enabled / disabled toggle
if old_state.state != new_state.state:
return True
old_attrs = old_state.attributes
new_attrs = new_state.attributes
# Next occurrence time changed
if old_attrs.get("next_trigger") != new_attrs.get("next_trigger"):
return True
# Target temperature / actions changed
return bool(old_attrs.get("actions") != new_attrs.get("actions"))
def _has_meaningful_monitored_change(self, event: Event[EventStateChangedData]) -> bool:
"""Return True only when a monitored sensor value changed significantly.
Tiny fluctuations in humidity or cloud coverage sensors are ignored.
Availability transitions are always considered meaningful.
"""
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if not old_state or not new_state:
return True
_unavailable = {"unavailable", "unknown"}
old_unavailable = old_state.state in _unavailable
new_unavailable = new_state.state in _unavailable
# Availability transition is always actionable
if old_unavailable != new_unavailable:
return True
# Both unavailable nothing changed
if old_unavailable and new_unavailable:
return False
try:
old_val = float(str(old_state.state))
new_val = float(str(new_state.state))
return abs(new_val - old_val) >= _MONITORED_ENTITY_CHANGE_THRESHOLD
except (TypeError, ValueError):
# Non-numeric state: trigger on any state string change
return bool(old_state.state != new_state.state)
def _is_meaningful_change_from_last(self, new_data: dict) -> bool:
"""Return True when *new_data* differs meaningfully from the last published event.
Prevents flooding sensors with identical or near-identical events.
"""
last = self._last_published_data
if last is None:
return True
# Core schedule fields any difference is significant
for key in ("next_schedule_time", "next_target_temperature", "scheduler_entity"):
if new_data.get(key) != last.get(key):
return True
# Current indoor temperature (0.1 °C tolerance)
new_temp = new_data.get("current_temp")
last_temp = last.get("current_temp")
# Transition between "no data" and a real temperature is always meaningful
if (new_temp is None) != (last_temp is None):
return True
if new_temp is not None and last_temp is not None and abs(new_temp - last_temp) >= 0.1:
return True
# Learned heating slope (0.05 °C/h tolerance)
new_lhs = new_data.get("learned_heating_slope")
last_lhs = last.get("learned_heating_slope")
# Transition between "no data" and a real slope is always meaningful
if (new_lhs is None) != (last_lhs is None):
return True
if new_lhs is not None and last_lhs is not None and abs(new_lhs - last_lhs) >= 0.05:
return True
# Anticipated start time (1-minute tolerance)
new_start = new_data.get("anticipated_start_time")
last_start = last.get("anticipated_start_time")
# If both are equal (including both None or identical strings/datetimes), no meaningful change
if new_start == last_start:
return False
# If one is None and the other is not, this is a meaningful change
if new_start is None or last_start is None:
return True
try:
new_dt = dt_util.parse_datetime(new_start) if isinstance(new_start, str) else new_start
last_dt = (
dt_util.parse_datetime(last_start) if isinstance(last_start, str) else last_start
)
if new_dt is None or last_dt is None:
return True
if abs((new_dt - last_dt).total_seconds()) >= _ANTICIPATION_TIME_TOLERANCE_SECONDS:
return True
except (TypeError, ValueError, AttributeError):
return True
return False
def _handle_vtherm_change(self, event: Event[EventStateChangedData]) -> None:
"""Handle VTherm state changes (temperature filter + recalculation trigger).
Args:
event: State change event
"""
if self._is_shutting_down:
return
old_state = event.data.get("old_state")
new_state = event.data.get("new_state")
if not old_state or not new_state:
return
# Check if we should ignore (self-induced change)
if self._ignore_vtherm_until and dt_util.now() < self._ignore_vtherm_until:
_LOGGER.debug("Ignoring self-induced VTherm change")
return
# Extract temperature changes (v8.0.0+ compatible)
old_temp = get_vtherm_attribute(old_state, "current_temperature")
new_temp = get_vtherm_attribute(new_state, "current_temperature")
if old_temp == new_temp:
_LOGGER.debug("VTherm change but temperature unchanged, skipping")
return
_LOGGER.debug("VTherm temperature changed: %s -> %s", old_temp, new_temp)
self._request_recalculate()
async def _recalculate_and_publish(self) -> None:
"""Recalculate anticipation and publish event for sensors if data changed.
Always fires the same unified event structure. Scheduling/clearing is
expressed via None values (never a ``clear_values`` flag) so that all
sensors share a single, consistent event shape.
"""
anticipation_data = await self._orchestrator.calculate_and_schedule_anticipation(
ihp_enabled=self._get_ihp_enabled()
)
if not anticipation_data:
anticipation_data = {}
# Build unified event structure (same keys always, None for missing values)
has_complete_data = (
anticipation_data.get("anticipated_start_time") is not None
and anticipation_data.get("next_schedule_time") is not None
)
if has_complete_data:
event_data: dict = {
"entry_id": self._entry_id,
"anticipated_start_time": anticipation_data["anticipated_start_time"].isoformat(),
"next_schedule_time": anticipation_data["next_schedule_time"].isoformat(),
"next_target_temperature": anticipation_data.get("next_target_temperature"),
"anticipation_minutes": anticipation_data.get("anticipation_minutes"),
"current_temp": anticipation_data.get("current_temp"),
"learned_heating_slope": anticipation_data.get("learned_heating_slope"),
"confidence_level": anticipation_data.get("confidence_level"),
"scheduler_entity": anticipation_data.get("scheduler_entity"),
}
else:
event_data = {
"entry_id": self._entry_id,
"anticipated_start_time": None,
"next_schedule_time": None,
"next_target_temperature": None,
"anticipation_minutes": None,
"current_temp": anticipation_data.get("current_temp"),
"learned_heating_slope": anticipation_data.get("learned_heating_slope"),
"confidence_level": None,
"scheduler_entity": None,
}
if self._is_meaningful_change_from_last(event_data):
self._hass.bus.async_fire(
"intelligent_heating_pilot_anticipation_calculated",
event_data,
)
self._last_published_data = event_data
_LOGGER.debug("Published anticipation event for sensors (data changed)")
else:
_LOGGER.debug(
"Skipped anticipation event: no meaningful change from last published data"
)
def ignore_vtherm_changes_for(self, seconds: int = 10) -> None:
"""Temporarily ignore VTherm changes (used after self-induced changes).
Args:
seconds: How long to ignore changes
"""
from datetime import timedelta
self._ignore_vtherm_until = dt_util.now() + timedelta(seconds=seconds)
async def async_cleanup(self) -> None:
"""Cleanup all event listeners and cancel pending tasks.
This is called during integration unload to ensure:
1. No more events trigger new calculations
2. All pending tasks are cancelled with timeout
"""
_LOGGER.debug("Starting event bridge cleanup with %d active tasks", len(self._active_tasks))
self._is_shutting_down = True
# Unsubscribe from all listeners to prevent new tasks
for unsub in self._listeners:
unsub()
self._listeners.clear()
# Cancel all active tasks with timeout
if self._active_tasks:
_LOGGER.debug("Cancelling %d active recalculation tasks", len(self._active_tasks))
for task in self._active_tasks:
if not task.done():
task.cancel()
# Wait for tasks to complete (with timeout to prevent hangs)
try:
await asyncio.wait_for(
asyncio.gather(*self._active_tasks, return_exceptions=True),
timeout=5.0, # 5-second timeout for all tasks
)
except asyncio.TimeoutError:
_LOGGER.warning(
"Timeout waiting for %d tasks to shutdown; forcing cleanup",
len(self._active_tasks),
)
finally:
self._active_tasks.clear()
_LOGGER.debug("Event bridge cleanup completed")
def cleanup(self) -> None:
"""Synchronous cleanup for backwards compatibility.
Note: This is called by code that doesn't support async, but doesn't
cancel pending tasks. Use async_cleanup() during normal unload.
"""
if self._is_shutting_down:
return
self._is_shutting_down = True
for unsub in self._listeners:
unsub()
self._listeners.clear()
_LOGGER.debug("Event bridge cleaned up (sync mode)")