"""Service calls for the Teslemetry integration.""" import logging import voluptuous as vol from voluptuous import All, Range from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import DOMAIN from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryEnergyData, TeslemetryVehicleData _LOGGER = logging.getLogger(__name__) # Attributes ATTR_ID = "id" ATTR_GPS = "gps" ATTR_TYPE = "type" ATTR_VALUE = "value" ATTR_LOCATION = "location" ATTR_LOCALE = "locale" ATTR_ORDER = "order" ATTR_TIMESTAMP = "timestamp" ATTR_FIELDS = "fields" ATTR_ENABLE = "enable" ATTR_TIME = "time" ATTR_PIN = "pin" ATTR_TOU_SETTINGS = "tou_settings" ATTR_PRECONDITIONING_ENABLED = "preconditioning_enabled" ATTR_PRECONDITIONING_WEEKDAYS = "preconditioning_weekdays_only" ATTR_DEPARTURE_TIME = "departure_time" ATTR_OFF_PEAK_CHARGING_ENABLED = "off_peak_charging_enabled" ATTR_OFF_PEAK_CHARGING_WEEKDAYS = "off_peak_charging_weekdays_only" ATTR_END_OFF_PEAK_TIME = "end_off_peak_time" ATTR_DAYS_OF_WEEK = "days_of_week" ATTR_START_TIME = "start_time" ATTR_END_TIME = "end_time" ATTR_ONE_TIME = "one_time" ATTR_NAME = "name" ATTR_PRECONDITION_TIME = "precondition_time" # Services SERVICE_NAVIGATE_ATTR_GPS_REQUEST = "navigation_gps_request" SERVICE_SET_SCHEDULED_CHARGING = "set_scheduled_charging" SERVICE_SET_SCHEDULED_DEPARTURE = "set_scheduled_departure" SERVICE_VALET_MODE = "valet_mode" SERVICE_SPEED_LIMIT = "speed_limit" SERVICE_TIME_OF_USE = "time_of_use" SERVICE_ADD_CHARGE_SCHEDULE = "add_charge_schedule" SERVICE_REMOVE_CHARGE_SCHEDULE = "remove_charge_schedule" SERVICE_ADD_PRECONDITION_SCHEDULE = "add_precondition_schedule" SERVICE_REMOVE_PRECONDITION_SCHEDULE = "remove_precondition_schedule" def async_get_device_for_service_call( hass: HomeAssistant, call: ServiceCall ) -> dr.DeviceEntry: """Get the device entry related to a service call.""" device_id = call.data[CONF_DEVICE_ID] device_registry = dr.async_get(hass) if (device_entry := device_registry.async_get(device_id)) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_device", translation_placeholders={"device_id": device_id}, ) return device_entry def async_get_config_for_device( hass: HomeAssistant, device_entry: dr.DeviceEntry ) -> ConfigEntry: """Get the config entry related to a device entry.""" config_entry: ConfigEntry for entry_id in device_entry.config_entries: if entry := hass.config_entries.async_get_entry(entry_id): if entry.domain == DOMAIN: config_entry = entry return config_entry def async_get_vehicle_for_entry( hass: HomeAssistant, device: dr.DeviceEntry, config: ConfigEntry ) -> TeslemetryVehicleData: """Get the vehicle data for a config entry.""" vehicle_data: TeslemetryVehicleData assert device.serial_number is not None for vehicle in config.runtime_data.vehicles: if vehicle.vin == device.serial_number: vehicle_data = vehicle return vehicle_data def async_get_energy_site_for_entry( hass: HomeAssistant, device: dr.DeviceEntry, config: ConfigEntry ) -> TeslemetryEnergyData: """Get the energy site data for a config entry.""" energy_data: TeslemetryEnergyData assert device.serial_number is not None for energysite in config.runtime_data.energysites: if str(energysite.id) == device.serial_number: energy_data = energysite return energy_data @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the Teslemetry services.""" async def navigate_gps_request(call: ServiceCall) -> None: """Send lat,lon,order with a vehicle.""" device = async_get_device_for_service_call(hass, call) config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) await handle_vehicle_command( vehicle.api.navigation_gps_request( lat=call.data[ATTR_GPS][CONF_LATITUDE], lon=call.data[ATTR_GPS][CONF_LONGITUDE], order=call.data.get(ATTR_ORDER), ) ) hass.services.async_register( DOMAIN, SERVICE_NAVIGATE_ATTR_GPS_REQUEST, navigate_gps_request, schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_GPS): { vol.Required(CONF_LATITUDE): cv.latitude, vol.Required(CONF_LONGITUDE): cv.longitude, }, vol.Optional(ATTR_ORDER): cv.positive_int, } ), ) async def set_scheduled_charging(call: ServiceCall) -> None: """Configure fleet telemetry.""" device = async_get_device_for_service_call(hass, call) config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) time: int | None = None # Convert time to minutes since minute if "time" in call.data: (hours, minutes, *_seconds) = call.data["time"].split(":") time = int(hours) * 60 + int(minutes) elif call.data["enable"]: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="set_scheduled_charging_time" ) await handle_vehicle_command( vehicle.api.set_scheduled_charging(enable=call.data["enable"], time=time) ) hass.services.async_register( DOMAIN, SERVICE_SET_SCHEDULED_CHARGING, set_scheduled_charging, schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_ENABLE): bool, vol.Optional(ATTR_TIME): str, } ), ) async def set_scheduled_departure(call: ServiceCall) -> None: """Configure fleet telemetry.""" device = async_get_device_for_service_call(hass, call) config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) enable = call.data.get("enable", True) # Preconditioning preconditioning_enabled = call.data.get(ATTR_PRECONDITIONING_ENABLED, False) preconditioning_weekdays_only = call.data.get( ATTR_PRECONDITIONING_WEEKDAYS, False ) departure_time: int | None = None if ATTR_DEPARTURE_TIME in call.data: (hours, minutes, *_seconds) = call.data[ATTR_DEPARTURE_TIME].split(":") departure_time = int(hours) * 60 + int(minutes) elif preconditioning_enabled: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="set_scheduled_departure_preconditioning", ) # Off peak charging off_peak_charging_enabled = call.data.get(ATTR_OFF_PEAK_CHARGING_ENABLED, False) off_peak_charging_weekdays_only = call.data.get( ATTR_OFF_PEAK_CHARGING_WEEKDAYS, False ) end_off_peak_time: int | None = None if ATTR_END_OFF_PEAK_TIME in call.data: (hours, minutes, *_seconds) = call.data[ATTR_END_OFF_PEAK_TIME].split(":") end_off_peak_time = int(hours) * 60 + int(minutes) elif off_peak_charging_enabled: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="set_scheduled_departure_off_peak", ) await handle_vehicle_command( vehicle.api.set_scheduled_departure( enable, preconditioning_enabled, preconditioning_weekdays_only, departure_time, off_peak_charging_enabled, off_peak_charging_weekdays_only, end_off_peak_time, ) ) hass.services.async_register( DOMAIN, SERVICE_SET_SCHEDULED_DEPARTURE, set_scheduled_departure, schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, vol.Optional(ATTR_ENABLE): bool, vol.Optional(ATTR_PRECONDITIONING_ENABLED): bool, vol.Optional(ATTR_PRECONDITIONING_WEEKDAYS): bool, vol.Optional(ATTR_DEPARTURE_TIME): str, vol.Optional(ATTR_OFF_PEAK_CHARGING_ENABLED): bool, vol.Optional(ATTR_OFF_PEAK_CHARGING_WEEKDAYS): bool, vol.Optional(ATTR_END_OFF_PEAK_TIME): str, } ), ) async def valet_mode(call: ServiceCall) -> None: """Configure fleet telemetry.""" device = async_get_device_for_service_call(hass, call) config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) await handle_vehicle_command( vehicle.api.set_valet_mode( call.data.get("enable"), call.data.get("pin", "") ) ) hass.services.async_register( DOMAIN, SERVICE_VALET_MODE, valet_mode, schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_ENABLE): cv.boolean, vol.Required(ATTR_PIN): All(cv.positive_int, Range(min=1000, max=9999)), } ), ) async def speed_limit(call: ServiceCall) -> None: """Configure fleet telemetry.""" device = async_get_device_for_service_call(hass, call) config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) enable = call.data.get("enable") if enable is True: await handle_vehicle_command( vehicle.api.speed_limit_activate(call.data.get("pin")) ) elif enable is False: await handle_vehicle_command( vehicle.api.speed_limit_deactivate(call.data.get("pin")) ) hass.services.async_register( DOMAIN, SERVICE_SPEED_LIMIT, speed_limit, schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_ENABLE): cv.boolean, vol.Required(ATTR_PIN): All(cv.positive_int, Range(min=1000, max=9999)), } ), ) async def time_of_use(call: ServiceCall) -> None: """Configure time of use settings.""" device = async_get_device_for_service_call(hass, call) config = async_get_config_for_device(hass, device) site = async_get_energy_site_for_entry(hass, device, config) resp = await handle_command( site.api.time_of_use_settings(call.data.get(ATTR_TOU_SETTINGS)) ) if "error" in resp: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="command_error", translation_placeholders={"error": resp["error"]}, ) hass.services.async_register( DOMAIN, SERVICE_TIME_OF_USE, time_of_use, schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_TOU_SETTINGS): dict, } ), ) async def add_charge_schedule(call: ServiceCall) -> None: """Configure charging schedule for a vehicle.""" device = async_get_device_for_service_call(hass, call) config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) # Extract parameters from the service call days_of_week = call.data[ATTR_DAYS_OF_WEEK] # If days_of_week is a list (from select with multiple), convert to comma-separated string if isinstance(days_of_week, list): days_of_week = ",".join(days_of_week) enabled = call.data[ATTR_ENABLE] # Optional parameters location = call.data.get( ATTR_LOCATION, { CONF_LATITUDE: hass.config.latitude, CONF_LONGITUDE: hass.config.longitude, }, ) # Handle time inputs start_time = None if start_time_obj := call.data.get(ATTR_START_TIME): # Convert time object to minutes since midnight start_time = start_time_obj.hour * 60 + start_time_obj.minute end_time = None if end_time_obj := call.data.get(ATTR_END_TIME): # Convert time object to minutes since midnight end_time = end_time_obj.hour * 60 + end_time_obj.minute one_time = call.data.get(ATTR_ONE_TIME) schedule_id = call.data.get(ATTR_ID) name = call.data.get(ATTR_NAME) await handle_vehicle_command( vehicle.api.add_charge_schedule( days_of_week=days_of_week, enabled=enabled, lat=location[CONF_LATITUDE], lon=location[CONF_LONGITUDE], start_time=start_time, end_time=end_time, one_time=one_time, id=schedule_id, name=name, ) ) hass.services.async_register( DOMAIN, SERVICE_ADD_CHARGE_SCHEDULE, add_charge_schedule, schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_DAYS_OF_WEEK): cv.ensure_list, vol.Required(ATTR_ENABLE): cv.boolean, vol.Optional(ATTR_LOCATION): { vol.Required(CONF_LATITUDE): cv.latitude, vol.Required(CONF_LONGITUDE): cv.longitude, }, vol.Optional(ATTR_START_TIME): cv.time, vol.Optional(ATTR_END_TIME): cv.time, vol.Optional(ATTR_ONE_TIME): cv.boolean, vol.Optional(ATTR_ID): cv.positive_int, vol.Optional(ATTR_NAME): cv.string, } ), ) async def remove_charge_schedule(call: ServiceCall) -> None: """Remove a charging schedule for a vehicle.""" device = async_get_device_for_service_call(hass, call) config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) # Extract parameters from the service call schedule_id = call.data[ATTR_ID] await handle_vehicle_command( vehicle.api.remove_charge_schedule( id=schedule_id, ) ) hass.services.async_register( DOMAIN, SERVICE_REMOVE_CHARGE_SCHEDULE, remove_charge_schedule, schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_ID): cv.positive_int, } ), ) async def add_precondition_schedule(call: ServiceCall) -> None: """Add or modify a precondition schedule for a vehicle.""" device = async_get_device_for_service_call(hass, call) config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) # Extract parameters from the service call days_of_week = call.data[ATTR_DAYS_OF_WEEK] # If days_of_week is a list (from select with multiple), convert to comma-separated string if isinstance(days_of_week, list): days_of_week = ",".join(days_of_week) enabled = call.data[ATTR_ENABLE] location = call.data.get( ATTR_LOCATION, { CONF_LATITUDE: hass.config.latitude, CONF_LONGITUDE: hass.config.longitude, }, ) # Convert time object to minutes since midnight precondition_time = ( call.data[ATTR_PRECONDITION_TIME].hour * 60 + call.data[ATTR_PRECONDITION_TIME].minute ) # Optional parameters schedule_id = call.data.get(ATTR_ID) one_time = call.data.get(ATTR_ONE_TIME) name = call.data.get(ATTR_NAME) await handle_vehicle_command( vehicle.api.add_precondition_schedule( days_of_week=days_of_week, enabled=enabled, lat=location[CONF_LATITUDE], lon=location[CONF_LONGITUDE], precondition_time=precondition_time, id=schedule_id, one_time=one_time, name=name, ) ) hass.services.async_register( DOMAIN, SERVICE_ADD_PRECONDITION_SCHEDULE, add_precondition_schedule, schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_DAYS_OF_WEEK): cv.ensure_list, vol.Required(ATTR_ENABLE): cv.boolean, vol.Optional(ATTR_LOCATION): { vol.Required(CONF_LATITUDE): cv.latitude, vol.Required(CONF_LONGITUDE): cv.longitude, }, vol.Required(ATTR_PRECONDITION_TIME): cv.time, vol.Optional(ATTR_ID): cv.positive_int, vol.Optional(ATTR_ONE_TIME): cv.boolean, vol.Optional(ATTR_NAME): cv.string, } ), ) async def remove_precondition_schedule(call: ServiceCall) -> None: """Remove a preconditioning schedule for a vehicle.""" device = async_get_device_for_service_call(hass, call) config = async_get_config_for_device(hass, device) vehicle = async_get_vehicle_for_entry(hass, device, config) # Extract parameters from the service call schedule_id = call.data[ATTR_ID] await handle_vehicle_command( vehicle.api.remove_precondition_schedule( id=schedule_id, ) ) hass.services.async_register( DOMAIN, SERVICE_REMOVE_PRECONDITION_SCHEDULE, remove_precondition_schedule, schema=vol.Schema( { vol.Required(CONF_DEVICE_ID): cv.string, vol.Required(ATTR_ID): cv.positive_int, } ), )