From a36935dceeeb6df2c8f06984d64fe4f42a95a84e Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Wed, 26 May 2021 07:36:36 -0700 Subject: [PATCH] Add services for Mazda integration (#51016) --- homeassistant/components/mazda/__init__.py | 106 ++++++++++++++- homeassistant/components/mazda/const.py | 11 ++ homeassistant/components/mazda/manifest.json | 2 +- homeassistant/components/mazda/services.yaml | 106 +++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/mazda/__init__.py | 7 + tests/components/mazda/test_init.py | 132 ++++++++++++++++++- 8 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/mazda/services.yaml diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index c34dfa10682..2c480aaf606 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -11,12 +11,18 @@ from pymazda import ( MazdaException, MazdaTokenExpiredException, ) +import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) +from homeassistant.helpers import aiohttp_client, device_registry +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -24,7 +30,7 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util.async_ import gather_with_concurrency -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN +from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_VEHICLES, DOMAIN, SERVICES _LOGGER = logging.getLogger(__name__) @@ -59,6 +65,77 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): _LOGGER.error("Error occurred during Mazda login request: %s", ex) raise ConfigEntryNotReady from ex + async def async_handle_service_call(service_call=None): + """Handle a service call.""" + # Get device entry from device registry + dev_reg = device_registry.async_get(hass) + device_id = service_call.data.get("device_id") + device_entry = dev_reg.async_get(device_id) + + # Get vehicle VIN from device identifiers + mazda_identifiers = [ + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ] + vin_identifier = next(iter(mazda_identifiers)) + vin = vin_identifier[1] + + # Get vehicle ID and API client from hass.data + vehicle_id = 0 + api_client = None + for entry_data in hass.data[DOMAIN].values(): + for vehicle in entry_data[DATA_VEHICLES]: + if vehicle["vin"] == vin: + vehicle_id = vehicle["id"] + api_client = entry_data[DATA_CLIENT] + + if vehicle_id == 0 or api_client is None: + raise HomeAssistantError("Vehicle ID not found") + + api_method = getattr(api_client, service_call.service) + try: + if service_call.service == "send_poi": + latitude = service_call.data.get("latitude") + longitude = service_call.data.get("longitude") + poi_name = service_call.data.get("poi_name") + await api_method(vehicle_id, latitude, longitude, poi_name) + else: + await api_method(vehicle_id) + except Exception as ex: + _LOGGER.exception("Error occurred during Mazda service call: %s", ex) + raise HomeAssistantError(ex) from ex + + def validate_mazda_device_id(device_id): + """Check that a device ID exists in the registry and has at least one 'mazda' identifier.""" + dev_reg = device_registry.async_get(hass) + device_entry = dev_reg.async_get(device_id) + + if device_entry is None: + raise vol.Invalid("Invalid device ID") + + mazda_identifiers = [ + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + ] + if len(mazda_identifiers) < 1: + raise vol.Invalid("Device ID is not a Mazda vehicle") + + return device_id + + service_schema = vol.Schema( + {vol.Required("device_id"): vol.All(cv.string, validate_mazda_device_id)} + ) + + service_schema_send_poi = service_schema.extend( + { + vol.Required("latitude"): cv.latitude, + vol.Required("longitude"): cv.longitude, + vol.Required("poi_name"): cv.string, + } + ) + async def async_update_data(): """Fetch data from Mazda API.""" try: @@ -73,6 +150,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): for vehicle, status in zip(vehicles, statuses): vehicle["status"] = status + hass.data[DOMAIN][entry.entry_id][DATA_VEHICLES] = vehicles + return vehicles except MazdaAuthenticationException as ex: raise ConfigEntryAuthFailed("Not authenticated with Mazda API") from ex @@ -94,6 +173,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN][entry.entry_id] = { DATA_CLIENT: mazda_client, DATA_COORDINATOR: coordinator, + DATA_VEHICLES: [], } # Fetch initial data so we have data when entities subscribe @@ -102,12 +182,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Setup components hass.config_entries.async_setup_platforms(entry, PLATFORMS) + # Register services + for service in SERVICES: + if service == "send_poi": + hass.services.async_register( + DOMAIN, + service, + async_handle_service_call, + schema=service_schema_send_poi, + ) + else: + hass.services.async_register( + DOMAIN, service, async_handle_service_call, schema=service_schema + ) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + # Only remove services if it is the last config entry + if len(hass.data[DOMAIN]) == 1: + for service in SERVICES: + hass.services.async_remove(DOMAIN, service) + if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/mazda/const.py b/homeassistant/components/mazda/const.py index c75f6bf3b77..5baeef3102d 100644 --- a/homeassistant/components/mazda/const.py +++ b/homeassistant/components/mazda/const.py @@ -4,5 +4,16 @@ DOMAIN = "mazda" DATA_CLIENT = "mazda_client" DATA_COORDINATOR = "coordinator" +DATA_VEHICLES = "vehicles" MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"} + +SERVICES = [ + "send_poi", + "start_charging", + "start_engine", + "stop_charging", + "stop_engine", + "turn_off_hazard_lights", + "turn_on_hazard_lights", +] diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json index 4ca4384e952..dd169159bc8 100644 --- a/homeassistant/components/mazda/manifest.json +++ b/homeassistant/components/mazda/manifest.json @@ -3,7 +3,7 @@ "name": "Mazda Connected Services", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mazda", - "requirements": ["pymazda==0.1.5"], + "requirements": ["pymazda==0.1.6"], "codeowners": ["@bdr99"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/homeassistant/components/mazda/services.yaml b/homeassistant/components/mazda/services.yaml new file mode 100644 index 00000000000..80d8c2f64f6 --- /dev/null +++ b/homeassistant/components/mazda/services.yaml @@ -0,0 +1,106 @@ +start_engine: + name: Start engine + description: Start the vehicle engine. + fields: + device_id: + name: Vehicle + description: The vehicle to start + required: true + selector: + device: + integration: mazda +stop_engine: + name: Stop engine + description: Stop the vehicle engine. + fields: + device_id: + name: Vehicle + description: The vehicle to stop + required: true + selector: + device: + integration: mazda +turn_on_hazard_lights: + name: Turn on hazard lights + description: Turn on the vehicle hazard lights. The lights will flash briefly and then turn off. + fields: + device_id: + name: Vehicle + description: The vehicle to turn hazard lights on + required: true + selector: + device: + integration: mazda +turn_off_hazard_lights: + name: Turn off hazard lights + description: Turn off the vehicle hazard lights if they have been manually turned on from inside the vehicle. + fields: + device_id: + name: Vehicle + description: The vehicle to turn hazard lights off + required: true + selector: + device: + integration: mazda +send_poi: + name: Send POI + description: Send a GPS location to the vehicle's navigation system as a POI (Point of Interest). Requires a navigation SD card installed in the vehicle. + fields: + device_id: + name: Vehicle + description: The vehicle to send the GPS location to + required: true + selector: + device: + integration: mazda + latitude: + name: Latitude + description: The latitude of the location to send + example: 12.34567 + required: true + selector: + number: + min: -90 + max: 90 + unit_of_measurement: ° + mode: box + longitude: + name: Longitude + description: The longitude of the location to send + example: -34.56789 + required: true + selector: + number: + min: -180 + max: 180 + unit_of_measurement: ° + mode: box + poi_name: + name: POI name + description: A friendly name for the location + example: Work + required: true + selector: + text: +start_charging: + name: Start charging + description: Start charging the vehicle. For electric vehicles only. + fields: + device_id: + name: Vehicle + description: The vehicle to start charging + required: true + selector: + device: + integration: mazda +stop_charging: + name: Stop charging + description: Stop charging the vehicle. For electric vehicles only. + fields: + device_id: + name: Vehicle + description: The vehicle to stop charging + required: true + selector: + device: + integration: mazda diff --git a/requirements_all.txt b/requirements_all.txt index 5559f89a102..b02a6191c9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1548,7 +1548,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.1.5 +pymazda==0.1.6 # homeassistant.components.mediaroom pymediaroom==0.6.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac9610f5285..0fa3d2ced9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -862,7 +862,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.1.5 +pymazda==0.1.6 # homeassistant.components.melcloud pymelcloud==2.5.2 diff --git a/tests/components/mazda/__init__.py b/tests/components/mazda/__init__.py index 9676b2b5765..7a81a9224d7 100644 --- a/tests/components/mazda/__init__.py +++ b/tests/components/mazda/__init__.py @@ -44,6 +44,13 @@ async def init_integration(hass: HomeAssistant, use_nickname=True) -> MockConfig client_mock.get_vehicle_status = AsyncMock(return_value=get_vehicle_status_fixture) client_mock.lock_doors = AsyncMock() client_mock.unlock_doors = AsyncMock() + client_mock.send_poi = AsyncMock() + client_mock.start_charging = AsyncMock() + client_mock.start_engine = AsyncMock() + client_mock.stop_charging = AsyncMock() + client_mock.stop_engine = AsyncMock() + client_mock.turn_off_hazard_lights = AsyncMock() + client_mock.turn_on_hazard_lights = AsyncMock() with patch( "homeassistant.components.mazda.config_flow.MazdaAPI", diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py index c9370459f1c..0280e8f34fa 100644 --- a/tests/components/mazda/test_init.py +++ b/tests/components/mazda/test_init.py @@ -4,8 +4,10 @@ import json from unittest.mock import patch from pymazda import MazdaAuthenticationException, MazdaException +import pytest +import voluptuous as vol -from homeassistant.components.mazda.const import DOMAIN +from homeassistant.components.mazda.const import DOMAIN, SERVICES from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_EMAIL, @@ -14,6 +16,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.util import dt as dt_util @@ -181,3 +184,130 @@ async def test_device_no_nickname(hass): assert reg_device.model == "2021 MAZDA3 2.5 S SE AWD" assert reg_device.manufacturer == "Mazda" assert reg_device.name == "2021 MAZDA3 2.5 S SE AWD" + + +async def test_services(hass): + """Test service calls.""" + client_mock = await init_integration(hass) + + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "JM000000000000000")}, + ) + device_id = reg_device.id + + for service in SERVICES: + service_data = {"device_id": device_id} + if service == "send_poi": + service_data["latitude"] = 1.2345 + service_data["longitude"] = 2.3456 + service_data["poi_name"] = "Work" + + await hass.services.async_call(DOMAIN, service, service_data, blocking=True) + await hass.async_block_till_done() + + api_method = getattr(client_mock, service) + if service == "send_poi": + api_method.assert_called_once_with(12345, 1.2345, 2.3456, "Work") + else: + api_method.assert_called_once_with(12345) + + +async def test_service_invalid_device_id(hass): + """Test service call when the specified device ID is invalid.""" + await init_integration(hass) + + with pytest.raises(vol.error.MultipleInvalid) as err: + await hass.services.async_call( + DOMAIN, "start_engine", {"device_id": "invalid"}, blocking=True + ) + await hass.async_block_till_done() + + assert "Invalid device ID" in str(err.value) + + +async def test_service_device_id_not_mazda_vehicle(hass): + """Test service call when the specified device ID is not the device ID of a Mazda vehicle.""" + await init_integration(hass) + + device_registry = dr.async_get(hass) + # Create another device and pass its device ID. + # Service should fail because device is from wrong domain. + other_device = device_registry.async_get_or_create( + config_entry_id="test_config_entry_id", + identifiers={("OTHER_INTEGRATION", "ID_FROM_OTHER_INTEGRATION")}, + ) + + with pytest.raises(vol.error.MultipleInvalid) as err: + await hass.services.async_call( + DOMAIN, "start_engine", {"device_id": other_device.id}, blocking=True + ) + await hass.async_block_till_done() + + assert "Device ID is not a Mazda vehicle" in str(err.value) + + +async def test_service_vehicle_id_not_found(hass): + """Test service call when the vehicle ID is not found.""" + await init_integration(hass) + + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "JM000000000000000")}, + ) + device_id = reg_device.id + + entries = hass.config_entries.async_entries(DOMAIN) + entry_id = entries[0].entry_id + + # Remove vehicle info from hass.data so that vehicle ID will not be found + hass.data[DOMAIN][entry_id]["vehicles"] = [] + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, "start_engine", {"device_id": device_id}, blocking=True + ) + await hass.async_block_till_done() + + assert str(err.value) == "Vehicle ID not found" + + +async def test_service_mazda_api_error(hass): + """Test the Mazda API raising an error when a service is called.""" + get_vehicles_fixture = json.loads(load_fixture("mazda/get_vehicles.json")) + get_vehicle_status_fixture = json.loads( + load_fixture("mazda/get_vehicle_status.json") + ) + + with patch( + "homeassistant.components.mazda.MazdaAPI.validate_credentials", + return_value=True, + ), patch( + "homeassistant.components.mazda.MazdaAPI.get_vehicles", + return_value=get_vehicles_fixture, + ), patch( + "homeassistant.components.mazda.MazdaAPI.get_vehicle_status", + return_value=get_vehicle_status_fixture, + ): + config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "JM000000000000000")}, + ) + device_id = reg_device.id + + with patch( + "homeassistant.components.mazda.MazdaAPI.start_engine", + side_effect=MazdaException("Test error"), + ), pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, "start_engine", {"device_id": device_id}, blocking=True + ) + await hass.async_block_till_done() + + assert str(err.value) == "Test error"