Files
Home-Assistant/custom_components/climate_scheduler/storage.py
T
2026-07-08 10:43:39 -04:00

1882 lines
85 KiB
Python

"""Storage management for Climate Scheduler."""
import logging
import copy
import re
from typing import Any, Dict, List, Optional
from datetime import datetime, time
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.const import UnitOfTemperature
from .const import DOMAIN, STORAGE_VERSION, STORAGE_KEY, DEFAULT_SCHEDULE, MIN_TEMP, MAX_TEMP
_LOGGER = logging.getLogger(__name__)
def validate_node(node: Dict[str, Any]) -> bool:
"""Validate a schedule node structure."""
if not isinstance(node, dict):
return False
# Check required fields
if "time" not in node or "temp" not in node:
_LOGGER.error(f"Node missing required fields: {node}")
return False
# Validate time format (HH:MM)
time_str = node["time"]
if not isinstance(time_str, str) or len(time_str) != 5 or time_str[2] != ":":
_LOGGER.error(f"Invalid time format: {time_str}")
return False
try:
hours, minutes = time_str.split(":")
h, m = int(hours), int(minutes)
# Normalize 24:00 to 23:59 to avoid clash with 00:00 on next day
if h == 24 and m == 0:
h, m = 23, 59
if not (0 <= h <= 23 and 0 <= m <= 59):
_LOGGER.error(f"Time out of range: {time_str}")
return False
except (ValueError, AttributeError):
_LOGGER.error(f"Cannot parse time: {time_str}")
return False
# Validate temperature is numeric
try:
float(node["temp"])
except (ValueError, TypeError):
_LOGGER.error(f"Invalid temperature: {node.get('temp')}")
return False
return True
class ScheduleStorage:
"""Handle storage of heating schedules."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize storage."""
self.hass = hass
self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
self._data: Dict[str, Any] = {}
async def async_load(self) -> None:
"""Load data from storage."""
data = await self._store.async_load()
if data is None:
self._data = {"groups": {}, "settings": {}, "advance_history": {}}
else:
self._data = data
changed_settings = False
# Collect nested settings layers (if any) by following repeated
# {"settings": {...}} wrappers.
#
# This repository has had a settings-shape mismatch where the UI
# sometimes treated {settings: {...}, version: {...}} as the actual
# settings dict. That can lead to recursive nesting like:
# data.settings.settings.settings...
#
# On load we:
# - gather all layers
# - select the layer with the newest parsed integration version
# - flatten to a single dict (dropping nested "settings" and "version")
# - backfill any missing keys from other layers (to avoid losing
# settings that only exist in older layers).
if "settings" in self._data and isinstance(self._data.get("settings"), dict):
layers: List[Dict[str, Any]] = []
s: Any = self._data["settings"]
while isinstance(s, dict):
layers.append(s)
if "settings" in s and isinstance(s.get("settings"), dict):
s = s["settings"]
else:
break
if len(layers) > 1:
changed_settings = True
def parse_version_string(ver: Any) -> tuple:
if not isinstance(ver, str):
return ()
# Extract numeric groups from version string, e.g. '1.14.0.13' -> (1,14,0,13)
nums = re.findall(r"\d+", ver)
return tuple(int(x) for x in nums) if nums else ()
def layer_version(layer: Dict[str, Any]) -> tuple:
v = layer.get("version")
if isinstance(v, dict):
return parse_version_string(v.get("integration") or v.get("version"))
return parse_version_string(v)
best_layer = layers[-1] # default: innermost
best_ver = ()
for layer in layers:
parsed = layer_version(layer)
if parsed and parsed > best_ver:
best_ver = parsed
best_layer = layer
# Start with the newest layer as the source of truth.
cleaned_settings: Dict[str, Any] = {
k: v
for k, v in best_layer.items()
if k not in {"settings", "version", "performance_tracking"}
}
# Backfill missing keys from any layer (newest doesn't always
# mean it contains all fields if the nesting was corrupted).
for layer in reversed(layers): # inner -> outer
for k, v in layer.items():
if k in {"settings", "version", "performance_tracking"}:
continue
if k not in cleaned_settings:
cleaned_settings[k] = v
self._data["settings"] = cleaned_settings
# Ensure groups key exists for backwards compatibility
if "groups" not in self._data:
self._data["groups"] = {}
# Ensure settings exist
if "settings" not in self._data:
self._data["settings"] = {}
# Ensure advance_history exists
if "advance_history" not in self._data:
self._data["advance_history"] = {}
# Migrate old single-schedule format to new day-based format
await self._migrate_to_day_schedules()
# Migrate to profile-based structure
await self._migrate_to_profiles()
# Migrate entities to single-entity groups (will remove entities key after migration)
await self._migrate_entities_to_groups()
# Migrate per-group profiles to global profile registry
await self._migrate_profiles_to_global()
# Normalize older tagged legacy profile names to metadata format
await self._migrate_legacy_profile_name_suffixes()
# Ensure global profile structure exists and schedule/profile views are synchronized
self._ensure_global_profiles_initialized()
self._sync_group_profile_views()
# Ensure min/max temp defaults are present in settings
settings = self._data.get("settings", {})
# Drop legacy/unreferenced settings keys that may have been persisted
# due to older settings payload shape mismatches.
if isinstance(settings, dict) and "performance_tracking" in settings:
del settings["performance_tracking"]
changed_settings = True
if "min_temp" not in settings:
# Set default based on temperature unit
if self.hass.config.units.temperature_unit == UnitOfTemperature.FAHRENHEIT:
settings["min_temp"] = 42.0 # Fahrenheit
else:
settings["min_temp"] = 5.0 # Celsius
if "max_temp" not in settings:
# Set default based on temperature unit
if self.hass.config.units.temperature_unit == UnitOfTemperature.FAHRENHEIT:
settings["max_temp"] = 86.0 # Fahrenheit
else:
settings["max_temp"] = 30.0 # Celsius
if "create_derivative_sensors" not in settings:
settings["create_derivative_sensors"] = True # Default to enabled
if "graph_type" not in settings:
settings["graph_type"] = "svg" # Default to SVG graph (classic)
self._data["settings"] = settings
# Persist cleaned settings if we flattened/cleaned nested layers
if locals().get("changed_settings"):
try:
await self.async_save()
_LOGGER.info("Persisted cleaned settings to storage")
except Exception as e:
_LOGGER.error(f"Failed to persist cleaned settings: {e}")
_LOGGER.debug(f"Loaded schedule data: {self._data}")
async def _migrate_to_day_schedules(self) -> None:
"""Migrate existing schedules to day-based format."""
migrated = False
for entity_id, entity_data in self._data.get("entities", {}).items():
# Check if already in new format (has schedule_mode key)
if "schedule_mode" not in entity_data:
# Migrate from old format
old_nodes = entity_data.get("nodes", [])
entity_data["schedule_mode"] = "all_days" # Default to all days
entity_data["schedules"] = {
"all_days": old_nodes
}
# Remove old nodes key
if "nodes" in entity_data:
del entity_data["nodes"]
migrated = True
_LOGGER.info(f"Migrated {entity_id} to day-based schedule format")
# Migrate groups
for group_name, group_data in self._data.get("groups", {}).items():
if "schedule_mode" not in group_data:
old_nodes = group_data.get("nodes", [])
group_data["schedule_mode"] = "all_days"
group_data["schedules"] = {
"all_days": old_nodes
}
if "nodes" in group_data:
del group_data["nodes"]
migrated = True
_LOGGER.info(f"Migrated group '{group_name}' to day-based schedule format")
if migrated:
await self.async_save()
async def _migrate_to_profiles(self) -> None:
"""Migrate existing schedules to profile-based format."""
migrated = False
# Migrate entities
for entity_id, entity_data in self._data.get("entities", {}).items():
if "profiles" not in entity_data or "active_profile" not in entity_data:
# Create Default profile from current schedule
schedule_mode = entity_data.get("schedule_mode", "all_days")
schedules = copy.deepcopy(entity_data.get("schedules", {"all_days": []}))
entity_data["profiles"] = {
"Default": {
"schedule_mode": schedule_mode,
"schedules": schedules
}
}
entity_data["active_profile"] = "Default"
migrated = True
_LOGGER.info(f"Migrated entity {entity_id} to profile-based format")
# Migrate groups
for group_name, group_data in self._data.get("groups", {}).items():
if "profiles" not in group_data or "active_profile" not in group_data:
# Create Default profile from current schedule
schedule_mode = group_data.get("schedule_mode", "all_days")
schedules = copy.deepcopy(group_data.get("schedules", {"all_days": []}))
group_data["profiles"] = {
"Default": {
"schedule_mode": schedule_mode,
"schedules": schedules
}
}
group_data["active_profile"] = "Default"
migrated = True
_LOGGER.info(f"Migrated group '{group_name}' to profile-based format")
if migrated:
await self.async_save()
async def _migrate_entities_to_groups(self) -> None:
"""Migrate individual entities to single-entity groups for unified backend."""
migrated = False
if "groups" not in self._data:
self._data["groups"] = {}
# Migrate individual entities to single-entity groups
for entity_id, entity_data in list(self._data.get("entities", {}).items()):
# Check if entity is already in a multi-entity group
entity_in_group = False
for group_name, group_data in self._data["groups"].items():
if entity_id in group_data.get("entities", []):
entity_in_group = True
break
# If not in a group, create a single-entity group
if not entity_in_group:
# Use entity_id as the group name (with a prefix to distinguish)
group_name = f"__entity_{entity_id}"
# Only create if it doesn't already exist
if group_name not in self._data["groups"]:
self._data["groups"][group_name] = {
"entities": [entity_id],
"enabled": entity_data.get("enabled", True),
"ignored": entity_data.get("ignored", False), # Preserve ignored status
"schedule_mode": entity_data.get("schedule_mode", "all_days"),
"schedules": copy.deepcopy(entity_data.get("schedules", {"all_days": []})),
"profiles": copy.deepcopy(entity_data.get("profiles", {
"Default": {
"schedule_mode": "all_days",
"schedules": {"all_days": []}
}
})),
"active_profile": entity_data.get("active_profile", "Default"),
"_is_single_entity_group": True # Internal marker
}
migrated = True
_LOGGER.info(f"Migrated entity {entity_id} to single-entity group '{group_name}'")
# Remove the legacy entities structure after migration
if "entities" in self._data and self._data["entities"]:
_LOGGER.info(f"Removing legacy entities structure after migration to single-entity groups")
del self._data["entities"]
migrated = True
if migrated:
await self.async_save()
async def async_save(self) -> None:
"""Save data to storage."""
self._sync_group_profile_views()
await self._store.async_save(self._data)
_LOGGER.debug(f"Saved schedule data: {self._data}")
def _get_default_schedule_template(self) -> List[Dict[str, Any]]:
"""Return configured default schedule template with validation fallback."""
settings = self._data.get("settings", {})
configured = settings.get("defaultSchedule") or settings.get("default_schedule")
if isinstance(configured, list) and configured:
valid_nodes: List[Dict[str, Any]] = []
for node in configured:
if validate_node(node):
valid_nodes.append(copy.deepcopy(node))
if valid_nodes:
return valid_nodes
return copy.deepcopy(DEFAULT_SCHEDULE)
def _build_schedules_from_template(self, default_schedule: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
"""Build schedule dictionary from the single default schedule template."""
return {"all_days": copy.deepcopy(default_schedule)}
def _ensure_global_profiles_initialized(self) -> None:
"""Ensure global profiles exist with at least one usable profile."""
profiles = self._data.get("profiles")
if not isinstance(profiles, dict):
profiles = {}
if not profiles:
default_schedule = self._get_default_schedule_template()
profiles["Default"] = {
"schedule_mode": "all_days",
"schedules": self._build_schedules_from_template(default_schedule)
}
self._data["profiles"] = profiles
def _resolve_group_active_global_profile(self, group_data: Dict[str, Any], global_profiles: Dict[str, Any]) -> Optional[str]:
"""Resolve the active global profile for a group using compatibility fields."""
active_global = group_data.get("active_profile_global")
if active_global in global_profiles:
return active_global
active_profile = group_data.get("active_profile")
if active_profile in global_profiles:
return active_profile
return "Default" if "Default" in global_profiles else next(iter(global_profiles), None)
def _sync_group_profile_views(self) -> None:
"""Align active schedule fields with selected global active profile."""
groups = self._data.get("groups", {})
if not isinstance(groups, dict):
return
self._ensure_global_profiles_initialized()
global_profiles = self._data.get("profiles", {})
for group_data in groups.values():
if not isinstance(group_data, dict):
continue
active_profile = self._resolve_group_active_global_profile(group_data, global_profiles)
if active_profile:
group_data["active_profile_global"] = active_profile
if active_profile:
active_profile_data = global_profiles.get(active_profile, {})
group_data["schedule_mode"] = active_profile_data.get("schedule_mode", "all_days")
group_data["schedules"] = copy.deepcopy(active_profile_data.get("schedules", {"all_days": []}))
async def _migrate_profiles_to_global(self) -> None:
"""Migrate legacy per-group profile dictionaries into global profiles."""
groups = self._data.get("groups", {})
if not isinstance(groups, dict):
return
global_profiles = self._data.get("profiles")
if not isinstance(global_profiles, dict):
global_profiles = {}
migrated = False
def make_unique_profile_name(base_name: str) -> str:
candidate = base_name
suffix = 2
while candidate in global_profiles:
candidate = f"{base_name} ({suffix})"
suffix += 1
return candidate
if not global_profiles:
for group_name, group_data in groups.items():
if not isinstance(group_data, dict):
continue
legacy_profiles = group_data.get("profiles", {})
active_profile = group_data.get("active_profile", "Default")
preserved_legacy_profiles: Dict[str, Any] = {}
if isinstance(legacy_profiles, dict) and legacy_profiles:
name_map: Dict[str, str] = {}
for profile_name, profile_data in legacy_profiles.items():
source_profile = profile_data if isinstance(profile_data, dict) else {}
new_profile_name = make_unique_profile_name(f"{group_name} - {profile_name}")
name_map[profile_name] = new_profile_name
global_profiles[new_profile_name] = {
"schedule_mode": source_profile.get("schedule_mode", group_data.get("schedule_mode", "all_days")),
"schedules": copy.deepcopy(source_profile.get("schedules", group_data.get("schedules", {"all_days": []})))
}
preserved_legacy_profiles[profile_name] = {
"schedule_mode": source_profile.get("schedule_mode", group_data.get("schedule_mode", "all_days")),
"schedules": copy.deepcopy(source_profile.get("schedules", group_data.get("schedules", {"all_days": []}))),
"legacy": True,
}
group_data["profiles"] = preserved_legacy_profiles
if active_profile in preserved_legacy_profiles:
group_data["active_profile_legacy"] = active_profile
group_data["active_profile"] = active_profile
mapped_active_profile = name_map.get(active_profile)
if mapped_active_profile:
group_data["active_profile_global"] = mapped_active_profile
elif name_map:
group_data["active_profile_global"] = next(iter(name_map.values()))
else:
fallback_name = make_unique_profile_name(f"{group_name} - Default")
global_profiles[fallback_name] = {
"schedule_mode": group_data.get("schedule_mode", "all_days"),
"schedules": copy.deepcopy(group_data.get("schedules", {"all_days": []}))
}
legacy_default_name = "Default"
preserved_legacy_profiles[legacy_default_name] = {
"schedule_mode": group_data.get("schedule_mode", "all_days"),
"schedules": copy.deepcopy(group_data.get("schedules", {"all_days": []})),
"legacy": True,
}
group_data["profiles"] = preserved_legacy_profiles
group_data["active_profile_legacy"] = legacy_default_name
group_data["active_profile"] = legacy_default_name
group_data["active_profile_global"] = fallback_name
migrated = True
if migrated:
self._data["profiles"] = global_profiles
self._sync_group_profile_views()
await self.async_save()
_LOGGER.info("Migrated legacy per-group profiles to global profiles")
async def _migrate_legacy_profile_name_suffixes(self) -> None:
"""Normalize legacy profile names from '<name> [legacy]' to '<name>' with metadata."""
groups = self._data.get("groups", {})
if not isinstance(groups, dict):
return
suffix = " [legacy]"
migrated = False
for group_data in groups.values():
if not isinstance(group_data, dict):
continue
profiles = group_data.get("profiles", {})
if not isinstance(profiles, dict) or not profiles:
continue
normalized_profiles: Dict[str, Any] = {}
name_map: Dict[str, str] = {}
group_changed = False
for profile_name, profile_data in profiles.items():
target_name = profile_name
source_profile = profile_data if isinstance(profile_data, dict) else {}
if isinstance(profile_name, str) and profile_name.endswith(suffix):
base_name = profile_name[: -len(suffix)]
candidate_name = base_name
if candidate_name in normalized_profiles:
# Avoid accidental overwrite if both names exist; keep original key.
candidate_name = profile_name
else:
group_changed = True
target_name = candidate_name
normalized_profile = copy.deepcopy(source_profile)
if profile_name != target_name and normalized_profile.get("legacy") is not True:
normalized_profile["legacy"] = True
group_changed = True
if target_name in normalized_profiles:
# Collision fallback (non-suffix duplicate): preserve existing and keep incoming key.
target_name = profile_name
normalized_profile = copy.deepcopy(source_profile)
if target_name != profile_name:
name_map[profile_name] = target_name
normalized_profiles[target_name] = normalized_profile
if group_changed:
group_data["profiles"] = normalized_profiles
for key in ("active_profile", "active_profile_legacy"):
current_value = group_data.get(key)
if isinstance(current_value, str) and current_value in name_map:
group_data[key] = name_map[current_value]
migrated = True
if migrated:
await self.async_save()
_LOGGER.info("Normalized legacy profile name suffixes to metadata format")
async def async_get_settings(self) -> Dict[str, Any]:
"""Return current global settings."""
return self._data.get("settings", {})
async def async_save_settings(self, settings: Dict[str, Any]) -> None:
"""Save global settings to storage by merging and persisting."""
if "settings" not in self._data:
self._data["settings"] = {}
# Merge provided settings
for k, v in settings.items():
self._data["settings"][k] = v
await self.async_save()
_LOGGER.debug(f"Saved settings: {self._data.get('settings')}")
async def async_get_advance_history(self) -> Dict[str, Any]:
"""Return advance history for all entities."""
return self._data.get("advance_history", {})
async def async_save_advance_history(self, history: Dict[str, Any]) -> None:
"""Save advance history to storage."""
self._data["advance_history"] = history
await self.async_save()
_LOGGER.debug(f"Saved advance history: {history}")
async def async_factory_reset(self) -> None:
"""Reset all data to factory defaults (freshly installed state)."""
_LOGGER.warning("Factory reset requested - clearing all schedules, groups, and settings")
# Reset to fresh state with default settings
self._data = {
"entities": {},
"groups": {},
"settings": {},
"advance_history": {}
}
# Set default min/max temp based on temperature unit
from homeassistant.const import UnitOfTemperature
if self.hass.config.units.temperature_unit == UnitOfTemperature.FAHRENHEIT:
self._data["settings"]["min_temp"] = 42.0 # Fahrenheit
self._data["settings"]["max_temp"] = 86.0 # Fahrenheit
else:
self._data["settings"]["min_temp"] = 5.0 # Celsius
self._data["settings"]["max_temp"] = 30.0 # Celsius
# Set default for derivative sensors
self._data["settings"]["create_derivative_sensors"] = True
await self.async_save()
_LOGGER.info("Factory reset completed - all data cleared and defaults restored")
def _find_single_entity_group(self, entity_id: str) -> Optional[str]:
"""Find the single-entity group name for an entity (checks both old __entity_ format and friendly name format)."""
# Check old format first
old_format = f"__entity_{entity_id}"
if old_format in self._data.get("groups", {}):
group_data = self._data["groups"][old_format]
if group_data.get("_is_single_entity_group") and entity_id in group_data.get("entities", []):
return old_format
# Check all groups for single-entity groups containing this entity
for group_name, group_data in self._data.get("groups", {}).items():
if (group_data.get("_is_single_entity_group") and
len(group_data.get("entities", [])) == 1 and
entity_id in group_data.get("entities", [])):
return group_name
return None
async def async_get_all_entities(self) -> List[str]:
"""Get list of all entity IDs with schedules (from single-entity groups)."""
entity_ids = []
for group_name, group_data in self._data.get("groups", {}).items():
# Extract entity IDs from all groups (single and multi-entity)
entity_ids.extend(group_data.get("entities", []))
return list(set(entity_ids)) # Remove duplicates
async def async_get_schedule(self, entity_id: str, day: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get schedule for an entity (from its single-entity group or multi-entity group). If day is specified, returns nodes for that day."""
# Check if entity is in a single-entity group
single_group_name = self._find_single_entity_group(entity_id)
if single_group_name:
group_data = self._data["groups"][single_group_name]
projected_group = self._project_group_runtime_view(group_data)
_LOGGER.debug(f"async_get_schedule: entity {entity_id} found in single-entity group - enabled={group_data.get('enabled', True)}")
# If no day specified, return the whole schedule structure
if day is None:
return projected_group
# Return nodes for specific day based on schedule mode
return {
"nodes": self._get_nodes_for_day(projected_group, day),
"enabled": group_data.get("enabled", True),
"schedule_mode": projected_group.get("schedule_mode", "all_days")
}
# Check if entity is in a multi-entity group
for group_name, group_data in self._data.get("groups", {}).items():
if entity_id in group_data.get("entities", []):
projected_group = self._project_group_runtime_view(group_data)
_LOGGER.debug(f"async_get_schedule: entity {entity_id} found in multi-entity group '{group_name}' - enabled={group_data.get('enabled', True)}")
# If no day specified, return the whole schedule structure
if day is None:
return projected_group
# Return nodes for specific day based on schedule mode
return {
"nodes": self._get_nodes_for_day(projected_group, day),
"enabled": group_data.get("enabled", True),
"schedule_mode": projected_group.get("schedule_mode", "all_days")
}
_LOGGER.debug(f"async_get_schedule: entity {entity_id} not found in any group")
return None
def _get_nodes_for_day(self, entity_data: Dict[str, Any], day: str) -> List[Dict[str, Any]]:
"""Get nodes for a specific day based on schedule mode."""
schedule_mode = entity_data.get("schedule_mode", "all_days")
schedules = entity_data.get("schedules", {})
if schedule_mode == "all_days":
return schedules.get("all_days", [])
elif schedule_mode == "5/2":
# If day is already weekday/weekend, use it directly
if day in ["weekday", "weekend"]:
return schedules.get(day, [])
# Otherwise map individual days to weekday/weekend
# Weekdays: mon, tue, wed, thu, fri -> "weekday"
# Weekends: sat, sun -> "weekend"
elif day in ["mon", "tue", "wed", "thu", "fri"]:
return schedules.get("weekday", [])
else:
return schedules.get("weekend", [])
elif schedule_mode == "individual":
return schedules.get(day, [])
return []
async def async_set_schedule(self, entity_id: str, nodes: List[Dict[str, Any]], day: Optional[str] = None, schedule_mode: Optional[str] = None) -> None:
"""Set schedule nodes for an entity by creating/updating its single-entity group."""
# Check if entity is in a multi-entity group
for group_name, group_data in self._data.get("groups", {}).items():
if not group_data.get("_is_single_entity_group", False) and entity_id in group_data.get("entities", []):
# Entity is in a real group, update the group schedule instead
_LOGGER.info(f"Entity {entity_id} is in group '{group_name}', updating group schedule")
await self.async_set_group_schedule(group_name, nodes, day, schedule_mode)
return
# Check if entity already has a single-entity group (old or new format)
single_group_name = self._find_single_entity_group(entity_id)
# If no existing group, create new one with friendly name
if not single_group_name:
friendly_name = entity_id
if state := self.hass.states.get(entity_id):
friendly_name = state.attributes.get("friendly_name", entity_id)
single_group_name = friendly_name
# Ensure groups dict exists
if "groups" not in self._data:
self._data["groups"] = {}
# Create group if it doesn't exist
if single_group_name not in self._data["groups"]:
self._data["groups"][single_group_name] = {
"entities": [entity_id],
"enabled": True,
"ignored": False,
"schedule_mode": schedule_mode or "all_days",
"schedules": {},
"profiles": {
"Default": {
"schedule_mode": schedule_mode or "all_days",
"schedules": {}
}
},
"active_profile": "Default",
"_is_single_entity_group": True
}
_LOGGER.info(f"Created single-entity group '{single_group_name}' for {entity_id}")
# Now update the group schedule
await self.async_set_group_schedule(single_group_name, nodes, day, schedule_mode)
async def async_add_entity(self, entity_id: str) -> None:
"""Add a new entity with default schedule by creating a single-entity group."""
# Get friendly name for the group
friendly_name = entity_id
if state := self.hass.states.get(entity_id):
friendly_name = state.attributes.get("friendly_name", entity_id)
single_group_name = friendly_name
if "groups" not in self._data:
self._data["groups"] = {}
if single_group_name not in self._data["groups"]:
self._data["groups"][single_group_name] = {
"entities": [entity_id],
"enabled": True,
"ignored": False,
"schedule_mode": "all_days",
"schedules": {
"all_days": DEFAULT_SCHEDULE.copy()
},
"profiles": {
"Default": {
"schedule_mode": "all_days",
"schedules": {
"all_days": DEFAULT_SCHEDULE.copy()
}
}
},
"active_profile": "Default",
"_is_single_entity_group": True
}
await self.async_save()
_LOGGER.info(f"Added entity {entity_id} with default schedule as single-entity group")
async def async_remove_entity(self, entity_id: str) -> None:
"""Remove an entity and its schedule (single-entity group)."""
# Remove the single-entity group
single_group_name = self._find_single_entity_group(entity_id)
if single_group_name and single_group_name in self._data.get("groups", {}):
del self._data["groups"][single_group_name]
await self.async_save()
_LOGGER.info(f"Removed single-entity group '{single_group_name}' for entity {entity_id}")
else:
_LOGGER.warning(f"Attempted to remove entity {entity_id} but its single-entity group doesn't exist")
async def async_set_ignored(self, entity_id: str, ignored: bool) -> None:
"""Set whether an entity should be ignored (not monitored)."""
_LOGGER.info(f"async_set_ignored called: entity_id={entity_id}, ignored={ignored}")
# Find which group this entity belongs to
entity_group_name = None
for group_name, group_data in self._data.get("groups", {}).items():
if entity_id in group_data.get("entities", []):
entity_group_name = group_name
break
# Update the group if the entity is in one
if entity_group_name:
group_data = self._data["groups"][entity_group_name]
group_data["ignored"] = ignored
# If ignored, disable the group; if not ignored, enable it
if ignored:
group_data["enabled"] = False
else:
group_data["enabled"] = True
schedules = group_data.get("schedules", {})
has_any_nodes = (
isinstance(schedules, dict)
and any(isinstance(nodes, list) and len(nodes) > 0 for nodes in schedules.values())
)
if not has_any_nodes:
default_schedule = self._get_default_schedule_template()
group_data["schedule_mode"] = "all_days"
group_data["schedules"] = self._build_schedules_from_template(default_schedule)
profiles = group_data.get("profiles", {})
if not isinstance(profiles, dict):
profiles = {}
if "Default" not in profiles or not isinstance(profiles.get("Default"), dict):
profiles["Default"] = {}
default_profile = profiles["Default"]
default_profile["schedule_mode"] = "all_days"
default_profile["schedules"] = copy.deepcopy(group_data["schedules"])
group_data["profiles"] = profiles
group_data["active_profile"] = group_data.get("active_profile") or "Default"
_LOGGER.info(
f"Applied configured default schedule to group '{entity_group_name}' while enabling monitoring"
)
_LOGGER.info(f"Set group '{entity_group_name}' ignored={ignored}, enabled={not ignored} for entity {entity_id}")
else:
# Entity is not in any group, create a single-entity group
if "groups" not in self._data:
self._data["groups"] = {}
# Get friendly name for the group
friendly_name = entity_id
if state := self.hass.states.get(entity_id):
friendly_name = state.attributes.get("friendly_name", entity_id)
single_group_name = friendly_name
if ignored:
# Create with empty schedule and ignored=True
self._data["groups"][single_group_name] = {
"entities": [entity_id],
"enabled": False,
"ignored": True,
"schedule_mode": "all_days",
"schedules": {"all_days": []},
"profiles": {
"Default": {
"schedule_mode": "all_days",
"schedules": {"all_days": []}
}
},
"active_profile": "Default",
"_is_single_entity_group": True
}
_LOGGER.info(f"Created single-entity group '{single_group_name}' for {entity_id} with ignored=True")
else:
# Create with default schedule and ignored=False
default_schedule = self._get_default_schedule_template()
self._data["groups"][single_group_name] = {
"entities": [entity_id],
"enabled": True,
"ignored": False,
"schedule_mode": "all_days",
"schedules": {"all_days": copy.deepcopy(default_schedule)},
"profiles": {
"Default": {
"schedule_mode": "all_days",
"schedules": {"all_days": copy.deepcopy(default_schedule)}
}
},
"active_profile": "Default",
"_is_single_entity_group": True
}
_LOGGER.info(f"Created single-entity group '{single_group_name}' for {entity_id} with default schedule")
await self.async_save()
async def async_is_ignored(self, entity_id: str) -> bool:
"""Check if an entity is marked as ignored."""
# Check if entity is in any group
for group_name, group_data in self._data.get("groups", {}).items():
if entity_id in group_data.get("entities", []):
return group_data.get("ignored", False)
# Entity not found in any group
return False
async def async_set_enabled(self, entity_id: str, enabled: bool) -> None:
"""Enable or disable scheduling for an entity (via its single-entity group or multi-entity group)."""
# Find which group this entity belongs to
entity_group_name = None
for group_name, group_data in self._data.get("groups", {}).items():
if entity_id in group_data.get("entities", []):
entity_group_name = group_name
break
# Update the group
if entity_group_name:
self._data["groups"][entity_group_name]["enabled"] = enabled
await self.async_save()
_LOGGER.info(f"Set group '{entity_group_name}' enabled={enabled} for entity {entity_id}")
else:
_LOGGER.warning(f"Entity {entity_id} not found in any group for async_set_enabled")
async def _reload_sensor_platform(self) -> None:
"""Reload the sensor platform to update derivative sensors."""
try:
# Get the config entry
entries = self.hass.config_entries.async_entries(DOMAIN)
if entries:
entry = entries[0]
# Reload the sensor platform
await self.hass.config_entries.async_reload(entry.entry_id)
_LOGGER.debug("Reloaded sensor platform for derivative sensor update")
except Exception as e:
_LOGGER.debug(f"Could not reload sensor platform: {e}")
async def async_cleanup_derivative_sensors(self, confirm_delete_all: bool = False) -> Dict[str, Any]:
"""Cleanup derivative sensors.
- Delete sensors for entities that no longer exist
- If auto-creation is disabled and confirm_delete_all=True, delete ALL derivative sensors
Returns a dict with cleanup results.
"""
settings = self._data.get("settings", {})
auto_creation_enabled = settings.get("create_derivative_sensors", True)
# Get all entity IDs from groups
all_entity_ids = await self.async_get_all_entities()
deleted_sensors = []
errors = []
# Get entity registry to find our sensors
from homeassistant.helpers import entity_registry as er
entity_registry = er.async_get(self.hass)
# Find all climate_scheduler rate sensors
climate_scheduler_sensors = [
entry
for entry in entity_registry.entities.values()
if entry.platform == DOMAIN
and entry.domain == "sensor"
and entry.unique_id and entry.unique_id.endswith("_rate")
]
# If auto-creation is disabled and user confirmed, delete ALL
if not auto_creation_enabled and confirm_delete_all:
_LOGGER.debug(f"Auto-creation disabled and confirmed - deleting all {len(climate_scheduler_sensors)} derivative sensors")
for entry in climate_scheduler_sensors:
try:
entity_registry.async_remove(entry.entity_id)
deleted_sensors.append(entry.entity_id)
_LOGGER.debug(f"Deleted derivative sensor {entry.entity_id}")
except Exception as e:
errors.append(f"{entry.entity_id}: {str(e)}")
_LOGGER.warning(f"Failed to delete {entry.entity_id}: {e}")
return {
"deleted_count": len(deleted_sensors),
"deleted_sensors": deleted_sensors,
"errors": errors,
"message": f"Deleted all {len(deleted_sensors)} climate scheduler derivative sensors"
}
# If auto-creation is disabled but not confirmed, return warning
if not auto_creation_enabled and not confirm_delete_all:
return {
"deleted_count": 0,
"deleted_sensors": [],
"errors": [],
"message": f"Auto-creation is disabled. Found {len(climate_scheduler_sensors)} derivative sensors. Set confirm_delete_all=true to delete all.",
"requires_confirmation": True
}
# Otherwise, only delete sensors for entities that no longer exist
for entry in climate_scheduler_sensors:
# Extract climate entity from unique_id: climate_scheduler_bedroom_rate -> climate.bedroom
unique_id = entry.unique_id
entity_name = unique_id.replace("climate_scheduler_", "").replace("_rate", "")
climate_entity_id = f"climate.{entity_name}"
if climate_entity_id not in all_entity_ids:
# Entity no longer exists in storage, delete the sensor
try:
entity_registry.async_remove(entry.entity_id)
deleted_sensors.append(entry.entity_id)
_LOGGER.debug(f"Deleted orphaned derivative sensor {entry.entity_id} (entity {climate_entity_id} no longer exists)")
except Exception as e:
errors.append(f"{entry.entity_id}: {str(e)}")
_LOGGER.warning(f"Failed to delete {entry.entity_id}: {e}")
return {
"deleted_count": len(deleted_sensors),
"deleted_sensors": deleted_sensors,
"errors": errors,
"message": f"Deleted {len(deleted_sensors)} orphaned derivative sensors"
}
async def async_cleanup_unmonitored_storage(self, delete: bool = False) -> Dict[str, Any]:
"""Cleanup stale storage references for unmonitored or missing entities.
Removes:
- Groups marked as ignored (unmonitored)
- Groups with no valid climate entities
- Invalid/missing entity references inside groups
- Invalid profile structures and broken active profile pointers
- Orphaned advance history entries
"""
groups = self._data.get("groups", {})
if not isinstance(groups, dict):
groups = {}
climate_entity_ids = set(self.hass.states.async_entity_ids("climate"))
changed = False
kept_groups: Dict[str, Any] = {}
kept_entities: set[str] = set()
removed_groups: list[Dict[str, Any]] = []
removed_entity_refs: list[Dict[str, Any]] = []
repaired_groups: list[str] = []
removed_profiles: list[Dict[str, Any]] = []
repaired_active_profiles: list[Dict[str, str]] = []
removed_advance_history: list[str] = []
for group_name, raw_group_data in groups.items():
if not isinstance(raw_group_data, dict):
removed_groups.append({"group": group_name, "reason": "invalid_group_data"})
changed = True
continue
group_data = copy.deepcopy(raw_group_data)
if group_data.get("ignored", False):
removed_groups.append({"group": group_name, "reason": "unmonitored"})
changed = True
continue
original_entities = group_data.get("entities", [])
if not isinstance(original_entities, list):
original_entities = []
normalized_entities: list[str] = []
seen_entities: set[str] = set()
for entity_id in original_entities:
if not isinstance(entity_id, str) or not entity_id:
removed_entity_refs.append({
"group": group_name,
"entity_id": entity_id,
"reason": "invalid_entity_reference",
})
changed = True
continue
if entity_id in seen_entities:
removed_entity_refs.append({
"group": group_name,
"entity_id": entity_id,
"reason": "duplicate_entity_reference",
})
changed = True
continue
seen_entities.add(entity_id)
if entity_id not in climate_entity_ids:
removed_entity_refs.append({
"group": group_name,
"entity_id": entity_id,
"reason": "entity_not_found",
})
changed = True
continue
normalized_entities.append(entity_id)
if not normalized_entities:
removed_groups.append({"group": group_name, "reason": "no_monitored_entities"})
changed = True
continue
if normalized_entities != original_entities:
group_data["entities"] = normalized_entities
changed = True
repaired_groups.append(group_name)
profiles = group_data.get("profiles", {})
if not isinstance(profiles, dict):
profiles = {}
changed = True
valid_profiles: Dict[str, Any] = {}
for profile_name, profile_data in profiles.items():
if not isinstance(profile_data, dict):
removed_profiles.append({
"group": group_name,
"profile": profile_name,
"reason": "invalid_profile_data",
})
changed = True
continue
profile_schedules = profile_data.get("schedules")
if not isinstance(profile_schedules, dict):
removed_profiles.append({
"group": group_name,
"profile": profile_name,
"reason": "invalid_profile_schedules",
})
changed = True
continue
valid_profiles[profile_name] = {
"schedule_mode": profile_data.get("schedule_mode", group_data.get("schedule_mode", "all_days")),
"schedules": copy.deepcopy(profile_schedules),
}
if profile_data.get("legacy") is True:
valid_profiles[profile_name]["legacy"] = True
if not valid_profiles:
valid_profiles["Default"] = {
"schedule_mode": group_data.get("schedule_mode", "all_days"),
"schedules": copy.deepcopy(group_data.get("schedules", {"all_days": []})),
}
changed = True
group_data["profiles"] = valid_profiles
active_profile = group_data.get("active_profile")
if active_profile not in valid_profiles:
new_active_profile = "Default" if "Default" in valid_profiles else next(iter(valid_profiles))
repaired_active_profiles.append({
"group": group_name,
"old": str(active_profile),
"new": new_active_profile,
})
group_data["active_profile"] = new_active_profile
changed = True
active_profile_data = group_data["profiles"][group_data["active_profile"]]
group_data["schedule_mode"] = active_profile_data.get("schedule_mode", "all_days")
group_data["schedules"] = copy.deepcopy(active_profile_data.get("schedules", {"all_days": []}))
should_be_single = len(normalized_entities) == 1
if group_data.get("_is_single_entity_group", False) != should_be_single:
group_data["_is_single_entity_group"] = should_be_single
changed = True
if group_name not in repaired_groups:
repaired_groups.append(group_name)
kept_groups[group_name] = group_data
kept_entities.update(normalized_entities)
current_groups = self._data.get("groups", {})
if current_groups != kept_groups:
changed = True
advance_history_raw = self._data.get("advance_history", {})
advance_history: Dict[str, Any] = copy.deepcopy(advance_history_raw) if isinstance(advance_history_raw, dict) else {}
if not isinstance(advance_history_raw, dict):
changed = True
for entity_id in list(advance_history.keys()):
if entity_id not in kept_entities:
removed_advance_history.append(entity_id)
del advance_history[entity_id]
changed = True
if "entities" in self._data and self._data.get("entities"):
changed = True
if not delete:
message = (
f"Preview: would remove {len(removed_groups)} groups, "
f"{len(removed_entity_refs)} stale entity references, "
f"{len(removed_profiles)} invalid profiles, and "
f"{len(removed_advance_history)} orphaned advance history entries."
)
return {
"delete": False,
"would_change": changed,
"would_groups_remaining": len(kept_groups),
"would_remove_groups": removed_groups,
"would_remove_entity_references": removed_entity_refs,
"would_remove_profiles": removed_profiles,
"would_repair_groups": repaired_groups,
"would_repair_active_profiles": repaired_active_profiles,
"would_remove_advance_history": removed_advance_history,
"message": message,
}
self._data["groups"] = kept_groups
self._data["advance_history"] = advance_history
if "entities" in self._data:
del self._data["entities"]
if changed:
await self.async_save()
message = (
f"Removed {len(removed_groups)} groups, "
f"{len(removed_entity_refs)} stale entity references, "
f"{len(removed_profiles)} invalid profiles, and "
f"{len(removed_advance_history)} orphaned advance history entries."
)
return {
"delete": True,
"changed": changed,
"groups_remaining": len(kept_groups),
"removed_groups": removed_groups,
"removed_entity_references": removed_entity_refs,
"removed_profiles": removed_profiles,
"repaired_groups": repaired_groups,
"repaired_active_profiles": repaired_active_profiles,
"removed_advance_history": removed_advance_history,
"message": message,
}
async def async_is_enabled(self, entity_id: str) -> bool:
"""Check if scheduling is enabled for an entity."""
# Check if entity is in any group
for group_name, group_data in self._data.get("groups", {}).items():
if entity_id in group_data.get("entities", []):
return group_data.get("enabled", True)
# Entity not found in any group
return False
def interpolate_temperature(self, nodes: List[Dict[str, Any]], current_time: time) -> float:
"""Calculate temperature at a given time using step function (hold until next node)."""
if not nodes:
return 18.0 # Default fallback
# Sort nodes by time
sorted_nodes = sorted(nodes, key=lambda n: self._time_to_minutes(n["time"]))
# Convert current time to minutes since midnight
current_minutes = current_time.hour * 60 + current_time.minute
# Find the active node (most recent node before or at current time)
active_node = None
for node in sorted_nodes:
node_minutes = self._time_to_minutes(node["time"])
if node_minutes <= current_minutes:
active_node = node
else:
break
# If no node found before current time, use last node from previous day (wrap around)
if active_node is None:
active_node = sorted_nodes[-1]
return active_node["temp"]
def get_active_node(self, nodes: List[Dict[str, Any]], current_time: time) -> Optional[Dict[str, Any]]:
"""Get the active node at a given time."""
if not nodes:
return None
# Sort nodes by time
sorted_nodes = sorted(nodes, key=lambda n: self._time_to_minutes(n["time"]))
# Convert current time to minutes since midnight
current_minutes = current_time.hour * 60 + current_time.minute
# Find the active node (most recent node before or at current time)
active_node = None
for node in sorted_nodes:
node_minutes = self._time_to_minutes(node["time"])
if node_minutes <= current_minutes:
active_node = node
else:
break
# If no node found before current time, use last node from previous day (wrap around)
if active_node is None:
active_node = sorted_nodes[-1]
return active_node
async def get_active_node_for_group(self, group_name: str, current_time: Optional[time] = None, current_day: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get the active node for a group at a given time, handling cross-day transitions in individual mode.
Args:
group_name: Name of the group
current_time: Time to check (defaults to now)
current_day: Day abbreviation (mon, tue, etc.) (defaults to today)
Returns:
The active node at the specified time, or None if no schedule exists
"""
if current_time is None:
current_time = datetime.now().time()
if current_day is None:
current_day = datetime.now().strftime('%a').lower()
# Get group schedule for current day
group_schedule = await self.async_get_group_schedule(group_name, current_day)
if not group_schedule or not group_schedule.get("nodes"):
return None
schedule_mode = group_schedule.get("schedule_mode", "all_days")
nodes = group_schedule["nodes"]
# In individual or 5/2 mode, check if we need previous day's schedule
if schedule_mode in ["individual", "5/2"] and nodes:
sorted_nodes = sorted(nodes, key=lambda n: self._time_to_minutes(n["time"]))
current_minutes = current_time.hour * 60 + current_time.minute
first_node_minutes = self._time_to_minutes(sorted_nodes[0]["time"])
if current_minutes < first_node_minutes:
# We're before the first node of today, get 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]
prev_day_schedule = await self.async_get_group_schedule(group_name, prev_day)
if prev_day_schedule and prev_day_schedule.get("nodes"):
prev_nodes = prev_day_schedule["nodes"]
sorted_prev_nodes = sorted(prev_nodes, key=lambda n: self._time_to_minutes(n["time"]))
return sorted_prev_nodes[-1]
# Normal case: get active node from current day's schedule
return self.get_active_node(nodes, current_time)
def get_next_node(self, nodes: List[Dict[str, Any]], current_time: time) -> Optional[Dict[str, Any]]:
"""Get the next scheduled node after the current time."""
if not nodes:
return None
# Sort nodes by time
sorted_nodes = sorted(nodes, key=lambda n: self._time_to_minutes(n["time"]))
# Convert current time to minutes since midnight
current_minutes = current_time.hour * 60 + current_time.minute
# Find the next node (first node after current time)
for node in sorted_nodes:
node_minutes = self._time_to_minutes(node["time"])
if node_minutes > current_minutes:
return node
# If no node found after current time, wrap around to first node (next day)
return sorted_nodes[0]
@staticmethod
def _time_to_minutes(time_str: str) -> int:
"""Convert HH:MM string to minutes since midnight."""
parts = time_str.split(":")
hours = int(parts[0])
minutes = int(parts[1])
return hours * 60 + minutes
# Group Management Methods
async def async_create_group(self, group_name: str) -> None:
"""Create a new group."""
if "groups" not in self._data:
self._data["groups"] = {}
if group_name in self._data["groups"]:
raise ValueError(f"Group '{group_name}' already exists")
self._data["groups"][group_name] = {
"entities": [],
"enabled": True,
"ignored": False,
"schedule_mode": "all_days",
"schedules": {
"all_days": [{"time": "00:00", "temp": 18}]
},
"profiles": {
"Default": {
"schedule_mode": "all_days",
"schedules": {
"all_days": [{"time": "00:00", "temp": 18}]
}
}
},
"active_profile": "Default"
}
await self.async_save()
_LOGGER.info(f"Created group '{group_name}'")
async def async_delete_group(self, group_name: str) -> None:
"""Delete a group."""
if group_name in self._data.get("groups", {}):
del self._data["groups"][group_name]
await self.async_save()
_LOGGER.info(f"Deleted group '{group_name}'")
async def async_rename_group(self, old_name: str, new_name: str) -> None:
"""Rename a group."""
if old_name not in self._data.get("groups", {}):
raise ValueError(f"Group '{old_name}' does not exist")
if new_name in self._data.get("groups", {}):
raise ValueError(f"Group '{new_name}' already exists")
# Rename the group
self._data["groups"][new_name] = self._data["groups"].pop(old_name)
await self.async_save()
_LOGGER.info(f"Renamed group from '{old_name}' to '{new_name}'")
async def async_add_entity_to_group(self, group_name: str, entity_id: str) -> None:
"""Add an entity to a group.
If the entity is unmonitored (not in any group), it will be added to the target group.
If the entity is in a single-entity group, that group will be deleted and the entity moved.
"""
if group_name not in self._data.get("groups", {}):
raise ValueError(f"Group '{group_name}' does not exist")
group_data = self._data["groups"][group_name]
# Check if entity is currently in a different group
old_single_entity_group = None
entity_found_in_group = False
for existing_group_name, existing_group_data in self._data.get("groups", {}).items():
if existing_group_name != group_name and entity_id in existing_group_data.get("entities", []):
# Found entity in a different group
entity_found_in_group = True
if existing_group_data.get("_is_single_entity_group") and len(existing_group_data.get("entities", [])) == 1:
# It's a single-entity group - mark for deletion
old_single_entity_group = existing_group_name
_LOGGER.info(f"Entity {entity_id} is in single-entity group '{existing_group_name}' which will be deleted")
# Remove entity from the old group
existing_group_data["entities"].remove(entity_id)
break
# Log if entity wasn't in any group (unmonitored entity being added)
if not entity_found_in_group:
_LOGGER.info(f"Adding unmonitored entity {entity_id} to group '{group_name}'")
if entity_id not in group_data["entities"]:
group_data["entities"].append(entity_id)
entity_count = len(group_data["entities"])
# Normalize _is_single_entity_group flag based on actual entity count
if entity_count == 1:
# Group now has exactly 1 entity - mark as single-entity group
group_data["_is_single_entity_group"] = True
_LOGGER.info(f"Group '{group_name}' now has 1 entity - marked as single-entity group")
elif entity_count >= 2:
# Group now has 2+ entities - ensure it's marked as multi-entity group
if group_data.get("_is_single_entity_group"):
group_data["_is_single_entity_group"] = False
_LOGGER.info(f"Group '{group_name}' now has {entity_count} entities - converted to multi-entity group")
# Delete the old single-entity group if it existed
if old_single_entity_group:
del self._data["groups"][old_single_entity_group]
_LOGGER.info(f"Deleted single-entity group '{old_single_entity_group}'")
await self.async_save()
_LOGGER.info(f"Added {entity_id} to group '{group_name}'")
async def async_remove_entity_from_group(self, group_name: str, entity_id: str) -> None:
"""Remove an entity from a group and create a new single-entity group for it."""
if group_name in self._data.get("groups", {}):
entities = self._data["groups"][group_name]["entities"]
if entity_id in entities:
# Get the current group data before removing the entity
group_data = self._data["groups"][group_name]
entities.remove(entity_id)
# If only 1 entity remains, convert the group to a single-entity group
if len(entities) == 1:
group_data["_is_single_entity_group"] = True
_LOGGER.info(f"Group '{group_name}' now has only 1 entity - converted to single-entity group")
# Create a new single-entity group for the removed entity using friendly name
friendly_name = entity_id
if state := self.hass.states.get(entity_id):
friendly_name = state.attributes.get("friendly_name", entity_id)
single_group_name = friendly_name
if "groups" not in self._data:
self._data["groups"] = {}
# Create the new single-entity group with current data from the group
self._data["groups"][single_group_name] = {
"entities": [entity_id],
"enabled": group_data.get("enabled", True),
"ignored": group_data.get("ignored", False),
"schedule_mode": group_data.get("schedule_mode", "all_days"),
"schedules": copy.deepcopy(group_data.get("schedules", {"all_days": []})),
"profiles": copy.deepcopy(group_data.get("profiles", {
"Default": {
"schedule_mode": "all_days",
"schedules": {"all_days": []}
}
})),
"active_profile": group_data.get("active_profile", "Default"),
"_is_single_entity_group": True
}
await self.async_save()
_LOGGER.info(f"Removed {entity_id} from group '{group_name}' and created single-entity group '{single_group_name}'")
async def async_get_groups(self) -> Dict[str, Any]:
"""Get all groups."""
self._sync_group_profile_views()
groups = self._data.get("groups", {})
projected: Dict[str, Any] = {}
for group_name, group_data in groups.items():
if isinstance(group_data, dict):
projected[group_name] = self._project_group_runtime_view(group_data)
else:
projected[group_name] = group_data
return projected
async def async_get_group(self, group_name: str) -> Optional[Dict[str, Any]]:
"""Get a specific group."""
self._sync_group_profile_views()
group_data = self._data.get("groups", {}).get(group_name)
if not isinstance(group_data, dict):
return group_data
return self._project_group_runtime_view(group_data)
def _project_group_runtime_view(self, group_data: Dict[str, Any]) -> Dict[str, Any]:
"""Return a runtime view exposing global profiles while preserving persisted legacy profiles."""
view = copy.deepcopy(group_data)
self._ensure_global_profiles_initialized()
global_profiles = self._data.get("profiles", {})
view["profiles"] = copy.deepcopy(global_profiles)
active_profile = self._resolve_group_active_global_profile(group_data, global_profiles)
if active_profile:
view["active_profile"] = active_profile
view["active_profile_global"] = active_profile
return view
async def async_get_entity_group(self, entity_id: str) -> Optional[str]:
"""Get the group name that an entity belongs to."""
for group_name, group_data in self._data.get("groups", {}).items():
if entity_id in group_data.get("entities", []):
return group_name
return None
async def async_set_group_schedule(self, group_name: str, nodes: List[Dict[str, Any]], day: Optional[str] = None, schedule_mode: Optional[str] = None, profile_name: Optional[str] = None) -> None:
"""Set schedule for a group (applies to all entities in the group).
If profile_name is provided, saves to that specific profile without changing the active profile.
Otherwise saves to the currently active profile.
"""
if group_name not in self._data.get("groups", {}):
raise ValueError(f"Group '{group_name}' does not exist")
group_data = self._data["groups"][group_name]
def apply_nodes_to_schedules(
schedules: Dict[str, Any],
mode: str,
schedule_nodes: List[Dict[str, Any]],
schedule_day: Optional[str],
) -> Dict[str, Any]:
"""Apply incoming nodes to a schedule dict using mode/day mapping semantics."""
updated = copy.deepcopy(schedules) if isinstance(schedules, dict) else {}
if schedule_day is None:
if mode == "all_days":
updated["all_days"] = schedule_nodes
elif mode == "5/2":
updated["weekday"] = schedule_nodes
else:
updated["mon"] = schedule_nodes
return updated
if mode == "5/2":
if schedule_day == "weekday":
updated["weekday"] = schedule_nodes
elif schedule_day == "weekend":
updated["weekend"] = schedule_nodes
elif schedule_day in ["mon", "tue", "wed", "thu", "fri"]:
updated["weekday"] = schedule_nodes
else:
updated["weekend"] = schedule_nodes
else:
updated[schedule_day] = schedule_nodes
return updated
# Determine which profile to save to (global profile namespace)
self._ensure_global_profiles_initialized()
global_profiles = self._data.get("profiles", {})
resolved_active_profile = self._resolve_group_active_global_profile(group_data, global_profiles)
target_profile = profile_name if profile_name else resolved_active_profile
# Verify profile exists if explicitly specified
if profile_name and target_profile not in global_profiles:
raise ValueError(f"Profile '{profile_name}' does not exist")
# Create profile if it doesn't exist (only for active profile flow)
if target_profile not in global_profiles:
global_profiles[target_profile] = {
"schedule_mode": group_data.get("schedule_mode", "all_days"),
"schedules": {}
}
is_explicit_non_active_profile_save = bool(profile_name and profile_name != resolved_active_profile)
if is_explicit_non_active_profile_save:
target_mode = schedule_mode if schedule_mode is not None else global_profiles[target_profile].get("schedule_mode", "all_days")
target_schedules = apply_nodes_to_schedules(
global_profiles[target_profile].get("schedules", {}),
target_mode,
nodes,
day,
)
global_profiles[target_profile]["schedule_mode"] = target_mode
global_profiles[target_profile]["schedules"] = copy.deepcopy(target_schedules)
else:
# Active-profile save path updates group runtime state and mirrors to active global profile
if schedule_mode is not None:
group_data["schedule_mode"] = schedule_mode
current_mode = group_data.get("schedule_mode", "all_days")
group_data["schedules"] = apply_nodes_to_schedules(
group_data.get("schedules", {}),
current_mode,
nodes,
day,
)
global_profiles[target_profile]["schedule_mode"] = current_mode
global_profiles[target_profile]["schedules"] = copy.deepcopy(group_data["schedules"])
self._data["profiles"] = global_profiles
# Keep group runtime schedule aligned with active profile for non-active profile saves
if is_explicit_non_active_profile_save:
active_profile = resolved_active_profile
if active_profile in global_profiles:
group_data["schedule_mode"] = global_profiles[active_profile].get("schedule_mode", "all_days")
group_data["schedules"] = copy.deepcopy(global_profiles[active_profile].get("schedules", {}))
current_mode = global_profiles[target_profile].get("schedule_mode", "all_days")
else:
current_mode = group_data.get("schedule_mode", "all_days")
_LOGGER.info(f"Saved group schedule to profile '{target_profile}' for group '{group_name}' - day: {day}, mode: {current_mode}, nodes: {len(nodes)}")
_LOGGER.debug(f"Profile schedules after save: {global_profiles[target_profile]['schedules'].keys()}")
await self.async_save()
_LOGGER.info(f"Set schedule for group '{group_name}' with {len(nodes)} nodes (day: {day}, mode: {schedule_mode})")
async def async_get_group_schedule(self, group_name: str, day: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Get schedule for a group (same logic as entity schedule retrieval)."""
if group_name not in self._data.get("groups", {}):
return None
group_data = self._data["groups"][group_name]
schedule_mode = group_data.get("schedule_mode", "all_days")
schedules = group_data.get("schedules", {})
# Same day resolution logic as entity schedules
if schedule_mode == "all_days":
nodes = schedules.get("all_days", [])
elif schedule_mode == "5/2":
if day in ["mon", "tue", "wed", "thu", "fri"]:
nodes = schedules.get("weekday", [])
else: # sat, sun
nodes = schedules.get("weekend", [])
else: # individual
nodes = schedules.get(day, [])
return {
"nodes": nodes,
"enabled": group_data.get("enabled", True),
"schedule_mode": schedule_mode,
"schedules": schedules
}
async def async_enable_group(self, group_name: str) -> None:
"""Enable a group schedule."""
if group_name not in self._data.get("groups", {}):
raise ValueError(f"Group '{group_name}' does not exist")
self._data["groups"][group_name]["enabled"] = True
await self.async_save()
_LOGGER.info(f"Enabled group '{group_name}'")
async def async_disable_group(self, group_name: str) -> None:
"""Disable a group schedule."""
if group_name not in self._data.get("groups", {}):
raise ValueError(f"Group '{group_name}' does not exist")
self._data["groups"][group_name]["enabled"] = False
await self.async_save()
_LOGGER.info(f"Disabled group '{group_name}'")
async def async_enable_schedule(self, schedule_id: str) -> None:
"""Enable a schedule by group name or entity_id."""
# Check if it's a group name
if schedule_id in self._data.get("groups", {}):
await self.async_enable_group(schedule_id)
else:
# Treat as entity_id - find its group
group_name = await self.async_get_entity_group(schedule_id)
if group_name:
await self.async_enable_group(group_name)
else:
raise ValueError(f"Schedule '{schedule_id}' not found")
async def async_disable_schedule(self, schedule_id: str) -> None:
"""Disable a schedule by group name or entity_id."""
# Check if it's a group name
if schedule_id in self._data.get("groups", {}):
await self.async_disable_group(schedule_id)
else:
# Treat as entity_id - find its group
group_name = await self.async_get_entity_group(schedule_id)
if group_name:
await self.async_disable_group(group_name)
else:
raise ValueError(f"Schedule '{schedule_id}' not found")
# Profile Management Methods
async def async_create_profile(self, target_id: str, profile_name: str) -> None:
"""Create a new global schedule profile."""
if target_id not in self._data.get("groups", {}):
raise ValueError(f"Group '{target_id}' does not exist")
self._ensure_global_profiles_initialized()
global_profiles = self._data.get("profiles", {})
if profile_name in global_profiles:
raise ValueError(f"Profile '{profile_name}' already exists")
# Seed from current target group's active schedule for convenience
target_group = self._data["groups"][target_id]
global_profiles[profile_name] = {
"schedule_mode": target_group.get("schedule_mode", "all_days"),
"schedules": copy.deepcopy(target_group.get("schedules", {"all_days": []}))
}
self._data["profiles"] = global_profiles
await self.async_save()
_LOGGER.info(f"Created global profile '{profile_name}'")
async def async_delete_profile(self, target_id: str, profile_name: str) -> None:
"""Delete a global schedule profile."""
if target_id not in self._data.get("groups", {}):
raise ValueError(f"Group '{target_id}' does not exist")
self._ensure_global_profiles_initialized()
global_profiles = self._data.get("profiles", {})
if profile_name not in global_profiles:
raise ValueError(f"Profile '{profile_name}' does not exist")
if len(global_profiles) <= 1:
raise ValueError(f"Cannot delete the last profile")
for group_name, group_data in self._data.get("groups", {}).items():
if group_data.get("active_profile") == profile_name:
raise ValueError(f"Cannot delete profile '{profile_name}' because it is active for group '{group_name}'")
del global_profiles[profile_name]
self._data["profiles"] = global_profiles
await self.async_save()
_LOGGER.info(f"Deleted global profile '{profile_name}'")
async def async_rename_profile(self, target_id: str, old_name: str, new_name: str) -> None:
"""Rename a global schedule profile."""
if target_id not in self._data.get("groups", {}):
raise ValueError(f"Group '{target_id}' does not exist")
self._ensure_global_profiles_initialized()
global_profiles = self._data.get("profiles", {})
if old_name not in global_profiles:
raise ValueError(f"Profile '{old_name}' does not exist")
if new_name in global_profiles:
raise ValueError(f"Profile '{new_name}' already exists")
global_profiles[new_name] = global_profiles.pop(old_name)
self._data["profiles"] = global_profiles
for group_data in self._data.get("groups", {}).values():
if group_data.get("active_profile") == old_name:
group_data["active_profile"] = new_name
await self.async_save()
_LOGGER.info(f"Renamed global profile from '{old_name}' to '{new_name}'")
async def async_set_active_profile(self, target_id: str, profile_name: str) -> None:
"""Set the active profile for a group."""
if target_id not in self._data.get("groups", {}):
raise ValueError(f"Group '{target_id}' does not exist")
self._ensure_global_profiles_initialized()
global_profiles = self._data.get("profiles", {})
if profile_name not in global_profiles:
raise ValueError(f"Profile '{profile_name}' does not exist")
target_data = self._data["groups"][target_id]
# Set the active global profile (runtime path)
target_data["active_profile_global"] = profile_name
# Keep legacy active profile pointer for downgrade compatibility
legacy_profiles = target_data.get("profiles", {})
legacy_active = target_data.get("active_profile")
legacy_candidate = None
if isinstance(legacy_profiles, dict):
if profile_name in legacy_profiles:
legacy_candidate = profile_name
else:
prefix = f"{target_id} - "
if profile_name.startswith(prefix):
original_name = profile_name[len(prefix):]
# Preferred mapping: original legacy profile key with explicit metadata
original_profile = legacy_profiles.get(original_name)
if isinstance(original_profile, dict) and original_profile.get("legacy") is True:
legacy_candidate = original_name
else:
# Backward compatibility for pre-metadata snapshots using name suffix tagging
tagged_name = f"{original_name} [legacy]"
tagged_profile = legacy_profiles.get(tagged_name)
if isinstance(tagged_profile, dict):
legacy_candidate = tagged_name
if legacy_candidate:
target_data["active_profile"] = legacy_candidate
target_data["active_profile_legacy"] = legacy_candidate
elif legacy_active in legacy_profiles:
target_data["active_profile"] = legacy_active
target_data["active_profile_legacy"] = legacy_active
elif isinstance(legacy_profiles, dict) and legacy_profiles:
fallback_legacy = next(iter(legacy_profiles))
target_data["active_profile"] = fallback_legacy
target_data["active_profile_legacy"] = fallback_legacy
# Load the profile's schedule into the main schedule fields
profile_data = global_profiles[profile_name]
target_data["schedule_mode"] = profile_data.get("schedule_mode", "all_days")
target_data["schedules"] = copy.deepcopy(profile_data.get("schedules", {}))
_LOGGER.info(f"Switched to profile '{profile_name}' for group '{target_id}'")
_LOGGER.debug(f"Loaded schedule mode: {target_data['schedule_mode']}, schedule keys: {target_data['schedules'].keys()}")
await self.async_save()
_LOGGER.info(f"Set active profile to '{profile_name}' for group '{target_id}'")
async def async_get_profiles(self, target_id: str) -> Dict[str, Any]:
"""Get all global profiles."""
if target_id not in self._data.get("groups", {}):
return {}
self._ensure_global_profiles_initialized()
return self._data.get("profiles", {})
async def async_get_active_profile_name(self, target_id: str) -> Optional[str]:
"""Get the name of the active profile for a group."""
target_key = "groups"
if target_id not in self._data.get(target_key, {}):
return None
target_data = self._data[target_key][target_id]
self._ensure_global_profiles_initialized()
global_profiles = self._data.get("profiles", {})
return self._resolve_group_active_global_profile(target_data, global_profiles)
async def async_get_global_profiles(self) -> Dict[str, Any]:
"""Get the global profile dictionary."""
self._ensure_global_profiles_initialized()
return self._data.get("profiles", {})