diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 084d51ff31b..45fd1eee327 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -4,6 +4,7 @@ import asyncio from typing import Final from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific +from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( InvalidToken, SubscriptionRequired, @@ -37,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: access_token=access_token, ) try: + scopes = (await teslemetry.metadata())["scopes"] products = (await teslemetry.products())["response"] except InvalidToken as e: raise ConfigEntryAuthFailed from e @@ -49,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: vehicles: list[TeslemetryVehicleData] = [] energysites: list[TeslemetryEnergyData] = [] for product in products: - if "vin" in product: + if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes: vin = product["vin"] api = VehicleSpecific(teslemetry.vehicle, vin) coordinator = TeslemetryVehicleDataCoordinator(hass, api) @@ -60,7 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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"] api = EnergySpecific(teslemetry.energy, site_id) energysites.append( @@ -86,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Setup Platforms hass.data.setdefault(DOMAIN, {})[entry.entry_id] = TeslemetryData( - vehicles, energysites + vehicles, energysites, scopes ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 0835785d194..4c1c05570ab 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from tesla_fleet_api.const import Scope + from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -17,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, TeslemetryClimateSide from .context import handle_command from .entity import TeslemetryVehicleEntity +from .models import TeslemetryVehicleData async def async_setup_entry( @@ -26,7 +29,7 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER) + TeslemetryClimateEntity(vehicle, TeslemetryClimateSide.DRIVER, data.scopes) for vehicle in data.vehicles ) @@ -48,6 +51,22 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): _attr_preset_modes = ["off", "keep", "dog", "camp"] _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 def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" @@ -82,6 +101,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_turn_on(self) -> None: """Set the climate state to on.""" + self.raise_for_scope() with handle_command(): await self.wake_up_if_asleep() await self.api.auto_conditioning_start() @@ -89,6 +109,7 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_turn_off(self) -> None: """Set the climate state to off.""" + self.raise_for_scope() with handle_command(): await self.wake_up_if_asleep() await self.api.auto_conditioning_stop() diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index eda3d26f341..d67a1bd1770 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -5,7 +5,7 @@ from typing import Any 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.update_coordinator import CoordinatorEntity @@ -83,6 +83,11 @@ class TeslemetryVehicleEntity(CoordinatorEntity[TeslemetryVehicleDataCoordinator self.coordinator.data[key] = value 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]): """Parent class for Teslemetry Energy Entities.""" diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index d6f15e2e932..615156e6fdc 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -6,6 +6,7 @@ import asyncio from dataclasses import dataclass from tesla_fleet_api import EnergySpecific, VehicleSpecific +from tesla_fleet_api.const import Scope from .coordinator import ( TeslemetryEnergyDataCoordinator, @@ -19,6 +20,7 @@ class TeslemetryData: vehicles: list[TeslemetryVehicleData] energysites: list[TeslemetryEnergyData] + scopes: list[Scope] @dataclass diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index f252787b37c..9040ec96a03 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -7,7 +7,23 @@ from unittest.mock import patch 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) diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 776cc231a5c..96e9ead8912 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -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) 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"], +} diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index e83e9d648cd..a05bc07b305 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -22,11 +22,11 @@ from homeassistant.components.climate import ( from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, Platform 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 . 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 @@ -176,3 +176,30 @@ async def test_asleep_or_offline( ) await hass.async_block_till_done() 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, + )