New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,481 @@
"""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)