New apps Added
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user