1128 lines
54 KiB
Python
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}")
|