Add services for Mazda integration (#51016)

This commit is contained in:
Brandon Rothweiler 2021-05-26 07:36:36 -07:00 committed by GitHub
parent a8a13da793
commit a36935dcee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 361 additions and 7 deletions

View File

@ -11,12 +11,18 @@ from pymazda import (
MazdaException, MazdaException,
MazdaTokenExpiredException, MazdaTokenExpiredException,
) )
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import (
from homeassistant.helpers import aiohttp_client ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers import aiohttp_client, device_registry
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import (
CoordinatorEntity, CoordinatorEntity,
DataUpdateCoordinator, DataUpdateCoordinator,
@ -24,7 +30,7 @@ from homeassistant.helpers.update_coordinator import (
) )
from homeassistant.util.async_ import gather_with_concurrency 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__) _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) _LOGGER.error("Error occurred during Mazda login request: %s", ex)
raise ConfigEntryNotReady from 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(): async def async_update_data():
"""Fetch data from Mazda API.""" """Fetch data from Mazda API."""
try: try:
@ -73,6 +150,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
for vehicle, status in zip(vehicles, statuses): for vehicle, status in zip(vehicles, statuses):
vehicle["status"] = status vehicle["status"] = status
hass.data[DOMAIN][entry.entry_id][DATA_VEHICLES] = vehicles
return vehicles return vehicles
except MazdaAuthenticationException as ex: except MazdaAuthenticationException as ex:
raise ConfigEntryAuthFailed("Not authenticated with Mazda API") from 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] = { hass.data[DOMAIN][entry.entry_id] = {
DATA_CLIENT: mazda_client, DATA_CLIENT: mazda_client,
DATA_COORDINATOR: coordinator, DATA_COORDINATOR: coordinator,
DATA_VEHICLES: [],
} }
# Fetch initial data so we have data when entities subscribe # 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 # Setup components
hass.config_entries.async_setup_platforms(entry, PLATFORMS) 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 return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 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: if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)

View File

@ -4,5 +4,16 @@ DOMAIN = "mazda"
DATA_CLIENT = "mazda_client" DATA_CLIENT = "mazda_client"
DATA_COORDINATOR = "coordinator" DATA_COORDINATOR = "coordinator"
DATA_VEHICLES = "vehicles"
MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"} 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",
]

View File

@ -3,7 +3,7 @@
"name": "Mazda Connected Services", "name": "Mazda Connected Services",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mazda", "documentation": "https://www.home-assistant.io/integrations/mazda",
"requirements": ["pymazda==0.1.5"], "requirements": ["pymazda==0.1.6"],
"codeowners": ["@bdr99"], "codeowners": ["@bdr99"],
"quality_scale": "platinum", "quality_scale": "platinum",
"iot_class": "cloud_polling" "iot_class": "cloud_polling"

View File

@ -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

View File

@ -1548,7 +1548,7 @@ pymailgunner==1.4
pymata-express==1.19 pymata-express==1.19
# homeassistant.components.mazda # homeassistant.components.mazda
pymazda==0.1.5 pymazda==0.1.6
# homeassistant.components.mediaroom # homeassistant.components.mediaroom
pymediaroom==0.6.4.1 pymediaroom==0.6.4.1

View File

@ -862,7 +862,7 @@ pymailgunner==1.4
pymata-express==1.19 pymata-express==1.19
# homeassistant.components.mazda # homeassistant.components.mazda
pymazda==0.1.5 pymazda==0.1.6
# homeassistant.components.melcloud # homeassistant.components.melcloud
pymelcloud==2.5.2 pymelcloud==2.5.2

View File

@ -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.get_vehicle_status = AsyncMock(return_value=get_vehicle_status_fixture)
client_mock.lock_doors = AsyncMock() client_mock.lock_doors = AsyncMock()
client_mock.unlock_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( with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI", "homeassistant.components.mazda.config_flow.MazdaAPI",

View File

@ -4,8 +4,10 @@ import json
from unittest.mock import patch from unittest.mock import patch
from pymazda import MazdaAuthenticationException, MazdaException 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.config_entries import ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
CONF_EMAIL, CONF_EMAIL,
@ -14,6 +16,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.util import dt as dt_util 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.model == "2021 MAZDA3 2.5 S SE AWD"
assert reg_device.manufacturer == "Mazda" assert reg_device.manufacturer == "Mazda"
assert reg_device.name == "2021 MAZDA3 2.5 S SE AWD" 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"