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,405 @@
"""The Climate Scheduler integration."""
import logging
import json
import time
from datetime import datetime, timedelta
from pathlib import Path
from homeassistant.config_entries import ConfigEntry
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.components.http import HomeAssistantView, StaticPathConfig
from homeassistant.util import json as json_util
from aiohttp import web
from .const import DOMAIN, UPDATE_INTERVAL_SECONDS
from .coordinator import HeatingSchedulerCoordinator
from .storage import ScheduleStorage
# Expose dynamic service descriptions for Home Assistant UI
from .services import async_get_services # noqa: E402,F401
_LOGGER = logging.getLogger(__name__)
async def _async_setup_common(hass: HomeAssistant) -> None:
"""Common setup for storage, coordinator, services and panel."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
# Initialize storage once
storage: ScheduleStorage | None = hass.data[DOMAIN].get("storage")
if storage is None:
storage = ScheduleStorage(hass)
await storage.async_load()
hass.data[DOMAIN]["storage"] = storage
# Initialize coordinator once
coordinator: HeatingSchedulerCoordinator | None = hass.data[DOMAIN].get("coordinator")
if coordinator is None:
coordinator = HeatingSchedulerCoordinator(
hass,
storage,
timedelta(seconds=UPDATE_INTERVAL_SECONDS)
)
hass.data[DOMAIN]["coordinator"] = coordinator
# Start coordinator updates
_LOGGER.info(f"Starting coordinator with {UPDATE_INTERVAL_SECONDS}s update interval")
await coordinator.async_refresh()
_LOGGER.info("Forcing initial temperature sync for all entities")
await coordinator.force_update_all()
# Schedule periodic updates
async def _scheduled_update(now):
"""Handle scheduled update."""
await coordinator.async_request_refresh()
from homeassistant.helpers.event import async_track_time_interval
async_track_time_interval(
hass,
_scheduled_update,
timedelta(seconds=UPDATE_INTERVAL_SECONDS)
)
# Avoid re-registering services, but be robust across updates/reloads.
# During an in-process reload/upgrade, `hass.data` can persist while new
# services from a newer version are missing. If so, re-register.
if hass.data[DOMAIN].get("services_registered"):
# Keep this list in sync with the services registered in `services.py`.
expected_services = (
"recreate_all_sensors",
"cleanup_malformed_sensors",
"set_schedule",
"get_schedule",
"clear_schedule",
"enable_schedule",
"disable_schedule",
"set_ignored",
"sync_all",
"create_group",
"delete_group",
"rename_group",
"add_to_group",
"remove_from_group",
"get_groups",
"list_groups",
"list_profiles",
"set_group_schedule",
"enable_group",
"disable_group",
"get_settings",
"save_settings",
"reload_integration",
"advance_schedule",
"advance_group",
"cancel_advance",
"get_advance_status",
"clear_advance_history",
"test_fire_event",
"create_profile",
"delete_profile",
"rename_profile",
"set_active_profile",
"get_profiles",
"cleanup_derivative_sensors",
"factory_reset",
"reregister_card",
"diagnostics",
)
missing = [s for s in expected_services if not hass.services.has_service(DOMAIN, s)]
if not missing:
# Services are already registered; still ensure frontend resources
# are registered/updated (important after install/upgrade or when
# Lovelace wasn't ready earlier during startup).
await _register_frontend_resources(hass)
return
_LOGGER.warning(
"Climate Scheduler services flagged as registered but missing %s; re-registering services",
missing,
)
hass.data[DOMAIN]["services_registered"] = False
# Register services from services module
from . import services as service_module
await service_module.async_setup_services(hass)
hass.data[DOMAIN]["services_registered"] = True
# Register frontend card resources
await _register_frontend_resources(hass)
async def _register_frontend_resources(hass: HomeAssistant) -> None:
"""Register the bundled frontend card as a Lovelace resource."""
# Get version from manifest.json first
manifest_path = Path(__file__).parent / "manifest.json"
try:
import json
manifest_text = await hass.async_add_executor_job(manifest_path.read_text)
manifest = json.loads(manifest_text)
frontend_version = manifest.get("version", f"u{int(time.time())}")
_LOGGER.debug("Current integration version: %s", frontend_version)
except Exception as e:
_LOGGER.warning("Failed to read manifest version: %s", e)
frontend_version = f"u{int(time.time())}"
# Always attempt to (re)register frontend resources on startup/update.
# We intentionally do not short-circuit when the stored version matches
# the current one, because we want to remove any existing resource
# entries and recreate them during install/update/reboot.
# Register static path for frontend files
frontend_path = Path(__file__).parent / "frontend"
if not frontend_path.exists():
_LOGGER.warning("Frontend directory not found at %s", frontend_path)
return
# Register the static path using the correct HA API.
# This only needs to happen once per HA runtime; avoid repeated registration
# and noisy logs when config entries reload.
should_cache = False
if not hass.data[DOMAIN].get("frontend_static_registered"):
# Expose at /<domain>/static so integrations can reliably reference it
await hass.http.async_register_static_paths([
StaticPathConfig(f"/{DOMAIN}/static", str(frontend_path), should_cache)
])
hass.data[DOMAIN]["frontend_static_registered"] = True
_LOGGER.info("Registered frontend static path: %s -> %s", f"/{DOMAIN}/static", frontend_path)
else:
_LOGGER.debug("Frontend static path already registered")
# Register Lovelace resource
try:
# Get lovelace resources
lovelace_data = hass.data.get("lovelace")
if lovelace_data is None:
_LOGGER.warning("Lovelace integration not available, card will need manual registration")
hass.data[DOMAIN]["frontend_registered_version"] = frontend_version
return
# Get resources based on HA version
from homeassistant.const import __version__ as ha_version
version_parts = [int(x) for x in ha_version.split(".")[:2]]
version_number = version_parts[0] * 1000 + version_parts[1]
if version_number >= 2025002: # 2025.2.0 and later
resources = lovelace_data.resources
else:
resources = lovelace_data.get("resources")
if resources is None:
_LOGGER.warning("Lovelace resources not available, card will need manual registration")
return
# Check for YAML mode
if not hasattr(resources, "store") or resources.store is None:
_LOGGER.info("Lovelace YAML mode detected, card must be registered manually")
# Check if the card is already registered in YAML
base_url = f"/{DOMAIN}/static/climate-scheduler-card.js"
card_already_registered = False
try:
for entry in resources.async_items():
entry_url = entry.get("url", "")
entry_base_url = entry_url.split("?")[0]
if entry_base_url == base_url:
card_already_registered = True
_LOGGER.info("Card is already registered in YAML configuration")
# Dismiss any existing notification since card is now present
await hass.services.async_call(
"persistent_notification",
"dismiss",
{"notification_id": f"{DOMAIN}_yaml_mode"},
)
break
except Exception as e:
_LOGGER.debug("Could not check YAML resources: %s", e)
# Only create notification if card is NOT registered
if not card_already_registered:
_LOGGER.warning("Card is not registered in YAML configuration - notification will be created")
await hass.services.async_call(
"persistent_notification",
"create",
{
"notification_id": f"{DOMAIN}_yaml_mode",
"title": "Climate Scheduler: Manual Card Registration Required",
"message": (
"Your Lovelace dashboard is configured via YAML mode. "
"The Climate Scheduler card cannot be auto-registered.\n\n"
"**To add the card manually:**\n\n"
"Add this to your Lovelace resources configuration:\n\n"
f"```yaml\n"
f"lovelace:\n"
f" mode: yaml\n"
f" resources:\n"
f" - url: /{DOMAIN}/static/climate-scheduler-card.js?v={frontend_version}\n"
f" type: module\n"
f"```\n\n"
"Then restart Home Assistant and refresh your browser."
),
},
)
return
# Ensure resources are loaded
if not resources.loaded:
await resources.async_load()
# Build URL with version for cache busting
# Use the integration-hosted static path we just registered
base_url = f"/{DOMAIN}/static/climate-scheduler-card.js"
url = f"{base_url}?v={frontend_version}"
# Check for old standalone card installations and remove them
old_card_patterns = [
"/hacsfiles/climate-scheduler/",
"/local/community/climate-scheduler/",
"climate-scheduler-card.js",
f"/{DOMAIN}/static/"
]
existing_entry = None
removed_old = False
for entry in list(resources.async_items()):
entry_url = entry["url"]
entry_base_url = entry_url.split("?")[0]
# Check if this is our new bundled card
if entry_base_url == base_url:
existing_entry = entry
continue
# Check if this is an old standalone card installation
if any(pattern in entry_url for pattern in old_card_patterns):
_LOGGER.info("Removing old standalone card registration: %s", entry_url)
await resources.async_delete_item(entry["id"])
removed_old = True
# If there's an existing bundled registration, remove it first so
# we always create a fresh entry (ensures consistent behavior on
# install/update/reboot and avoids stale IDs or metadata).
if existing_entry:
try:
_LOGGER.info("Removing existing bundled frontend card registration: %s", existing_entry.get("url"))
await resources.async_delete_item(existing_entry["id"])
except Exception: # noqa: BLE001
_LOGGER.debug("Failed to remove existing bundled frontend resource, will attempt to (re)create it")
# Create a new resource entry for the bundled card
if hasattr(resources, "async_create_item"):
await resources.async_create_item({"res_type": "module", "url": url})
if removed_old:
_LOGGER.info("Successfully migrated to bundled frontend card (version %s)", frontend_version)
else:
_LOGGER.info("Successfully registered bundled frontend card (version %s)", frontend_version)
else:
_LOGGER.warning("Cannot auto-register card: Lovelace resources are in YAML mode")
# Note: Version mismatch detection and refresh notification is handled by the frontend
# JavaScript, which can accurately detect when the browser cache is stale
except Exception as err:
_LOGGER.error("Failed to auto-register frontend card: %s", err)
# Store the version we just registered
hass.data[DOMAIN]["frontend_registered_version"] = frontend_version
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up via YAML by importing into a config entry, else no-op."""
if DOMAIN in config:
# Import legacy YAML into a config entry
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Climate Scheduler from a config entry."""
await _async_setup_common(hass)
# Get current version from manifest
manifest_path = Path(__file__).parent / "manifest.json"
current_version = None
try:
manifest_text = await hass.async_add_executor_job(manifest_path.read_text)
manifest = json.loads(manifest_text)
current_version = manifest.get("version")
except Exception as e:
_LOGGER.warning("Failed to read manifest version: %s", e)
# Check if this is first install or an upgrade
stored_version = entry.data.get("version")
is_first_install = stored_version is None
is_upgrade = stored_version is not None and current_version != stored_version
# Update version in config entry if changed
if current_version and (is_first_install or is_upgrade):
new_data = dict(entry.data)
new_data["version"] = current_version
hass.config_entries.async_update_entry(entry, data=new_data)
if is_first_install:
_LOGGER.info("First install detected (version %s) - will reload integration after setup", current_version)
elif is_upgrade:
_LOGGER.info("Upgrade detected (%s -> %s) - will reload integration after setup", stored_version, current_version)
# Forward entry setup to sensor, climate, and switch platforms
await hass.config_entries.async_forward_entry_setups(entry, ["sensor", "climate", "switch"])
# Reload integration after first install or upgrade to ensure UI is properly initialized
if is_first_install or is_upgrade:
async def _delayed_reload():
"""Reload integration after a short delay to allow setup to complete."""
await asyncio.sleep(2) # Wait for platforms to fully initialize
try:
_LOGGER.info("Performing post-setup reload to initialize UI")
await hass.config_entries.async_reload(entry.entry_id)
except Exception as e:
_LOGGER.warning("Post-setup reload failed: %s", e)
import asyncio
hass.async_create_task(_delayed_reload())
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
# Unload all platforms
unload_ok = await hass.config_entries.async_unload_platforms(entry, ["sensor", "climate", "switch"])
# Remove panel and services only if this is the last entry
entries = hass.config_entries.async_entries(DOMAIN)
if len(entries) <= 1:
_LOGGER.debug("[BACKEND] Unloading last config entry - removing all services (including set_group_schedule)")
# Unregister services
for svc in [
"set_schedule","get_schedule","clear_schedule","enable_schedule","disable_schedule",
"sync_all","create_group","delete_group","add_to_group","remove_from_group",
"get_groups","set_group_schedule","enable_group","disable_group","get_settings",
"save_settings","reload_integration","advance_schedule","advance_group",
"cancel_advance","get_advance_status","clear_advance_history","test_fire_event","create_profile",
"delete_profile","rename_profile","set_active_profile","get_profiles",
"cleanup_derivative_sensors","factory_reset"
]:
try:
hass.services.async_remove(DOMAIN, svc)
if svc == "set_group_schedule":
_LOGGER.debug("[BACKEND] Removed service: set_group_schedule")
except Exception: # noqa: BLE001
pass
_LOGGER.info("[BACKEND] All services removed during unload")
hass.data.pop(DOMAIN, None)
else:
_LOGGER.info("[BACKEND] Unloading entry but keeping services (not last entry)")
return unload_ok
@@ -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)
@@ -0,0 +1,28 @@
from __future__ import annotations
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from .const import DOMAIN
class ClimateSchedulerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def async_step_user(self, user_input: dict | None = None):
# Only allow a single entry
if self._async_current_entries():
return self.async_abort(reason="already_configured")
if user_input is not None:
return self.async_create_entry(title="Climate Scheduler", data={})
# No options to configure — empty form confirms creation
return self.async_show_form(step_id="user", data_schema=vol.Schema({}))
async def async_step_import(self, user_input: dict | None = None):
# Support YAML import for backward compatibility
if self._async_current_entries():
return self.async_abort(reason="already_configured")
return self.async_create_entry(title="Climate Scheduler", data={})
@@ -0,0 +1,24 @@
"""Constants for the Climate Scheduler integration."""
DOMAIN = "climate_scheduler"
# Storage
STORAGE_VERSION = 1
STORAGE_KEY = "climate_scheduler_data"
# Default schedule nodes (time in HH:MM format, temp in Celsius)
DEFAULT_SCHEDULE = []
# Temperature settings (in Celsius)
MIN_TEMP = 5.0
MAX_TEMP = 30.0
NO_CHANGE_TEMP = None # Special value to indicate temperature should not be changed
# Update interval
UPDATE_INTERVAL_SECONDS = 60 # Check schedules every minute
# Settings keys
SETTING_USE_WORKDAY = "use_workday_integration" # Whether to use Workday integration for 5/2 scheduling
SETTING_WORKDAYS = "workdays" # List of days considered workdays (e.g., ["mon", "tue", "wed", "thu", "fri"])
# Default workdays (Monday through Friday)
DEFAULT_WORKDAYS = ["mon", "tue", "wed", "thu", "fri"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
1.15.1
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,673 @@
/**
* Home Assistant API Integration
* Handles communication with Home Assistant via WebSocket API
* Supports both custom panel mode (using hass object) and iframe mode (WebSocket)
*/
class HomeAssistantAPI {
constructor() {
this.connection = null;
this.messageId = 1;
this.pendingRequests = new Map();
this.stateUpdateCallbacks = [];
this.hass = null; // For custom panel mode
this.usingHassObject = false; // Track which mode we're in
}
/**
* Set hass object (custom panel mode)
*/
setHassObject(hass) {
this.hass = hass;
this.usingHassObject = true;
}
async connect() {
// If we already have a hass object, we're in custom panel mode
if (this.hass && this.usingHassObject) {
console.log('Using existing hass connection from custom panel');
return Promise.resolve();
}
try {
// Get auth token - works in both browser and mobile app
const authToken = await this.getAuthToken();
// Connect to Home Assistant WebSocket API
// Use relative path for mobile app compatibility
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/api/websocket`;
this.connection = new WebSocket(wsUrl);
return new Promise((resolve, reject) => {
this.connection.onopen = () => {
console.log('WebSocket connected');
};
this.connection.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message, authToken, resolve, reject);
};
this.connection.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
this.connection.onclose = () => {
console.log('WebSocket disconnected');
};
});
} catch (error) {
console.error('Failed to connect:', error);
throw error;
}
}
handleMessage(message, authToken, resolveConnection, rejectConnection) {
if (message.type === 'auth_required') {
// Send authentication
this.send({
type: 'auth',
access_token: authToken
});
} else if (message.type === 'auth_ok') {
console.log('Authenticated successfully');
resolveConnection();
} else if (message.type === 'auth_invalid') {
console.error('Authentication failed');
rejectConnection(new Error('Authentication failed'));
} else if (message.type === 'result') {
// Handle response to our request
const pending = this.pendingRequests.get(message.id);
if (pending) {
if (message.success) {
pending.resolve(message.result);
} else {
pending.reject(message.error);
}
this.pendingRequests.delete(message.id);
}
} else if (message.type === 'event') {
// Handle state change events
if (message.event && message.event.event_type === 'state_changed') {
this.notifyStateUpdate(message.event.data);
}
}
}
async getAuthToken() {
// Method 1: Check if running in Home Assistant panel context (works in mobile app)
if (window.hassConnection) {
try {
const auth = await window.hassConnection;
if (auth && auth.auth && auth.auth.data && auth.auth.data.access_token) {
return auth.auth.data.access_token;
}
} catch (e) {
console.warn('Could not get token from hassConnection:', e);
}
}
// Method 2: Try to get from parent window context
if (window.parent && window.parent.hassConnection && window.parent !== window) {
try {
const auth = await window.parent.hassConnection;
if (auth && auth.auth && auth.auth.data && auth.auth.data.access_token) {
return auth.auth.data.access_token;
}
} catch (e) {
console.warn('Could not get token from parent.hassConnection:', e);
}
}
// Method 3: Fallback to localStorage (browser only)
try {
const token = localStorage.getItem('hassTokens');
if (token) {
const tokens = JSON.parse(token);
if (tokens.access_token) {
return tokens.access_token;
}
}
} catch (e) {
console.warn('Could not get token from localStorage:', e);
}
throw new Error('No authentication token found. Please ensure the panel is loaded within Home Assistant.');
}
send(message) {
if (this.connection && this.connection.readyState === WebSocket.OPEN) {
this.connection.send(JSON.stringify(message));
} else {
throw new Error('WebSocket not connected');
}
}
async sendRequest(message) {
// If using hass object (custom panel mode), use hass.callWS
if (this.hass && this.usingHassObject) {
try {
return await this.hass.callWS(message);
} catch (error) {
console.error('Error calling hass.callWS:', error);
throw error;
}
}
// Fallback to WebSocket mode
const id = this.messageId++;
const request = { id, ...message };
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
this.send(request);
// Timeout after 30 seconds
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
async getStates() {
// Custom panel mode
if (this.hass && this.usingHassObject) {
return Object.values(this.hass.states);
}
// WebSocket mode
return await this.sendRequest({ type: 'get_states' });
}
async getConfig() {
// Custom panel mode
if (this.hass && this.usingHassObject) {
return this.hass.config;
}
// WebSocket mode
return await this.sendRequest({ type: 'get_config' });
}
async getClimateEntities() {
const states = await this.getStates();
return states.filter(state =>
state.entity_id.startsWith('climate.') &&
!state.entity_id.startsWith('climate.climate_scheduler_')
);
}
async callService(domain, service, serviceData, returnResponse = false) {
// Custom panel mode
if (this.hass && this.usingHassObject) {
// Use callWS for return_response to avoid triggering haptic feedback
if (returnResponse) {
const result = await this.hass.callWS({
type: 'call_service',
domain,
service,
service_data: serviceData,
return_response: true
});
return result?.response;
} else {
// Use callService for non-return-response calls
return await this.hass.callService(domain, service, serviceData);
}
}
// WebSocket mode
const requestData = {
type: 'call_service',
domain,
service,
service_data: serviceData
};
if (returnResponse) {
requestData.return_response = true;
}
return await this.sendRequest(requestData);
}
async setLogLevel(level = 'debug') {
return await this.callService('logger', 'set_level', {
'custom_components.climate_scheduler': level
});
}
async getSchedule(entityId, day = null) {
try {
const serviceData = {
schedule_id: entityId
};
if (day) {
serviceData.day = day;
}
// Call our custom service to get schedule with return_response
const result = await this.callService('climate_scheduler', 'get_schedule',
serviceData, true); // Pass true to enable return_response
return result;
} catch (error) {
console.error('Failed to get schedule:', error);
return null;
}
}
async setSchedule(entityId, nodes, day = null, scheduleMode = null) {
const serviceData = {
schedule_id: entityId,
nodes: nodes
};
if (day) {
serviceData.day = day;
}
if (scheduleMode) {
serviceData.schedule_mode = scheduleMode;
}
return await this.callService('climate_scheduler', 'set_schedule', serviceData);
}
async enableSchedule(entityId) {
return await this.callService('climate_scheduler', 'enable_schedule', {
schedule_id: entityId
});
}
async disableSchedule(entityId) {
return await this.callService('climate_scheduler', 'disable_schedule', {
schedule_id: entityId
});
}
async advanceSchedule(entityId) {
return await this.callService('climate_scheduler', 'advance_schedule', {
schedule_id: entityId
});
}
async advanceGroup(groupName) {
return await this.callService('climate_scheduler', 'advance_group', {
schedule_id: groupName
});
}
async cancelAdvance(entityId) {
return await this.callService('climate_scheduler', 'cancel_advance', {
schedule_id: entityId
});
}
async getAdvanceStatus(entityId) {
try {
const result = await this.callService('climate_scheduler', 'get_advance_status', {
schedule_id: entityId
}, true);
// Normalize across modes:
// - hass.callWS path returns `result.response`
// - websocket path may return `{response: ...}`
return result?.response ?? result;
} catch (error) {
console.error('Failed to get advance status:', error);
return { is_active: false, history: [] };
}
}
async clearAdvanceHistory(entityId) {
return await this.callService('climate_scheduler', 'clear_advance_history', {
schedule_id: entityId
});
}
async testFireEvent(groupName, node, day) {
return await this.callService('climate_scheduler', 'test_fire_event', {
schedule_id: groupName,
node: JSON.stringify(node),
day: day
});
}
async getOverrideStatus(entityId) {
try {
const result = await this.callService('climate_scheduler', 'get_override_status', {
schedule_id: entityId
}, true);
return result;
} catch (error) {
console.error('Failed to get override status:', error);
return { has_override: false };
}
}
async clearSchedule(entityId) {
return await this.callService('climate_scheduler', 'clear_schedule', {
schedule_id: entityId
});
}
async setIgnored(entityId, ignored) {
return await this.callService('climate_scheduler', 'set_ignored', {
schedule_id: entityId,
ignored: ignored
});
}
// Group management methods
async createGroup(groupName) {
return await this.callService('climate_scheduler', 'create_group', {
schedule_id: groupName
});
}
async deleteGroup(groupName) {
return await this.callService('climate_scheduler', 'delete_group', {
schedule_id: groupName
});
}
async renameGroup(oldName, newName) {
return await this.callService('climate_scheduler', 'rename_group', {
old_name: oldName,
new_name: newName
});
}
async addToGroup(groupName, entityId) {
return await this.callService('climate_scheduler', 'add_to_group', {
schedule_id: groupName,
entity_id: entityId
});
}
async removeFromGroup(groupName, entityId) {
return await this.callService('climate_scheduler', 'remove_from_group', {
schedule_id: groupName,
entity_id: entityId
});
}
async getGroups() {
try {
const result = await this.callService('climate_scheduler', 'get_groups', {}, true);
return result;
} catch (error) {
console.error('Failed to get groups:', error);
return { groups: {} };
}
}
async setGroupSchedule(groupName, nodes, day = null, scheduleMode = null, profileName = null) {
const callStartTime = performance.now();
console.debug('[HA-API] setGroupSchedule called', {
timestamp: new Date().toISOString(),
groupName,
nodeCount: nodes?.length,
day,
scheduleMode,
profileName,
usingHassObject: this.usingHassObject
});
// Guard: ensure a valid groupName is provided before calling HA service.
if (!groupName) {
const msg = 'setGroupSchedule called without a valid groupName';
console.error('[HA-API]', msg, groupName, nodes, day, scheduleMode);
// Throw so callers can handle the error rather than sending null to HA
throw new Error(msg);
}
const serviceData = {
schedule_id: groupName,
nodes: nodes
};
if (day) {
serviceData.day = day;
}
if (scheduleMode) {
serviceData.schedule_mode = scheduleMode;
}
if (profileName) {
serviceData.profile_name = profileName;
}
console.debug('[HA-API] Calling climate_scheduler.set_group_schedule', {
serviceData: { ...serviceData, nodes: `[${nodes?.length} nodes]` },
connectionMode: this.usingHassObject ? 'hass-object' : 'websocket'
});
try {
const result = await this.callService('climate_scheduler', 'set_group_schedule', serviceData);
console.debug('[HA-API] set_group_schedule succeeded', {
duration: (performance.now() - callStartTime).toFixed(2) + 'ms',
result
});
return result;
} catch (error) {
console.error('[HA-API] setGroupSchedule failed:', {
error,
errorCode: error?.code,
errorMessage: error?.message,
translationKey: error?.translation_key,
translationPlaceholders: error?.translation_placeholders,
groupName,
duration: (performance.now() - callStartTime).toFixed(2) + 'ms',
connectionMode: this.usingHassObject ? 'hass-object' : 'websocket'
});
throw error;
}
}
async enableGroup(groupName) {
return await this.callService('climate_scheduler', 'enable_group', {
schedule_id: groupName
});
}
async disableGroup(groupName) {
return await this.callService('climate_scheduler', 'disable_group', {
schedule_id: groupName
});
}
async getHistory(entityId, startTime, endTime) {
try {
// Format times as ISO strings
const start = startTime.toISOString();
const end = endTime ? endTime.toISOString() : new Date().toISOString();
// Use recorder history API
const result = await this.sendRequest({
type: 'history/history_during_period',
start_time: start,
end_time: end,
entity_ids: [entityId],
minimal_response: false,
no_attributes: false
});
return result;
} catch (error) {
console.error('Failed to get history:', error);
return null;
}
}
async subscribeToStateChanges() {
// Custom panel mode - hass object handles state updates automatically
if (this.hass && this.usingHassObject) {
// Set up listener for hass state changes
// The panel will call updateHassConnection when hass changes
console.log('Using hass object state updates');
return Promise.resolve();
}
// WebSocket mode
return await this.sendRequest({
type: 'subscribe_events',
event_type: 'state_changed'
});
}
async getSettings() {
try {
const result = await this.callService('climate_scheduler', 'get_settings', {}, true);
// Normalize across execution modes:
// - In custom panel mode, callService(..., true) returns the service response directly.
// - In raw websocket mode, callService may return { response: <service_response> }.
const payload = result?.response ?? result ?? {};
// Service response includes version metadata, but app.js expects the raw settings dict.
// If the response shape is { settings: {...}, version: {...} }, return settings.
if (
payload &&
typeof payload === 'object' &&
payload.version &&
payload.settings &&
typeof payload.settings === 'object'
) {
return payload.settings;
}
return payload;
} catch (error) {
console.error('Failed to get settings:', error);
return {};
}
}
async saveSettings(settings) {
try {
await this.callService('climate_scheduler', 'save_settings', { settings: JSON.stringify(settings) });
return true;
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
}
async cleanupDerivativeSensors(confirmDeleteAll = false) {
try {
const result = await this.callService('climate_scheduler', 'cleanup_derivative_sensors', {
confirm_delete_all: confirmDeleteAll
}, true);
return result?.response || result || {};
} catch (error) {
console.error('Failed to cleanup derivative sensors:', error);
throw error;
}
}
async cleanupOrphanedClimateEntities(deleteEntities = false) {
try {
const result = await this.callService('climate_scheduler', 'cleanup_orphaned_climate_entities', {
delete: deleteEntities
}, true);
return result?.response || result || {};
} catch (error) {
console.error('Failed to cleanup orphaned climate entities:', error);
throw error;
}
}
async cleanupUnmonitoredStorage(deleteEntries = false) {
try {
const result = await this.callService('climate_scheduler', 'cleanup_unmonitored_storage', {
delete: deleteEntries
}, true);
return result?.response || result || {};
} catch (error) {
console.error('Failed to cleanup unmonitored storage:', error);
throw error;
}
}
// Profile management methods
async createProfile(scheduleId, profileName) {
return await this.callService('climate_scheduler', 'create_profile', {
schedule_id: scheduleId,
profile_name: profileName
});
}
async deleteProfile(scheduleId, profileName) {
return await this.callService('climate_scheduler', 'delete_profile', {
schedule_id: scheduleId,
profile_name: profileName
});
}
async renameProfile(scheduleId, oldName, newName) {
return await this.callService('climate_scheduler', 'rename_profile', {
schedule_id: scheduleId,
old_name: oldName,
new_name: newName
});
}
async setActiveProfile(scheduleId, profileName) {
return await this.callService('climate_scheduler', 'set_active_profile', {
schedule_id: scheduleId,
profile_name: profileName
});
}
async getProfiles(scheduleId) {
try {
const result = await this.callService('climate_scheduler', 'get_profiles', {
schedule_id: scheduleId
}, true);
return result;
} catch (error) {
console.error('Failed to get profiles:', error);
return { profiles: {}, active_profile: null };
}
}
async runDiagnostics() {
try {
const result = await this.callService('climate_scheduler', 'diagnostics', {}, true);
return result;
} catch (error) {
console.error('Failed to run diagnostics:', error);
throw error;
}
}
onStateUpdate(callback) {
this.stateUpdateCallbacks.push(callback);
}
notifyStateUpdate(data) {
this.stateUpdateCallbacks.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error('Error in state update callback:', error);
}
});
}
async subscribeToEvents(eventType, callback) {
if (this.usingHassObject && this.hass) {
// Use hass connection for event subscription
const conn = this.hass.connection;
if (conn && conn.subscribeEvents) {
return await conn.subscribeEvents(callback, eventType);
}
}
console.warn('Event subscription not available');
return null;
}}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,598 @@
/**
* Climate Scheduler Custom Panel
* Modern Home Assistant custom panel implementation (replaces legacy iframe approach)
*/
// Version checking - detect if browser cache is stale
(async function () {
try {
const scriptUrl = document.currentScript?.src || new URL(import.meta.url).href;
const loadedVersion = new URL(scriptUrl).searchParams.get('v');
// Fetch the current server version
const response = await fetch('/climate_scheduler/static/.version');
if (response.ok) {
const serverVersion = (await response.text()).trim().split(',')[0];
// Compare versions - check the aren't None and if they don't match, user has stale cache
if ((loadedVersion && serverVersion) && loadedVersion !== serverVersion) {
console.warn('[Climate Scheduler] Version mismatch detected. Loaded:', loadedVersion, 'Server:', serverVersion);
// Store in sessionStorage to avoid showing repeatedly
const notificationKey = 'climate_scheduler_refresh_shown';
const shownVersion = sessionStorage.getItem(notificationKey);
if (shownVersion !== serverVersion) {
// Show persistent notification
const event = new CustomEvent('hass-notification', {
bubbles: true,
cancelable: false,
composed: true,
detail: {
message: 'Climate Scheduler has been updated. Please refresh your browser (Ctrl+F5 or Cmd+Shift+R) to load the new version.',
duration: 0 // Persistent notification
}
});
document.body.dispatchEvent(event);
// Mark as shown for this session
sessionStorage.setItem(notificationKey, serverVersion);
console.info('[Climate Scheduler] Refresh notification displayed');
}
}
}
}
catch (e) {
console.warn('[Climate Scheduler] Version check failed:', e);
}
})();
// Load other JavaScript files
const loadScript = (src) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = () => resolve();
script.onerror = reject;
document.head.appendChild(script);
});
};
// Track if scripts are loaded
let scriptsLoaded = false;
const getVersion = () => {
const scriptUrl = import.meta.url;
const version = new URL(scriptUrl).searchParams.get('v');
if (!version)
return null;
// If version has comma (dev: "tag,timestamp"), use timestamp for cache busting
if (version.includes(',')) {
const parts = version.split(',');
return parts[1]; // timestamp
}
// Otherwise use version as-is (HACS tag or production tag)
return version;
};
// Load dependencies in order
const loadScripts = () => {
if (scriptsLoaded)
return Promise.resolve();
// Determine base path from where panel.js was loaded
const scriptUrl = import.meta.url;
const url = new URL(scriptUrl);
// Remove panel.js and query params to get base path
const basePath = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/'));
const version = getVersion();
return Promise.all([
loadScript(`${basePath}/utils.js?v=${version}`),
loadScript(`${basePath}/ha-api.js?v=${version}`)
]).then(() => {
return loadScript(`${basePath}/app.js?v=${version}`);
}).then(() => {
scriptsLoaded = true;
}).catch(error => {
console.error('Failed to load Climate Scheduler scripts:', error);
throw error;
});
};
class ClimateSchedulerPanel extends HTMLElement {
constructor() {
super();
this._hass = null;
this.narrow = false;
this.panel = null;
}
// Declare properties that Home Assistant looks for
static get properties() {
return {
hass: { type: Object },
narrow: { type: Boolean },
route: { type: Object },
panel: { type: Object }
};
}
async connectedCallback() {
this.render();
// Store reference to this panel element globally so app.js can query within it
window.climateSchedulerPanelRoot = this;
// Wait for scripts to load before initializing
try {
await loadScripts();
// Small delay to ensure DOM is fully rendered
await new Promise(resolve => setTimeout(resolve, 100));
// Update version info in footer
const versionElement = this.querySelector('#version-info');
if (versionElement) {
try {
const scriptUrl = import.meta.url;
const versionParam = new URL(scriptUrl).searchParams.get('v');
let version = '';
if (versionParam) {
if (versionParam.includes(',')) {
// Has timestamp - dev deployment: "tag,timestamp"
const parts = versionParam.split(',');
const tag = (parts[0] || 'unknown').replace(/^v/, '');
version = `v${tag} (dev)`;
}
else {
// No timestamp - production: just tag
const tag = versionParam.replace(/^v/, '');
version = `v${tag}`;
}
}
else {
version = '(manual)';
}
versionElement.textContent = `Climate Scheduler ${version}`;
}
catch (e) {
console.warn('Failed to determine version:', e);
versionElement.textContent = 'Climate Scheduler';
}
}
// Initialize the app when panel is loaded and scripts are ready
if (window.initClimateSchedulerApp) {
window.initClimateSchedulerApp(this.hass);
}
}
catch (error) {
console.error('Failed to initialize Climate Scheduler:', error);
}
}
set hass(value) {
this._hass = value;
// Apply theme based on Home Assistant theme mode
if (value && value.themes) {
const isDark = value.themes.darkMode;
if (isDark) {
// Dark mode is default, remove attribute
document.documentElement.removeAttribute('data-theme');
this.removeAttribute('data-theme');
}
else {
// Light mode needs explicit attribute
document.documentElement.setAttribute('data-theme', 'light');
this.setAttribute('data-theme', 'light');
}
}
// Pass hass object to app if it's already initialized
if (window.updateHassConnection && value) {
window.updateHassConnection(value);
}
}
get hass() {
return this._hass;
}
render() {
if (!this.querySelector('.container')) {
// Load CSS using same base path detection as scripts
const scriptUrl = import.meta.url;
const url = new URL(scriptUrl);
const basePath = url.origin + url.pathname.substring(0, url.pathname.lastIndexOf('/'));
const version = getVersion();
const styleLink = document.createElement('link');
styleLink.rel = 'stylesheet';
styleLink.href = `${basePath}/styles.css?v=${version}`;
this.appendChild(styleLink);
// Create container div for content
const container = document.createElement('div');
container.innerHTML = `
<div class="container">
<section class="entity-selector">
<div class="groups-section">
<h3 class="section-title">Monitored (<span id="groups-count">0</span>)</h3>
<div id="groups-list" class="groups-list">
<!-- Dynamically populated with groups -->
</div>
</div>
<div class="profiles-section">
<div class="group-container collapsed" id="global-profile-container">
<div class="group-header" id="toggle-global-profiles">
<div style="display: flex; align-items: center; gap: 8px;">
<span class="group-toggle-icon" style="transform: rotate(-90deg);"></span>
<span class="group-title">Profiles</span>
</div>
</div>
<div id="global-profile-list" style="display: none; padding: 12px 16px;">
<div class="profile-controls">
<select id="profile-dropdown" class="profile-dropdown">
<option value="" disabled selected>Select a profile to edit...</option>
</select>
<button id="new-profile-btn" class="btn-profile" title="Create new profile"></button>
<button id="rename-profile-btn" class="btn-profile" title="Rename profile"></button>
<button id="delete-profile-btn" class="btn-profile" title="Delete profile">🗑</button>
</div>
</div>
</div>
</div>
<div class="ignored-section">
<div class="group-container collapsed" id="ignored-container">
<div class="group-header" id="toggle-ignored">
<div style="display: flex; align-items: center; gap: 8px;">
<span class="group-toggle-icon" style="transform: rotate(-90deg);"></span>
<span class="group-title">Unmonitored</span>
</div>
</div>
<div id="ignored-entity-list" class="entity-list ignored-list" style="display: none;">
<div class="filter-box">
<input type="text" id="ignored-filter" placeholder="Filter by name..." />
</div>
<div id="ignored-entities-container">
<span id="ignored-count" style="display: none;">0</span>
<!-- Dynamically populated -->
</div>
</div>
</div>
</div>
</section>
<!-- Modals -->
<div id="confirm-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Clear Schedule?</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to clear the entire schedule for <strong id="confirm-entity-name"></strong>?</p>
<p>This action cannot be undone.</p>
</div>
<div class="modal-actions">
<button id="confirm-cancel" class="btn-secondary">Cancel</button>
<button id="confirm-clear" class="btn-danger">Clear Schedule</button>
</div>
</div>
</div>
<div id="create-group-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Create New Group</h3>
</div>
<div class="modal-body">
<label for="new-group-name">Group Name:</label>
<input type="text" id="new-group-name" placeholder="e.g., Bedrooms" style="width: 100%; padding: 8px; margin-top: 8px;" />
</div>
<div class="modal-actions">
<button id="create-group-cancel" class="btn-secondary">Cancel</button>
<button id="create-group-confirm" class="btn-primary">Create Group</button>
</div>
</div>
</div>
<div id="add-to-group-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Add to Group</h3>
</div>
<div class="modal-body">
<p>Add <strong id="add-entity-name"></strong> to group:</p>
<select id="add-to-group-select" style="width: 100%; padding: 8px; margin-top: 8px; margin-bottom: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;">
<!-- Populated dynamically -->
</select>
<p style="text-align: center; color: var(--text-secondary); margin: 8px 0;">or</p>
<input type="text" id="new-group-name-inline" placeholder="Create new group..." style="width: 100%; padding: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;" />
</div>
<div class="modal-actions">
<button id="add-to-group-cancel" class="btn-secondary">Cancel</button>
<button id="add-to-group-confirm" class="btn-primary">Add to Group</button>
</div>
</div>
</div>
<div id="convert-temperature-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Convert All Schedules</h3>
</div>
<div class="modal-body">
<p style="margin-bottom: 16px;">This will convert all saved schedules (entities and groups) as well as the default schedule and min/max settings.</p>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Current unit (convert FROM):</label>
<div style="display: flex; gap: 16px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="radio" name="convert-from-unit" value="°C" id="convert-from-celsius" style="cursor: pointer;">
<span>Celsius (°C)</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="radio" name="convert-from-unit" value="°F" id="convert-from-fahrenheit" style="cursor: pointer;">
<span>Fahrenheit (°F)</span>
</label>
</div>
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Target unit (convert TO):</label>
<div style="display: flex; gap: 16px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="radio" name="convert-to-unit" value="°C" id="convert-to-celsius" style="cursor: pointer;">
<span>Celsius (°C)</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="radio" name="convert-to-unit" value="°F" id="convert-to-fahrenheit" style="cursor: pointer;">
<span>Fahrenheit (°F)</span>
</label>
</div>
</div>
<p style="color: var(--warning, #ff9800); font-size: 0.9rem;"><strong>Warning:</strong> This action cannot be undone. Make sure you select the correct source and target units.</p>
</div>
<div class="modal-actions">
<button id="convert-temperature-cancel" class="btn-secondary">Cancel</button>
<button id="convert-temperature-confirm" class="btn-primary">Convert Schedules</button>
</div>
</div>
</div>
<div id="edit-group-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>Edit Group</h3>
</div>
<div class="modal-body">
<label for="edit-group-name">Group Name:</label>
<input type="text" id="edit-group-name" placeholder="Group name" style="width: 100%; padding: 8px; margin-top: 8px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius: 6px;" />
</div>
<div class="modal-actions">
<button id="edit-group-delete" class="btn-danger">Delete Group</button>
<div style="flex: 1;"></div>
<button id="edit-group-cancel" class="btn-secondary">Cancel</button>
<button id="edit-group-save" class="btn-primary">Save</button>
</div>
</div>
</div>
<!-- Instructions Section (Collapsible) -->
<div class="instructions-container">
<div id="global-instructions-toggle" class="instructions-toggle">
<span class="toggle-icon"></span>
<span class="toggle-text">Instructions</span>
</div>
<div id="global-graph-instructions" class="graph-instructions collapsed" style="display: none;">
<p>📍 <strong>Double-click or double-tap</strong> the line to add a new node</p>
<p>👆 <strong>Drag nodes</strong> vertically to change temperature or horizontally to move their time</p>
<p> <strong>Drag the horizontal segment</strong> between two nodes to shift that period while preserving its duration</p>
<p>📋 <strong>Copy / Paste</strong> buttons duplicate a schedule across days or entities</p>
<p> <strong>Tap a node</strong> to open its settings panel for HVAC/fan/swing/preset values</p>
</div>
</div>
<!-- Settings Panel -->
<div id="settings-panel" class="settings-panel collapsed">
<div class="settings-header" id="settings-toggle">
<div style="display: flex; align-items: center; gap: 8px;">
<span class="collapse-indicator" style="transform: rotate(-90deg);"></span>
<h3>Settings</h3>
</div>
</div>
<div class="settings-content">
<div class="settings-flex" style="display: flex; gap: 24px; align-items: flex-start;">
<div class="settings-main" style="flex: 1; min-width: 0;">
<div class="settings-section">
<h4>Group Management</h4>
<button id="create-group-btn" class="btn-secondary" style="width: 100%;">
+ Create New Group
</button>
</div>
<div class="settings-section">
<h4>Default Schedule</h4>
<p class="settings-description">Set the default temperature schedule used when clearing or creating new schedules</p>
<div class="graph-container">
<keyframe-timeline id="default-schedule-graph" class="temperature-graph" showHeader="false"></keyframe-timeline>
</div>
<div style="margin-top: 8px;">
<button id="clear-default-schedule-btn" class="btn-danger-outline">Clear Schedule</button>
</div>
<div id="default-node-settings-panel" class="node-settings-panel" style="display: none;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h4>Node Settings</h4>
<button id="default-delete-node-btn" class="btn-danger-outline" style="padding: 4px 12px; font-size: 0.9rem;">Delete Node</button>
</div>
<div class="node-info">
<span>Time: <strong id="default-node-time">--:--</strong></span>
<span>Temperature: <strong id="default-node-temp">--°C</strong></span>
</div>
<div class="setting-item" id="default-hvac-mode-item">
<label for="default-node-hvac-mode">HVAC Mode:</label>
<select id="default-node-hvac-mode"><option value="">-- No Change --</option></select>
</div>
<div class="setting-item" id="default-fan-mode-item">
<label for="default-node-fan-mode">Fan Mode:</label>
<select id="default-node-fan-mode"><option value="">-- No Change --</option></select>
</div>
<div class="setting-item" id="default-swing-mode-item">
<label for="default-node-swing-mode">Swing Mode:</label>
<select id="default-node-swing-mode"><option value="">-- No Change --</option></select>
</div>
<div class="setting-item" id="default-preset-mode-item">
<label for="default-node-preset-mode">Preset Mode:</label>
<select id="default-node-preset-mode"><option value="">-- No Change --</option></select>
</div>
</div>
</div>
<div class="settings-section">
<h4>Graph Options</h4>
<div class="setting-row" style="display:flex; gap:18px; align-items:flex-start; flex-wrap: wrap;">
<div class="setting-item" style="flex:1; min-width:280px;">
<label for="tooltip-mode">Tooltip Display:</label>
<select id="tooltip-mode">
<option value="history">Show Historical Temperature</option>
<option value="cursor">Show Cursor Position</option>
</select>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Choose what information to display when hovering over the graph</p>
</div>
<div style="display:flex; gap:12px; align-items:center;">
<div style="display:flex; flex-direction:column; gap:6px;">
<label for="min-temp" style="font-weight:600;">Min Temp (<span id="min-unit">°C</span>)</label>
<input id="min-temp" type="number" step="0.1" placeholder="e.g. 5.0" style="width:120px; padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label for="max-temp" style="font-weight:600;">Max Temp (<span id="max-unit">°C</span>)</label>
<input id="max-temp" type="number" step="0.1" placeholder="e.g. 30.0" style="width:120px; padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;" />
</div>
</div>
</div>
<div class="setting-item" style="margin-top: 12px;">
<label>
<input type="checkbox" id="debug-panel-toggle" style="margin-right: 8px;"> Show Debug Panel
</label>
</div>
</div>
<div class="settings-section">
<h4>Temperature Precision</h4>
<div class="setting-row" style="display:flex; gap:18px; align-items:flex-start; flex-wrap: wrap;">
<div class="setting-item" style="flex:1; min-width:280px;">
<label for="graph-snap-step">Graph Snap Step:</label>
<select id="graph-snap-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
<option value="0.1">0.1°</option>
<option value="0.5">0.5°</option>
<option value="1">1.0°</option>
</select>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Temperature rounding when dragging nodes on the graph</p>
</div>
<div class="setting-item" style="flex:1; min-width:280px;">
<label for="input-temp-step">Input Field Step:</label>
<select id="input-temp-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
<option value="0.1">0.1°</option>
<option value="0.5">0.5°</option>
<option value="1">1.0°</option>
</select>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Step size for temperature input fields and up/down buttons</p>
</div>
<div class="setting-item" style="flex:1; min-width:280px;">
<label for="humidity-step">Humidity Slider Step:</label>
<select id="humidity-step" style="padding:6px; background: var(--surface-light); color: var(--text-primary); border: 1px solid var(--border); border-radius:6px;">
<option value="1">1%</option>
<option value="2">2%</option>
<option value="5">5%</option>
</select>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">Step size for humidity slider in node settings dialog</p>
</div>
</div>
</div>
<div class="settings-section">
<h4>Derivative Sensors</h4>
<p class="settings-description">Automatically create sensors to track heating/cooling rates for performance analysis</p>
<div class="setting-item" style="max-width: 100%;">
<label>
<input type="checkbox" id="create-derivative-sensors" style="margin-right: 8px;"> Auto-create derivative sensors
</label>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;">When enabled, creates sensor.climate_scheduler_[name]_rate for each thermostat to track temperature change rate (°C/h)</p>
</div>
</div>
<div class="settings-section">
<h4>Workday Integration</h4>
<p class="settings-description">Configure which days are workdays for 5/2 mode scheduling</p>
<div class="setting-item" style="max-width: 100%;">
<label id="use-workday-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="use-workday-integration" style="margin-right: 8px;" disabled>
<span>Use Workday integration for 5/2 scheduling</span>
</label>
<p class="settings-description" style="margin-top: 5px; font-size: 0.85rem;" id="workday-help-text">Checking if Workday integration is installed...</p>
</div>
<div id="workday-selector" style="margin-top: 16px; display: none;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Select Workdays:</label>
<p class="settings-description" style="margin-bottom: 8px; font-size: 0.85rem;">Choose which days are considered workdays when Workday integration is disabled</p>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="mon" style="cursor: pointer;">
<span>Monday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="tue" style="cursor: pointer;">
<span>Tuesday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="wed" style="cursor: pointer;">
<span>Wednesday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="thu" style="cursor: pointer;">
<span>Thursday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="fri" style="cursor: pointer;">
<span>Friday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="sat" style="cursor: pointer;">
<span>Saturday</span>
</label>
<label style="display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: var(--surface-light); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
<input type="checkbox" class="workday-checkbox" value="sun" style="cursor: pointer;">
<span>Sunday</span>
</label>
</div>
</div>
</div>
</div>
<!-- right column removed: min/max now inline in Graph Options -->
</div>
<div class="settings-actions" style="margin-top: 12px; display: flex; gap: 12px; flex-wrap: wrap;">
<button id="refresh-entities-menu" class="btn-secondary">Refresh Entities</button>
<button id="sync-all-menu" class="btn-secondary">Sync All Thermostats</button>
<button id="reload-integration-menu" class="btn-secondary">Reload Integration</button>
<button id="convert-temperature-btn" class="btn-secondary">Convert All Schedules...</button>
<button id="run-diagnostics-btn" class="btn-secondary">Run Diagnostics</button>
<button id="cleanup-derivative-sensors-btn" class="btn-secondary">Cleanup Derivative Sensors</button>
<button id="cleanup-orphaned-climate-btn" class="btn-secondary">Cleanup Orphaned Entities</button>
<button id="cleanup-storage-btn" class="btn-secondary">Cleanup Unmonitored Storage</button>
<button id="reset-defaults" class="btn-secondary">Reset to Defaults</button>
</div>
</div>
</div>
<!-- Debug Panel -->
<div id="debug-panel" class="debug-panel" style="display: none;">
<div class="debug-header">
<h3>Debug Console</h3>
<button id="clear-debug" class="btn-secondary" style="padding: 4px 8px; font-size: 0.85rem;">Clear</button>
</div>
<div id="debug-content" class="debug-content">
<!-- Debug messages will appear here -->
</div>
</div>
<footer>
<img alt="Integration Usage" src="https://img.shields.io/badge/dynamic/json?color=41BDF5&logo=home-assistant&label=integration%20usage&suffix=%20installs&cacheSeconds=15600&url=https://analytics.home-assistant.io/custom_integrations.json&query=$.climate_scheduler.total" />
<p><span id="version-info">Climate Scheduler</span>, created by <a href="https://neave.engineering" target="_blank" rel="noopener noreferrer" style="color: var(--primary)">Keegan Neave</a></p>
<p><a href="https://www.buymeacoffee.com/kneave" target="_blank" rel="noopener noreferrer" style="color: var(--primary);"> Buy me a coffee</a></p>
</div>
</footer>
</div>
`;
this.appendChild(container);
}
}
}
customElements.define('climate-scheduler-panel', ClimateSchedulerPanel);
//# sourceMappingURL=panel.js.map
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,185 @@
/**
* Shared Utility Functions
* Common utilities used across the Climate Scheduler frontend
*/
// ============================================================================
// TIMEZONE UTILITIES
// ============================================================================
/**
* Get a Date-like object in the server's timezone
* @param {Date} date - The UTC date to convert
* @param {string} serverTimeZone - The server's timezone (e.g., 'America/New_York')
* @returns {Object} Date-like object with methods that return values in server timezone
*/
function getServerDate(date, serverTimeZone) {
if (!serverTimeZone) return date; // Fallback to local time if no timezone set
// Use Intl.DateTimeFormat to get date components in server timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: serverTimeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
const parts = formatter.formatToParts(date);
const partsObj = {};
parts.forEach(part => {
partsObj[part.type] = part.value;
});
return {
getFullYear: () => parseInt(partsObj.year),
getMonth: () => parseInt(partsObj.month) - 1, // JS months are 0-indexed
getDate: () => parseInt(partsObj.day),
getHours: () => parseInt(partsObj.hour),
getMinutes: () => parseInt(partsObj.minute),
getSeconds: () => parseInt(partsObj.second),
getTime: () => date.getTime(),
_originalDate: date
};
}
/**
* Get current server time as Date-like object
* @param {string} serverTimeZone - The server's timezone
* @returns {Object} Date-like object with current time in server timezone
*/
function getServerNow(serverTimeZone) {
return getServerDate(new Date(), serverTimeZone);
}
/**
* Convert UTC timestamp to server timezone Date-like object
* @param {Date} utcDate - The UTC date to convert
* @param {string} serverTimeZone - The server's timezone
* @returns {Object} Date-like object in server timezone
*/
function utcToServerDate(utcDate, serverTimeZone) {
return getServerDate(utcDate, serverTimeZone);
}
// ============================================================================
// TEMPERATURE CONVERSION UTILITIES
// ============================================================================
/**
* Convert Celsius to Fahrenheit
* @param {number} celsius - Temperature in Celsius
* @returns {number} Temperature in Fahrenheit
*/
function celsiusToFahrenheit(celsius) {
return (celsius * 9/5) + 32;
}
/**
* Convert Fahrenheit to Celsius
* @param {number} fahrenheit - Temperature in Fahrenheit
* @returns {number} Temperature in Celsius
*/
function fahrenheitToCelsius(fahrenheit) {
return (fahrenheit - 32) * 5/9;
}
/**
* Convert temperature between units
* @param {number} temp - Temperature value
* @param {string} fromUnit - Source unit ('°C' or '°F')
* @param {string} toUnit - Target unit ('°C' or '°F')
* @returns {number} Converted temperature
*/
function convertTemperature(temp, fromUnit, toUnit) {
if (fromUnit === toUnit) return temp;
if (fromUnit === '°C' && toUnit === '°F') return celsiusToFahrenheit(temp);
if (fromUnit === '°F' && toUnit === '°C') return fahrenheitToCelsius(temp);
return temp;
}
// ============================================================================
// TIME UTILITIES
// ============================================================================
/**
* Convert time string to minutes since midnight
* @param {string} timeStr - Time in 'HH:MM' format
* @returns {number} Minutes since midnight
*/
function timeToMinutes(timeStr) {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
}
/**
* Convert minutes since midnight to time string
* @param {number} minutes - Minutes since midnight
* @returns {string} Time in 'HH:MM' format
*/
function minutesToTime(minutes) {
const hours = Math.floor(minutes / 60);
const mins = Math.floor(minutes % 60);
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
}
/**
* Format hours and minutes as 'HH:MM' time string
* @param {number} hours - Hours (0-23)
* @param {number} minutes - Minutes (0-59)
* @returns {string} Time in 'HH:MM' format
*/
function formatTimeString(hours, minutes) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
/**
* Adjust time by adding/subtracting minutes with 24-hour wraparound
* @param {string} timeStr - Time in 'HH:MM' format
* @param {number} minutesToAdd - Minutes to add (negative to subtract)
* @returns {string} New time in 'HH:MM' format
*/
function adjustTime(timeStr, minutesToAdd) {
let totalMinutes = timeToMinutes(timeStr) + minutesToAdd;
// Handle wraparound
while (totalMinutes < 0) totalMinutes += 1440;
while (totalMinutes >= 1440) totalMinutes -= 1440;
return minutesToTime(totalMinutes);
}
/**
* Interpolate temperature at a given time (step function - hold until next node)
* @param {Array} nodes - Array of schedule nodes with {time, temp} properties
* @param {string} timeStr - Time in 'HH:MM' format
* @returns {number} Interpolated temperature
*/
function interpolateTemperature(nodes, timeStr) {
if (nodes.length === 0) return 18;
const sorted = [...nodes].sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
const currentMinutes = timeToMinutes(timeStr);
// Find the most recent node before or at current time
let activeNode = null;
for (let i = 0; i < sorted.length; i++) {
const nodeMinutes = timeToMinutes(sorted[i].time);
if (nodeMinutes <= currentMinutes) {
activeNode = sorted[i];
} else {
break;
}
}
// If no node found before current time, use last node (wrap around from previous day)
if (!activeNode) {
activeNode = sorted[sorted.length - 1];
}
return activeNode.temp;
}
@@ -0,0 +1,20 @@
{
"domain": "climate_scheduler",
"name": "Climate Scheduler",
"codeowners": [
"@kneave"
],
"config_flow": true,
"dependencies": [
"climate",
"http"
],
"documentation": "https://github.com/kneave/climate-scheduler",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/kneave/climate-scheduler/issues",
"requirements": [
],
"version": "1.15.1"
}
@@ -0,0 +1,452 @@
"""Sensor platform for Climate Scheduler."""
import logging
from datetime import timedelta
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util import dt as dt_util
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# How many historical samples to keep for derivative calculation
SAMPLE_SIZE = 10
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Climate Scheduler sensors from a config entry."""
from homeassistant.helpers import device_registry as dr, entity_registry as er
storage = hass.data[DOMAIN]["storage"]
coordinator = hass.data[DOMAIN]["coordinator"]
# Get settings to check if derivative sensors are enabled
settings = storage._data.get("settings", {})
sensors = []
# Always create coldest and warmest sensors
sensors.append(ColdestEntitySensor(hass, storage, coordinator))
sensors.append(WarmestEntitySensor(hass, storage, coordinator))
if not settings.get("create_derivative_sensors", True):
async_add_entities(sensors, True)
return
# Get registries
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
# Get all entities from groups (both single and multi-entity groups)
all_entity_ids = await storage.async_get_all_entities()
for entity_id in all_entity_ids:
# Skip ignored entities
if await storage.async_is_ignored(entity_id):
continue
# Find the device for this climate entity
entity_entry = entity_registry.async_get(entity_id)
_LOGGER.debug(f"Entity entry for {entity_id}: {entity_entry}")
device_id = None
if entity_entry and entity_entry.device_id:
device_id = entity_entry.device_id
_LOGGER.debug(f"Found device {device_id} for {entity_id}")
# Create main temperature rate sensor
_LOGGER.debug(f"Creating derivative sensor for {entity_id}")
sensors.append(ClimateSchedulerRateSensor(hass, entity_id, entity_id, "current_temperature", device_id))
if device_id:
# Find all sensor entities on the same device
device_sensors = [
entry for entry in entity_registry.entities.values()
if entry.device_id == device_id
and entry.domain == "sensor"
and "floor" in entry.entity_id.lower() # Look for "floor" in the entity_id
]
_LOGGER.debug(f"Found {len(device_sensors)} floor sensors for {entity_id}: {[s.entity_id for s in device_sensors]}")
# Create derivative sensors for floor temperature sensors
for floor_sensor in device_sensors:
_LOGGER.debug(f"Creating floor derivative sensor for {entity_id} tracking {floor_sensor.entity_id}")
sensors.append(ClimateSchedulerRateSensor(
hass,
entity_id, # Associated climate entity
floor_sensor.entity_id, # Floor sensor to track
"state", # Use state instead of attribute
device_id # Link to same device
))
else:
_LOGGER.warning(f"No device found for {entity_id}, skipping floor sensor detection")
if sensors:
async_add_entities(sensors, True)
class ClimateSchedulerRateSensor(SensorEntity):
"""Sensor that tracks the rate of temperature change for a climate entity."""
def __init__(
self,
hass: HomeAssistant,
climate_entity_id: str,
source_entity_id: str,
temperature_attribute: str = "current_temperature",
device_id: str = None
) -> None:
"""Initialize the sensor."""
self.hass = hass
self._climate_entity_id = climate_entity_id
self._source_entity_id = source_entity_id
self._temperature_attribute = temperature_attribute
self._device_id = device_id # Store device_id for device_info
# Extract name from entity_id (e.g., climate.bedroom -> bedroom)
entity_name = climate_entity_id.split(".")[-1]
friendly_name = entity_name.replace("_", " ").title()
# Determine if this is a floor sensor (tracking a separate sensor entity)
is_floor = source_entity_id != climate_entity_id
if is_floor:
# For floor sensors, include the floor sensor name in unique_id to avoid collisions
floor_sensor_name = source_entity_id.split(".")[-1]
# Strip any previously-created climate_scheduler prefix to avoid recursive names
if floor_sensor_name.startswith("climate_scheduler_"):
floor_sensor_name = floor_sensor_name[len("climate_scheduler_") :]
# Strip trailing _rate if present
if floor_sensor_name.endswith("_rate"):
floor_sensor_name = floor_sensor_name[: -len("_rate")]
# Prevent duplicated segments like "front_room_front_room_..."
if floor_sensor_name.startswith(f"{entity_name}_"):
floor_sensor_suffix = floor_sensor_name[len(entity_name) + 1 :]
else:
floor_sensor_suffix = floor_sensor_name
suffix = f"{floor_sensor_suffix.replace('_', ' ').title()} Rate"
unique_suffix = f"{floor_sensor_suffix}_rate"
else:
suffix = "Rate"
unique_suffix = "rate"
_LOGGER.debug(f"Creating sensor climate_scheduler_{entity_name}_{unique_suffix} with device_id: {device_id}")
self._attr_name = f"Climate Scheduler {friendly_name} {suffix}"
self._attr_unique_id = f"climate_scheduler_{entity_name}_{unique_suffix}"
# Let Home Assistant manage the `entity_id` from the `unique_id`
self._attr_device_class = None # No standard device class for rate
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = "°C/h"
self._attr_icon = "mdi:thermometer-lines" if not is_floor else "mdi:floor-plan"
# Storage for temperature samples (timestamp, temperature)
self._samples = []
self._attr_native_value = 0.0 # Start at 0 instead of None
# Set device_info during initialization to ensure proper device linking
if device_id:
device_registry = dr.async_get(hass)
device = device_registry.async_get(device_id)
if device:
self._attr_device_info = {
"identifiers": device.identifiers,
}
_LOGGER.debug(f"Linked sensor {self._attr_unique_id} to device {device_id} with identifiers {device.identifiers}")
else:
_LOGGER.error(f"Could not find device {device_id} in registry for sensor {self._attr_unique_id}")
else:
_LOGGER.warning(f"No device_id provided for sensor {self._attr_unique_id}")
async def async_added_to_hass(self) -> None:
"""Register state listener when entity is added."""
# Listen to state changes of the source entity
self.async_on_remove(
async_track_state_change_event(
self.hass,
[self._source_entity_id],
self._async_source_state_changed,
)
)
# Get initial state
state = self.hass.states.get(self._source_entity_id)
if state:
# For separate sensor entities, use the state value
if self._temperature_attribute == "state":
try:
temp = float(state.state)
now = dt_util.utcnow()
self._samples.append((now, temp))
except (ValueError, TypeError):
pass
# For climate entity attributes, use the attribute
elif state.attributes.get(self._temperature_attribute) is not None:
temp = float(state.attributes[self._temperature_attribute])
now = dt_util.utcnow()
self._samples.append((now, temp))
@callback
def _async_source_state_changed(self, event) -> None:
"""Handle source entity state changes."""
new_state = event.data.get("new_state")
if not new_state:
return
try:
# For separate sensor entities, use the state value
if self._temperature_attribute == "state":
temp = float(new_state.state)
# For climate entity attributes, use the attribute
else:
current_temp = new_state.attributes.get(self._temperature_attribute)
if current_temp is None:
return
temp = float(current_temp)
now = dt_util.utcnow()
# Add new sample
self._samples.append((now, temp))
# Keep only last SAMPLE_SIZE samples
if len(self._samples) > SAMPLE_SIZE:
self._samples = self._samples[-SAMPLE_SIZE:]
# Calculate derivative if we have enough samples
if len(self._samples) >= 2:
self._calculate_rate()
self.async_write_ha_state()
except (ValueError, TypeError) as e:
_LOGGER.debug(f"Error processing temperature for {self._climate_entity_id}: {e}")
def _calculate_rate(self) -> None:
"""Calculate the rate of temperature change."""
if len(self._samples) < 2:
self._attr_native_value = 0.0 # Not enough data, rate is 0
return
# Get oldest and newest samples
oldest_time, oldest_temp = self._samples[0]
newest_time, newest_temp = self._samples[-1]
# Calculate time difference in hours
time_diff = (newest_time - oldest_time).total_seconds() / 3600
if time_diff == 0:
self._attr_native_value = 0.0
return
# Calculate temperature change rate (°C per hour)
temp_diff = newest_temp - oldest_temp
rate = temp_diff / time_diff
# Round to 2 decimal places
self._attr_native_value = round(rate, 2)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional state attributes."""
return {
"climate_entity": self._climate_entity_id,
"source_entity": self._source_entity_id,
"temperature_attribute": self._temperature_attribute,
"sample_count": len(self._samples),
"time_window_minutes": 5,
}
class ColdestEntitySensor(SensorEntity):
"""Sensor that shows the coldest climate entity."""
def __init__(
self,
hass: HomeAssistant,
storage,
coordinator
) -> None:
"""Initialize the sensor."""
self.hass = hass
self._storage = storage
self._coordinator = coordinator
self._attr_name = "Climate Scheduler Coldest Entity"
self._attr_unique_id = "climate_scheduler_coldest_entity"
# Let Home Assistant manage the `entity_id` from the `unique_id`
self._attr_device_class = SensorDeviceClass.TEMPERATURE
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
self._attr_icon = "mdi:snowflake"
self._attr_native_value = None
self._coldest_entity_id = None
self._coldest_friendly_name = None
self._remove_listener = None
async def async_added_to_hass(self) -> None:
"""Register state listener when entity is added."""
# Listen to coordinator updates
self._remove_listener = self._coordinator.async_add_listener(self._handle_coordinator_update)
# Initial update
await self._async_update()
async def async_will_remove_from_hass(self) -> None:
"""Unregister listener when entity is removed."""
if self._remove_listener:
self._remove_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.hass.async_create_task(self._async_update())
async def _async_update(self) -> None:
"""Update the sensor value."""
# Get all entities from groups
all_entities = await self._storage.async_get_all_entities()
coldest_temp = None
coldest_entity = None
coldest_name = None
for entity_id in all_entities:
# Skip ignored entities
if await self._storage.async_is_ignored(entity_id):
continue
state = self.hass.states.get(entity_id)
if not state:
continue
# Check if entity has current_temperature attribute
current_temp = state.attributes.get("current_temperature")
if current_temp is None:
continue
try:
temp = float(current_temp)
if coldest_temp is None or temp < coldest_temp:
coldest_temp = temp
coldest_entity = entity_id
coldest_name = state.attributes.get("friendly_name", entity_id)
except (ValueError, TypeError):
continue
self._attr_native_value = coldest_temp
self._coldest_entity_id = coldest_entity
self._coldest_friendly_name = coldest_name
self.async_write_ha_state()
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional state attributes."""
return {
"entity_id": self._coldest_entity_id,
"friendly_name": self._coldest_friendly_name,
}
class WarmestEntitySensor(SensorEntity):
"""Sensor that shows the warmest climate entity."""
def __init__(
self,
hass: HomeAssistant,
storage,
coordinator
) -> None:
"""Initialize the sensor."""
self.hass = hass
self._storage = storage
self._coordinator = coordinator
self._attr_name = "Climate Scheduler Warmest Entity"
self._attr_unique_id = "climate_scheduler_warmest_entity"
# Let Home Assistant manage the `entity_id` from the `unique_id`
self._attr_device_class = SensorDeviceClass.TEMPERATURE
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
self._attr_icon = "mdi:fire"
self._attr_native_value = None
self._warmest_entity_id = None
self._warmest_friendly_name = None
self._remove_listener = None
async def async_added_to_hass(self) -> None:
"""Register state listener when entity is added."""
# Listen to coordinator updates
self._remove_listener = self._coordinator.async_add_listener(self._handle_coordinator_update)
# Initial update
await self._async_update()
async def async_will_remove_from_hass(self) -> None:
"""Unregister listener when entity is removed."""
if self._remove_listener:
self._remove_listener()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.hass.async_create_task(self._async_update())
async def _async_update(self) -> None:
"""Update the sensor value."""
# Get all entities from groups
all_entities = await self._storage.async_get_all_entities()
warmest_temp = None
warmest_entity = None
warmest_name = None
for entity_id in all_entities:
# Skip ignored entities
if await self._storage.async_is_ignored(entity_id):
continue
state = self.hass.states.get(entity_id)
if not state:
continue
# Check if entity has current_temperature attribute
current_temp = state.attributes.get("current_temperature")
if current_temp is None:
continue
try:
temp = float(current_temp)
if warmest_temp is None or temp > warmest_temp:
warmest_temp = temp
warmest_entity = entity_id
warmest_name = state.attributes.get("friendly_name", entity_id)
except (ValueError, TypeError):
continue
self._attr_native_value = warmest_temp
self._warmest_entity_id = warmest_entity
self._warmest_friendly_name = warmest_name
self.async_write_ha_state()
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return additional state attributes."""
return {
"entity_id": self._warmest_entity_id,
"friendly_name": self._warmest_friendly_name,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,499 @@
# Static service definitions for Climate Scheduler.
# These provide UI documentation and selectors. The services are still
# registered dynamically in Python; keep this file up-to-date when fields
# or selectors change.
recreate_all_sensors:
name: Recreate all sensors
description: >-
Delete ALL Climate Scheduler sensor entities and reload the integration to
recreate them cleanly. Requires confirmation.
fields:
confirm:
description: Must be set to true to confirm deletion of all sensors
example: true
required: true
selector:
boolean: {}
cleanup_malformed_sensors:
name: Cleanup malformed sensors
description: >-
Scan for unexpected Climate Scheduler sensor entities and optionally remove
them. Returns expected and unexpected sensor entity lists.
fields:
delete:
description: Whether to actually delete the unexpected entities (default
is false for dry-run)
required: false
example: true
selector:
boolean: {}
cleanup_orphaned_climate_entities:
name: Cleanup orphaned entities
description: >-
Scan for orphaned Climate Scheduler entities (climate, sensor, and switch entities without matching
groups or climate entities in storage) and optionally remove them. Returns expected count and orphaned entity lists.
fields:
delete:
description: Whether to actually delete the orphaned entities (default
is false for dry-run)
required: false
example: true
selector:
boolean: {}
set_schedule:
name: Set climate schedule
description: Configure temperature schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
example: climate.living_room
selector:
entity:
domain: climate
nodes:
description: >-
List of schedule nodes with time and temperature (JSON string).
required: true
example: '[{"time": "07:00", "temp": 21}, {"time": "23:00", "temp": 18}]'
day:
description: Day of week for this schedule (all_days, mon, tue, wed, thu,
fri, sat, sun, weekday, weekend)
required: false
default: all_days
selector:
text: {}
schedule_mode:
description: Schedule mode (all_days, 5/2, individual)
required: false
default: all_days
selector:
text: {}
get_schedule:
name: Get climate schedule
description: Retrieve the current schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
selector:
entity:
domain: climate
clear_schedule:
name: Clear climate schedule
description: Clear the schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
selector:
entity:
domain: climate
enable_schedule:
name: Enable schedule
description: Enable the schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
selector:
text: {}
disable_schedule:
name: Disable schedule
description: Disable the schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
selector:
text: {}
set_ignored:
name: Set ignored status
description: Mark a schedule target as ignored or not
fields:
schedule_id:
description: Schedule ID
required: true
selector:
text: {}
ignored:
description: True to ignore, False to un-ignore
required: true
selector:
boolean: {}
sync_all:
name: Sync all
description: Force immediate sync of all thermostats managed by Climate Scheduler
fields: {}
create_group:
name: Create group
description: Create a new Climate Scheduler group
fields:
schedule_id:
description: New group name
required: true
selector:
text: {}
delete_group:
name: Delete group
description: Delete an existing group
fields:
schedule_id:
description: Group name to delete
required: true
selector:
text: {}
rename_group:
name: Rename group
description: Rename an existing group
fields:
old_name:
description: Existing group name
required: true
selector:
text: {}
new_name:
description: New group name
required: true
selector:
text: {}
add_to_group:
name: Add entity to group
description: Add a climate entity to a group
fields:
schedule_id:
description: Target group name
required: true
selector:
text: {}
entity_id:
description: Climate entity id to add
required: true
selector:
entity:
domain: climate
remove_from_group:
name: Remove entity from group
description: Remove a climate entity from a group
fields:
schedule_id:
description: Target group name
required: true
selector:
text: {}
entity_id:
description: Climate entity id to remove
required: true
selector:
entity:
domain: climate
get_groups:
name: Get groups
description: Return stored groups data
fields: {}
list_groups:
name: List groups
description: Return a simple list of group names
fields: {}
list_profiles:
name: List profiles
description: Return a list of profiles across all groups
fields: {}
set_group_schedule:
name: Set group schedule
description: Set schedule for a whole group
fields:
schedule_id:
description: Target group name
required: true
selector:
text: {}
nodes:
description: JSON string of schedule nodes
required: true
selector:
text: {}
day:
description: Day identifier
required: false
selector:
text: {}
schedule_mode:
description: Schedule mode
required: false
selector:
text: {}
enable_group:
name: Enable group
description: Enable a group's schedule
fields:
schedule_id:
description: Group name
required: true
selector:
text: {}
disable_group:
name: Disable group
description: Disable a group's schedule
fields:
schedule_id:
description: Group name
required: true
selector:
text: {}
get_settings:
name: Get settings
description: Retrieve current integration settings and version info
fields: {}
save_settings:
name: Save settings
description: Save integration settings (JSON string)
fields:
settings:
description: Settings JSON string
required: true
selector:
text: {}
reload_integration:
name: Reload integration
description: Reload the Climate Scheduler integration
fields: {}
reregister_card:
name: Reregister card
description: >-
Automatically registers the Climate Scheduler card in Lovelace resources.
Removes any existing registrations and creates a fresh entry.
Use this if the card is not appearing in the card picker.
fields:
resource_url:
description: >-
(Optional) Custom URL for the card resource. If not provided,
automatically uses the bundled card with the current integration version.
required: false
example: /climate_scheduler/static/climate-scheduler-card.js
selector:
text: {}
resource_type:
description: Resource type (e.g. `module` or `js`)
required: false
default: module
selector:
text: {}
advance_schedule:
name: Advance schedule
description: Temporarily advance a schedule for a schedule target
fields:
schedule_id:
description: Schedule ID
required: true
selector:
text: {}
advance_group:
name: Advance group
description: Advance schedule for a group
fields:
schedule_id:
description: Group name
required: true
selector:
text: {}
cancel_advance:
name: Cancel advance
description: Cancel an advanced schedule for a schedule target
fields:
schedule_id:
description: Schedule ID (entity id)
required: true
selector:
text: {}
get_advance_status:
name: Get advance status
description: Get current advance status for a schedule target
fields:
schedule_id:
description: Schedule ID (entity id)
required: true
selector:
text: {}
clear_advance_history:
name: Clear advance history
description: Clear the advance history for a schedule target
fields:
schedule_id:
description: Schedule ID (entity id)
required: true
selector:
text: {}
test_fire_event:
name: Test fire event
description: Fire a test node_activated event with specific node settings for testing automations
fields:
schedule_id:
description: Group name or entity ID to fire event for
required: true
example: Downstairs
selector:
text: {}
node:
description: Node data (time, temp, hvac_mode, etc.) as JSON string
required: false
example: '{"time": "08:00", "temp": 21, "hvac_mode": "heat"}'
selector:
text: {}
day:
description: Day of week (mon, tue, wed, thu, fri, sat, sun)
required: false
example: mon
selector:
text: {}
create_profile:
name: Create profile
description: Create a profile for a schedule
fields:
schedule_id:
description: Schedule ID (entity id or group id)
required: true
selector:
text: {}
profile_name:
description: New profile name
required: true
selector:
text: {}
delete_profile:
name: Delete profile
description: Delete a profile from a schedule
fields:
schedule_id:
description: Schedule ID (entity id or group id)
required: true
selector:
text: {}
profile_name:
description: Profile name to delete
required: true
selector:
text: {}
rename_profile:
name: Rename profile
description: Rename a profile
fields:
schedule_id:
description: Schedule ID (entity id or group id)
required: true
selector:
text: {}
old_name:
description: Existing profile name
required: true
selector:
text: {}
new_name:
description: New profile name
required: true
selector:
text: {}
set_active_profile:
name: Set active profile
description: Set the active profile for a schedule
fields:
schedule_id:
description: Schedule ID (entity id or group id)
required: true
selector:
text: {}
profile_name:
description: Profile name to set active
required: true
selector:
text: {}
get_profiles:
name: Get profiles
description: Get profiles for a schedule
fields:
schedule_id:
description: Schedule ID (entity id or group id)
required: true
selector:
text: {}
cleanup_derivative_sensors:
name: Cleanup derivative sensors
description: Remove derivative sensors based on auto-creation setting
fields:
confirm_delete_all:
description: Confirm deletion of all derivative sensors
required: false
default: false
example: false
selector:
boolean: {}
cleanup_unmonitored_storage:
name: Cleanup unmonitored storage
description: >-
Remove stale storage references for unmonitored or missing entities,
including obsolete groups, invalid profiles, stale entity references,
and orphaned advance history entries.
fields:
delete:
description: Execute deletion; set false to preview only
required: false
default: false
example: true
selector:
boolean: {}
factory_reset:
name: Factory reset
description: Reset all stored data
fields:
confirm:
description: Must be set to true to confirm factory reset
required: true
selector:
boolean: {}
diagnostics:
name: Run diagnostics
description: >-
Run comprehensive diagnostics to troubleshoot card visibility issues.
Returns integration version, card registration status, and accessibility checks.
Use this when users report they cannot find the card in Lovelace.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,421 @@
"""Switch platform for Climate Scheduler - Exposes schedules in scheduler-component format."""
import logging
import hashlib
from datetime import datetime, time, timedelta
from typing import Any, Dict, List, Optional
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Climate Scheduler switch entities from a config entry."""
from homeassistant.helpers import entity_registry as er
storage = hass.data[DOMAIN]["storage"]
coordinator = hass.data[DOMAIN]["coordinator"]
entity_registry = er.async_get(hass)
# Clean up old switch entities that don't have the token suffix
# (These are from before we added the unique_id token)
old_entities_removed = 0
for entry in list(entity_registry.entities.values()):
if entry.platform != DOMAIN or entry.domain != "switch":
continue
# Old entities have unique_id like "climate_scheduler_schedule_bathroom"
# New entities have unique_id like "climate_scheduler_schedule_climate.bathroom_abc123"
# or "climate_scheduler_schedule_Bathroom_abc123"
if entry.unique_id and entry.unique_id.startswith(f"{DOMAIN}_schedule_"):
# Check if it's missing the token (no underscore followed by 6 hex chars at the end)
parts = entry.unique_id.split("_")
last_part = parts[-1] if parts else ""
# If the last part is not a 6-character hex token, it's an old entity
if len(last_part) != 6 or not all(c in "0123456789abcdef" for c in last_part.lower()):
_LOGGER.info(f"Removing old scheduler entity: {entry.entity_id} (unique_id: {entry.unique_id})")
entity_registry.async_remove(entry.entity_id)
old_entities_removed += 1
if old_entities_removed > 0:
_LOGGER.info(f"Removed {old_entities_removed} old scheduler switch entities")
switches = []
# Create switch entity for each group (including single-entity groups)
all_groups = await storage.async_get_groups()
for group_name, group_data in all_groups.items():
# Skip ignored groups
if group_data.get("ignored", False):
continue
switches.append(ClimateSchedulerSwitch(
hass,
coordinator,
storage,
group_name,
group_data
))
if switches:
async_add_entities(switches, True)
_LOGGER.info(f"Created {len(switches)} scheduler switch entities")
class ClimateSchedulerSwitch(CoordinatorEntity, SwitchEntity):
"""Switch entity that exposes a climate schedule in scheduler-component format."""
def __init__(
self,
hass: HomeAssistant,
coordinator,
storage,
group_name: str,
group_data: Dict[str, Any],
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.hass = hass
self.storage = storage
self._group_name = group_name
self._group_data = group_data
# Generate a stable token for the entity_id (6 chars like scheduler-component)
token = hashlib.md5(group_name.encode()).hexdigest()[:6]
# For single-entity groups, use a cleaner name
if group_name.startswith("__entity_"):
entity_id = group_name.replace("__entity_", "")
self._attr_name = f"Schedule {entity_id.split('.')[-1]}"
self._attr_unique_id = f"{DOMAIN}_schedule_{entity_id}_{token}"
else:
self._attr_name = f"Schedule {group_name}"
self._attr_unique_id = f"{DOMAIN}_schedule_{group_name}_{token}"
self._attr_has_entity_name = False
self._attr_should_poll = True
# Cache for computed attributes
self._cached_next_trigger: Optional[datetime] = None
self._cached_next_slot: Optional[int] = None
self._cached_actions: List[Dict[str, Any]] = []
self._cached_next_entries: List[Dict[str, Any]] = []
@property
def is_on(self) -> bool:
"""Return true if schedule is enabled."""
# Refresh group data from storage
self._refresh_group_data()
return self._group_data.get("enabled", True)
@property
def state(self) -> str:
"""Return the state of the scheduler.
States match scheduler-component:
- off: Schedule is disabled
- on: Schedule is enabled and waiting for next trigger
- triggered: Currently executing (we'll use this briefly after firing)
"""
if not self.is_on:
return "off"
# Check if this was recently triggered (within last minute)
# For now, we'll just return "on" - can enhance later
return "on"
@property
def extra_state_attributes(self) -> Dict[str, Any]:
"""Return scheduler-component compatible attributes."""
self._refresh_group_data()
self._compute_schedule_attributes()
active_profile = self._group_data.get("active_profile", "Default")
profiles = self._group_data.get("profiles", {})
profile_data = profiles.get(active_profile, {})
schedule_mode = profile_data.get("schedule_mode", "all_days")
schedules = profile_data.get("schedules", {})
# Get entities affected by this schedule
entities = self._group_data.get("entities", [])
attrs = {
# Core scheduler-component attributes
"next_trigger": self._cached_next_trigger.isoformat() if self._cached_next_trigger else None,
"next_slot": self._cached_next_slot,
"actions": self._cached_actions,
# Fallback format
"next_entries": self._cached_next_entries,
# Additional metadata
"schedule_mode": schedule_mode,
"schedules": schedules,
"active_profile": active_profile,
"profiles": list(profiles.keys()),
"entities": entities,
# Compatibility fields
"weekdays": self._get_weekdays_list(schedule_mode),
"timeslots": self._cached_actions, # Alias
}
return attrs
def _refresh_group_data(self) -> None:
"""Refresh group data from storage."""
# This is called frequently, so we cache the lookup
import asyncio
if asyncio.iscoroutinefunction(self.storage.async_get_group):
# Can't call async from sync property, so we'll need to handle this differently
# For now, keep the cached version and update on coordinator updates
pass
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
# Refresh group data when coordinator updates
loop = self.hass.loop
if loop and loop.is_running():
loop.create_task(self._async_refresh_group_data())
self.async_write_ha_state()
async def _async_refresh_group_data(self) -> None:
"""Async refresh of group data."""
group_data = await self.storage.async_get_group(self._group_name)
if group_data:
self._group_data = group_data
def _compute_schedule_attributes(self) -> None:
"""Compute next_trigger, next_slot, actions, and next_entries."""
now = dt_util.now()
current_time = now.time()
current_day = now.strftime("%a").lower() # mon, tue, wed, etc.
active_profile = self._group_data.get("active_profile", "Default")
profiles = self._group_data.get("profiles", {})
profile_data = profiles.get(active_profile, {})
schedule_mode = profile_data.get("schedule_mode", "all_days")
schedules = profile_data.get("schedules", {})
# Determine which schedule to use based on mode and current day
current_schedule = self._get_schedule_for_day(schedules, schedule_mode, current_day)
if not current_schedule:
self._cached_next_trigger = None
self._cached_next_slot = None
self._cached_actions = []
self._cached_next_entries = []
return
# Sort nodes by time
sorted_nodes = sorted(current_schedule, key=lambda n: self._time_str_to_minutes(n.get("time", "00:00")))
# Find next node
next_node = None
next_node_index = None
for idx, node in enumerate(sorted_nodes):
node_time_str = node.get("time", "00:00")
node_minutes = self._time_str_to_minutes(node_time_str)
current_minutes = current_time.hour * 60 + current_time.minute
if node_minutes > current_minutes:
next_node = node
next_node_index = idx
break
# If no node found after current time, use first node tomorrow
if next_node is None and sorted_nodes:
next_node = sorted_nodes[0]
next_node_index = 0
# Add one day
next_trigger_dt = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
else:
next_trigger_dt = now.replace(hour=0, minute=0, second=0, microsecond=0)
if next_node:
# Parse time
time_str = next_node.get("time", "00:00")
try:
hours, minutes = time_str.split(":")
next_trigger_dt = next_trigger_dt.replace(hour=int(hours), minute=int(minutes))
except (ValueError, AttributeError):
_LOGGER.warning(f"Invalid time format: {time_str}")
next_trigger_dt = None
self._cached_next_trigger = next_trigger_dt
self._cached_next_slot = next_node_index
# Build actions list for all nodes
entities = self._group_data.get("entities", [])
# If entities list is empty, try to derive from group name for single-entity groups
if not entities and self._group_name.startswith("__entity_"):
entities = [self._group_name.replace("__entity_", "")]
_LOGGER.debug(f"Derived entity from group name: {entities}")
self._cached_actions = []
for node in sorted_nodes:
temp = node.get("temp")
# If still no entities, create a generic action without entity_id
# This allows the data structure to be populated even if entities are missing
if not entities:
action = {
"entity_id": "unknown",
"service": "climate.set_temperature",
"data": {"temperature": temp}
}
# Add HVAC mode if present
if "hvac_mode" in node:
action["data"]["hvac_mode"] = node["hvac_mode"]
# Add preset mode if present
if "preset_mode" in node:
action["service"] = "climate.set_preset_mode"
action["data"] = {"preset_mode": node["preset_mode"]}
self._cached_actions.append(action)
else:
# Normal case: create actions for each entity
for entity_id in entities:
action = {
"entity_id": entity_id,
"service": "climate.set_temperature",
"data": {"temperature": temp}
}
# Add HVAC mode if present
if "hvac_mode" in node:
action["data"]["hvac_mode"] = node["hvac_mode"]
# Add preset mode if present
if "preset_mode" in node:
action["service"] = "climate.set_preset_mode"
action["data"] = {"preset_mode": node["preset_mode"]}
self._cached_actions.append(action)
# Build next_entries (fallback format)
self._cached_next_entries = []
for node in sorted_nodes:
temp = node.get("temp")
time_str = node.get("time", "00:00")
# Calculate absolute datetime for this node
try:
hours, minutes = time_str.split(":")
node_dt = now.replace(hour=int(hours), minute=int(minutes), second=0, microsecond=0)
# If node time is in the past today, it's for tomorrow
if node_dt < now:
node_dt += timedelta(days=1)
entry_actions = []
# If still no entities, create a generic action
if not entities:
entry_actions.append({
"entity_id": "unknown",
"service": "climate.set_temperature",
"data": {"temperature": temp}
})
else:
for entity_id in entities:
entry_actions.append({
"entity_id": entity_id,
"service": "climate.set_temperature",
"data": {"temperature": temp}
})
self._cached_next_entries.append({
"time": node_dt.isoformat(),
"trigger_time": node_dt.isoformat(),
"actions": entry_actions
})
except (ValueError, AttributeError):
continue
else:
self._cached_next_trigger = None
self._cached_next_slot = None
self._cached_actions = []
self._cached_next_entries = []
def _get_schedule_for_day(
self,
schedules: Dict[str, List[Dict[str, Any]]],
schedule_mode: str,
current_day: str
) -> List[Dict[str, Any]]:
"""Get the appropriate schedule for the current day."""
if schedule_mode == "all_days":
return schedules.get("all_days", [])
elif schedule_mode == "5/2":
if current_day in ["mon", "tue", "wed", "thu", "fri"]:
return schedules.get("weekday", [])
else:
return schedules.get("weekend", [])
elif schedule_mode == "individual":
return schedules.get(current_day, [])
else:
return schedules.get("all_days", [])
def _get_weekdays_list(self, schedule_mode: str) -> List[str]:
"""Get weekdays list for scheduler-component compatibility."""
if schedule_mode == "all_days":
return ["daily"]
elif schedule_mode == "5/2":
return ["workday", "weekend"]
elif schedule_mode == "individual":
return ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
else:
return ["daily"]
@staticmethod
def _time_str_to_minutes(time_str: str) -> int:
"""Convert HH:MM string to minutes since midnight."""
try:
hours, minutes = time_str.split(":")
return int(hours) * 60 + int(minutes)
except (ValueError, AttributeError):
return 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable the schedule."""
await self.storage.async_enable_schedule(self._group_name)
await self.coordinator.async_request_refresh()
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable the schedule."""
await self.storage.async_disable_schedule(self._group_name)
await self.coordinator.async_request_refresh()
self.async_write_ha_state()
@property
def device_info(self) -> Dict[str, Any]:
"""Return device info for this scheduler."""
return {
"identifiers": {(DOMAIN, f"scheduler_{self._group_name}")},
"name": self._attr_name,
"manufacturer": "Climate Scheduler",
"model": "Schedule Controller",
"sw_version": "1.0",
}
@@ -0,0 +1,151 @@
{
"title": "Climate Scheduler",
"config": {
"step": {
"user": {
"title": "Configure Climate Scheduler",
"description": "Climate Scheduler is ready to use. No additional configuration needed."
}
}
},
"services": {
"set_schedule": {
"name": "Set climate schedule",
"description": "Configure temperature schedule for a schedule target"
},
"get_schedule": {
"name": "Get climate schedule",
"description": "Retrieve the current schedule for a schedule target"
},
"clear_schedule": {
"name": "Clear climate schedule",
"description": "Clear the schedule for a schedule target"
},
"enable_schedule": {
"name": "Enable climate schedule",
"description": "Enable the schedule for a schedule target"
},
"disable_schedule": {
"name": "Disable climate schedule",
"description": "Disable the schedule for a schedule target"
},
"create_group": {
"name": "Create thermostat group",
"description": "Create a new group to share schedules between multiple thermostats"
},
"delete_group": {
"name": "Delete thermostat group",
"description": "Delete a thermostat group"
},
"add_to_group": {
"name": "Add thermostat to group",
"description": "Add a climate entity to a group"
},
"remove_from_group": {
"name": "Remove thermostat from group",
"description": "Remove a climate entity from a group"
},
"get_groups": {
"name": "Get all groups",
"description": "Retrieve all thermostat groups"
},
"set_group_schedule": {
"name": "Set group schedule",
"description": "Set schedule for all thermostats in a group"
},
"sync_all": {
"name": "Sync all thermostats",
"description": "Force synchronization of all enabled thermostats with their schedules"
},
"enable_group": {
"name": "Enable group schedule",
"description": "Enable automatic scheduling for all thermostats in a group"
},
"disable_group": {
"name": "Disable group schedule",
"description": "Disable automatic scheduling for all thermostats in a group"
},
"get_settings": {
"name": "Get settings",
"description": "Retrieve global integration settings (min/max temp, default schedule, etc.)"
},
"save_settings": {
"name": "Save settings",
"description": "Save global integration settings"
},
"reload_integration": {
"name": "Reload integration (Dev)",
"description": "Reload the Climate Scheduler integration (development only)"
}
,
"reregister_card": {
"name": "Reregister card",
"description": "Automatically registers the Climate Scheduler card in Lovelace resources. Removes any existing registrations and creates a fresh entry."
}
,
"recreate_all_sensors": {
"name": "Recreate all sensors",
"description": "Delete and recreate all derivative sensors"
},
"cleanup_malformed_sensors": {
"name": "Cleanup malformed sensors",
"description": "Scan for unexpected derivative sensors and optionally remove them"
},
"set_ignored": {
"name": "Set ignored",
"description": "Mark a schedule target as ignored or not"
},
"advance_schedule": {
"name": "Advance schedule",
"description": "Temporarily advance a schedule for a schedule target"
},
"advance_group": {
"name": "Advance group",
"description": "Temporarily advance schedules for a group"
},
"cancel_advance": {
"name": "Cancel advance",
"description": "Cancel an advanced schedule action for a schedule target"
},
"get_advance_status": {
"name": "Get advance status",
"description": "Get current advance status for a schedule target"
},
"clear_advance_history": {
"name": "Clear advance history",
"description": "Clear stored advance history for a schedule target"
},
"test_fire_event": {
"name": "Test fire event",
"description": "Fire a test node_activated event with the current active node settings for testing automations"
},
"create_profile": {
"name": "Create profile",
"description": "Create a profile for a schedule"
},
"delete_profile": {
"name": "Delete profile",
"description": "Delete a profile from a schedule"
},
"rename_profile": {
"name": "Rename profile",
"description": "Rename a profile"
},
"set_active_profile": {
"name": "Set active profile",
"description": "Set the active profile for a schedule"
},
"get_profiles": {
"name": "Get profiles",
"description": "Get profiles for a schedule"
},
"cleanup_derivative_sensors": {
"name": "Cleanup derivative sensors",
"description": "Remove derivative sensors based on the auto-creation setting"
},
"factory_reset": {
"name": "Factory reset",
"description": "Reset all Climate Scheduler data to a fresh state (destructive)"
}
}
}
@@ -0,0 +1,151 @@
{
"title": "Climate Scheduler",
"config": {
"step": {
"user": {
"title": "Konfigurácia plánovača klimatizácie",
"description": "Plánovač klimatizácie je pripravený na použitie. Nie je potrebná žiadna ďalšia konfigurácia."
}
}
},
"services": {
"set_schedule": {
"name": "Nastavenie klimatického harmonogramu",
"description": "Konfigurácia teplotného plánu pre cieľový plán"
},
"get_schedule": {
"name": "Získajte klimatický harmonogram",
"description": "Načítať aktuálny rozvrh pre cieľ rozvrhu"
},
"clear_schedule": {
"name": "Vymazať klimatický harmonogram",
"description": "Vymazať plán pre cieľ plánu"
},
"enable_schedule": {
"name": "Povoliť klimatický plán",
"description": "Povoliť plán pre cieľ plánu"
},
"disable_schedule": {
"name": "Zakázať klimatizačný plán",
"description": "Zakázať plán pre cieľ plánu"
},
"create_group": {
"name": "Vytvoriť skupinu termostatov",
"description": "Vytvorte novú skupinu na zdieľanie harmonogramov medzi viacerými termostatmi"
},
"delete_group": {
"name": "Odstrániť skupinu termostatov",
"description": "Odstrániť skupinu termostatov"
},
"add_to_group": {
"name": "Pridať termostat do skupiny",
"description": "Pridanie klimatickej entity do skupiny"
},
"remove_from_group": {
"name": "Odstrániť termostat zo skupiny",
"description": "Odstránenie klimatickej entity zo skupiny"
},
"get_groups": {
"name": "Získať všetky skupiny",
"description": "Načítať všetky skupiny termostatov"
},
"set_group_schedule": {
"name": "Nastaviť rozvrh skupiny",
"description": "Nastavenie plánu pre všetky termostaty v skupine"
},
"sync_all": {
"name": "Synchronizovať všetky termostaty",
"description": "Vynútiť synchronizáciu všetkých povolených termostatov s ich harmonogramami"
},
"enable_group": {
"name": "Povoliť skupinový rozvrh",
"description": "Povoliť automatické plánovanie pre všetky termostaty v skupine"
},
"disable_group": {
"name": "Zakázať skupinový rozvrh",
"description": "Zakázať automatické plánovanie pre všetky termostaty v skupine"
},
"get_settings": {
"name": "Získať nastavenia",
"description": "Získanie globálnych nastavení integrácie (min./max. teplota, predvolený harmonogram atď.)"
},
"save_settings": {
"name": "Uložiť nastavenia",
"description": "Uložiť nastavenia globálnej integrácie"
},
"reload_integration": {
"name": "Integrácia obnovenia (vývojár)",
"description": "Obnoviť integráciu plánovača klimatizácie (iba pre vývojárov)"
}
,
"reregister_card": {
"name": "Znovu zaregistrovať kartu",
"description": "Automaticky zaregistruje kartu Climate Scheduler v zdrojoch Lovelace. Odstráni existujúce registrácie a vytvorí novú."
}
,
"recreate_all_sensors": {
"name": "Znovu vytvorte všetky senzory",
"description": "Odstráňte a znovu vytvorte všetky odvodené senzory"
},
"cleanup_malformed_sensors": {
"name": "Vyčistenie chybných senzorov",
"description": "Vyhľadajte neočakávané derivačné senzory a voliteľne ich odstráňte"
},
"set_ignored": {
"name": "Ignorované",
"description": "Označiť cieľ plánu ako ignorovaný alebo nie"
},
"advance_schedule": {
"name": "Pokročilý rozvrh",
"description": "Dočasný pokročilý plán pre cieľ plánu"
},
"advance_group": {
"name": "Pokročilá skupina",
"description": "Dočasné pokročilé rozvrhy pre skupinu"
},
"cancel_advance": {
"name": "Zrušiť pokročilé",
"description": "Zrušenie rozšírenej akcie plánovania pre cieľ plánovania"
},
"get_advance_status": {
"name": "Získať stav zálohy",
"description": "Získanie aktuálneho stavu zálohy pre cieľ plánu"
},
"clear_advance_history": {
"name": "Vymazať históriu záloh",
"description": "Vymazať uloženú históriu predbežných nastavení pre cieľ plánu"
},
"test_fire_event": {
"name": "Test fire event",
"description": "Fire a test node_activated event with the current active node settings for testing automations"
},
"create_profile": {
"name": "Vytvorriť profil",
"description": "Vytvorenie profilu pre rozvrh"
},
"delete_profile": {
"name": "Zmazať profil",
"description": "Odstránenie profilu z rozvrhu"
},
"rename_profile": {
"name": "Premenovať profil",
"description": "Premenovať profil"
},
"set_active_profile": {
"name": "Nastaviť aktívny profil",
"description": "Nastavenie aktívneho profilu pre plán"
},
"get_profiles": {
"name": "Získať profily",
"description": "Získajte profily pre rozvrh"
},
"cleanup_derivative_sensors": {
"name": "Čistenie derivačných senzorov",
"description": "Odstrániť odvodené senzory na základe nastavenia automatického vytvárania"
},
"factory_reset": {
"name": "Obnovenie továrenských nastavení",
"description": "Obnoviť všetky údaje plánovača klimatizácie do nového stavu (deštruktívne)"
}
}
}