Files
HomeAssistance/custom_components/climate_scheduler/switch.py
T
2026-06-05 22:34:31 -04:00

422 lines
17 KiB
Python

"""Switch platform for Climate Scheduler - Exposes schedules in scheduler-component format."""
import logging
import hashlib
from datetime import datetime, time, timedelta
from typing import Any, Dict, List, Optional
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Climate Scheduler switch entities from a config entry."""
from homeassistant.helpers import entity_registry as er
storage = hass.data[DOMAIN]["storage"]
coordinator = hass.data[DOMAIN]["coordinator"]
entity_registry = er.async_get(hass)
# Clean up old switch entities that don't have the token suffix
# (These are from before we added the unique_id token)
old_entities_removed = 0
for entry in list(entity_registry.entities.values()):
if entry.platform != DOMAIN or entry.domain != "switch":
continue
# Old entities have unique_id like "climate_scheduler_schedule_bathroom"
# New entities have unique_id like "climate_scheduler_schedule_climate.bathroom_abc123"
# or "climate_scheduler_schedule_Bathroom_abc123"
if entry.unique_id and entry.unique_id.startswith(f"{DOMAIN}_schedule_"):
# Check if it's missing the token (no underscore followed by 6 hex chars at the end)
parts = entry.unique_id.split("_")
last_part = parts[-1] if parts else ""
# If the last part is not a 6-character hex token, it's an old entity
if len(last_part) != 6 or not all(c in "0123456789abcdef" for c in last_part.lower()):
_LOGGER.info(f"Removing old scheduler entity: {entry.entity_id} (unique_id: {entry.unique_id})")
entity_registry.async_remove(entry.entity_id)
old_entities_removed += 1
if old_entities_removed > 0:
_LOGGER.info(f"Removed {old_entities_removed} old scheduler switch entities")
switches = []
# Create switch entity for each group (including single-entity groups)
all_groups = await storage.async_get_groups()
for group_name, group_data in all_groups.items():
# Skip ignored groups
if group_data.get("ignored", False):
continue
switches.append(ClimateSchedulerSwitch(
hass,
coordinator,
storage,
group_name,
group_data
))
if switches:
async_add_entities(switches, True)
_LOGGER.info(f"Created {len(switches)} scheduler switch entities")
class ClimateSchedulerSwitch(CoordinatorEntity, SwitchEntity):
"""Switch entity that exposes a climate schedule in scheduler-component format."""
def __init__(
self,
hass: HomeAssistant,
coordinator,
storage,
group_name: str,
group_data: Dict[str, Any],
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.hass = hass
self.storage = storage
self._group_name = group_name
self._group_data = group_data
# Generate a stable token for the entity_id (6 chars like scheduler-component)
token = hashlib.md5(group_name.encode()).hexdigest()[:6]
# For single-entity groups, use a cleaner name
if group_name.startswith("__entity_"):
entity_id = group_name.replace("__entity_", "")
self._attr_name = f"Schedule {entity_id.split('.')[-1]}"
self._attr_unique_id = f"{DOMAIN}_schedule_{entity_id}_{token}"
else:
self._attr_name = f"Schedule {group_name}"
self._attr_unique_id = f"{DOMAIN}_schedule_{group_name}_{token}"
self._attr_has_entity_name = False
self._attr_should_poll = True
# Cache for computed attributes
self._cached_next_trigger: Optional[datetime] = None
self._cached_next_slot: Optional[int] = None
self._cached_actions: List[Dict[str, Any]] = []
self._cached_next_entries: List[Dict[str, Any]] = []
@property
def is_on(self) -> bool:
"""Return true if schedule is enabled."""
# Refresh group data from storage
self._refresh_group_data()
return self._group_data.get("enabled", True)
@property
def state(self) -> str:
"""Return the state of the scheduler.
States match scheduler-component:
- off: Schedule is disabled
- on: Schedule is enabled and waiting for next trigger
- triggered: Currently executing (we'll use this briefly after firing)
"""
if not self.is_on:
return "off"
# Check if this was recently triggered (within last minute)
# For now, we'll just return "on" - can enhance later
return "on"
@property
def extra_state_attributes(self) -> Dict[str, Any]:
"""Return scheduler-component compatible attributes."""
self._refresh_group_data()
self._compute_schedule_attributes()
active_profile = self._group_data.get("active_profile", "Default")
profiles = self._group_data.get("profiles", {})
profile_data = profiles.get(active_profile, {})
schedule_mode = profile_data.get("schedule_mode", "all_days")
schedules = profile_data.get("schedules", {})
# Get entities affected by this schedule
entities = self._group_data.get("entities", [])
attrs = {
# Core scheduler-component attributes
"next_trigger": self._cached_next_trigger.isoformat() if self._cached_next_trigger else None,
"next_slot": self._cached_next_slot,
"actions": self._cached_actions,
# Fallback format
"next_entries": self._cached_next_entries,
# Additional metadata
"schedule_mode": schedule_mode,
"schedules": schedules,
"active_profile": active_profile,
"profiles": list(profiles.keys()),
"entities": entities,
# Compatibility fields
"weekdays": self._get_weekdays_list(schedule_mode),
"timeslots": self._cached_actions, # Alias
}
return attrs
def _refresh_group_data(self) -> None:
"""Refresh group data from storage."""
# This is called frequently, so we cache the lookup
import asyncio
if asyncio.iscoroutinefunction(self.storage.async_get_group):
# Can't call async from sync property, so we'll need to handle this differently
# For now, keep the cached version and update on coordinator updates
pass
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
# Refresh group data when coordinator updates
loop = self.hass.loop
if loop and loop.is_running():
loop.create_task(self._async_refresh_group_data())
self.async_write_ha_state()
async def _async_refresh_group_data(self) -> None:
"""Async refresh of group data."""
group_data = await self.storage.async_get_group(self._group_name)
if group_data:
self._group_data = group_data
def _compute_schedule_attributes(self) -> None:
"""Compute next_trigger, next_slot, actions, and next_entries."""
now = dt_util.now()
current_time = now.time()
current_day = now.strftime("%a").lower() # mon, tue, wed, etc.
active_profile = self._group_data.get("active_profile", "Default")
profiles = self._group_data.get("profiles", {})
profile_data = profiles.get(active_profile, {})
schedule_mode = profile_data.get("schedule_mode", "all_days")
schedules = profile_data.get("schedules", {})
# Determine which schedule to use based on mode and current day
current_schedule = self._get_schedule_for_day(schedules, schedule_mode, current_day)
if not current_schedule:
self._cached_next_trigger = None
self._cached_next_slot = None
self._cached_actions = []
self._cached_next_entries = []
return
# Sort nodes by time
sorted_nodes = sorted(current_schedule, key=lambda n: self._time_str_to_minutes(n.get("time", "00:00")))
# Find next node
next_node = None
next_node_index = None
for idx, node in enumerate(sorted_nodes):
node_time_str = node.get("time", "00:00")
node_minutes = self._time_str_to_minutes(node_time_str)
current_minutes = current_time.hour * 60 + current_time.minute
if node_minutes > current_minutes:
next_node = node
next_node_index = idx
break
# If no node found after current time, use first node tomorrow
if next_node is None and sorted_nodes:
next_node = sorted_nodes[0]
next_node_index = 0
# Add one day
next_trigger_dt = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
else:
next_trigger_dt = now.replace(hour=0, minute=0, second=0, microsecond=0)
if next_node:
# Parse time
time_str = next_node.get("time", "00:00")
try:
hours, minutes = time_str.split(":")
next_trigger_dt = next_trigger_dt.replace(hour=int(hours), minute=int(minutes))
except (ValueError, AttributeError):
_LOGGER.warning(f"Invalid time format: {time_str}")
next_trigger_dt = None
self._cached_next_trigger = next_trigger_dt
self._cached_next_slot = next_node_index
# Build actions list for all nodes
entities = self._group_data.get("entities", [])
# If entities list is empty, try to derive from group name for single-entity groups
if not entities and self._group_name.startswith("__entity_"):
entities = [self._group_name.replace("__entity_", "")]
_LOGGER.debug(f"Derived entity from group name: {entities}")
self._cached_actions = []
for node in sorted_nodes:
temp = node.get("temp")
# If still no entities, create a generic action without entity_id
# This allows the data structure to be populated even if entities are missing
if not entities:
action = {
"entity_id": "unknown",
"service": "climate.set_temperature",
"data": {"temperature": temp}
}
# Add HVAC mode if present
if "hvac_mode" in node:
action["data"]["hvac_mode"] = node["hvac_mode"]
# Add preset mode if present
if "preset_mode" in node:
action["service"] = "climate.set_preset_mode"
action["data"] = {"preset_mode": node["preset_mode"]}
self._cached_actions.append(action)
else:
# Normal case: create actions for each entity
for entity_id in entities:
action = {
"entity_id": entity_id,
"service": "climate.set_temperature",
"data": {"temperature": temp}
}
# Add HVAC mode if present
if "hvac_mode" in node:
action["data"]["hvac_mode"] = node["hvac_mode"]
# Add preset mode if present
if "preset_mode" in node:
action["service"] = "climate.set_preset_mode"
action["data"] = {"preset_mode": node["preset_mode"]}
self._cached_actions.append(action)
# Build next_entries (fallback format)
self._cached_next_entries = []
for node in sorted_nodes:
temp = node.get("temp")
time_str = node.get("time", "00:00")
# Calculate absolute datetime for this node
try:
hours, minutes = time_str.split(":")
node_dt = now.replace(hour=int(hours), minute=int(minutes), second=0, microsecond=0)
# If node time is in the past today, it's for tomorrow
if node_dt < now:
node_dt += timedelta(days=1)
entry_actions = []
# If still no entities, create a generic action
if not entities:
entry_actions.append({
"entity_id": "unknown",
"service": "climate.set_temperature",
"data": {"temperature": temp}
})
else:
for entity_id in entities:
entry_actions.append({
"entity_id": entity_id,
"service": "climate.set_temperature",
"data": {"temperature": temp}
})
self._cached_next_entries.append({
"time": node_dt.isoformat(),
"trigger_time": node_dt.isoformat(),
"actions": entry_actions
})
except (ValueError, AttributeError):
continue
else:
self._cached_next_trigger = None
self._cached_next_slot = None
self._cached_actions = []
self._cached_next_entries = []
def _get_schedule_for_day(
self,
schedules: Dict[str, List[Dict[str, Any]]],
schedule_mode: str,
current_day: str
) -> List[Dict[str, Any]]:
"""Get the appropriate schedule for the current day."""
if schedule_mode == "all_days":
return schedules.get("all_days", [])
elif schedule_mode == "5/2":
if current_day in ["mon", "tue", "wed", "thu", "fri"]:
return schedules.get("weekday", [])
else:
return schedules.get("weekend", [])
elif schedule_mode == "individual":
return schedules.get(current_day, [])
else:
return schedules.get("all_days", [])
def _get_weekdays_list(self, schedule_mode: str) -> List[str]:
"""Get weekdays list for scheduler-component compatibility."""
if schedule_mode == "all_days":
return ["daily"]
elif schedule_mode == "5/2":
return ["workday", "weekend"]
elif schedule_mode == "individual":
return ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
else:
return ["daily"]
@staticmethod
def _time_str_to_minutes(time_str: str) -> int:
"""Convert HH:MM string to minutes since midnight."""
try:
hours, minutes = time_str.split(":")
return int(hours) * 60 + int(minutes)
except (ValueError, AttributeError):
return 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable the schedule."""
await self.storage.async_enable_schedule(self._group_name)
await self.coordinator.async_request_refresh()
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable the schedule."""
await self.storage.async_disable_schedule(self._group_name)
await self.coordinator.async_request_refresh()
self.async_write_ha_state()
@property
def device_info(self) -> Dict[str, Any]:
"""Return device info for this scheduler."""
return {
"identifiers": {(DOMAIN, f"scheduler_{self._group_name}")},
"name": self._attr_name,
"manufacturer": "Climate Scheduler",
"model": "Schedule Controller",
"sw_version": "1.0",
}