Add API scope checks to Teslemetry (#113640)
This commit is contained in:
parent
a33aacfcaa
commit
f249a9ba4b
7 changed files with 99 additions and 8 deletions
|
@ -4,6 +4,7 @@ import asyncio
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific
|
from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific
|
||||||
|
from tesla_fleet_api.const import Scope
|
||||||
from tesla_fleet_api.exceptions import (
|
from tesla_fleet_api.exceptions import (
|
||||||
InvalidToken,
|
InvalidToken,
|
||||||
SubscriptionRequired,
|
SubscriptionRequired,
|
||||||
|
@ -37,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
scopes = (await teslemetry.metadata())["scopes"]
|
||||||
products = (await teslemetry.products())["response"]
|
products = (await teslemetry.products())["response"]
|
||||||
except InvalidToken as e:
|
except InvalidToken as e:
|
||||||
raise ConfigEntryAuthFailed from e
|
raise ConfigEntryAuthFailed from e
|
||||||
|
@ -49,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
vehicles: list[TeslemetryVehicleData] = []
|
vehicles: list[TeslemetryVehicleData] = []
|
||||||
energysites: list[TeslemetryEnergyData] = []
|
energysites: list[TeslemetryEnergyData] = []
|
||||||
for product in products:
|
for product in products:
|
||||||
if "vin" in product:
|
if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes:
|
||||||
vin = product["vin"]
|
vin = product["vin"]
|
||||||
api = VehicleSpecific(teslemetry.vehicle, vin)
|
api = VehicleSpecific(teslemetry.vehicle, vin)
|
||||||
coordinator = TeslemetryVehicleDataCoordinator(hass, api)
|
coordinator = TeslemetryVehicleDataCoordinator(hass, api)
|
||||||
|
@ -60,7 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
vin=vin,
|
vin=vin,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif "energy_site_id" in product:
|
elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes:
|
||||||
site_id = product["energy_site_id"]
|
site_id = product["energy_site_id"]
|
||||||
api = EnergySpecific(teslemetry.energy, site_id)
|
api = EnergySpecific(teslemetry.energy, site_id)
|
||||||
energysites.append(
|
energysites.append(
|
||||||
|
@ -86,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
|
||||||
# Setup Platforms
|
# Setup Platforms
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = TeslemetryData(
|
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = TeslemetryData(
|
||||||
vehicles, energysites
|
vehicles, energysites, scopes
|
||||||
)
|
)
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from tesla_fleet_api.const import Scope
|
||||||
|
|
||||||
from homeassistant.components.climate import (
|
from homeassistant.components.climate import (
|
||||||
ClimateEntity,
|
ClimateEntity,
|
||||||
ClimateEntityFeature,
|
ClimateEntityFeature,
|
||||||
|
@ -17,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from .const import DOMAIN, TeslemetryClimateSide
|
from .const import DOMAIN, TeslemetryClimateSide
|
||||||
from .context import handle_command
|
from .context import handle_command
|
||||||
from .entity import TeslemetryVehicleEntity
|
from .entity import TeslemetryVehicleEntity
|
||||||
|
from .models import TeslemetryVehicleData
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
|
@ -26,7 +29,7 @@ async def async_setup_entry(
|
||||||
data = hass.data[DOMAIN][entry.entry_id]
|
data = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER)
|
TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER, data.scopes)
|
||||||
for vehicle in data.vehicles
|
for vehicle in data.vehicles
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -48,6 +51,22 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
|
||||||
_attr_preset_modes = ["off", "keep", "dog", "camp"]
|
_attr_preset_modes = ["off", "keep", "dog", "camp"]
|
||||||
_enable_turn_on_off_backwards_compatibility = False
|
_enable_turn_on_off_backwards_compatibility = False
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
data: TeslemetryVehicleData,
|
||||||
|
side: TeslemetryClimateSide,
|
||||||
|
scopes: Scope,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the climate."""
|
||||||
|
self.scoped = Scope.VEHICLE_CMDS in scopes
|
||||||
|
if not self.scoped:
|
||||||
|
self._attr_supported_features = ClimateEntityFeature(0)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
data,
|
||||||
|
side,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hvac_mode(self) -> HVACMode | None:
|
def hvac_mode(self) -> HVACMode | None:
|
||||||
"""Return hvac operation ie. heat, cool mode."""
|
"""Return hvac operation ie. heat, cool mode."""
|
||||||
|
@ -82,6 +101,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
|
||||||
|
|
||||||
async def async_turn_on(self) -> None:
|
async def async_turn_on(self) -> None:
|
||||||
"""Set the climate state to on."""
|
"""Set the climate state to on."""
|
||||||
|
self.raise_for_scope()
|
||||||
with handle_command():
|
with handle_command():
|
||||||
await self.wake_up_if_asleep()
|
await self.wake_up_if_asleep()
|
||||||
await self.api.auto_conditioning_start()
|
await self.api.auto_conditioning_start()
|
||||||
|
@ -89,6 +109,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
|
||||||
|
|
||||||
async def async_turn_off(self) -> None:
|
async def async_turn_off(self) -> None:
|
||||||
"""Set the climate state to off."""
|
"""Set the climate state to off."""
|
||||||
|
self.raise_for_scope()
|
||||||
with handle_command():
|
with handle_command():
|
||||||
await self.wake_up_if_asleep()
|
await self.wake_up_if_asleep()
|
||||||
await self.api.auto_conditioning_stop()
|
await self.api.auto_conditioning_stop()
|
||||||
|
|
|
@ -5,7 +5,7 @@ from typing import Any
|
||||||
|
|
||||||
from tesla_fleet_api.exceptions import TeslaFleetError
|
from tesla_fleet_api.exceptions import TeslaFleetError
|
||||||
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
@ -83,6 +83,11 @@ class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator
|
||||||
self.coordinator.data[key] = value
|
self.coordinator.data[key] = value
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
def raise_for_scope(self):
|
||||||
|
"""Raise an error if a scope is not available."""
|
||||||
|
if not self.scoped:
|
||||||
|
raise ServiceValidationError("Missing required scope")
|
||||||
|
|
||||||
|
|
||||||
class TeslemetryEnergyEntity(CoordinatorEntity[TeslemetryEnergyDataCoordinator]):
|
class TeslemetryEnergyEntity(CoordinatorEntity[TeslemetryEnergyDataCoordinator]):
|
||||||
"""Parent class for Teslemetry Energy Entities."""
|
"""Parent class for Teslemetry Energy Entities."""
|
||||||
|
|
|
@ -6,6 +6,7 @@ import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from tesla_fleet_api import EnergySpecific, VehicleSpecific
|
from tesla_fleet_api import EnergySpecific, VehicleSpecific
|
||||||
|
from tesla_fleet_api.const import Scope
|
||||||
|
|
||||||
from .coordinator import (
|
from .coordinator import (
|
||||||
TeslemetryEnergyDataCoordinator,
|
TeslemetryEnergyDataCoordinator,
|
||||||
|
@ -19,6 +20,7 @@ class TeslemetryData:
|
||||||
|
|
||||||
vehicles: list[TeslemetryVehicleData]
|
vehicles: list[TeslemetryVehicleData]
|
||||||
energysites: list[TeslemetryEnergyData]
|
energysites: list[TeslemetryEnergyData]
|
||||||
|
scopes: list[Scope]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
@ -7,7 +7,23 @@ from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from .const import LIVE_STATUS, PRODUCTS, RESPONSE_OK, VEHICLE_DATA, WAKE_UP_ONLINE
|
from .const import (
|
||||||
|
LIVE_STATUS,
|
||||||
|
METADATA,
|
||||||
|
PRODUCTS,
|
||||||
|
RESPONSE_OK,
|
||||||
|
VEHICLE_DATA,
|
||||||
|
WAKE_UP_ONLINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_metadata():
|
||||||
|
"""Mock Tesla Fleet Api metadata method."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.teslemetry.Teslemetry.metadata", return_value=METADATA
|
||||||
|
) as mock_products:
|
||||||
|
yield mock_products
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
|
@ -16,3 +16,21 @@ VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN)
|
||||||
LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN)
|
LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN)
|
||||||
|
|
||||||
RESPONSE_OK = {"response": {}, "error": None}
|
RESPONSE_OK = {"response": {}, "error": None}
|
||||||
|
|
||||||
|
METADATA = {
|
||||||
|
"region": "NA",
|
||||||
|
"scopes": [
|
||||||
|
"openid",
|
||||||
|
"offline_access",
|
||||||
|
"user_data",
|
||||||
|
"vehicle_device_data",
|
||||||
|
"vehicle_cmds",
|
||||||
|
"vehicle_charging_cmds",
|
||||||
|
"energy_device_data",
|
||||||
|
"energy_cmds",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
METADATA_NOSCOPE = {
|
||||||
|
"region": "NA",
|
||||||
|
"scopes": ["openid", "offline_access", "vehicle_device_data"],
|
||||||
|
}
|
||||||
|
|
|
@ -22,11 +22,11 @@ from homeassistant.components.climate import (
|
||||||
from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL
|
from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL
|
||||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from . import assert_entities, setup_platform
|
from . import assert_entities, setup_platform
|
||||||
from .const import WAKE_UP_ASLEEP, WAKE_UP_ONLINE
|
from .const import METADATA_NOSCOPE, WAKE_UP_ASLEEP, WAKE_UP_ONLINE
|
||||||
|
|
||||||
from tests.common import async_fire_time_changed
|
from tests.common import async_fire_time_changed
|
||||||
|
|
||||||
|
@ -176,3 +176,30 @@ async def test_asleep_or_offline(
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
mock_wake_up.assert_called_once()
|
mock_wake_up.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_climate_noscope(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_metadata,
|
||||||
|
) -> None:
|
||||||
|
"""Tests that the climate entity is correct."""
|
||||||
|
mock_metadata.return_value = METADATA_NOSCOPE
|
||||||
|
|
||||||
|
await setup_platform(hass, [Platform.CLIMATE])
|
||||||
|
entity_id = "climate.test_climate"
|
||||||
|
|
||||||
|
with pytest.raises(ServiceValidationError):
|
||||||
|
await hass.services.async_call(
|
||||||
|
CLIMATE_DOMAIN,
|
||||||
|
SERVICE_SET_HVAC_MODE,
|
||||||
|
{ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HomeAssistantError):
|
||||||
|
await hass.services.async_call(
|
||||||
|
CLIMATE_DOMAIN,
|
||||||
|
SERVICE_SET_TEMPERATURE,
|
||||||
|
{ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue