1910 lines
85 KiB
Python
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)")
|