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