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

1910 lines
85 KiB
Python

"""Service definitions and handlers for Climate Scheduler."""
import logging
from typing import Any
import json
from pathlib import Path
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers import selector
from homeassistant.util import json as json_util
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .coordinator import HeatingSchedulerCoordinator
from .storage import ScheduleStorage
_LOGGER = logging.getLogger(__name__)
async def async_get_services(hass: HomeAssistant) -> dict[str, Any]:
"""Return dynamic service definitions with runtime-populated selectors."""
# Get current groups and profiles from storage
storage: ScheduleStorage = hass.data[DOMAIN]["storage"]
all_groups = await storage.async_get_groups()
# Get group names (excluding internal single-entity groups)
group_names = [
name
for name, group_data in all_groups.items()
if not name.startswith("__entity_") and not group_data.get("_is_single_entity_group", False)
]
# Get global profile options
global_profiles = await storage.async_get_global_profiles()
profile_options = [
{
"value": profile_name,
"label": profile_name,
}
for profile_name in global_profiles.keys()
]
return {
"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",
"required": True,
"default": False,
"example": 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 entity lists.",
"fields": {
"delete": {
"description": "Whether to actually delete the unexpected entities (default is false for dry-run)",
"required": False,
"default": False,
"example": True,
"selector": {"boolean": {}},
}
},
},
"set_schedule": {
"name": "Set climate schedule",
"description": "Configure temperature schedule for a climate entity",
"fields": {
"schedule_id": {
"description": "Climate entity to control",
"required": True,
"example": "climate.living_room",
"selector": {"entity": {"domain": "climate"}},
},
"nodes": {
"description": "List of schedule nodes with time and temperature",
"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",
"example": "mon",
"selector": {"text": {}},
},
"schedule_mode": {
"description": "Schedule mode (all_days, 5/2, individual)",
"required": False,
"default": "all_days",
"example": "individual",
"selector": {"text": {}},
},
},
},
"get_schedule": {
"name": "Get climate schedule",
"description": "Retrieve the current schedule for a climate entity",
"fields": {
"schedule_id": {
"description": "Climate entity to get schedule for",
"required": True,
"example": "climate.living_room",
"selector": {"entity": {"domain": "climate"}},
}
},
},
"clear_schedule": {
"name": "Clear climate schedule",
"description": "Remove the schedule for a climate entity",
"fields": {
"schedule_id": {
"description": "Climate entity to clear schedule for",
"required": True,
"example": "climate.living_room",
"selector": {"entity": {"domain": "climate"}},
}
},
},
"enable_schedule": {
"name": "Enable climate schedule",
"description": "Enable automatic scheduling for a climate entity or group",
"fields": {
"schedule_id": {
"description": "Climate entity ID (e.g. climate.living_room) or group name to enable",
"required": True,
"example": "climate.living_room",
"selector": {"text": {}},
}
},
},
"disable_schedule": {
"name": "Disable climate schedule",
"description": "Disable automatic scheduling for a climate entity or group",
"fields": {
"schedule_id": {
"description": "Climate entity ID (e.g. climate.living_room) or group name to disable",
"required": True,
"example": "climate.living_room",
"selector": {"text": {}},
}
},
},
"create_group": {
"name": "Create thermostat group",
"description": "Create a new group to share schedules between multiple thermostats",
"fields": {
"schedule_id": {
"description": "Name for the new group",
"required": True,
"example": "Bedrooms",
"selector": {"text": {}},
}
},
},
"delete_group": {
"name": "Delete thermostat group",
"description": "Delete a thermostat group",
"fields": {
"schedule_id": {
"description": "Name of the group to delete",
"required": True,
"example": "Bedrooms",
"selector": {"select": {"options": group_names}},
}
},
},
"rename_group": {
"name": "Rename thermostat group",
"description": "Rename a thermostat group",
"fields": {
"old_name": {
"description": "Current name of the group",
"required": True,
"example": "Bedrooms",
"selector": {"select": {"options": group_names}},
},
"new_name": {
"description": "New name for the group",
"required": True,
"example": "Upstairs Bedrooms",
"selector": {"text": {}},
},
},
},
"add_to_group": {
"name": "Add thermostat to group",
"description": "Add a climate entity to a group",
"fields": {
"schedule_id": {
"description": "Name of the group",
"required": True,
"example": "Bedrooms",
"selector": {"select": {"options": group_names}},
},
"entity_id": {
"description": "Climate entity to add to the group",
"required": True,
"example": "climate.bedroom_1",
"selector": {"entity": {"domain": "climate"}},
},
},
},
"remove_from_group": {
"name": "Remove thermostat from group",
"description": "Remove a climate entity from a group",
"fields": {
"schedule_id": {
"description": "Name of the group",
"required": True,
"example": "Bedrooms",
"selector": {"select": {"options": group_names}},
},
"entity_id": {
"description": "Climate entity to remove from the group",
"required": True,
"example": "climate.bedroom_1",
"selector": {"entity": {"domain": "climate"}},
},
},
},
"get_groups": {
"name": "Get all groups",
"description": "Retrieve all thermostat groups",
},
"list_groups": {
"name": "List all group names",
"description": "Get a simple list of all thermostat group names for populating selectors",
},
"set_group_schedule": {
"name": "Set group schedule",
"description": "Set schedule for all thermostats in a group",
"fields": {
"schedule_id": {
"description": "Name of the group",
"required": True,
"example": "Bedrooms",
"selector": {"select": {"options": group_names}},
},
"nodes": {
"description": "List of schedule nodes with time and temperature",
"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",
"example": "mon",
"selector": {"text": {}},
},
"schedule_mode": {
"description": "Schedule mode (all_days, 5/2, individual)",
"required": False,
"default": "all_days",
"example": "individual",
"selector": {"text": {}},
},
},
},
"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",
"fields": {
"schedule_id": {
"description": "Name of the group to enable",
"required": True,
"example": "Bedrooms",
"selector": {"select": {"options": group_names}},
}
},
},
"disable_group": {
"name": "Disable group schedule",
"description": "Disable automatic scheduling for all thermostats in a group",
"fields": {
"schedule_id": {
"description": "Name of the group to disable",
"required": True,
"example": "Bedrooms",
"selector": {"select": {"options": group_names}},
}
},
},
"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",
"fields": {
"settings": {
"description": "Settings object with min_temp, max_temp, defaultSchedule, tooltipMode, etc.",
"required": True,
"example": '{"min_temp": 10, "max_temp": 30}',
}
},
},
"set_ignored": {
"name": "Set entity ignored status",
"description": "Mark an entity as ignored (not monitored) or un-ignore it",
"fields": {
"schedule_id": {
"description": "Climate entity to set ignored status for",
"required": True,
"example": "climate.living_room",
"selector": {"entity": {"domain": "climate"}},
},
"ignored": {
"description": "Whether to ignore this entity (true) or monitor it (false)",
"required": True,
"example": True,
"selector": {"boolean": {}},
},
},
},
"reload_integration": {
"name": "Reload integration",
"description": "Reload the Climate Scheduler integration",
"fields": {},
},
"advance_schedule": {
"name": "Advance to next scheduled node",
"description": "Manually advance a climate entity to its next scheduled temperature and settings, even if the scheduled time hasn't arrived yet",
"fields": {
"schedule_id": {
"description": "Climate entity to advance to next scheduled node",
"required": True,
"example": "climate.living_room",
"selector": {"entity": {"domain": "climate"}},
}
},
},
"advance_group": {
"name": "Advance group to next scheduled node",
"description": "Manually advance all climate entities in a group to their next scheduled temperature and settings, even if the scheduled time hasn't arrived yet",
"fields": {
"schedule_id": {
"description": "Name of the group to advance",
"required": True,
"example": "Bedrooms",
"selector": {"select": {"options": group_names}},
}
},
},
"cancel_advance": {
"name": "Cancel advance override",
"description": "Cancel an active advance override and return the climate entity to its current scheduled settings",
"fields": {
"schedule_id": {
"description": "Climate entity to cancel advance for",
"required": True,
"example": "climate.living_room",
"selector": {"entity": {"domain": "climate"}},
}
},
},
"clear_advance_history": {
"name": "Clear advance history",
"description": "Clear all advance history markers for a climate entity",
"fields": {
"schedule_id": {
"description": "Climate entity to clear history for",
"required": True,
"example": "climate.living_room",
"selector": {"entity": {"domain": "climate"}},
}
},
},
"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": {}},
}
},
},
"get_advance_status": {
"name": "Get advance status",
"description": "Check if a climate entity has an active advance override",
"fields": {
"schedule_id": {
"description": "Climate entity to check",
"required": True,
"example": "climate.living_room",
"selector": {"entity": {"domain": "climate"}},
}
},
},
"create_profile": {
"name": "Create schedule profile",
"description": "Create a new schedule profile for an entity or group",
"fields": {
"schedule_id": {
"description": "Climate entity ID or group name to create profile for",
"required": True,
"example": "climate.living_room",
"selector": {"text": {}},
},
"profile_name": {
"description": "Name for the new profile",
"required": True,
"example": "Winter Schedule",
"selector": {"text": {}},
},
},
},
"delete_profile": {
"name": "Delete schedule profile",
"description": "Delete a schedule profile from an entity or group",
"fields": {
"schedule_id": {
"description": "Climate entity ID or group name to delete profile from",
"required": True,
"example": "climate.living_room",
"selector": {"text": {}},
},
"profile_name": {
"description": "Name of the profile to delete",
"required": True,
"example": "Winter Schedule",
"selector": {"select": {"options": profile_options}},
},
},
},
"rename_profile": {
"name": "Rename schedule profile",
"description": "Rename a schedule profile for an entity or group",
"fields": {
"schedule_id": {
"description": "Climate entity ID or group name",
"required": True,
"example": "climate.living_room",
"selector": {"text": {}},
},
"old_name": {
"description": "Current name of the profile",
"required": True,
"example": "Winter Schedule",
"selector": {"select": {"options": profile_options}},
},
"new_name": {
"description": "New name for the profile",
"required": True,
"example": "Cold Weather Schedule",
"selector": {"text": {}},
},
},
},
"set_active_profile": {
"name": "Set active schedule profile",
"description": "Switch to a different schedule profile for an entity or group",
"fields": {
"schedule_id": {
"description": "Climate entity ID or group name",
"required": True,
"example": "climate.living_room",
"selector": {"text": {}},
},
"profile_name": {
"description": "Name of the profile to activate",
"required": True,
"example": "Winter Schedule",
"selector": {"select": {"options": profile_options}},
},
},
},
"get_profiles": {
"name": "Get schedule profiles",
"description": "Get list of all available profiles for an entity or group",
"fields": {
"schedule_id": {
"description": "Climate entity ID or group name",
"required": True,
"example": "climate.living_room",
"selector": {"text": {}},
},
},
},
"list_profiles": {
"name": "List all profile names",
"description": "Get a list of all global profiles for populating selectors",
},
"cleanup_derivative_sensors": {
"name": "Cleanup derivative sensors",
"description": "Remove orphaned derivative sensors for entities that no longer exist",
"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 schedules/profiles/entity references for unmonitored or missing entities from storage",
"fields": {
"delete": {
"description": "When true, execute deletion; when false, return a preview only",
"required": False,
"default": False,
"example": True,
"selector": {"boolean": {}},
}
},
},
"factory_reset": {
"name": "Factory Reset",
"description": "Reset all Climate Scheduler data to freshly installed state. WARNING - This will delete all schedules, groups, profiles, and settings permanently.",
"fields": {
"confirm": {
"description": "Must be set to true to confirm the factory reset",
"required": True,
"example": True,
"selector": {"boolean": {}},
}
},
},
"diagnostics": {
"name": "Run Diagnostics",
"description": "Run comprehensive diagnostics to troubleshoot card visibility and integration issues. Returns version info, card registration status, and accessibility checks.",
},
}
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up all services for the Climate Scheduler integration."""
# Define voluptuous schemas for services (used for validation)
service_schemas: dict[str, vol.Schema] = {
"recreate_all_sensors": vol.Schema({vol.Required("confirm"): cv.boolean}),
"cleanup_malformed_sensors": vol.Schema({vol.Optional("delete", default=False): cv.boolean}),
"cleanup_orphaned_climate_entities": vol.Schema({vol.Optional("delete", default=False): cv.boolean}),
"set_schedule": vol.Schema({
vol.Required("schedule_id"): cv.string,
# Accept either a JSON string (legacy) or a structured list/object from the UI
vol.Required("nodes"): vol.Any(cv.string, list, dict),
vol.Optional("day", default="all_days"): cv.string,
vol.Optional("schedule_mode", default="all_days"): cv.string,
}),
"get_schedule": vol.Schema({vol.Required("schedule_id"): cv.string}),
"clear_schedule": vol.Schema({vol.Required("schedule_id"): cv.string}),
"enable_schedule": vol.Schema({vol.Required("schedule_id"): cv.string}),
"disable_schedule": vol.Schema({vol.Required("schedule_id"): cv.string}),
"set_ignored": vol.Schema({vol.Required("schedule_id"): cv.string, vol.Required("ignored"): cv.boolean}),
"sync_all": vol.Schema({}),
"create_group": vol.Schema({vol.Required("schedule_id"): cv.string}),
"delete_group": vol.Schema({vol.Required("schedule_id"): cv.string}),
"rename_group": vol.Schema({vol.Required("old_name"): cv.string, vol.Required("new_name"): cv.string}),
"add_to_group": vol.Schema({vol.Required("schedule_id"): cv.string, vol.Required("entity_id"): cv.string}),
"remove_from_group": vol.Schema({vol.Required("schedule_id"): cv.string, vol.Required("entity_id"): cv.string}),
"get_groups": vol.Schema({}),
"list_groups": vol.Schema({}),
"list_profiles": vol.Schema({}),
"set_group_schedule": vol.Schema({
vol.Required("schedule_id"): cv.string,
vol.Required("nodes"): vol.Any(cv.string, list, dict),
vol.Optional("day"): cv.string,
vol.Optional("schedule_mode"): cv.string,
vol.Optional("profile_name"): cv.string
}),
"enable_group": vol.Schema({vol.Required("schedule_id"): cv.string}),
"disable_group": vol.Schema({vol.Required("schedule_id"): cv.string}),
"get_settings": vol.Schema({}),
"save_settings": vol.Schema({vol.Required("settings"): cv.string}),
"reload_integration": vol.Schema({}),
"advance_schedule": vol.Schema({vol.Required("schedule_id"): cv.string}),
"advance_group": vol.Schema({vol.Required("schedule_id"): cv.string}),
"cancel_advance": vol.Schema({vol.Required("schedule_id"): cv.string}),
"get_advance_status": vol.Schema({vol.Required("schedule_id"): cv.string}),
"clear_advance_history": vol.Schema({vol.Required("schedule_id"): cv.string}),
"test_fire_event": vol.Schema({
vol.Required("schedule_id"): cv.string,
vol.Optional("node"): cv.string,
vol.Optional("day"): cv.string
}),
"delete_profile": vol.Schema({vol.Required("schedule_id"): cv.string, vol.Required("profile_name"): cv.string}),
"rename_profile": vol.Schema({vol.Required("schedule_id"): cv.string, vol.Required("old_name"): cv.string, vol.Required("new_name"): cv.string}),
"set_active_profile": vol.Schema({vol.Required("schedule_id"): cv.string, vol.Required("profile_name"): cv.string}),
"get_profiles": vol.Schema({vol.Required("schedule_id"): cv.string}),
"create_profile": vol.Schema({vol.Required("schedule_id"): cv.string, vol.Required("profile_name"): cv.string}),
"cleanup_derivative_sensors": vol.Schema({vol.Optional("confirm_delete_all", default=False): cv.boolean}),
"cleanup_unmonitored_storage": vol.Schema({vol.Optional("delete", default=False): cv.boolean}),
"factory_reset": vol.Schema({vol.Required("confirm"): cv.boolean}),
"reregister_card": vol.Schema({
vol.Optional("resource_url"): cv.string,
vol.Optional("resource_type", default="module"): cv.string,
}),
"diagnostics": vol.Schema({}),
}
async def handle_recreate_all_sensors(call: ServiceCall) -> dict:
"""Handle recreate_all_sensors service call."""
confirm = call.data.get("confirm", False)
if not confirm:
raise ValueError("You must confirm deletion by setting 'confirm' to true.")
# Find and delete all sensor entities created by this integration
deleted_count = 0
errors = []
domain = "sensor"
from homeassistant.helpers import entity_registry as er
entity_reg = er.async_get(hass)
sensor_entities = [e for e in entity_reg.entities.values() if e.domain == domain and e.platform == DOMAIN]
for entity in sensor_entities:
try:
entity_reg.async_remove(entity.entity_id)
deleted_count += 1
except Exception as e:
errors.append(f"{entity.entity_id}: {e}")
# Reload the integration
for entry in hass.config_entries.async_entries(DOMAIN):
await hass.config_entries.async_reload(entry.entry_id)
message = f"Deleted {deleted_count} sensor entities. Integration reloaded."
if errors:
message += f" Errors: {len(errors)}."
return {"deleted_count": deleted_count, "errors": errors, "message": message}
async def handle_cleanup_malformed_sensors(call: ServiceCall) -> dict:
"""Handle cleanup_malformed_sensors service call - identify and optionally remove malformed/unexpected sensors."""
delete = call.data.get("delete", False)
_LOGGER.info(f"Scanning for malformed Climate Scheduler sensors (delete={delete})")
# Get registries
from homeassistant.helpers import entity_registry as er, device_registry as dr
entity_reg = er.async_get(hass)
device_reg = dr.async_get(hass)
# Get all sensor entities created by this integration
sensor_entities = [e for e in entity_reg.entities.values() if e.domain == "sensor" and e.platform == DOMAIN]
# Build expected IDs
expected_entity_ids = set()
expected_entity_ids.add("sensor.climate_scheduler_coldest_entity")
expected_entity_ids.add("sensor.climate_scheduler_warmest_entity")
# Collect climate entities from storage groups
storage: ScheduleStorage = hass.data[DOMAIN]["storage"]
groups = await storage.async_get_groups()
all_climate_entities = set()
for group_name, group_data in groups.items():
entities_list = group_data.get("entities", [])
all_climate_entities.update(entities_list)
# Use settings to decide whether derivative sensors are expected
settings = storage._data.get("settings", {})
create_derivative = settings.get("create_derivative_sensors", True)
if create_derivative:
for climate_entity_id in all_climate_entities:
if "." in climate_entity_id:
entity_name = climate_entity_id.split(".", 1)[1]
expected_entity_ids.add(f"sensor.climate_scheduler_{entity_name}_rate")
climate_entry = entity_reg.async_get(climate_entity_id)
if climate_entry and climate_entry.device_id:
device_id = climate_entry.device_id
device_sensors = [
e for e in entity_reg.entities.values()
if e.device_id == device_id and e.domain == "sensor" and e.entity_id != climate_entity_id
]
for sensor in device_sensors:
sensor_state = hass.states.get(sensor.entity_id)
if not sensor_state:
continue
attrs = sensor_state.attributes
if ("temperature" in str(sensor_state.state).lower() or
attrs.get("device_class") == "temperature" or
attrs.get("unit_of_measurement") in ["°C", "°F"]):
floor_sensor_name = sensor.entity_id.split(".", 1)[1]
# 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
expected_entity_ids.add(
f"sensor.climate_scheduler_{entity_name}_{floor_sensor_suffix}_rate"
)
# Find unexpected entities
problematic = []
for entry in sensor_entities:
if entry.entity_id not in expected_entity_ids and entry.entity_id.startswith("sensor.climate_scheduler_"):
problematic.append({
"entity_id": entry.entity_id,
"unique_id": entry.unique_id,
"name": entry.name or entry.original_name,
"reason": "Not in expected entity list"
})
if not problematic:
_LOGGER.info("No problematic sensor entities found")
return {
"expected_entities": sorted(list(expected_entity_ids)),
"unexpected_entities": []
}
_LOGGER.warning(f"Found {len(problematic)} unexpected sensor entities")
if delete:
removed = []
for info in problematic:
try:
entity_reg.async_remove(info["entity_id"])
removed.append(info["entity_id"])
_LOGGER.info(f"Removed unexpected entity: {info['entity_id']}")
except Exception as e:
_LOGGER.error(f"Failed to remove entity {info['entity_id']}: {e}")
return {
"expected_entities": sorted(list(expected_entity_ids)),
"unexpected_entities": [p["entity_id"] for p in problematic],
"removed": removed
}
# Dry run
return {
"expected_entities": sorted(list(expected_entity_ids)),
"unexpected_entities": [p["entity_id"] for p in problematic]
}
async def handle_cleanup_orphaned_climate_entities(call: ServiceCall) -> dict:
"""Handle cleanup_orphaned_climate_entities service call - identify and optionally remove orphaned entities."""
delete = call.data.get("delete", False)
_LOGGER.info(f"Scanning for orphaned Climate Scheduler entities (delete={delete})")
# Get entity registry
from homeassistant.helpers import entity_registry as er
entity_reg = er.async_get(hass)
# Get all entities created by this integration (climate, sensor, switch)
integration_entities = [
e for e in entity_reg.entities.values()
if e.platform == DOMAIN and e.domain in ["climate", "sensor", "switch"]
]
# Get storage to check which groups exist
storage: ScheduleStorage = hass.data[DOMAIN]["storage"]
groups = await storage.async_get_groups()
settings = storage._data.get("settings", {})
# Build expected unique IDs from existing groups
expected_unique_ids = set()
# Always expect coldest/warmest sensors
expected_unique_ids.add("climate_scheduler_coldest_entity")
expected_unique_ids.add("climate_scheduler_warmest_entity")
# Collect all climate entities from all groups
all_climate_entities = set()
for group_name, group_data in groups.items():
entities_list = group_data.get("entities", [])
# Skip empty groups
if len(entities_list) == 0:
continue
all_climate_entities.update(entities_list)
# Climate entity for this group
unique_id = f"climate_scheduler_group_{group_name.lower().replace(' ', '_')}"
expected_unique_ids.add(unique_id)
# Switch entity for this group (if not ignored)
if not group_data.get("ignored", False):
import hashlib
token = hashlib.md5(group_name.encode()).hexdigest()[:6]
if group_name.startswith("__entity_"):
entity_id = group_name.replace("__entity_", "")
expected_unique_ids.add(f"climate_scheduler_schedule_{entity_id}_{token}")
else:
expected_unique_ids.add(f"climate_scheduler_schedule_{group_name}_{token}")
# Add derivative sensors if enabled
if settings.get("create_derivative_sensors", True):
for climate_entity_id in all_climate_entities:
if "." in climate_entity_id:
entity_name = climate_entity_id.split(".", 1)[1]
expected_unique_ids.add(f"climate_scheduler_{entity_name}_rate")
# Check for floor sensors on same device
climate_entry = entity_reg.async_get(climate_entity_id)
if climate_entry and climate_entry.device_id:
device_id = climate_entry.device_id
device_sensors = [
e for e in entity_reg.entities.values()
if e.device_id == device_id
and e.domain == "sensor"
and "floor" in e.entity_id.lower()
]
for sensor in device_sensors:
floor_sensor_name = sensor.entity_id.split(".", 1)[1]
expected_unique_ids.add(f"climate_scheduler_{entity_name}_{floor_sensor_name}_rate")
# Find orphaned entities
orphaned = []
for entry in integration_entities:
if entry.unique_id not in expected_unique_ids:
orphaned.append({
"entity_id": entry.entity_id,
"unique_id": entry.unique_id,
"domain": entry.domain,
"name": entry.name or entry.original_name,
"reason": "No matching group/entity in storage"
})
if not orphaned:
_LOGGER.info("No orphaned entities found")
return {
"expected_count": len(expected_unique_ids),
"orphaned_entities": []
}
_LOGGER.warning(f"Found {len(orphaned)} orphaned entities")
if delete:
removed = []
for info in orphaned:
try:
entity_reg.async_remove(info["entity_id"])
removed.append(info["entity_id"])
_LOGGER.info(f"Removed orphaned entity: {info['entity_id']} ({info['domain']})")
except Exception as e:
_LOGGER.error(f"Failed to remove entity {info['entity_id']}: {e}")
return {
"expected_count": len(expected_unique_ids),
"orphaned_entities": [o["entity_id"] for o in orphaned],
"removed": removed
}
# Dry run
return {
"expected_count": len(expected_unique_ids),
"orphaned_entities": [o["entity_id"] for o in orphaned]
}
storage: ScheduleStorage = hass.data[DOMAIN]["storage"]
coordinator: HeatingSchedulerCoordinator = hass.data[DOMAIN]["coordinator"]
# Get dynamic data for selectors
all_groups = await storage.async_get_groups()
group_names = [
name
for name, group_data in all_groups.items()
if not name.startswith("__entity_") and not group_data.get("_is_single_entity_group", False)
]
# Build global profile options
global_profiles = await storage.async_get_global_profiles()
profile_options = [
{
"value": profile_name,
"label": profile_name,
}
for profile_name in global_profiles.keys()
]
# Build dynamic selectors
group_selector = selector.SelectSelector(
selector.SelectSelectorConfig(
options=group_names,
mode=selector.SelectSelectorMode.DROPDOWN,
)
) if group_names else cv.string
profile_selector = selector.SelectSelector(
selector.SelectSelectorConfig(
options=profile_options,
mode=selector.SelectSelectorMode.DROPDOWN,
)
) if profile_options else cv.string
entity_selector = selector.EntitySelector(
selector.EntitySelectorConfig(domain="climate")
)
# Service handler functions (schemas are defined in async_get_services() return value above)
async def handle_set_schedule(call: ServiceCall) -> None:
"""Handle set_schedule service call."""
entity_id = call.data["schedule_id"]
nodes = call.data["nodes"]
day = call.data.get("day")
schedule_mode = call.data.get("schedule_mode")
await storage.async_set_schedule(entity_id, nodes, day, schedule_mode)
# Force immediate update
if entity_id in coordinator.last_node_states:
del coordinator.last_node_states[entity_id]
await coordinator.async_request_refresh()
async def handle_get_schedule(call: ServiceCall) -> dict:
"""Handle get_schedule service call."""
entity_id = call.data["schedule_id"]
schedule = await storage.async_get_schedule(entity_id)
if schedule is None:
return {"schedule": None, "enabled": False}
schedules = schedule.get("schedules", {})
schedule_mode = schedule.get("schedule_mode", "all_days")
enabled = schedule.get("enabled", True)
profiles = schedule.get("profiles", {})
active_profile = schedule.get("active_profile", "Default")
return {
"schedules": schedules,
"schedule_mode": schedule_mode,
"enabled": enabled,
"profiles": profiles,
"active_profile": active_profile
}
async def handle_clear_schedule(call: ServiceCall) -> None:
"""Handle clear_schedule service call."""
entity_id = call.data["schedule_id"]
await storage.async_clear_schedule(entity_id)
await coordinator.async_request_refresh()
async def _enable_target(target_id: str) -> None:
"""Enable scheduling for either a single entity or a group name.
If `target_id` is a group name, enable scheduling for all entities
in that group by invoking storage.async_set_enabled for each entity.
Otherwise treat `target_id` as an entity id and enable its group.
"""
# Check if target is a group name
group = await storage.async_get_group(target_id)
if group is not None:
# It's a group name - enable all member entities via storage
entities = group.get("entities", [])
for entity_id in entities:
await storage.async_set_enabled(entity_id, True)
if entity_id in coordinator.last_node_states:
del coordinator.last_node_states[entity_id]
# Force a single refresh after enabling group members
await coordinator.async_request_refresh()
_LOGGER.info(f"Enabled schedule for group '{target_id}' (via member enable)")
return
# Not a group - treat as entity id
await storage.async_set_enabled(target_id, True)
if target_id in coordinator.last_node_states:
del coordinator.last_node_states[target_id]
await coordinator.async_request_refresh()
_LOGGER.info(f"Enabled schedule for {target_id}")
async def _disable_target(target_id: str) -> None:
"""Disable scheduling for either a single entity or a group name.
If `target_id` is a group name, disable scheduling for all entities
in that group by invoking storage.async_set_enabled for each entity.
Otherwise treat `target_id` as an entity id and disable its group.
"""
group = await storage.async_get_group(target_id)
if group is not None:
entities = group.get("entities", [])
for entity_id in entities:
await storage.async_set_enabled(entity_id, False)
_LOGGER.info(f"Disabled schedule for group '{target_id}' (via member disable)")
return
# Not a group - treat as entity id
await storage.async_set_enabled(target_id, False)
_LOGGER.info(f"Disabled schedule for {target_id}")
async def _advance_target(target_id: str) -> None:
"""Advance either a single entity schedule_id or a group schedule_id.
If `target_id` matches a stored group, advances all entities in the group.
Otherwise treats `target_id` as a single climate entity id.
"""
group = await storage.async_get_group(target_id)
if group is not None:
await coordinator.async_advance_group(target_id)
_LOGGER.info(f"Advanced schedule for group '{target_id}'")
return
await coordinator.async_advance_schedule(target_id)
_LOGGER.info(f"Advanced schedule for {target_id}")
async def handle_enable_schedule(call: ServiceCall) -> None:
"""Handle enable_schedule service call (supports entity or group)."""
target = call.data["schedule_id"]
await _enable_target(target)
async def handle_disable_schedule(call: ServiceCall) -> None:
"""Handle disable_schedule service call (supports entity or group)."""
target = call.data["schedule_id"]
await _disable_target(target)
async def handle_sync_all(call: ServiceCall) -> None:
"""Handle sync_all service call."""
coordinator.last_node_states.clear()
await coordinator.async_request_refresh()
_LOGGER.info("Forced sync of all thermostats")
async def handle_create_group(call: ServiceCall) -> None:
"""Handle create_group service call."""
group_name = call.data["schedule_id"]
try:
await storage.async_create_group(group_name)
_LOGGER.info(f"Created group '{group_name}'")
except ValueError as err:
_LOGGER.error(f"Error creating group: {err}")
raise
async def handle_delete_group(call: ServiceCall) -> None:
"""Handle delete_group service call."""
group_name = call.data["schedule_id"]
try:
await storage.async_delete_group(group_name)
_LOGGER.info(f"Deleted group '{group_name}'")
except ValueError as err:
_LOGGER.error(f"Error deleting group: {err}")
raise
async def handle_rename_group(call: ServiceCall) -> None:
"""Handle rename_group service call."""
old_name = call.data["old_name"]
new_name = call.data["new_name"]
try:
await storage.async_rename_group(old_name, new_name)
_LOGGER.info(f"Renamed group '{old_name}' to '{new_name}'")
except ValueError as err:
_LOGGER.error(f"Error renaming group: {err}")
raise
async def handle_add_to_group(call: ServiceCall) -> None:
"""Handle add_to_group service call."""
group_name = call.data["schedule_id"]
entity_id = call.data["entity_id"]
try:
await storage.async_add_entity_to_group(group_name, entity_id)
_LOGGER.info(f"Added {entity_id} to group '{group_name}'")
except ValueError as err:
_LOGGER.error(f"Error adding to group: {err}")
raise
async def handle_remove_from_group(call: ServiceCall) -> None:
"""Handle remove_from_group service call."""
group_name = call.data["schedule_id"]
entity_id = call.data["entity_id"]
await storage.async_remove_entity_from_group(group_name, entity_id)
_LOGGER.info(f"Removed {entity_id} from group '{group_name}'")
async def handle_get_groups(call: ServiceCall) -> dict:
"""Handle get_groups service call."""
groups = await storage.async_get_groups()
return {"groups": groups}
async def handle_list_groups(call: ServiceCall) -> dict:
"""Handle list_groups service call - return simple list of group names."""
groups = await storage.async_get_groups()
group_names = [
name
for name, group_data in groups.items()
if not name.startswith("__entity_") and not group_data.get("_is_single_entity_group", False)
]
return {"groups": group_names}
async def handle_list_profiles(call: ServiceCall) -> dict:
"""Handle list_profiles service call - return all global profiles."""
global_profiles = await storage.async_get_global_profiles()
profiles_list = [
{
"value": profile_name,
"label": profile_name,
}
for profile_name in global_profiles.keys()
]
return {"profiles": profiles_list}
async def handle_set_group_schedule(call: ServiceCall) -> None:
"""Handle set_group_schedule service call."""
group_name = call.data["schedule_id"]
nodes = call.data["nodes"]
day = call.data.get("day")
schedule_mode = call.data.get("schedule_mode")
profile_name = call.data.get("profile_name")
_LOGGER.debug(
"[BACKEND] set_group_schedule service called: group=%s, nodes=%d, day=%s, mode=%s, profile=%s",
group_name,
len(nodes) if isinstance(nodes, list) else "?",
day,
schedule_mode,
profile_name
)
try:
await storage.async_set_group_schedule(group_name, nodes, day, schedule_mode, profile_name)
_LOGGER.debug(
"[BACKEND] set_group_schedule succeeded: group=%s",
group_name
)
# Force immediate update for all entities in the group
group_data = await storage.async_get_groups()
if group_name in group_data and "entities" in group_data[group_name]:
for entity_id in group_data[group_name]["entities"]:
if entity_id in coordinator.last_node_states:
del coordinator.last_node_states[entity_id]
await coordinator.async_request_refresh()
except ValueError as err:
_LOGGER.error(
"[BACKEND] set_group_schedule failed with ValueError: group=%s, error=%s",
group_name,
err
)
raise
except Exception as err:
_LOGGER.error(
"[BACKEND] set_group_schedule failed with unexpected error: group=%s, error=%s",
group_name,
err,
exc_info=True
)
raise
async def handle_enable_group(call: ServiceCall) -> None:
"""Handle enable_group service call."""
group_name = call.data["schedule_id"]
try:
# Delegate to single-entity enable logic so group behaviour
# is consistent and backwards-compatible.
await _enable_target(group_name)
except ValueError as err:
_LOGGER.error(f"Error enabling group: {err}")
raise
async def handle_disable_group(call: ServiceCall) -> None:
"""Handle disable_group service call."""
group_name = call.data["schedule_id"]
try:
# Delegate to single-entity disable logic so group behaviour
# reuses the same implementation.
await _disable_target(group_name)
except ValueError as err:
_LOGGER.error(f"Error disabling group: {err}")
raise
async def handle_get_settings(call: ServiceCall) -> dict:
"""Handle get_settings service call."""
settings = await storage.async_get_settings()
# Include version information
from homeassistant.const import __version__ as ha_version
# Get integration version from manifest
integration_version = "unknown"
try:
from homeassistant.loader import async_get_integration
integration = await async_get_integration(hass, DOMAIN)
integration_version = integration.version
except Exception:
pass
return {
"settings": settings,
"version": {
"integration": integration_version,
"home_assistant": ha_version
}
}
async def handle_save_settings(call: ServiceCall) -> None:
"""Handle save_settings service call."""
settings_json = call.data["settings"]
import json
settings = json.loads(settings_json)
# Defensive: older/broken clients may send the full get_settings payload
# (e.g. {"settings": {...}, "version": {...}}). Storage expects the inner settings dict.
if isinstance(settings, dict) and isinstance(settings.get("settings"), dict) and settings.get("version") is not None:
settings = settings["settings"]
await storage.async_save_settings(settings)
_LOGGER.info("Settings saved")
async def handle_set_ignored(call: ServiceCall) -> None:
"""Handle set_ignored service call."""
entity_id = call.data["schedule_id"]
ignored = call.data["ignored"]
await storage.async_set_ignored(entity_id, ignored)
_LOGGER.info(f"Set {entity_id} ignored status to {ignored}")
async def handle_reload_integration(call: ServiceCall) -> None:
"""Handle reload_integration service call."""
_LOGGER.info("Reloading Climate Scheduler integration")
# Trigger a config entry reload
for entry in hass.config_entries.async_entries(DOMAIN):
await hass.config_entries.async_reload(entry.entry_id)
async def handle_advance_schedule(call: ServiceCall) -> None:
"""Handle advance_schedule service call."""
target_id = call.data["schedule_id"]
await _advance_target(target_id)
async def handle_advance_group(call: ServiceCall) -> None:
"""Handle advance_group service call."""
group_name = call.data["schedule_id"]
await _advance_target(group_name)
async def handle_cancel_advance(call: ServiceCall) -> None:
"""Handle cancel_advance service call."""
target_id = call.data["schedule_id"]
await coordinator.async_cancel_advance(target_id)
_LOGGER.info(f"Cancelled advance for {target_id}")
async def handle_get_advance_status(call: ServiceCall) -> dict:
"""Handle get_advance_status service call."""
entity_id = call.data["schedule_id"]
status = await coordinator.async_get_advance_status(entity_id)
# Frontend expects {is_active, history}. Keep backwards-compatible keys too.
is_active = status.get("is_advanced", False)
history = coordinator.get_advance_history(entity_id)
return {
"entity_id": entity_id,
"is_active": is_active,
"history": history,
# legacy/extra fields
"is_advanced": status.get("is_advanced", False),
"advance_time": status.get("advance_time"),
"original_node": status.get("original_node"),
"advanced_node": status.get("advanced_node"),
}
async def handle_test_fire_event(call: ServiceCall) -> None:
"""Handle test_fire_event service call - fires an event without applying settings."""
target_id = call.data["schedule_id"]
node_data = call.data.get("node")
day = call.data.get("day")
_LOGGER.debug(f"[BACKEND] test_fire_event called for: {target_id}")
# Parse node data if provided as JSON string
if isinstance(node_data, str):
import json
node_data = json.loads(node_data)
# Get current day if not provided
if not day:
import datetime
now = datetime.datetime.now()
day = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][now.weekday()]
# Determine if this is a group or entity
all_groups = await storage.async_get_groups()
if target_id in all_groups:
# It's a group
group_name = target_id
group_data = all_groups[group_name]
# Get all entities in the group
entities = group_data.get("entities", [])
# Fire event with provided node data
hass.bus.async_fire(
f"{DOMAIN}_node_activated",
{
# TODO: Remove entity_id in future version - deprecated in favor of entities list
"entity_id": entities[0] if len(entities) == 1 else None, # Single entity for backwards compatibility
"entities": entities,
"group_name": group_name,
"node": {
"time": node_data.get("time"),
"temp": node_data.get("temp"),
"hvac_mode": node_data.get("hvac_mode"),
"fan_mode": node_data.get("fan_mode"),
"swing_mode": node_data.get("swing_mode"),
"preset_mode": node_data.get("preset_mode"),
"A": node_data.get("A"),
"B": node_data.get("B"),
"C": node_data.get("C"),
},
"day": day,
"trigger_type": "test",
}
)
_LOGGER.info(f"Test event fired for group: {group_name} with node at {node_data.get('time')}")
else:
# Assume it's an entity - find its group
entity_id = target_id
group_name = None
for gname, gdata in all_groups.items():
if entity_id in gdata.get("entities", []):
group_name = gname
break
if not group_name:
_LOGGER.error(f"Entity {entity_id} not found in any group")
return
# Get all entities in the group
group_data = all_groups[group_name]
entities = group_data.get("entities", [])
# Fire event with provided node data
hass.bus.async_fire(
f"{DOMAIN}_node_activated",
{
"entities": entities,
"group_name": group_name,
"node": {
"time": node_data.get("time"),
"temp": node_data.get("temp"),
"hvac_mode": node_data.get("hvac_mode"),
"fan_mode": node_data.get("fan_mode"),
"swing_mode": node_data.get("swing_mode"),
"preset_mode": node_data.get("preset_mode"),
"A": node_data.get("A"),
"B": node_data.get("B"),
"C": node_data.get("C"),
},
"day": day,
"trigger_type": "test",
}
)
_LOGGER.info(f"Test event fired for entity: {entity_id} with node at {node_data.get('time')}")
async def handle_clear_advance_history(call: ServiceCall) -> None:
"""Handle clear_advance_history service call."""
entity_id = call.data["schedule_id"]
await coordinator.clear_advance_history(entity_id)
_LOGGER.info(f"Cleared advance history for {entity_id}")
async def handle_create_profile(call: ServiceCall) -> None:
"""Handle create_profile service call."""
target_id = call.data["schedule_id"]
profile_name = call.data["profile_name"]
try:
await storage.async_create_profile(target_id, profile_name)
_LOGGER.info(f"Created profile '{profile_name}' for schedule '{target_id}'")
except ValueError as err:
_LOGGER.error(f"Error creating profile: {err}")
raise
async def handle_delete_profile(call: ServiceCall) -> None:
"""Handle delete_profile service call."""
target_id = call.data["schedule_id"]
profile_name = call.data["profile_name"]
try:
await storage.async_delete_profile(target_id, profile_name)
_LOGGER.info(f"Deleted profile '{profile_name}' from schedule '{target_id}'")
except ValueError as err:
_LOGGER.error(f"Error deleting profile: {err}")
raise
async def handle_rename_profile(call: ServiceCall) -> None:
"""Handle rename_profile service call."""
target_id = call.data["schedule_id"]
old_name = call.data["old_name"]
new_name = call.data["new_name"]
try:
await storage.async_rename_profile(target_id, old_name, new_name)
_LOGGER.info(f"Renamed profile '{old_name}' to '{new_name}' for schedule '{target_id}'")
except ValueError as err:
_LOGGER.error(f"Error renaming profile: {err}")
raise
async def handle_set_active_profile(call: ServiceCall) -> None:
"""Handle set_active_profile service call."""
target_id = call.data["schedule_id"]
profile_name = call.data["profile_name"]
try:
await storage.async_set_active_profile(target_id, profile_name)
_LOGGER.info(f"Set active profile to '{profile_name}' for schedule '{target_id}'")
# Fire event for profile change
hass.bus.async_fire(
f"{DOMAIN}_profile_changed",
{
"schedule_id": target_id,
"profile_name": profile_name,
}
)
# Force immediate update - clear state for all entities in the group
group_data = await storage.async_get_groups()
if target_id in group_data and "entities" in group_data[target_id]:
for entity_id in group_data[target_id]["entities"]:
if entity_id in coordinator.last_node_states:
del coordinator.last_node_states[entity_id]
await coordinator.async_request_refresh()
except ValueError as err:
_LOGGER.error(f"Error setting active profile: {err}")
raise
async def handle_get_profiles(call: ServiceCall) -> dict:
"""Handle get_profiles service call."""
target_id = call.data["schedule_id"]
profiles = await storage.async_get_profiles(target_id)
active_profile = await storage.async_get_active_profile_name(target_id)
return {
"profiles": profiles,
"active_profile": active_profile
}
async def handle_cleanup_derivative_sensors(call: ServiceCall) -> dict:
"""Handle cleanup_derivative_sensors service call."""
confirm_delete_all = call.data.get("confirm_delete_all", False)
result = await storage.async_cleanup_derivative_sensors(confirm_delete_all)
return result
async def handle_cleanup_unmonitored_storage(call: ServiceCall) -> dict:
"""Handle cleanup_unmonitored_storage service call."""
delete = call.data.get("delete", False)
result = await storage.async_cleanup_unmonitored_storage(delete=delete)
if delete:
coordinator.last_node_states.clear()
await coordinator.async_request_refresh()
return result
async def handle_factory_reset(call: ServiceCall) -> None:
"""Handle factory_reset service call."""
confirm = call.data.get("confirm", False)
if not confirm:
raise ValueError("Factory reset must be confirmed by setting 'confirm' to true")
_LOGGER.warning("Factory reset initiated - deleting all schedules, groups, and settings")
# Clear coordinator state
coordinator.last_node_states.clear()
# Reset storage
await storage.async_factory_reset()
# Force refresh
await coordinator.async_request_refresh()
_LOGGER.info("Factory reset completed successfully")
async def handle_reregister_card(call: ServiceCall) -> dict:
"""Handle reregister_card service call.
Deletes any existing frontend resource entries and registers the card.
If resource_url is not provided, automatically uses the bundled card URL.
"""
resource_url = call.data.get("resource_url")
resource_type = call.data.get("resource_type", "module")
# If no URL provided, use the bundled card URL with current version
if not resource_url:
# Get version from manifest
manifest_path = Path(__file__).parent / "manifest.json"
try:
import json
import time
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())}")
except Exception as e:
_LOGGER.warning("Failed to read manifest version: %s", e)
import time
frontend_version = f"u{int(time.time())}"
base_url = f"/{DOMAIN}/static/climate-scheduler-card.js"
resource_url = f"{base_url}?v={frontend_version}"
_LOGGER.info("Auto-generated card URL: %s", resource_url)
# Use the Lovelace resources API to manage frontend resources.
lovelace = hass.data.get("lovelace")
if lovelace is None:
raise RuntimeError("Lovelace resources helper not available; cannot reregister resource via API")
# Determine resources helper (API varies by HA version)
try:
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]
except Exception:
version_number = 0
if version_number >= 2025002 and hasattr(lovelace, "resources"):
resources = lovelace.resources
else:
# Older HA versions expose resources differently
resources = getattr(lovelace, "get", lambda name: None)("resources") if hasattr(lovelace, "get") else None
if resources is None:
raise RuntimeError("Lovelace resources API not available; cannot reregister resource")
# Check if resources are in YAML mode (read-only)
if not hasattr(resources, "async_create_item"):
return {
"error": "yaml_mode",
"message": (
"Lovelace resources are configured via YAML and cannot be modified programmatically. "
f"Please add the resource manually to your lovelace configuration:\n\n"
f"lovelace:\n"
f" mode: yaml\n"
f" resources:\n"
f" - url: {resource_url}\n"
f" type: {resource_type}"
)
}
# Ensure resources are loaded and available
if not getattr(resources, "loaded", True):
await resources.async_load()
# Normalize incoming url (compare base without query)
base_url = resource_url.split("?")[0]
removed = []
existing_entry = None
for entry in list(resources.async_items()):
entry_url = entry.get("url")
if not entry_url:
continue
entry_base = entry_url.split("?")[0]
if entry_base == base_url:
existing_entry = entry
continue
# remove exact matches of provided url
if entry_url == resource_url:
removed.append(entry)
if hasattr(resources, "async_delete_item"):
await resources.async_delete_item(entry["id"])
url = resource_url
added = None
if existing_entry:
# update existing item to new url
if hasattr(resources, "async_update_item"):
await resources.async_update_item(existing_entry["id"], {"url": url})
added = {"id": existing_entry["id"], "url": url}
_LOGGER.info("Updated existing Lovelace resource to %s", url)
else:
# create new resource item
item = {"res_type": resource_type, "url": url}
if hasattr(resources, "async_create_item"):
await resources.async_create_item(item)
added = item
_LOGGER.info("Created new Lovelace resource %s", url)
return {"removed": [r.get("url") for r in removed], "added": added, "message": f"Reregistered resource {url}"}
async def handle_diagnostics(call: ServiceCall) -> dict:
"""Handle diagnostics service call - returns troubleshooting info about the integration and card."""
_LOGGER.info("Running integration diagnostics")
result = {
"integration": {},
"card_registration": {},
"card_accessibility": {},
"storage": {},
"climate_entities": {},
"devices": {}
}
# Get integration version from manifest
try:
manifest_path = Path(__file__).parent / "manifest.json"
import json
manifest_text = await hass.async_add_executor_job(manifest_path.read_text)
manifest = json.loads(manifest_text)
result["integration"]["version"] = manifest.get("version", "unknown")
result["integration"]["domain"] = manifest.get("domain", DOMAIN)
result["integration"]["name"] = manifest.get("name", "Climate Scheduler")
except Exception as e:
result["integration"]["version"] = "error reading manifest"
result["integration"]["error"] = str(e)
# Check if card is registered in Lovelace resources
try:
lovelace_data = hass.data.get("lovelace")
if lovelace_data is None:
result["card_registration"]["status"] = "lovelace_unavailable"
result["card_registration"]["message"] = "Lovelace integration not available"
else:
# Get resources based on HA version
from homeassistant.const import __version__ as ha_version
result["integration"]["ha_version"] = 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:
resources = lovelace_data.resources
else:
resources = lovelace_data.get("resources")
if resources is None:
result["card_registration"]["status"] = "resources_unavailable"
result["card_registration"]["message"] = "Lovelace resources not available"
elif not hasattr(resources, "store") or resources.store is None:
result["card_registration"]["status"] = "yaml_mode"
result["card_registration"]["message"] = "Lovelace is in YAML mode"
else:
# Ensure resources are loaded
if not resources.loaded:
await resources.async_load()
# Look for our card in registered resources
expected_base_url = f"/{DOMAIN}/static/climate-scheduler-card.js"
registered_cards = []
for entry in resources.async_items():
entry_url = entry.get("url", "")
entry_base = entry_url.split("?")[0]
# Check for our card
if expected_base_url in entry_base or "climate-scheduler-card.js" in entry_url:
# Try multiple possible field names for type
entry_type = entry.get("type") or entry.get("res_type") or "module"
registered_cards.append({
"url": entry_url,
"type": entry_type,
"id": entry.get("id", "unknown")
})
if registered_cards:
result["card_registration"]["status"] = "registered"
result["card_registration"]["count"] = len(registered_cards)
result["card_registration"]["resources"] = registered_cards
else:
result["card_registration"]["status"] = "not_registered"
result["card_registration"]["message"] = "Card not found in Lovelace resources"
except Exception as e:
result["card_registration"]["status"] = "error"
result["card_registration"]["error"] = str(e)
# Check if card file is accessible via URL
try:
import aiohttp
import asyncio
card_url = f"/{DOMAIN}/static/climate-scheduler-card.js"
# Get the base URL for this HA instance
base_url = hass.config.internal_url or hass.config.external_url or "http://localhost:8123"
full_url = f"{base_url}{card_url}"
result["card_accessibility"]["url"] = card_url
result["card_accessibility"]["full_url"] = full_url
# Try to fetch the card content
try:
session = aiohttp.ClientSession()
try:
async with asyncio.timeout(5):
async with session.get(full_url, ssl=False) as response:
if response.status == 200:
content = await response.text()
lines = content.split('\n')
first_lines = lines[:10]
result["card_accessibility"]["status"] = "accessible"
result["card_accessibility"]["http_status"] = 200
result["card_accessibility"]["content_length"] = len(content)
result["card_accessibility"]["total_lines"] = len(lines)
result["card_accessibility"]["first_10_lines"] = first_lines
# Basic validation that it looks like the card
content_lower = content.lower()
if "climate" in content_lower and "scheduler" in content_lower:
result["card_accessibility"]["appears_valid"] = True
else:
result["card_accessibility"]["appears_valid"] = False
else:
result["card_accessibility"]["status"] = "http_error"
result["card_accessibility"]["http_status"] = response.status
finally:
await session.close()
except asyncio.TimeoutError:
result["card_accessibility"]["status"] = "timeout"
result["card_accessibility"]["error"] = "Request timed out after 5 seconds"
except Exception as e:
result["card_accessibility"]["status"] = "error"
result["card_accessibility"]["error"] = str(e)
# Get full storage data
try:
storage: ScheduleStorage = hass.data[DOMAIN]["storage"]
storage_data = await storage.async_get_groups()
result["storage"]["status"] = "loaded"
result["storage"]["data"] = storage_data
result["storage"]["group_count"] = len(storage_data)
# Count entities across all groups
total_entities = 0
for group_data in storage_data.values():
if isinstance(group_data.get("entities"), list):
total_entities += len(group_data["entities"])
result["storage"]["total_entities"] = total_entities
except Exception as e:
result["storage"]["status"] = "error"
result["storage"]["error"] = str(e)
# Get all climate entities detected by Home Assistant
try:
from homeassistant.helpers import entity_registry as er
entity_reg = er.async_get(hass)
# Get all climate entities
climate_entities = []
for entity in entity_reg.entities.values():
if entity.domain == "climate":
climate_entities.append({
"entity_id": entity.entity_id,
"platform": entity.platform,
"disabled": entity.disabled,
"name": entity.name or entity.original_name,
"device_id": entity.device_id,
})
result["climate_entities"]["status"] = "loaded"
result["climate_entities"]["count"] = len(climate_entities)
result["climate_entities"]["entities"] = climate_entities
# Get states for climate entities
climate_states = []
for entity in climate_entities:
entity_id = entity["entity_id"]
state = hass.states.get(entity_id)
if state:
climate_states.append({
"entity_id": entity_id,
"state": state.state,
"available": state.state != "unavailable",
"attributes": dict(state.attributes) # Include all attributes
})
result["climate_entities"]["states"] = climate_states
result["climate_entities"]["available_count"] = sum(1 for s in climate_states if s["available"])
except Exception as e:
result["climate_entities"]["status"] = "error"
result["climate_entities"]["error"] = str(e)
# Get all devices associated with the integration
try:
from homeassistant.helpers import device_registry as dr
device_reg = dr.async_get(hass)
# Get all devices for this integration
integration_devices = []
for device in device_reg.devices.values():
# Check if device has any config entry that belongs to our integration
for entry_id in device.config_entries:
entry = hass.config_entries.async_get_entry(entry_id)
if entry and entry.domain == DOMAIN:
integration_devices.append({
"id": device.id,
"name": device.name,
"name_by_user": device.name_by_user,
"manufacturer": device.manufacturer,
"model": device.model,
"sw_version": device.sw_version,
"hw_version": device.hw_version,
"config_entries": list(device.config_entries),
"connections": list(device.connections),
"identifiers": list(device.identifiers),
"via_device_id": device.via_device_id,
"disabled_by": device.disabled_by,
"entry_type": device.entry_type,
})
break
result["devices"]["status"] = "loaded"
result["devices"]["count"] = len(integration_devices)
result["devices"]["devices"] = integration_devices
except Exception as e:
result["devices"]["status"] = "error"
result["devices"]["error"] = str(e)
return result
# Register all services (schemas come from async_get_services() dynamically)
hass.services.async_register(DOMAIN, "recreate_all_sensors", handle_recreate_all_sensors, service_schemas.get("recreate_all_sensors"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "cleanup_malformed_sensors", handle_cleanup_malformed_sensors, service_schemas.get("cleanup_malformed_sensors"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "cleanup_orphaned_climate_entities", handle_cleanup_orphaned_climate_entities, service_schemas.get("cleanup_orphaned_climate_entities"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "set_schedule", handle_set_schedule, service_schemas.get("set_schedule"))
hass.services.async_register(DOMAIN, "get_schedule", handle_get_schedule, service_schemas.get("get_schedule"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "clear_schedule", handle_clear_schedule, service_schemas.get("clear_schedule"))
hass.services.async_register(DOMAIN, "enable_schedule", handle_enable_schedule, service_schemas.get("enable_schedule"))
hass.services.async_register(DOMAIN, "disable_schedule", handle_disable_schedule, service_schemas.get("disable_schedule"))
hass.services.async_register(DOMAIN, "set_ignored", handle_set_ignored, service_schemas.get("set_ignored"))
hass.services.async_register(DOMAIN, "sync_all", handle_sync_all, service_schemas.get("sync_all"))
hass.services.async_register(DOMAIN, "create_group", handle_create_group, service_schemas.get("create_group"))
hass.services.async_register(DOMAIN, "delete_group", handle_delete_group, service_schemas.get("delete_group"))
hass.services.async_register(DOMAIN, "rename_group", handle_rename_group, service_schemas.get("rename_group"))
hass.services.async_register(DOMAIN, "add_to_group", handle_add_to_group, service_schemas.get("add_to_group"))
hass.services.async_register(DOMAIN, "remove_from_group", handle_remove_from_group, service_schemas.get("remove_from_group"))
hass.services.async_register(DOMAIN, "get_groups", handle_get_groups, service_schemas.get("get_groups"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "list_groups", handle_list_groups, service_schemas.get("list_groups"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "list_profiles", handle_list_profiles, service_schemas.get("list_profiles"), supports_response=SupportsResponse.ONLY)
_LOGGER.debug("[BACKEND] Registering Climate Scheduler services...")
hass.services.async_register(DOMAIN, "set_group_schedule", handle_set_group_schedule, service_schemas.get("set_group_schedule"))
_LOGGER.debug("[BACKEND] Registered: set_group_schedule")
hass.services.async_register(DOMAIN, "enable_group", handle_enable_group, service_schemas.get("enable_group"))
hass.services.async_register(DOMAIN, "disable_group", handle_disable_group, service_schemas.get("disable_group"))
hass.services.async_register(DOMAIN, "get_settings", handle_get_settings, service_schemas.get("get_settings"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "save_settings", handle_save_settings, service_schemas.get("save_settings"))
hass.services.async_register(DOMAIN, "reload_integration", handle_reload_integration, service_schemas.get("reload_integration"))
hass.services.async_register(DOMAIN, "advance_schedule", handle_advance_schedule, service_schemas.get("advance_schedule"))
hass.services.async_register(DOMAIN, "advance_group", handle_advance_group, service_schemas.get("advance_group"))
hass.services.async_register(DOMAIN, "cancel_advance", handle_cancel_advance, service_schemas.get("cancel_advance"))
hass.services.async_register(DOMAIN, "get_advance_status", handle_get_advance_status, service_schemas.get("get_advance_status"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "clear_advance_history", handle_clear_advance_history, service_schemas.get("clear_advance_history"))
hass.services.async_register(DOMAIN, "test_fire_event", handle_test_fire_event, service_schemas.get("test_fire_event"))
hass.services.async_register(DOMAIN, "create_profile", handle_create_profile, service_schemas.get("create_profile"))
hass.services.async_register(DOMAIN, "delete_profile", handle_delete_profile, service_schemas.get("delete_profile"))
hass.services.async_register(DOMAIN, "rename_profile", handle_rename_profile, service_schemas.get("rename_profile"))
hass.services.async_register(DOMAIN, "set_active_profile", handle_set_active_profile, service_schemas.get("set_active_profile"))
hass.services.async_register(DOMAIN, "get_profiles", handle_get_profiles, service_schemas.get("get_profiles"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "cleanup_derivative_sensors", handle_cleanup_derivative_sensors, service_schemas.get("cleanup_derivative_sensors"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "cleanup_unmonitored_storage", handle_cleanup_unmonitored_storage, service_schemas.get("cleanup_unmonitored_storage"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "factory_reset", handle_factory_reset, service_schemas.get("factory_reset"))
hass.services.async_register(DOMAIN, "reregister_card", handle_reregister_card, service_schemas.get("reregister_card"), supports_response=SupportsResponse.ONLY)
hass.services.async_register(DOMAIN, "diagnostics", handle_diagnostics, service_schemas.get("diagnostics"), supports_response=SupportsResponse.ONLY)
_LOGGER.debug("[BACKEND] All Climate Scheduler services registered successfully (including set_group_schedule)")