New apps Added
This commit is contained in:
+538
@@ -0,0 +1,538 @@
|
||||
"""Home Assistant scheduler reader adapter.
|
||||
|
||||
This adapter implements ISchedulerReader by reading from Home Assistant
|
||||
scheduler entities. It translates HA entity states into domain value objects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from ...domain.interfaces.climate_data_reader_interface import IClimateDataReader
|
||||
from ...domain.interfaces.scheduler_reader_interface import ISchedulerReader
|
||||
from ...domain.value_objects import ScheduledTimeslot
|
||||
from ..vtherm_compat import get_vtherm_attribute
|
||||
from .utils import get_entity_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import State
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Default temperature used for native HA schedule entities that don't store temperature.
|
||||
# The actual temperature is resolved from the linked VTherm entity when available.
|
||||
_DEFAULT_NATIVE_SCHEDULE_TEMPERATURE: float = 20.0
|
||||
|
||||
|
||||
class HASchedulerReader(ISchedulerReader):
|
||||
"""Home Assistant implementation of scheduler reader.
|
||||
|
||||
Reads scheduled heating timeslots from Home Assistant scheduler entities.
|
||||
Supports the scheduler-component data format:
|
||||
https://github.com/nielsfaber/scheduler-component/#data-format
|
||||
|
||||
This adapter contains NO business logic - it only translates HA states
|
||||
to domain value objects.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
scheduler_entity_ids: list[str],
|
||||
vtherm_entity_id: str | None = None,
|
||||
climate_reader: IClimateDataReader | None = None,
|
||||
) -> None:
|
||||
"""Initialize the scheduler reader adapter.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
scheduler_entity_ids: List of scheduler entity IDs to monitor
|
||||
vtherm_entity_id: Optional VTherm climate entity ID used to resolve
|
||||
preset temperatures (e.g., when actions use preset modes).
|
||||
climate_reader: Optional climate data reader used to resolve the
|
||||
current VTherm target temperature for native HA schedule entities
|
||||
(schedule.*) that do not store a temperature themselves.
|
||||
"""
|
||||
self._hass = hass
|
||||
self._scheduler_entity_ids = scheduler_entity_ids
|
||||
self._vtherm_entity_id = vtherm_entity_id
|
||||
self._climate_reader = climate_reader
|
||||
|
||||
async def get_next_timeslot(self) -> ScheduledTimeslot | None:
|
||||
"""Retrieve the next scheduled heating timeslot.
|
||||
|
||||
Scans all configured scheduler entities and returns the earliest
|
||||
upcoming timeslot with a valid time and temperature.
|
||||
|
||||
Supports both HACS Scheduler (switch.*) and native HA Schedule (schedule.*)
|
||||
entities. Native schedules use the next_event attribute and require special
|
||||
state-aware handling since "off" means "not in active timeslot", not disabled.
|
||||
|
||||
Returns:
|
||||
The next schedule timeslot, or None if no valid timeslots found
|
||||
or if no scheduler entities are configured.
|
||||
"""
|
||||
if not self._scheduler_entity_ids or len(self._scheduler_entity_ids) == 0:
|
||||
_LOGGER.debug("No scheduler entities configured - scheduler is optional")
|
||||
return None
|
||||
|
||||
chosen_time: datetime | None = None
|
||||
chosen_temp: float | None = None
|
||||
chosen_entity: str | None = None
|
||||
|
||||
for entity_id in self._scheduler_entity_ids:
|
||||
state = self._hass.states.get(entity_id)
|
||||
if not state:
|
||||
# Use debug level if HA is still starting up, warning otherwise
|
||||
device_name = get_entity_name(self._hass, entity_id)
|
||||
if self._hass.is_running:
|
||||
_LOGGER.warning("[%s] Scheduler entity not found", device_name)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"[%s] Scheduler entity not yet available (HA starting)", device_name
|
||||
)
|
||||
continue
|
||||
|
||||
# Native HA schedule entities (schedule.*) use next_event and state-aware logic
|
||||
if entity_id.startswith("schedule."):
|
||||
next_time, target_temp = self._extract_native_schedule_data(state)
|
||||
else:
|
||||
# Skip disabled HACS schedulers (state is "off")
|
||||
if state.state == "off":
|
||||
device_name = get_entity_name(self._hass, entity_id)
|
||||
_LOGGER.debug(
|
||||
"[%s] Scheduler is disabled (state: off), skipping", device_name
|
||||
)
|
||||
continue
|
||||
|
||||
# Extract next trigger time and target temperature (HACS format)
|
||||
next_time, target_temp = self._extract_timeslot_data(state)
|
||||
|
||||
if next_time and target_temp is not None:
|
||||
# Keep track of the earliest timeslot
|
||||
if not chosen_time or next_time < chosen_time:
|
||||
chosen_time = next_time
|
||||
chosen_temp = target_temp
|
||||
chosen_entity = entity_id
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Skipped scheduler %s: time=%s temp=%s", entity_id, next_time, target_temp
|
||||
)
|
||||
|
||||
if chosen_time and chosen_temp is not None and chosen_entity:
|
||||
device_name = get_entity_name(self._hass, chosen_entity)
|
||||
_LOGGER.info(
|
||||
"[%s] Next timeslot at %s (%.1f°C)",
|
||||
device_name,
|
||||
chosen_time.strftime("%H:%M"),
|
||||
chosen_temp,
|
||||
)
|
||||
return ScheduledTimeslot(
|
||||
target_time=chosen_time,
|
||||
target_temp=chosen_temp,
|
||||
timeslot_id=f"{chosen_entity}_{chosen_time.isoformat()}",
|
||||
scheduler_entity=chosen_entity,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"No valid scheduler timeslot found (check scheduler configuration and preset temperatures)"
|
||||
)
|
||||
return None
|
||||
|
||||
def _extract_timeslot_data(self, state: State) -> tuple[datetime | None, float | None]:
|
||||
"""Extract next trigger time and target temperature from scheduler state.
|
||||
|
||||
Supports multiple scheduler attribute layouts:
|
||||
- Standard: next_trigger + next_slot + actions
|
||||
- Fallback: next_entries with time and actions
|
||||
|
||||
Args:
|
||||
state: Home Assistant scheduler entity state
|
||||
|
||||
Returns:
|
||||
Tuple of (next_time, target_temp), either can be None if not found
|
||||
"""
|
||||
attrs = state.attributes
|
||||
|
||||
# Debug logging to understand the actual structure
|
||||
_LOGGER.debug(
|
||||
"Scheduler %s attributes: next_trigger=%s, next_slot=%s, actions=%s, next_entries=%s",
|
||||
state.entity_id,
|
||||
attrs.get("next_trigger"),
|
||||
attrs.get("next_slot"),
|
||||
type(attrs.get("actions")),
|
||||
type(attrs.get("next_entries")),
|
||||
)
|
||||
|
||||
# Try standard format first
|
||||
next_time = self._parse_next_trigger(attrs.get("next_trigger"))
|
||||
target_temp = self._extract_target_temp_standard(attrs)
|
||||
|
||||
# Fallback to next_entries format
|
||||
if not next_time or target_temp is None:
|
||||
next_time_fallback, target_temp_fallback = self._extract_from_next_entries(attrs)
|
||||
if not next_time:
|
||||
next_time = next_time_fallback
|
||||
if target_temp is None:
|
||||
target_temp = target_temp_fallback
|
||||
|
||||
return next_time, target_temp
|
||||
|
||||
def _parse_next_trigger(self, next_trigger_raw: str | None) -> datetime | None:
|
||||
"""Parse next_trigger attribute to datetime.
|
||||
|
||||
Args:
|
||||
next_trigger_raw: Raw next_trigger value from scheduler
|
||||
|
||||
Returns:
|
||||
Parsed datetime with timezone, or None if parsing fails
|
||||
"""
|
||||
if not next_trigger_raw:
|
||||
return None
|
||||
|
||||
# Try HA's robust datetime parser first
|
||||
parsed = dt_util.parse_datetime(str(next_trigger_raw))
|
||||
|
||||
# Fallback to ISO format parsing
|
||||
if parsed is None:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(next_trigger_raw))
|
||||
except ValueError:
|
||||
_LOGGER.debug("Failed to parse next_trigger: %s", next_trigger_raw)
|
||||
return None
|
||||
|
||||
# Ensure timezone is set
|
||||
if parsed and parsed.tzinfo is None:
|
||||
parsed = dt_util.as_local(parsed)
|
||||
|
||||
return cast(datetime, parsed) if parsed else None
|
||||
|
||||
def _extract_target_temp_standard(self, attrs: dict) -> float | None:
|
||||
"""Extract target temperature from standard scheduler format.
|
||||
|
||||
Uses next_slot index to find the action in the actions list.
|
||||
|
||||
Args:
|
||||
attrs: Scheduler entity attributes
|
||||
|
||||
Returns:
|
||||
Target temperature in Celsius, or None if not found
|
||||
"""
|
||||
next_slot = attrs.get("next_slot")
|
||||
actions = attrs.get("actions")
|
||||
|
||||
if not isinstance(actions, list):
|
||||
return None
|
||||
|
||||
if not isinstance(next_slot, int) or next_slot < 0 or next_slot >= len(actions):
|
||||
return None
|
||||
|
||||
action = actions[next_slot]
|
||||
return self._extract_temp_from_action(action)
|
||||
|
||||
def _extract_from_next_entries(self, attrs: dict) -> tuple[datetime | None, float | None]:
|
||||
"""Extract time and temperature from next_entries fallback format.
|
||||
|
||||
Args:
|
||||
attrs: Scheduler entity attributes
|
||||
|
||||
Returns:
|
||||
Tuple of (next_time, target_temp)
|
||||
"""
|
||||
next_entries = attrs.get("next_entries")
|
||||
|
||||
if not isinstance(next_entries, list) or not next_entries:
|
||||
return None, None
|
||||
|
||||
entry = next_entries[0]
|
||||
|
||||
# Extract time
|
||||
time_raw = entry.get("time") or entry.get("start") or entry.get("trigger_time")
|
||||
next_time = self._parse_next_trigger(time_raw) if time_raw else None
|
||||
|
||||
# Extract temperature from first action
|
||||
entry_actions = entry.get("actions", [])
|
||||
target_temp = None
|
||||
if isinstance(entry_actions, list) and entry_actions:
|
||||
target_temp = self._extract_temp_from_action(entry_actions[0])
|
||||
|
||||
return next_time, target_temp
|
||||
|
||||
def _extract_temp_from_action(self, action: dict) -> float | None:
|
||||
"""Extract target temperature from a scheduler action.
|
||||
|
||||
Supports:
|
||||
- climate.set_temperature with direct temperature value
|
||||
- climate.set_preset_mode with preset mapped to temperature
|
||||
|
||||
Args:
|
||||
action: Scheduler action dictionary
|
||||
|
||||
Returns:
|
||||
Target temperature in Celsius, or None if not found
|
||||
"""
|
||||
if not isinstance(action, dict):
|
||||
return None
|
||||
|
||||
service = action.get("service") or action.get("service_call")
|
||||
data = action.get("data") or action.get("service_data") or {}
|
||||
|
||||
# Direct temperature setting
|
||||
if service == "climate.set_temperature":
|
||||
temp = data.get("temperature")
|
||||
if temp is not None:
|
||||
try:
|
||||
return float(temp)
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.warning("Invalid temperature in action: %s", temp)
|
||||
return None
|
||||
|
||||
# Preset mode requires mapping via VTherm attributes
|
||||
# This is handled by getting the current VTherm state when the preset is active
|
||||
if service == "climate.set_preset_mode":
|
||||
preset = data.get("preset_mode") or data.get("preset") or data.get("mode")
|
||||
if isinstance(preset, str):
|
||||
# Try resolving using VTherm attributes if available
|
||||
resolved = self._resolve_preset_temperature(preset)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
_LOGGER.debug(
|
||||
"Could not resolve preset '%s' to temperature (entity=%s)",
|
||||
preset,
|
||||
self._vtherm_entity_id,
|
||||
)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _extract_native_schedule_data(self, state: State) -> tuple[datetime | None, float | None]:
|
||||
"""Extract next timeslot from a native HA schedule entity.
|
||||
|
||||
Native HA schedule entities (schedule.*) use the next_event attribute,
|
||||
which always points to the NEXT state change:
|
||||
- State = "off" → next_event = next ON time (use for preheating anticipation)
|
||||
- State = "on" → next_event = next OFF time (skip - already in active period)
|
||||
|
||||
Args:
|
||||
state: Native HA schedule entity state
|
||||
|
||||
Returns:
|
||||
Tuple of (next_time, target_temp), either can be None
|
||||
"""
|
||||
# Use the already-available state object to derive a friendly device name
|
||||
device_name = cast(str, state.attributes.get("friendly_name") or state.entity_id)
|
||||
|
||||
if state.state != "off":
|
||||
# Schedule is ON, next_event points to next OFF time - not useful for preheating
|
||||
_LOGGER.debug(
|
||||
"[%s] Native schedule is ON (next_event = next OFF time), skipping for preheating",
|
||||
device_name,
|
||||
)
|
||||
return None, None
|
||||
|
||||
# Schedule is OFF, next_event is the next ON time - use for preheating
|
||||
attrs = state.attributes
|
||||
next_time = self._parse_datetime_value(attrs.get("next_event"))
|
||||
if not next_time:
|
||||
_LOGGER.debug("[%s] Native schedule has no valid next_event attribute", device_name)
|
||||
return None, None
|
||||
|
||||
target_temp = self._get_native_schedule_temperature()
|
||||
_LOGGER.debug(
|
||||
"[%s] Native schedule next ON event at %s (%.1f°C)",
|
||||
device_name,
|
||||
next_time.strftime("%H:%M"),
|
||||
target_temp,
|
||||
)
|
||||
return next_time, target_temp
|
||||
|
||||
def _parse_datetime_value(self, value: object) -> datetime | None:
|
||||
"""Parse a datetime value that can be either a datetime object or an ISO string.
|
||||
|
||||
Native HA schedule entities can return next_event as either a datetime
|
||||
object or an ISO format string, so both formats must be handled.
|
||||
|
||||
Args:
|
||||
value: Value to parse (datetime object or string)
|
||||
|
||||
Returns:
|
||||
Parsed datetime with timezone, or None if parsing fails
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, datetime):
|
||||
# Already a datetime - ensure timezone is set
|
||||
if value.tzinfo is None:
|
||||
return dt_util.as_local(value)
|
||||
return value
|
||||
|
||||
# Try string parsing using existing method
|
||||
return self._parse_next_trigger(str(value))
|
||||
|
||||
def _get_native_schedule_temperature(self) -> float:
|
||||
"""Get target temperature for native HA schedule entities.
|
||||
|
||||
Since native HA schedules don't store temperature, retrieves it from the
|
||||
injected climate data reader (IClimateDataReader), which is the designated
|
||||
adapter for reading VTherm state. Falls back to
|
||||
_DEFAULT_NATIVE_SCHEDULE_TEMPERATURE if no climate reader is configured or
|
||||
the VTherm temperature cannot be resolved.
|
||||
|
||||
Returns:
|
||||
Target temperature in Celsius from VTherm, or the default value
|
||||
"""
|
||||
if self._climate_reader is None:
|
||||
_LOGGER.debug(
|
||||
"No climate reader configured for native schedule temperature resolution, "
|
||||
"using %.1f°C default",
|
||||
_DEFAULT_NATIVE_SCHEDULE_TEMPERATURE,
|
||||
)
|
||||
return _DEFAULT_NATIVE_SCHEDULE_TEMPERATURE
|
||||
|
||||
temp = self._climate_reader.get_current_target_temperature()
|
||||
if temp is not None:
|
||||
_LOGGER.debug(
|
||||
"Native schedule temperature resolved from climate reader: %.1f°C",
|
||||
temp,
|
||||
)
|
||||
return temp
|
||||
|
||||
_LOGGER.debug(
|
||||
"Could not resolve temperature from climate reader, using %.1f°C default",
|
||||
_DEFAULT_NATIVE_SCHEDULE_TEMPERATURE,
|
||||
)
|
||||
return _DEFAULT_NATIVE_SCHEDULE_TEMPERATURE
|
||||
|
||||
async def is_scheduler_enabled(self, scheduler_entity_id: str) -> bool:
|
||||
"""Check if a specific scheduler is enabled.
|
||||
|
||||
For native HA schedule entities (schedule.*), the "off" state means the
|
||||
schedule is not in an active timeslot — not that it is disabled. These
|
||||
entities are therefore always considered enabled *when the entity exists
|
||||
and is available*. Returns False if the entity cannot be found.
|
||||
|
||||
For HACS switch-based schedulers, a scheduler is considered enabled if
|
||||
its state is NOT "off". States like "on", "idle", "waiting" are enabled.
|
||||
|
||||
Args:
|
||||
scheduler_entity_id: The scheduler entity ID to check
|
||||
|
||||
Returns:
|
||||
True if the scheduler is enabled, False otherwise
|
||||
"""
|
||||
state = self._hass.states.get(scheduler_entity_id)
|
||||
if not state:
|
||||
_LOGGER.debug("Scheduler entity not found when checking state: %s", scheduler_entity_id)
|
||||
return False
|
||||
|
||||
# Native HA schedules: "off" means outside an active timeslot, not disabled
|
||||
if scheduler_entity_id.startswith("schedule."):
|
||||
if state.state == "unavailable":
|
||||
_LOGGER.debug("Native HA schedule %s is unavailable", scheduler_entity_id)
|
||||
return False
|
||||
_LOGGER.debug(
|
||||
"Native HA schedule %s is considered enabled (state: %s)",
|
||||
scheduler_entity_id,
|
||||
state.state,
|
||||
)
|
||||
return True
|
||||
|
||||
is_enabled = state.state != "off"
|
||||
_LOGGER.debug(
|
||||
"Scheduler %s state: %s (enabled: %s)", scheduler_entity_id, state.state, is_enabled
|
||||
)
|
||||
return cast(bool, is_enabled)
|
||||
|
||||
def _resolve_preset_temperature(self, preset: str) -> float | None:
|
||||
"""Resolve a preset name to a numeric temperature using VTherm attributes.
|
||||
|
||||
This uses the VTherm climate entity attributes as the source of truth. It
|
||||
attempts common attribute naming conventions, with v8.0.0+ compatibility
|
||||
using get_vtherm_attribute() to access both legacy and new data structures.
|
||||
|
||||
Args:
|
||||
preset: Preset mode name from the scheduler action
|
||||
|
||||
Returns:
|
||||
Temperature in Celsius, or None if it cannot be resolved.
|
||||
"""
|
||||
if not self._vtherm_entity_id:
|
||||
return None
|
||||
state = self._hass.states.get(self._vtherm_entity_id)
|
||||
if not state:
|
||||
_LOGGER.debug("VTherm entity not found: %s", self._vtherm_entity_id)
|
||||
return None
|
||||
|
||||
key = str(preset).lower().replace(" ", "_")
|
||||
|
||||
# First, try the preset_temperatures dict (VTherm v8.0.0+ format)
|
||||
preset_temps = get_vtherm_attribute(state, "preset_temperatures")
|
||||
if isinstance(preset_temps, dict):
|
||||
# VTherm uses format like "eco_temp", "boost_temp", "comfort_temp"
|
||||
# Try multiple key patterns
|
||||
preset_keys_to_try = [
|
||||
f"{key}_temp", # eco_temp, boost_temp
|
||||
f"{key}_temperature", # eco_temperature
|
||||
key, # eco, boost (fallback)
|
||||
preset.lower(), # Original lowercase
|
||||
]
|
||||
|
||||
for preset_key in preset_keys_to_try:
|
||||
if preset_key in preset_temps:
|
||||
try:
|
||||
temp_value = float(preset_temps[preset_key])
|
||||
# Ignore 0 values as they indicate uninitialized presets
|
||||
if temp_value > 0:
|
||||
_LOGGER.debug(
|
||||
"Resolved preset '%s' to %.1f°C (from %s)",
|
||||
preset,
|
||||
temp_value,
|
||||
preset_key,
|
||||
)
|
||||
return temp_value
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Skipping preset '%s' with 0°C (likely uninitialized)", preset
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.debug(
|
||||
"Invalid preset_temperatures value for %s: %s",
|
||||
preset_key,
|
||||
preset_temps[preset_key],
|
||||
)
|
||||
|
||||
# Fallback: try common naming patterns with v8.0.0+ compatibility
|
||||
candidate_keys = [
|
||||
f"{key}_temperature",
|
||||
f"{key}_temp",
|
||||
f"temperature_{key}",
|
||||
f"temp_{key}",
|
||||
]
|
||||
|
||||
for k in candidate_keys:
|
||||
# Use get_vtherm_attribute to handle both legacy and v8.0.0+ formats
|
||||
value = get_vtherm_attribute(state, k)
|
||||
if value is not None:
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
_LOGGER.debug("Invalid preset attribute %s=%s", k, value)
|
||||
|
||||
# Fallback: if this preset is currently active, use current target temperature
|
||||
current_preset = get_vtherm_attribute(state, "preset_mode")
|
||||
if isinstance(current_preset, str) and current_preset.lower() == key:
|
||||
for target_key in ("temperature", "target_temperature", "target_temp"):
|
||||
value = get_vtherm_attribute(state, target_key)
|
||||
if value is not None:
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user