Files
HomeAssistance/custom_components/climate_scheduler/__init__.py
T
2026-06-05 22:34:31 -04:00

406 lines
17 KiB
Python

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