482 lines
20 KiB
Python
482 lines
20 KiB
Python
"""Climate platform for Climate Scheduler."""
|
|
import asyncio
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
from datetime import datetime, timedelta, time
|
|
|
|
from homeassistant.components.climate import (
|
|
ClimateEntity,
|
|
ClimateEntityFeature,
|
|
HVACMode,
|
|
HVACAction,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
from homeassistant.util.unit_conversion import TemperatureConverter
|
|
|
|
from .const import DOMAIN
|
|
from .coordinator import HeatingSchedulerCoordinator
|
|
from .storage import ScheduleStorage
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
__all__ = ["async_setup_entry", "ClimateSchedulerGroupEntity"]
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
config_entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up Climate Scheduler climate entities from a config entry."""
|
|
storage: ScheduleStorage = hass.data[DOMAIN]["storage"]
|
|
coordinator: HeatingSchedulerCoordinator = hass.data[DOMAIN]["coordinator"]
|
|
|
|
# Get all groups - create climate cards for all groups with entities
|
|
groups = await storage.async_get_groups()
|
|
entities = []
|
|
groups_normalized = False
|
|
|
|
for group_name, group_data in groups.items():
|
|
# Normalize _is_single_entity_group flag based on actual entity count
|
|
# This fixes inconsistency where groups with 1 entity might not have the flag set
|
|
entity_count = len(group_data.get("entities", []))
|
|
is_single_entity = entity_count == 1
|
|
has_flag = group_data.get("_is_single_entity_group", False)
|
|
|
|
# If flag doesn't match actual entity count, normalize it
|
|
if is_single_entity != has_flag:
|
|
group_data["_is_single_entity_group"] = is_single_entity
|
|
groups_normalized = True
|
|
_LOGGER.info(
|
|
f"Normalized _is_single_entity_group flag for group '{group_name}': "
|
|
f"entity_count={entity_count}, flag={is_single_entity}"
|
|
)
|
|
|
|
# Skip virtual groups (no entities) - but include all groups with at least one entity
|
|
if entity_count == 0:
|
|
continue
|
|
|
|
# Create climate entity for this group (including single-entity groups)
|
|
entities.append(
|
|
ClimateSchedulerGroupEntity(
|
|
coordinator,
|
|
storage,
|
|
group_name,
|
|
group_data,
|
|
)
|
|
)
|
|
_LOGGER.info(f"Created climate entity for group '{group_name}' with {entity_count} entities")
|
|
|
|
# Save normalized flags if any were updated
|
|
if groups_normalized:
|
|
await storage.async_save()
|
|
_LOGGER.info("Saved normalized group flags to storage")
|
|
|
|
if entities:
|
|
async_add_entities(entities, True)
|
|
_LOGGER.info(f"Added {len(entities)} climate scheduler group entities")
|
|
|
|
|
|
class ClimateSchedulerGroupEntity(CoordinatorEntity, ClimateEntity):
|
|
"""Climate entity representing a schedule group."""
|
|
|
|
_attr_has_entity_name = True
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: HeatingSchedulerCoordinator,
|
|
storage: ScheduleStorage,
|
|
group_name: str,
|
|
group_data: Dict[str, Any],
|
|
) -> None:
|
|
"""Initialize the climate entity."""
|
|
super().__init__(coordinator)
|
|
|
|
self._storage = storage
|
|
self._group_name = group_name
|
|
self._attr_has_entity_name = True
|
|
self._attr_name = f"Climate Schedule {group_name}"
|
|
self._attr_unique_id = f"climate_scheduler_group_{group_name.lower().replace(' ', '_')}"
|
|
|
|
# Climate entity configuration - will be set when added to hass
|
|
self._attr_temperature_unit = UnitOfTemperature.CELSIUS # Default, updated in async_added_to_hass
|
|
self._attr_supported_features = (
|
|
ClimateEntityFeature.TARGET_TEMPERATURE |
|
|
ClimateEntityFeature.TURN_ON |
|
|
ClimateEntityFeature.TURN_OFF |
|
|
ClimateEntityFeature.PRESET_MODE
|
|
)
|
|
|
|
# Temperature control settings
|
|
self._attr_min_temp = 5.0
|
|
self._attr_max_temp = 35.0
|
|
self._attr_target_temp_step = 0.5
|
|
|
|
# Simplified modes: Auto (Active - follows schedule) or Off (Idle)
|
|
self._attr_hvac_modes = [
|
|
HVACMode.AUTO, # Active - follows schedule
|
|
HVACMode.OFF, # Idle - turns off members
|
|
]
|
|
|
|
# Preset modes from profiles
|
|
profiles = group_data.get("profiles", {})
|
|
self._attr_preset_modes = list(profiles.keys()) if profiles else []
|
|
self._attr_preset_mode = group_data.get("active_profile", "Default")
|
|
|
|
# State tracking
|
|
self._member_entities: List[str] = group_data.get("entities", [])
|
|
self._enabled: bool = group_data.get("enabled", True)
|
|
self._attr_current_temperature: Optional[float] = None
|
|
self._attr_target_temperature: Optional[float] = None
|
|
self._attr_hvac_mode: Optional[HVACMode] = HVACMode.AUTO if self._enabled else HVACMode.OFF
|
|
self._attr_hvac_action: Optional[HVACAction] = None # Will be set in _update_state
|
|
|
|
# Cache for attributes
|
|
self._active_node: Optional[Dict[str, Any]] = None
|
|
self._next_node: Optional[Dict[str, Any]] = None
|
|
self._member_temps: Dict[str, float] = {}
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
"""When entity is added to hass, set temperature unit from system config."""
|
|
await super().async_added_to_hass()
|
|
# Use the same approach as storage.py - get temperature unit from system config
|
|
self._attr_temperature_unit = self.hass.config.units.temperature_unit
|
|
|
|
# Set temperature step from first member entity if available
|
|
if self._member_entities:
|
|
first_member = self.hass.states.get(self._member_entities[0])
|
|
if first_member:
|
|
member_step = first_member.attributes.get("target_temp_step", 0.5)
|
|
self._attr_target_temp_step = member_step
|
|
|
|
_LOGGER.debug(
|
|
"Climate entity %s initialized with temperature unit: %s, step: %s",
|
|
self.entity_id,
|
|
self._attr_temperature_unit,
|
|
self._attr_target_temp_step,
|
|
)
|
|
# Perform initial state update
|
|
self._update_state()
|
|
|
|
@property
|
|
def unique_id(self) -> str:
|
|
"""Return unique ID."""
|
|
return self._attr_unique_id
|
|
|
|
@property
|
|
def device_info(self):
|
|
"""Return device info to link this entity to the Climate Scheduler integration."""
|
|
return {
|
|
"identifiers": {(DOMAIN, "climate_scheduler")},
|
|
"name": "Climate Scheduler",
|
|
"manufacturer": "Climate Scheduler",
|
|
"model": "Schedule Manager",
|
|
}
|
|
|
|
@callback
|
|
def _handle_coordinator_update(self) -> None:
|
|
"""Handle updated data from the coordinator."""
|
|
# Update enabled state and profiles from storage
|
|
group_data = self._storage._data.get("groups", {}).get(self._group_name)
|
|
if group_data:
|
|
self._enabled = group_data.get("enabled", True)
|
|
profiles = group_data.get("profiles", {})
|
|
self._attr_preset_modes = list(profiles.keys()) if profiles else []
|
|
self._attr_preset_mode = group_data.get("active_profile", "Default")
|
|
# Update HVAC mode based on enabled state
|
|
self._attr_hvac_mode = HVACMode.AUTO if self._enabled else HVACMode.OFF
|
|
self._update_state()
|
|
self.async_write_ha_state()
|
|
|
|
def _update_state(self) -> None:
|
|
"""Update entity state from member entities and schedule."""
|
|
# 1. Calculate average current temperature from members (in their native unit)
|
|
temps = []
|
|
temp_map = {}
|
|
|
|
for entity_id in self._member_entities:
|
|
state = self.hass.states.get(entity_id)
|
|
if state and state.attributes.get("current_temperature") is not None:
|
|
try:
|
|
temp = float(state.attributes["current_temperature"])
|
|
# Store temperature in its native unit, no conversion
|
|
temps.append(temp)
|
|
temp_map[entity_id] = round(temp, 1)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if temps:
|
|
self._attr_current_temperature = round(sum(temps) / len(temps), 1)
|
|
else:
|
|
self._attr_current_temperature = None
|
|
|
|
self._member_temps = temp_map
|
|
|
|
# 2. Check if any member is actively requesting heat/cool and determine overall action
|
|
heating_count = 0
|
|
cooling_count = 0
|
|
for entity_id in self._member_entities:
|
|
state = self.hass.states.get(entity_id)
|
|
if state:
|
|
hvac_action = state.attributes.get("hvac_action")
|
|
if hvac_action == "heating":
|
|
heating_count += 1
|
|
elif hvac_action == "cooling":
|
|
cooling_count += 1
|
|
|
|
# Set hvac_action based on member states and enabled status
|
|
if not self._enabled:
|
|
self._attr_hvac_action = HVACAction.OFF
|
|
elif heating_count > 0:
|
|
self._attr_hvac_action = HVACAction.HEATING
|
|
elif cooling_count > 0:
|
|
self._attr_hvac_action = HVACAction.COOLING
|
|
else:
|
|
self._attr_hvac_action = HVACAction.IDLE
|
|
|
|
# 3. Get active node from schedule to determine setpoint
|
|
current_time = datetime.now().time()
|
|
current_day = datetime.now().strftime('%a').lower()
|
|
|
|
# Get the schedule data from storage (synchronously accessible)
|
|
group_data = self._storage._data.get("groups", {}).get(self._group_name, {})
|
|
schedule_mode = group_data.get("schedule_mode", "all_days")
|
|
schedules = group_data.get("schedules", {})
|
|
|
|
# Get nodes for current day using the same logic as coordinator
|
|
if schedule_mode == "all_days":
|
|
nodes = schedules.get("all_days", [])
|
|
elif schedule_mode == "5/2":
|
|
if current_day in ["mon", "tue", "wed", "thu", "fri"]:
|
|
nodes = schedules.get("weekday", [])
|
|
else:
|
|
nodes = schedules.get("weekend", [])
|
|
else: # individual
|
|
nodes = schedules.get(current_day, [])
|
|
|
|
# In individual or 5/2 mode, if current time is before all nodes today,
|
|
# use previous day's last node
|
|
if schedule_mode in ["individual", "5/2"] and nodes:
|
|
sorted_nodes = sorted(nodes, key=lambda n: self._storage._time_to_minutes(n["time"]))
|
|
current_minutes = current_time.hour * 60 + current_time.minute
|
|
if sorted_nodes and current_minutes < self._storage._time_to_minutes(sorted_nodes[0]["time"]):
|
|
# We're before the first node of today, need previous day/period's last node
|
|
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 nodes based on schedule mode
|
|
if schedule_mode == "5/2":
|
|
# In 5/2 mode, determine if previous day is weekday or weekend
|
|
if prev_day in ["mon", "tue", "wed", "thu", "fri"]:
|
|
prev_day_nodes = schedules.get("weekday", [])
|
|
else:
|
|
prev_day_nodes = schedules.get("weekend", [])
|
|
else:
|
|
# In individual mode, use specific day
|
|
prev_day_nodes = schedules.get(prev_day, [])
|
|
|
|
if prev_day_nodes:
|
|
sorted_prev_nodes = sorted(prev_day_nodes, key=lambda n: self._storage._time_to_minutes(n["time"]))
|
|
last_prev_node = sorted_prev_nodes[-1]
|
|
# Prepend previous day's last node with time "00:00"
|
|
carryover_node = {**last_prev_node, "time": "00:00"}
|
|
nodes = [carryover_node] + nodes
|
|
|
|
# Determine the active setpoint from schedule
|
|
active_setpoint = None
|
|
if nodes:
|
|
active_node = self._storage.get_active_node(nodes, current_time)
|
|
if active_node:
|
|
active_setpoint = active_node.get("temp")
|
|
_LOGGER.debug(f"Active node for {self._group_name}: temp={active_setpoint}")
|
|
else:
|
|
_LOGGER.debug(f"No active node for {self._group_name} at {current_time}")
|
|
else:
|
|
_LOGGER.debug(f"No schedule data for {self._group_name} on {current_day}")
|
|
|
|
# Show the scheduled setpoint as target temperature, or use a default
|
|
if active_setpoint is not None:
|
|
self._attr_target_temperature = active_setpoint
|
|
else:
|
|
# Fallback: use average of member target temps, or 20°C default
|
|
member_targets = []
|
|
for entity_id in self._member_entities:
|
|
state = self.hass.states.get(entity_id)
|
|
if state and state.attributes.get("temperature") is not None:
|
|
try:
|
|
member_targets.append(float(state.attributes["temperature"]))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if member_targets:
|
|
self._attr_target_temperature = round(sum(member_targets) / len(member_targets), 1)
|
|
else:
|
|
self._attr_target_temperature = 20.0 # Default fallback
|
|
|
|
_LOGGER.debug(f"No schedule setpoint for {self._group_name}, using fallback: {self._attr_target_temperature}")
|
|
|
|
# HVAC mode reflects enabled state (not member states)
|
|
# This is already set in _handle_coordinator_update
|
|
|
|
self._active_node = None
|
|
self._next_node = None
|
|
|
|
def _map_hvac_mode(self, mode_str: str) -> HVACMode:
|
|
"""Map string hvac_mode to HVACMode enum."""
|
|
mapping = {
|
|
"off": HVACMode.OFF,
|
|
"heat": HVACMode.HEAT,
|
|
"cool": HVACMode.COOL,
|
|
"heat_cool": HVACMode.HEAT_COOL,
|
|
"auto": HVACMode.AUTO,
|
|
"dry": HVACMode.DRY,
|
|
"fan_only": HVACMode.FAN_ONLY,
|
|
}
|
|
return mapping.get(mode_str.lower() if mode_str else "heat", HVACMode.HEAT)
|
|
|
|
@property
|
|
def extra_state_attributes(self) -> Dict[str, Any]:
|
|
"""Return entity specific state attributes."""
|
|
attrs = {
|
|
"member_entities": self._member_entities,
|
|
"member_count": len(self._member_entities),
|
|
"group_name": self._group_name,
|
|
"schedule_enabled": self._enabled,
|
|
}
|
|
|
|
# Add member temperatures if available
|
|
if self._member_temps:
|
|
attrs["member_temperatures"] = self._member_temps
|
|
|
|
# Placeholder for future phases - active node info
|
|
if self._active_node:
|
|
attrs["active_node"] = {
|
|
"time": self._active_node.get("time"),
|
|
"temp": self._active_node.get("temp"),
|
|
"hvac_mode": self._active_node.get("hvac_mode"),
|
|
}
|
|
|
|
if self._next_node:
|
|
attrs["next_node"] = {
|
|
"time": self._next_node.get("time"),
|
|
"temp": self._next_node.get("temp"),
|
|
"hvac_mode": self._next_node.get("hvac_mode"),
|
|
}
|
|
|
|
return attrs
|
|
|
|
async def async_set_temperature(self, **kwargs) -> None:
|
|
"""Set new target temperature for all member entities."""
|
|
temperature = kwargs.get("temperature")
|
|
if temperature is None:
|
|
_LOGGER.warning("No temperature provided to async_set_temperature")
|
|
return
|
|
|
|
# Apply temperature to all member entities
|
|
for entity_id in self._member_entities:
|
|
try:
|
|
await self.hass.services.async_call(
|
|
"climate",
|
|
"set_temperature",
|
|
{
|
|
"entity_id": entity_id,
|
|
"temperature": temperature,
|
|
},
|
|
blocking=True,
|
|
)
|
|
_LOGGER.info(f"Set temperature {temperature} for {entity_id}")
|
|
except Exception as e:
|
|
_LOGGER.error(f"Failed to set temperature for {entity_id}: {e}")
|
|
|
|
# Set override for each member until next scheduled node
|
|
current_time = datetime.now().time()
|
|
current_day = datetime.now().strftime('%a').lower()
|
|
|
|
group_data = self._storage._data.get("groups", {}).get(self._group_name, {})
|
|
schedules = group_data.get("schedules", {})
|
|
schedule_data = schedules.get(current_day)
|
|
|
|
if schedule_data and "nodes" in schedule_data:
|
|
nodes = schedule_data["nodes"]
|
|
next_node = self._storage.get_next_node(nodes, current_time)
|
|
if next_node:
|
|
# Calculate when override expires (at next node time)
|
|
next_node_time_str = next_node["time"]
|
|
next_node_hours, next_node_minutes = map(int, next_node_time_str.split(":"))
|
|
override_until = datetime.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 <= datetime.now():
|
|
override_until += timedelta(days=1)
|
|
|
|
# Set override for all members
|
|
for entity_id in self._member_entities:
|
|
self.coordinator.override_until[entity_id] = override_until
|
|
|
|
_LOGGER.info(
|
|
f"Set manual override for group {self._group_name} until {override_until}"
|
|
)
|
|
|
|
# Update target temperature immediately and trigger state refresh
|
|
self._attr_target_temperature = temperature
|
|
|
|
# Schedule a state update after a short delay to allow member entities to react
|
|
async def delayed_update():
|
|
await asyncio.sleep(2) # Wait for members to start heating
|
|
self._update_state()
|
|
self.async_write_ha_state()
|
|
|
|
self.hass.async_create_task(delayed_update())
|
|
self.async_write_ha_state()
|
|
|
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
|
"""Set HVAC mode - AUTO (active) or OFF (idle)."""
|
|
if hvac_mode == HVACMode.OFF:
|
|
# Turn off schedule (idle mode)
|
|
await self.async_turn_off()
|
|
elif hvac_mode == HVACMode.AUTO:
|
|
# Turn on schedule (active mode)
|
|
await self.async_turn_on()
|
|
else:
|
|
_LOGGER.warning(f"Unsupported HVAC mode {hvac_mode} for climate scheduler group")
|
|
|
|
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
|
"""Set the preset mode (profile) for this group."""
|
|
try:
|
|
await self._storage.async_set_active_profile(self._group_name, preset_mode)
|
|
except ValueError as err:
|
|
_LOGGER.error(f"Profile switch failed for group {self._group_name}: {err}")
|
|
return
|
|
|
|
# Force coordinator refresh to apply new schedule immediately
|
|
await self.coordinator.async_request_refresh()
|
|
|
|
_LOGGER.info(f"Set profile {preset_mode} for group {self._group_name}")
|
|
|
|
async def async_turn_on(self) -> None:
|
|
"""Enable the schedule."""
|
|
# Enable schedule for all member entities
|
|
for entity_id in self._member_entities:
|
|
await self._storage.async_set_enabled(entity_id, True)
|
|
self._enabled = True
|
|
self.async_write_ha_state()
|
|
_LOGGER.info("Enabled schedule for group %s", self._group_name)
|
|
|
|
async def async_turn_off(self) -> None:
|
|
"""Disable the schedule."""
|
|
# Disable schedule for all member entities
|
|
for entity_id in self._member_entities:
|
|
await self._storage.async_set_enabled(entity_id, False)
|
|
self._enabled = False
|
|
self.async_write_ha_state()
|
|
_LOGGER.info("Disabled schedule for group %s", self._group_name)
|