"""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 //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