Files
2026-07-08 10:43:39 -04:00

1128 lines
54 KiB
Python

"""Coordinator for Climate Scheduler."""
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.util import dt as dt_util
from .const import DOMAIN, MIN_TEMP, MAX_TEMP, NO_CHANGE_TEMP, SETTING_USE_WORKDAY, SETTING_WORKDAYS, DEFAULT_WORKDAYS
from .storage import ScheduleStorage
_LOGGER = logging.getLogger(__name__)
class HeatingSchedulerCoordinator(DataUpdateCoordinator):
"""Coordinator to manage heating schedule updates."""
# Backwards-compatible service API wrappers
# Services call these names (async_advance_schedule/async_advance_group/async_cancel_advance)
# but the coordinator implementation uses advance_to_next_node/advance_group_to_next_node/cancel_advance.
async def async_advance_schedule(self, entity_id: str) -> Dict[str, Any]:
"""Service wrapper: advance a single climate entity to its next node."""
return await self.advance_to_next_node(entity_id)
async def async_advance_group(self, group_name: str) -> Dict[str, Any]:
"""Service wrapper: advance all entities in a group to their next node."""
return await self.advance_group_to_next_node(group_name)
async def async_cancel_advance(self, entity_id: str) -> Dict[str, Any]:
"""Service wrapper: cancel an active advance override for an entity."""
return await self.cancel_advance(entity_id)
async def async_get_advance_status(self, entity_id: str) -> dict:
"""Return advance override status for a climate entity or a group schedule_id."""
now = dt_util.now()
# If this is a group schedule_id, aggregate across member entities.
group = await self.storage.async_get_group(entity_id)
if group is not None:
member_ids = group.get("entities", [])
latest_until: Optional[datetime] = None
for member_id in member_ids:
until = self.override_until.get(member_id)
if until and until > now:
if latest_until is None or until > latest_until:
latest_until = until
return {
"entity_id": entity_id,
"is_advanced": latest_until is not None,
"advance_time": latest_until.isoformat() if latest_until else None,
"original_node": None,
"advanced_node": None,
}
# Non-group: check if the entity has an active override (advance)
is_advanced = False
advance_time = None
original_node = None
advanced_node = None
if entity_id in self.override_until:
until = self.override_until[entity_id]
if until > now:
is_advanced = True
advance_time = until.isoformat()
history = self.advance_history.get(entity_id, [])
if history:
last = history[-1]
original_node = last.get("original_node")
advanced_node = last.get("advanced_node")
return {
"entity_id": entity_id,
"is_advanced": is_advanced,
"advance_time": advance_time,
"original_node": original_node,
"advanced_node": advanced_node,
}
def __init__(
self,
hass: HomeAssistant,
storage: ScheduleStorage,
update_interval: timedelta,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=update_interval,
)
self.storage = storage
self.last_node_states = {} # Track last node state (temp + modes) for each entity
self.last_node_times = {} # Track last node time for each entity to detect time transitions
self.override_until = {} # Track entities with advance override (entity_id -> time)
self.advance_history = {} # Track advance events (entity_id -> list of {activated_at, target_time, cancelled_at})
self._workday_available = None # Cache for workday integration availability
async def async_config_entry_first_refresh(self) -> None:
"""Handle the first refresh."""
# Load advance history from storage
self.advance_history = await self.storage.async_get_advance_history()
_LOGGER.debug(f"Loaded advance history from storage: {self.advance_history}")
# Check for workday integration
self._check_workday_integration()
# Call parent's first refresh
await super().async_config_entry_first_refresh()
def _check_workday_integration(self) -> bool:
"""Check if the Workday integration is available."""
if self._workday_available is not None:
return self._workday_available
# Check if binary_sensor.workday_sensor exists
workday_state = self.hass.states.get("binary_sensor.workday_sensor")
self._workday_available = workday_state is not None
if self._workday_available:
_LOGGER.info("Workday integration detected (binary_sensor.workday_sensor found) - available for 5/2 mode scheduling")
else:
_LOGGER.debug("Workday integration not found (binary_sensor.workday_sensor not present) - 5/2 mode will use basic day matching")
return self._workday_available
async def is_workday_enabled(self) -> bool:
"""Return whether Workday integration should be used for 5/2 scheduling."""
# First check if integration is available
if not self.is_workday_available():
return False
# Then check if user has enabled it in settings
try:
settings = await self.storage.async_get_settings()
use_workday = settings.get(SETTING_USE_WORKDAY, False)
return use_workday
except Exception as e:
_LOGGER.debug(f"Failed to check workday setting: {e}")
return False
async def get_workdays(self) -> List[str]:
"""Return list of days considered workdays from settings."""
try:
settings = await self.storage.async_get_settings()
workdays = settings.get(SETTING_WORKDAYS, DEFAULT_WORKDAYS)
# Validate workdays list
if not isinstance(workdays, list) or not workdays:
_LOGGER.warning(f"Invalid workdays setting: {workdays}, using defaults")
return DEFAULT_WORKDAYS.copy()
return workdays
except Exception as e:
_LOGGER.debug(f"Failed to get workdays setting: {e}")
return DEFAULT_WORKDAYS.copy()
async def is_workday(self, day: str) -> bool:
"""Check if a given day (e.g., 'mon', 'tue') is a workday.
Args:
day: Three-letter day abbreviation (mon, tue, wed, thu, fri, sat, sun)
Returns:
True if the day is configured as a workday, False otherwise
"""
workdays = await self.get_workdays()
return day.lower() in [d.lower() for d in workdays]
def is_workday_available(self) -> bool:
"""Return whether the Workday integration is available."""
if self._workday_available is None:
self._check_workday_integration()
return self._workday_available or False
async def force_update_all(self) -> None:
"""Force update all thermostats to their scheduled temperatures."""
_LOGGER.info("Force updating all thermostats to scheduled temperatures")
# Clear last node states and times to force updates
self.last_node_states.clear()
self.last_node_times.clear()
# Trigger immediate refresh
await self.async_request_refresh()
async def advance_to_next_node(self, entity_id: str) -> Dict[str, Any]:
"""Manually advance a specific entity to its next scheduled node."""
_LOGGER.info(f"Advancing {entity_id} to next scheduled node")
now = dt_util.now()
current_time = now.time()
current_day = now.strftime('%a').lower()
# Load global settings (min/max temps)
try:
settings = await self.storage.async_get_settings()
except Exception:
settings = {}
min_temp = settings.get("min_temp", MIN_TEMP)
max_temp = settings.get("max_temp", MAX_TEMP)
# Find the entity's group (all entities are in groups now)
groups = await self.storage.async_get_groups()
schedule_data = None
group_name = None
for g_name, group_data in groups.items():
if not group_data.get("enabled", True):
continue
# Skip ignored groups
if group_data.get("ignored", False):
continue
if entity_id in group_data.get("entities", []):
schedule_data = await self.storage.async_get_group_schedule(g_name, current_day)
group_name = g_name
is_single = group_data.get("_is_single_entity_group", False)
group_type = "single-entity" if is_single else "multi-entity"
_LOGGER.info(f"{entity_id} is in enabled {group_type} group '{g_name}'")
break
# If not in any group, entity schedule is effectively disabled or ignored
if not schedule_data:
return {
"success": False,
"error": "Entity schedule is disabled, ignored, or not found"
}
if not schedule_data or "nodes" not in schedule_data:
return {
"success": False,
"error": "No schedule found for entity"
}
nodes = schedule_data["nodes"]
if not nodes:
return {
"success": False,
"error": "Schedule has no nodes"
}
# Get the next node with day-aware wrap-around.
schedule_mode = schedule_data.get("schedule_mode", "all_days")
current_minutes = current_time.hour * 60 + current_time.minute
def _time_to_minutes(time_str: str) -> int:
hours, minutes = map(int, time_str.split(":"))
return hours * 60 + minutes
sorted_nodes = sorted(nodes, key=lambda n: _time_to_minutes(n["time"]))
next_node = None
next_node_day = current_day
# Try to find the next node later today first.
for node in sorted_nodes:
node_minutes = _time_to_minutes(node["time"])
if node_minutes > current_minutes:
next_node = node
break
# If none later today, wrap to tomorrow's first node where relevant.
if next_node is None:
days_of_week = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
current_day_index = days_of_week.index(current_day)
next_day = days_of_week[(current_day_index + 1) % 7]
# In schedule modes with day-specific schedules, prefer tomorrow's first node.
if schedule_mode in ["individual", "5/2"]:
next_day_schedule = await self.storage.async_get_group_schedule(group_name, next_day)
next_day_nodes = next_day_schedule.get("nodes", []) if next_day_schedule else []
if next_day_nodes:
next_node = sorted(next_day_nodes, key=lambda n: _time_to_minutes(n["time"]))[0]
next_node_day = next_day
# Fallback: wrap to today's first node if tomorrow schedule is empty/missing.
if next_node is None:
next_node = sorted_nodes[0]
next_node_day = next_day
if not next_node:
return {
"success": False,
"error": "Could not determine next node"
}
# Set override to prevent auto-revert until next node's scheduled time
next_node_time_str = next_node["time"]
next_node_hours, next_node_minutes = map(int, next_node_time_str.split(":"))
override_until = dt_util.now().replace(hour=next_node_hours, minute=next_node_minutes, second=0, microsecond=0)
# If next node time is earlier in the day than current time, it's tomorrow
if override_until <= dt_util.now():
override_until += timedelta(days=1)
self.override_until[entity_id] = override_until
# Record advance activation in history
if entity_id not in self.advance_history:
self.advance_history[entity_id] = []
self.advance_history[entity_id].append({
"activated_at": dt_util.now().isoformat(),
"target_time": next_node_time_str,
"target_node": next_node,
"cancelled_at": None
})
# Save history to storage
await self.storage.async_save_advance_history(self.advance_history)
_LOGGER.info(f"Set override for {entity_id} until {override_until}")
_LOGGER.info(f"{entity_id} next node: {next_node}")
# Clamp target temp (or use None for no change)
target_temp = next_node.get("temp")
is_no_change = next_node.get("noChange", False)
if is_no_change:
clamped_temp = None
_LOGGER.info(f"{entity_id} temp set to NO_CHANGE - will not modify temperature")
else:
clamped_temp = max(min_temp, min(max_temp, target_temp)) if target_temp is not None else None
# Create node signature
node_signature = {
"temp": clamped_temp,
"hvac_mode": next_node.get("hvac_mode"),
"fan_mode": next_node.get("fan_mode"),
"swing_mode": next_node.get("swing_mode"),
"preset_mode": next_node.get("preset_mode"),
}
# Update last node state to mark this as applied
self.last_node_states[entity_id] = node_signature
# Get entity state
state = self.hass.states.get(entity_id)
if state is None:
return {
"success": False,
"error": "Entity not found"
}
# Get entity capabilities
hvac_modes = state.attributes.get("hvac_modes", [])
fan_modes = state.attributes.get("fan_modes", [])
swing_modes = state.attributes.get("swing_modes", [])
preset_modes = state.attributes.get("preset_modes", [])
# Check if this is a preset-only entity
current_temperature = state.attributes.get("current_temperature")
is_preset_only = current_temperature is None
# Apply the next node settings
target_hvac_mode = next_node.get("hvac_mode")
if target_hvac_mode == "off":
_LOGGER.info(f"Advancing {entity_id} - turning off")
try:
await self.hass.services.async_call(
"climate",
"turn_off",
{"entity_id": entity_id},
blocking=True,
)
except Exception as e:
_LOGGER.debug(f"turn_off failed for {entity_id}, trying set_hvac_mode: {e}")
if "off" in hvac_modes:
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": "off"},
blocking=True,
)
else:
# Apply HVAC mode
if "hvac_mode" in next_node and next_node["hvac_mode"] != "off" and next_node["hvac_mode"] in hvac_modes:
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": next_node["hvac_mode"]},
blocking=True,
)
# Set temperature after applying HVAC/off mode
if clamped_temp is not None and not is_preset_only:
_LOGGER.info(f"Advancing {entity_id} to temp={clamped_temp}°C")
try:
await self.hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": entity_id,
ATTR_TEMPERATURE: clamped_temp,
},
blocking=True,
)
except Exception as exc:
if target_hvac_mode == "off":
_LOGGER.warning(f"Failed to set temperature after turning off {entity_id}: {exc}")
else:
return {
"success": False,
"error": f"Failed to set temperature: {str(exc)}"
}
elif clamped_temp is not None and is_preset_only:
_LOGGER.info(f"Skipping temperature change for {entity_id} (preset-only entity)")
else:
_LOGGER.info(f"Skipping temperature change for {entity_id} (NO_CHANGE set)")
# Apply fan mode
if "fan_mode" in next_node and fan_modes and next_node["fan_mode"] in fan_modes:
await self.hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": entity_id, "fan_mode": next_node["fan_mode"]},
blocking=True,
)
# Apply swing mode
if "swing_mode" in next_node and swing_modes and next_node["swing_mode"] in swing_modes:
await self.hass.services.async_call(
"climate",
"set_swing_mode",
{"entity_id": entity_id, "swing_mode": next_node["swing_mode"]},
blocking=True,
)
# Apply preset mode
if "preset_mode" in next_node and preset_modes and next_node["preset_mode"] in preset_modes:
await self.hass.services.async_call(
"climate",
"set_preset_mode",
{"entity_id": entity_id, "preset_mode": next_node["preset_mode"]},
blocking=True,
)
# Fire event for manual advance
# Get all entities in the group for event data
group_entities = groups[group_name].get("entities", [])
self.hass.bus.async_fire(
f"{DOMAIN}_node_activated",
{
# TODO: Remove entity_id in future version - deprecated in favor of entities list
"entity_id": entity_id, # Specific entity that was advanced
"entities": group_entities, # All entities in the group
"group_name": group_name,
"node": {
"time": next_node.get("time"),
"temp": clamped_temp,
"hvac_mode": next_node.get("hvac_mode"),
"fan_mode": next_node.get("fan_mode"),
"swing_mode": next_node.get("swing_mode"),
"preset_mode": next_node.get("preset_mode"),
"A": next_node.get("A"),
"B": next_node.get("B"),
"C": next_node.get("C"),
},
"day": next_node_day,
"trigger_type": "manual_advance",
}
)
_LOGGER.info(f"Fired node_activated event for {entity_id} (manual_advance)")
return {
"success": True,
"next_node": next_node,
"next_node_day": next_node_day,
"applied_temp": clamped_temp
}
async def cancel_advance(self, entity_id: str) -> Dict[str, Any]:
"""Cancel an active advance override for an entity or a group schedule_id."""
async def _cancel_single(target_entity_id: str) -> None:
# Mark the most recent advance as cancelled in history (even if override expired)
if target_entity_id in self.advance_history and self.advance_history[target_entity_id]:
latest = self.advance_history[target_entity_id][-1]
if latest.get("cancelled_at") is None:
latest["cancelled_at"] = dt_util.now().isoformat()
# Remove override if it exists
if target_entity_id in self.override_until:
del self.override_until[target_entity_id]
# Clear last node state to force immediate update to current schedule
if target_entity_id in self.last_node_states:
del self.last_node_states[target_entity_id]
group = await self.storage.async_get_group(entity_id)
if group is not None:
member_ids = group.get("entities", [])
for member_id in member_ids:
await _cancel_single(member_id)
# Cancel group-level history/override if present.
await _cancel_single(entity_id)
await self.storage.async_save_advance_history(self.advance_history)
_LOGGER.info(f"Cancelled advance for group '{entity_id}' ({len(member_ids)} members)")
await self.async_request_refresh()
return {"success": True}
_LOGGER.info(f"cancel_advance called for {entity_id}")
if entity_id not in self.advance_history or not self.advance_history[entity_id]:
_LOGGER.warning(f"No advance history found for {entity_id} to cancel")
await _cancel_single(entity_id)
await self.storage.async_save_advance_history(self.advance_history)
_LOGGER.info(f"Cancelled advance for {entity_id}")
await self.async_request_refresh()
return {"success": True}
async def clear_advance_history(self, entity_id: str) -> Dict[str, Any]:
"""Clear advance history for an entity."""
if entity_id in self.advance_history:
del self.advance_history[entity_id]
# Save updated history to storage
await self.storage.async_save_advance_history(self.advance_history)
_LOGGER.info(f"Cleared advance history for {entity_id}")
return {
"success": True
}
def get_advance_history(self, entity_id: str, hours: int = 24) -> List[Dict[str, Any]]:
"""Get advance history for an entity within the last N hours."""
if entity_id not in self.advance_history:
return []
cutoff = dt_util.now() - timedelta(hours=hours)
history = []
for event in self.advance_history[entity_id]:
activated = datetime.fromisoformat(event["activated_at"])
if activated >= cutoff:
history.append(event)
return history
async def advance_group_to_next_node(self, group_name: str) -> Dict[str, Any]:
"""Advance all entities in a group to their next scheduled node."""
_LOGGER.info(f"Advancing group '{group_name}' to next scheduled node")
# Get the group
groups = await self.storage.async_get_groups()
if group_name not in groups:
return {
"success": False,
"error": f"Group '{group_name}' not found"
}
group_data = groups[group_name]
if not group_data.get("enabled", True):
return {
"success": False,
"error": "Group is disabled"
}
entity_ids = group_data.get("entities", [])
if not entity_ids:
return {
"success": False,
"error": "Group has no entities"
}
# Advance each entity in the group
results = {}
success_count = 0
error_count = 0
first_success_entity_id: Optional[str] = None
first_success_next_node: Optional[Dict[str, Any]] = None
for entity_id in entity_ids:
try:
result = await self.advance_to_next_node(entity_id)
results[entity_id] = result
if result.get("success"):
success_count += 1
if first_success_entity_id is None:
first_success_entity_id = entity_id
first_success_next_node = result.get("next_node")
else:
error_count += 1
except Exception as e:
results[entity_id] = {
"success": False,
"error": str(e)
}
error_count += 1
# Record a group-level advance entry so schedule_id==group_name behaves consistently.
if success_count > 0 and first_success_entity_id and first_success_next_node:
if group_name not in self.advance_history:
self.advance_history[group_name] = []
self.advance_history[group_name].append(
{
"activated_at": dt_util.now().isoformat(),
"target_time": first_success_next_node.get("time"),
"target_node": first_success_next_node,
"cancelled_at": None,
}
)
# Mirror an override timestamp for the group id (used by status checks).
member_until = self.override_until.get(first_success_entity_id)
if member_until is not None:
self.override_until[group_name] = member_until
await self.storage.async_save_advance_history(self.advance_history)
return {
"success": success_count > 0,
"total_entities": len(entity_ids),
"success_count": success_count,
"error_count": error_count,
"results": results
}
def get_override_status(self, entity_id: str) -> Dict[str, Any]:
"""Get override status for an entity."""
if entity_id in self.override_until:
override_time = self.override_until[entity_id]
if dt_util.now() < override_time:
return {
"has_override": True,
"override_until": override_time.isoformat()
}
return {"has_override": False}
async def _async_update_data(self) -> Dict[str, Any]:
"""Update heating schedules."""
_LOGGER.info("=== COORDINATOR UPDATE CYCLE START ===")
try:
now = dt_util.now()
current_time = now.time()
current_day = now.strftime('%a').lower() # Get day: mon, tue, wed, etc.
_LOGGER.info(f"Current time: {current_time}, day: {current_day}")
# Load global settings (min/max temps)
try:
settings = await self.storage.async_get_settings()
except Exception:
settings = {}
min_temp = settings.get("min_temp", MIN_TEMP)
max_temp = settings.get("max_temp", MAX_TEMP)
# Get all groups and build a map of entities to their group schedules
# Now ALL entities are in groups (either multi-entity or single-entity groups)
groups = await self.storage.async_get_groups()
entity_group_schedules = {} # Maps entity_id -> (group_name, schedule_data)
groups_migrated = False
for group_name, group_data in groups.items():
# Migrate existing groups - add enabled=True if missing
if "enabled" not in group_data:
group_data["enabled"] = True
groups_migrated = True
_LOGGER.info(f"Migrated group '{group_name}' - added enabled=True")
if not group_data.get("enabled", True):
_LOGGER.debug(f"Skipping disabled group '{group_name}'")
continue
# Skip ignored groups (single-entity groups marked as ignored)
if group_data.get("ignored", False):
_LOGGER.debug(f"Skipping ignored group '{group_name}'")
continue
# Get group schedule for current day
group_schedule = await self.storage.async_get_group_schedule(group_name, current_day)
if group_schedule and "nodes" in group_schedule:
# In individual or 5/2 mode, if current time is before all nodes today,
# we need to check previous day's schedule for the active node
schedule_mode = group_schedule.get("schedule_mode", "all_days")
nodes = group_schedule["nodes"]
if schedule_mode in ["individual", "5/2"] and nodes:
# Check if current time is before all nodes today
sorted_nodes = sorted(nodes, key=lambda n: self.storage._time_to_minutes(n["time"]))
current_minutes = current_time.hour * 60 + current_time.minute
first_node_minutes = self.storage._time_to_minutes(sorted_nodes[0]["time"])
if current_minutes < first_node_minutes:
# We're before the first node of today, need previous day/period's last node
_LOGGER.info(f"Group '{group_name}': Current time {current_time} is before first node today, checking previous period")
# Calculate previous day
days_of_week = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
current_day_index = days_of_week.index(current_day)
prev_day = days_of_week[(current_day_index - 1) % 7]
# Get previous day's schedule
prev_day_schedule = await self.storage.async_get_group_schedule(group_name, prev_day)
if prev_day_schedule and prev_day_schedule.get("nodes"):
# Use previous day's last node as the active node until first node today
prev_nodes = prev_day_schedule["nodes"]
sorted_prev_nodes = sorted(prev_nodes, key=lambda n: self.storage._time_to_minutes(n["time"]))
last_prev_node = sorted_prev_nodes[-1]
# Prepend the previous day's last node to today's schedule with time "00:00"
# This way get_active_node will correctly use it as the active node until first node today
carryover_node = {**last_prev_node, "time": "00:00", "_from_previous_day": True}
group_schedule["nodes"] = [carryover_node] + nodes
_LOGGER.info(f"Group '{group_name}': Carrying over previous period's node (temp={last_prev_node.get('temp')}) to bridge to first node at {sorted_nodes[0]['time']}")
entities_list = group_data.get("entities", [])
if len(entities_list) == 0:
# Virtual group with no entities - track separately for event-only processing
entity_group_schedules[f"_virtual_{group_name}"] = (group_name, group_schedule, True)
_LOGGER.info(f"Virtual group '{group_name}' will fire events only (no entities)")
else:
# Map all entities in this group to this schedule
for entity_id in entities_list:
entity_group_schedules[entity_id] = (group_name, group_schedule, False)
is_single = group_data.get("_is_single_entity_group", False)
group_type = "single-entity" if is_single else "multi-entity"
_LOGGER.info(f"{entity_id} will use enabled {group_type} group '{group_name}' schedule")
# Save storage if any groups were migrated
if groups_migrated:
await self.storage.async_save()
_LOGGER.info("Saved migrated group data")
_LOGGER.info(f"Found {len(entity_group_schedules)} entities with enabled group schedules")
results = {}
# Process all entities that have group schedules (both single and multi-entity groups)
for entity_id, (group_name, schedule_data, is_virtual) in entity_group_schedules.items():
# Handle virtual groups (no entities, events only)
if is_virtual:
_LOGGER.info(f"Processing virtual group: '{group_name}'")
nodes = schedule_data["nodes"]
active_node = self.storage.get_active_node(nodes, current_time)
if not active_node:
_LOGGER.debug(f"No active node for virtual group '{group_name}'")
continue
# Create signature for virtual group tracking (includes time for comparison)
virtual_key = f"_virtual_{group_name}"
node_time = active_node.get("time")
node_signature = {
"time": node_time,
"temp": active_node.get("temp"),
"hvac_mode": active_node.get("hvac_mode"),
"fan_mode": active_node.get("fan_mode"),
"swing_mode": active_node.get("swing_mode"),
"preset_mode": active_node.get("preset_mode"),
"A": active_node.get("A"),
"B": active_node.get("B"),
"C": active_node.get("C"),
}
# Check if we've transitioned to a new node (time or state change)
last_node = self.last_node_states.get(virtual_key)
last_node_time = self.last_node_times.get(virtual_key)
# For virtual groups, only fire event on transitions (no settings to apply)
if last_node == node_signature:
_LOGGER.debug(f"Virtual group '{group_name}' still on same node (time: {node_time}), skipping")
results[virtual_key] = {
"updated": False,
"reason": "same_node"
}
continue
# Node has changed, fire event
self.last_node_states[virtual_key] = node_signature
self.last_node_times[virtual_key] = node_time
self.hass.bus.async_fire(
f"{DOMAIN}_node_activated",
{
# TODO: Remove entity_id in future version - deprecated in favor of entities list
"entity_id": None, # No entity for virtual groups
"entities": [], # Empty list for virtual groups
"group_name": group_name,
"node": {
"time": active_node.get("time"),
"temp": active_node.get("temp"),
"hvac_mode": active_node.get("hvac_mode"),
"fan_mode": active_node.get("fan_mode"),
"swing_mode": active_node.get("swing_mode"),
"preset_mode": active_node.get("preset_mode"),
"A": active_node.get("A"),
"B": active_node.get("B"),
"C": active_node.get("C"),
},
"previous_node": last_node,
"day": current_day,
"trigger_type": "scheduled",
}
)
_LOGGER.info(f"Fired node_activated event for virtual group '{group_name}' (scheduled)")
results[virtual_key] = {
"updated": True,
"virtual": True
}
continue
# Check if entity exists in Home Assistant first
state = self.hass.states.get(entity_id)
if state is None:
_LOGGER.debug(f"Entity {entity_id} not found in Home Assistant, skipping (may have been removed or renamed)")
continue
_LOGGER.info(f"Processing entity: {entity_id} from group '{group_name}'")
# Check if entity has an active advance override
if entity_id in self.override_until:
override_time = self.override_until[entity_id]
if dt_util.now() < override_time:
_LOGGER.debug(f"Skipping {entity_id} - advance override active until {override_time}")
results[entity_id] = {
"updated": False,
"reason": "advance_override_active"
}
continue
else:
# Override expired, mark as completed in history
_LOGGER.info(f"Override expired for {entity_id}, resuming normal scheduling")
history_updated = False
if entity_id in self.advance_history and self.advance_history[entity_id]:
# Find the most recent uncompleted advance
for event in reversed(self.advance_history[entity_id]):
if event["cancelled_at"] is None:
event["cancelled_at"] = dt_util.now().isoformat()
_LOGGER.info(f"Marked advance as completed for {entity_id}")
history_updated = True
break
if history_updated:
# Save updated history to storage
await self.storage.async_save_advance_history(self.advance_history)
del self.override_until[entity_id]
# Entity is in a group (either multi-entity or single-entity group)
_LOGGER.info(f"{entity_id} using group '{group_name}' schedule")
_LOGGER.info(f"{entity_id} schedule data for {current_day}: {schedule_data}")
if not schedule_data or "nodes" not in schedule_data:
_LOGGER.debug(f"No schedule nodes for {entity_id}")
continue
nodes = schedule_data["nodes"]
_LOGGER.info(f"{entity_id} has {len(nodes)} nodes for {current_day}")
# Get active node (includes temp and other settings)
active_node = self.storage.get_active_node(nodes, current_time)
if not active_node:
_LOGGER.debug(f"No active node for {entity_id}")
continue
target_temp = active_node.get("temp")
_LOGGER.info(f"{entity_id} active node: {active_node}")
# Clamp target temp to global min/max BEFORE creating signature
# This prevents infinite update loops where unclamped signature differs from clamped output
# Handle NO_CHANGE temperature (noChange flag)
is_no_change = active_node.get("noChange", False)
if is_no_change:
clamped_temp = None
_LOGGER.info(f"{entity_id} temp set to NO_CHANGE - will not modify temperature")
else:
clamped_temp = target_temp
if target_temp is not None:
if target_temp < min_temp:
clamped_temp = min_temp
_LOGGER.debug(f"Clamping {entity_id} target {target_temp} -> {clamped_temp}")
elif target_temp > max_temp:
clamped_temp = max_temp
_LOGGER.debug(f"Clamping {entity_id} target {target_temp} -> {clamped_temp}")
# Create a state signature for the node using CLAMPED temp + modes
node_signature = {
"temp": clamped_temp,
"hvac_mode": active_node.get("hvac_mode"),
"fan_mode": active_node.get("fan_mode"),
"swing_mode": active_node.get("swing_mode"),
"preset_mode": active_node.get("preset_mode"),
}
# Get the node time for tracking
node_time = active_node.get("time")
last_node_time = self.last_node_times.get(entity_id)
# Check if we've transitioned to a new node or if this is first run
last_node = self.last_node_states.get(entity_id)
node_time_changed = last_node_time != node_time
node_state_changed = last_node != node_signature
is_first_run = last_node is None
# Only apply settings on: time transitions, state changes (user edits), or first run (initialization)
if not is_first_run and not node_time_changed and not node_state_changed:
# Still on same node with same settings, don't override manual changes
_LOGGER.debug(f"{entity_id} still on same node (time: {node_time}), skipping")
results[entity_id] = {
"updated": False,
"target_temp": target_temp,
"reason": "same_node"
}
continue
# Log the reason for applying settings
if is_first_run:
_LOGGER.info(f"{entity_id} first run - applying initial settings")
elif node_time_changed and not node_state_changed:
_LOGGER.info(f"{entity_id} node time changed ({last_node_time} -> {node_time}), applying settings")
elif node_state_changed and not node_time_changed:
_LOGGER.info(f"{entity_id} node state changed: {last_node} -> {node_signature}")
else:
_LOGGER.info(f"{entity_id} node changed (time and state)")
self.last_node_states[entity_id] = node_signature
self.last_node_times[entity_id] = node_time
# SPECIAL CASE: If temperature is NO_CHANGE, use the current target temperature from the climate entity.
temp_to_apply = clamped_temp
if is_no_change:
current_target_for_reapply = state.attributes.get("temperature")
# For entities using min/max range (heat_cool/auto mode), check target_temp_high/low
target_temp_high = state.attributes.get("target_temp_high")
target_temp_low = state.attributes.get("target_temp_low")
if current_target_for_reapply is not None:
temp_to_apply = current_target_for_reapply
_LOGGER.info(f"{entity_id} NO_CHANGE - will use current temperature {temp_to_apply}°C")
elif target_temp_high is not None or target_temp_low is not None:
# Entity uses min/max range - NO_CHANGE doesn't apply to range entities
# Still set temp_to_apply to None so we skip temperature but apply modes
temp_to_apply = None
_LOGGER.info(f"{entity_id} NO_CHANGE - entity uses temp range (high={target_temp_high}, low={target_temp_low}), will apply modes only")
else:
temp_to_apply = None
_LOGGER.info(f"{entity_id} NO_CHANGE but no current temperature available")
# Re-get current state (we checked it exists earlier)
_LOGGER.info(f"{entity_id} state found: {state.state}")
# Get current target temperature
current_target = state.attributes.get("temperature")
_LOGGER.info(f"{entity_id} current target: {current_target}°C")
# Check if this is a preset-only entity (no current_temperature sensor)
current_temperature = state.attributes.get("current_temperature")
is_preset_only = current_temperature is None
if is_preset_only:
_LOGGER.info(f"{entity_id} is preset-only (no current_temperature), will skip temperature changes")
# Get entity capabilities
supported_features = state.attributes.get("supported_features", 0)
hvac_modes = state.attributes.get("hvac_modes", [])
fan_modes = state.attributes.get("fan_modes", [])
swing_modes = state.attributes.get("swing_modes", [])
preset_modes = state.attributes.get("preset_modes", [])
# Check if we're turning off - apply mode first
target_hvac_mode = active_node.get("hvac_mode")
_LOGGER.info(f"{entity_id} target_hvac_mode: {target_hvac_mode}, supported modes: {hvac_modes}")
if target_hvac_mode == "off":
_LOGGER.info(f"Turning off {entity_id}")
# Try using turn_off service first (more reliable for some integrations)
try:
await self.hass.services.async_call(
"climate",
"turn_off",
{
"entity_id": entity_id,
},
blocking=True,
)
except Exception as e:
# Fallback to set_hvac_mode if turn_off not supported
_LOGGER.debug(f"turn_off failed for {entity_id}, trying set_hvac_mode: {e}")
if "off" in hvac_modes:
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{
"entity_id": entity_id,
"hvac_mode": "off",
},
blocking=True,
)
else:
# Apply HVAC mode if specified in node and supported by entity (except off, handled above)
if "hvac_mode" in active_node and active_node["hvac_mode"] != "off" and active_node["hvac_mode"] in hvac_modes:
_LOGGER.info(f"Setting HVAC mode to {active_node['hvac_mode']}")
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{
"entity_id": entity_id,
"hvac_mode": active_node["hvac_mode"],
},
blocking=True,
)
elif "hvac_mode" in active_node and active_node["hvac_mode"] != "off":
_LOGGER.debug(f"HVAC mode {active_node['hvac_mode']} not supported by {entity_id}")
# Apply temperature after HVAC/off mode
if temp_to_apply is not None and not is_preset_only:
_LOGGER.info(
f"Updating {entity_id} to new node: temp={temp_to_apply}°C"
)
service_data = {
"entity_id": entity_id,
ATTR_TEMPERATURE: temp_to_apply,
}
try:
await self.hass.services.async_call(
"climate",
"set_temperature",
service_data,
blocking=True,
)
except Exception as exc:
if target_hvac_mode == "off":
_LOGGER.warning(f"Failed to set temperature after turning off {entity_id}: {exc}")
else:
_LOGGER.error(f"Failed to set_temperature for {entity_id}: {exc}")
results[entity_id] = {
"updated": False,
"target_temp": target_temp,
"applied_temp": None,
"error": str(exc),
}
# Skip further actions for this entity
continue
elif temp_to_apply is not None and is_preset_only:
_LOGGER.info(f"Skipping temperature change for {entity_id} (preset-only entity)")
else:
_LOGGER.info(f"Skipping temperature change for {entity_id} (NO_CHANGE with no current temperature)")
# Apply fan mode if specified in node and supported by entity
if "fan_mode" in active_node and fan_modes and active_node["fan_mode"] in fan_modes:
_LOGGER.info(f"Setting fan mode to {active_node['fan_mode']}")
await self.hass.services.async_call(
"climate",
"set_fan_mode",
{
"entity_id": entity_id,
"fan_mode": active_node["fan_mode"],
},
blocking=True,
)
elif "fan_mode" in active_node and fan_modes:
_LOGGER.debug(f"Fan mode {active_node['fan_mode']} not supported by {entity_id}")
# Apply swing mode if specified in node and supported by entity
if "swing_mode" in active_node and swing_modes and active_node["swing_mode"] in swing_modes:
_LOGGER.info(f"Setting swing mode to {active_node['swing_mode']}")
await self.hass.services.async_call(
"climate",
"set_swing_mode",
{
"entity_id": entity_id,
"swing_mode": active_node["swing_mode"],
},
blocking=True,
)
elif "swing_mode" in active_node and swing_modes:
_LOGGER.debug(f"Swing mode {active_node['swing_mode']} not supported by {entity_id}")
# Apply preset mode if specified in node and supported by entity
if "preset_mode" in active_node and preset_modes and active_node["preset_mode"] in preset_modes:
_LOGGER.info(f"Setting preset mode to {active_node['preset_mode']}")
await self.hass.services.async_call(
"climate",
"set_preset_mode",
{
"entity_id": entity_id,
"preset_mode": active_node["preset_mode"],
},
blocking=True,
)
elif "preset_mode" in active_node and preset_modes:
_LOGGER.debug(f"Preset mode {active_node['preset_mode']} not supported by {entity_id}")
# Fire event for scheduled node activation ONLY if node time changed (scheduled transition)
# Do NOT fire events when only state changed (user editing current node)
if node_time_changed:
# Get all entities in the group for event data
all_groups = await self.storage.async_get_groups()
group_entities = all_groups.get(group_name, {}).get("entities", [])
self.hass.bus.async_fire(
f"{DOMAIN}_node_activated",
{
# TODO: Remove entity_id in future version - deprecated in favor of entities list
"entity_id": entity_id,
"entities": group_entities, # All entities in the group
"group_name": group_name,
"node": {
"time": active_node.get("time"),
"temp": clamped_temp,
"hvac_mode": active_node.get("hvac_mode"),
"fan_mode": active_node.get("fan_mode"),
"swing_mode": active_node.get("swing_mode"),
"preset_mode": active_node.get("preset_mode"),
"A": active_node.get("A"),
"B": active_node.get("B"),
"C": active_node.get("C"),
},
"previous_node": last_node,
"day": current_day,
"trigger_type": "scheduled",
}
)
_LOGGER.info(f"Fired node_activated event for {entity_id} (scheduled transition)")
else:
_LOGGER.debug(f"Skipping event for {entity_id} - settings applied but not a time transition (user edit or first run)")
results[entity_id] = {
"updated": True,
"target_temp": target_temp,
"previous_temp": current_target,
}
return results
except Exception as err:
_LOGGER.error(f"Error updating heating schedules: {err}")
raise UpdateFailed(f"Error updating heating schedules: {err}")