Add services for Mazda integration ()

pull/51111/head
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,
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)

View File

@ -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",
]

View File

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

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
# homeassistant.components.mazda
pymazda==0.1.5
pymazda==0.1.6
# homeassistant.components.mediaroom
pymediaroom==0.6.4.1

View File

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

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.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",

View File

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