diff --git a/.coveragerc b/.coveragerc index c05cab27f79ca19b1e80d4ebbe14d56dd39f39d9..e20b26ff182d4600cd19798df666688f5203f95c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1371,11 +1371,6 @@ omit = homeassistant/components/telnet/switch.py homeassistant/components/temper/sensor.py homeassistant/components/tensorflow/image_processing.py - homeassistant/components/teslemetry/__init__.py - homeassistant/components/teslemetry/climate.py - homeassistant/components/teslemetry/coordinator.py - homeassistant/components/teslemetry/entity.py - homeassistant/components/teslemetry/context.py homeassistant/components/tfiac/climate.py homeassistant/components/thermoworks_smoke/sensor.py homeassistant/components/thethingsnetwork/* diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 0c2a16fa15b855b161b8914e5845fd21a3c578bf..fb74e905181dc7342ef81c9888f1832a7683aced 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -2,7 +2,7 @@ import asyncio from typing import Final -from tesla_fleet_api import Teslemetry +from tesla_fleet_api import Teslemetry, VehicleSpecific from tesla_fleet_api.exceptions import InvalidToken, PaymentRequired, TeslaFleetError from homeassistant.config_entries import ConfigEntry @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: continue vin = product["vin"] - api = teslemetry.vehicle.specific(vin) + api = VehicleSpecific(teslemetry.vehicle, vin) coordinator = TeslemetryVehicleDataCoordinator(hass, api) data.append( TeslemetryVehicleData( diff --git a/homeassistant/components/teslemetry/context.py b/homeassistant/components/teslemetry/context.py index c2c9317f671fd14fff0a51bbc3e66a2bf11242f1..942f1ccdd4bdcfbb2f6f6c22c881c677df3a2551 100644 --- a/homeassistant/components/teslemetry/context.py +++ b/homeassistant/components/teslemetry/context.py @@ -13,4 +13,4 @@ def handle_command(): try: yield except TeslaFleetError as e: - raise HomeAssistantError from e + raise HomeAssistantError("Teslemetry command failed") from e diff --git a/tests/components/teslemetry/__init__.py b/tests/components/teslemetry/__init__.py index 422a2ecaac97ea7618d7d8f3552136cba10e2ba6..eae58127d1d0d1bdaaf7109c40d28b008a65bd7e 100644 --- a/tests/components/teslemetry/__init__.py +++ b/tests/components/teslemetry/__init__.py @@ -1 +1,50 @@ """Tests for the Teslemetry integration.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.teslemetry.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import CONFIG + +from tests.common import MockConfigEntry + + +async def setup_platform(hass: HomeAssistant, platforms: list[Platform] | None = None): + """Set up the Teslemetry platform.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + ) + mock_entry.add_to_hass(hass) + + if platforms is None: + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + else: + with patch("homeassistant.components.teslemetry.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + return mock_entry + + +def assert_entities( + hass: HomeAssistant, + entry_id: str, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that all entities match their snapshot.""" + entity_entries = er.async_entries_for_config_entry(entity_registry, entry_id) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..0fc279eaa215a023edc4848d98db94b371746f77 --- /dev/null +++ b/tests/components/teslemetry/conftest.py @@ -0,0 +1,47 @@ +"""Fixtures for Tessie.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from .const import PRODUCTS, RESPONSE_OK, VEHICLE_DATA, WAKE_UP_ONLINE + + +@pytest.fixture(autouse=True) +def mock_products(): + """Mock Tesla Fleet Api products method.""" + with patch( + "homeassistant.components.teslemetry.Teslemetry.products", return_value=PRODUCTS + ) as mock_products: + yield mock_products + + +@pytest.fixture(autouse=True) +def mock_vehicle_data(): + """Mock Tesla Fleet API Vehicle Specific vehicle_data method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.vehicle_data", + return_value=VEHICLE_DATA, + ) as mock_vehicle_data: + yield mock_vehicle_data + + +@pytest.fixture(autouse=True) +def mock_wake_up(): + """Mock Tesla Fleet API Vehicle Specific wake_up method.""" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.wake_up", + return_value=WAKE_UP_ONLINE, + ) as mock_wake_up: + yield mock_wake_up + + +@pytest.fixture(autouse=True) +def mock_request(): + """Mock Tesla Fleet API Vehicle Specific class.""" + with patch( + "homeassistant.components.teslemetry.Teslemetry._request", + return_value=RESPONSE_OK, + ) as mock_request: + yield mock_request diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 527ef98efcadc09fc66808934fffc569671596a8..0feb056fa7235408ddfb672e88b94ac49cc82187 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -1,5 +1,16 @@ """Constants for the teslemetry tests.""" +from homeassistant.components.teslemetry.const import DOMAIN, TeslemetryState from homeassistant.const import CONF_ACCESS_TOKEN +from tests.common import load_json_object_fixture + CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} + +WAKE_UP_ONLINE = {"response": {"state": TeslemetryState.ONLINE}, "error": None} +WAKE_UP_ASLEEP = {"response": {"state": TeslemetryState.ASLEEP}, "error": None} + +PRODUCTS = load_json_object_fixture("products.json", DOMAIN) +VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) + +RESPONSE_OK = {"response": {}, "error": None} diff --git a/tests/components/teslemetry/fixtures/products.json b/tests/components/teslemetry/fixtures/products.json new file mode 100644 index 0000000000000000000000000000000000000000..430c3b39dc8f38511a87145970621ac461f5b37d --- /dev/null +++ b/tests/components/teslemetry/fixtures/products.json @@ -0,0 +1,99 @@ +{ + "response": [ + { + "id": 1234, + "user_id": 1234, + "vehicle_id": 1234, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "display_name": "Test", + "option_codes": null, + "cached_data": null, + "granular_access": { "hide_private": false }, + "tokens": ["abc", "def"], + "state": "asleep", + "in_service": false, + "id_s": "1234", + "calendar_enabled": true, + "api_version": 71, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": true, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1705701487912, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "command_signing": "allowed", + "release_notes_supported": true + }, + { + "energy_site_id": 2345, + "resource_type": "wall_connector", + "id": "ID1234", + "asset_site_id": "abcdef", + "warp_site_number": "ID1234", + "go_off_grid_test_banner_enabled": null, + "storm_mode_enabled": null, + "powerwall_onboarding_settings_set": null, + "powerwall_tesla_electric_interested_in": null, + "vpp_tour_enabled": null, + "sync_grid_alert_enabled": false, + "breaker_alert_enabled": false, + "components": { + "battery": false, + "solar": false, + "grid": false, + "load_meter": false, + "wall_connectors": [ + { "device_id": "abcdef", "din": "12345", "is_active": true } + ] + }, + "features": {} + } + ], + "count": 2 +} diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json new file mode 100644 index 0000000000000000000000000000000000000000..44556c1c8df509a7c3c4708384e1818bd733605d --- /dev/null +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -0,0 +1,269 @@ +{ + "response": { + "id": 1234, + "user_id": 1234, + "vehicle_id": 1234, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["abc", "def"], + "state": "online", + "in_service": false, + "id_s": "1234", + "calendar_enabled": true, + "api_version": 71, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 77, + "battery_range": 266.87, + "charge_amps": 16, + "charge_current_request": 16, + "charge_current_request_max": 16, + "charge_enable_request": true, + "charge_energy_added": 0, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 0, + "charge_miles_added_rated": 0, + "charge_port_cold_weather_mode": false, + "charge_port_color": "<invalid>", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 0, + "charger_actual_current": 0, + "charger_phases": null, + "charger_pilot_current": 16, + "charger_power": 0, + "charger_voltage": 2, + "charging_state": "Stopped", + "conn_charge_cable": "IEC", + "est_battery_range": 275.04, + "fast_charger_brand": "<invalid>", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 266.87, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 0, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "Off", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": null, + "scheduled_charging_start_time_app": 600, + "scheduled_departure_time": 1704837600, + "scheduled_departure_time_minutes": 480, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0, + "timestamp": 1705707520649, + "trip_charging": false, + "usable_battery_level": 77, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": false, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": false, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 29.8, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 251, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30, + "passenger_temp_setting": 22, + "remote_heater_control_enabled": false, + "right_temp_direction": 251, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1705707520649, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": -27.855946, + "active_route_longitude": 153.345056, + "active_route_traffic_minutes_delay": 0, + "power": 0, + "shift_state": null, + "speed": null, + "timestamp": 1705707520649 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1705707520649 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": true, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1705707520649, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 71, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.44.30.8 06f534d46010", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,187f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": false, + "media_info": { + "audio_volume": 2.6667, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Stopped", + "now_playing_album": "", + "now_playing_artist": "", + "now_playing_duration": 0, + "now_playing_elapsed": 0, + "now_playing_source": "Spotify", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": true + }, + "notifications_supported": true, + "odometer": 6481.019282, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 0, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "", + "version": " " + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 69, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1705707520649, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1705700812, + "tpms_last_seen_pressure_time_fr": 1705700793, + "tpms_last_seen_pressure_time_rl": 1705700794, + "tpms_last_seen_pressure_time_rr": 1705700823, + "tpms_pressure_fl": 2.775, + "tpms_pressure_fr": 2.8, + "tpms_pressure_rl": 2.775, + "tpms_pressure_rr": 2.775, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + } + } +} diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr new file mode 100644 index 0000000000000000000000000000000000000000..f0f6f1b0140e327b931bc05fe9c971c552671d31 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -0,0 +1,73 @@ +# serializer version: 1 +# name: test_climate[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + <HVACMode.HEAT_COOL: 'heat_cool'>, + <HVACMode.OFF: 'off'>, + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + }), + 'config_entry_id': <ANY>, + 'device_class': None, + 'device_id': <ANY>, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': <ANY>, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': <ClimateEntityFeature: 17>, + 'translation_key': <TeslemetryClimateSide.DRIVER: 'driver_temp'>, + 'unique_id': 'VINVINVIN-driver_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.test_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30.0, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + <HVACMode.HEAT_COOL: 'heat_cool'>, + <HVACMode.OFF: 'off'>, + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': 'off', + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': <ClimateEntityFeature: 17>, + 'temperature': 22.0, + }), + 'context': <ANY>, + 'entity_id': 'climate.test_climate', + 'last_changed': <ANY>, + 'last_updated': <ANY>, + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py new file mode 100644 index 0000000000000000000000000000000000000000..ede38a695e20de418d0bc6183074bc97731855d5 --- /dev/null +++ b/tests/components/teslemetry/test_climate.py @@ -0,0 +1,131 @@ +"""Test the Teslemetry climate platform.""" + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import InvalidCommand, VehicleOffline + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_ON, + HVACMode, +) +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.helpers import entity_registry as er + +from . import assert_entities, setup_platform + +from tests.common import async_fire_time_changed + + +async def test_climate( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the climate entity is correct.""" + + entry = await setup_platform(hass, [Platform.CLIMATE]) + + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + entity_id = "climate.test_climate" + state = hass.states.get(entity_id) + + # Turn On + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.HEAT_COOL + + # Set Temp + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20 + + # Set Preset + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: "keep"}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == "keep" + + # Turn Off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF + + +async def test_errors( + hass: HomeAssistant, +) -> None: + """Tests service error is handled.""" + + await setup_platform(hass, platforms=[Platform.CLIMATE]) + entity_id = "climate.test_climate" + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.auto_conditioning_start", + side_effect=InvalidCommand, + ) as mock_on, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_on.assert_called_once() + assert error.from_exception == InvalidCommand + + +async def test_asleep_or_offline( + hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory +) -> None: + """Tests asleep is handled.""" + + await setup_platform(hass, [Platform.CLIMATE]) + entity_id = "climate.test_climate" + mock_vehicle_data.assert_called_once() + + # Put the vehicle alseep + mock_vehicle_data.reset_mock() + mock_vehicle_data.side_effect = VehicleOffline + freezer.tick(timedelta(seconds=SYNC_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_vehicle_data.assert_called_once() + + # Run a command that will wake up the vehicle, but not immediately + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, blocking=True + ) + await hass.async_block_till_done() diff --git a/tests/components/teslemetry/test_config_flow.py b/tests/components/teslemetry/test_config_flow.py index ca9b89bedb34287d0cbc7577bf730a7fb8a2849f..b89967bfa35582701e1618522dc8d511f0136a33 100644 --- a/tests/components/teslemetry/test_config_flow.py +++ b/tests/components/teslemetry/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Teslemetry config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from aiohttp import ClientConnectionError import pytest @@ -16,13 +16,12 @@ from .const import CONFIG @pytest.fixture(autouse=True) -def teslemetry_config_entry_mock(): +def mock_test(): """Mock Teslemetry api class.""" with patch( - "homeassistant.components.teslemetry.config_flow.Teslemetry", - ) as teslemetry_config_entry_mock: - teslemetry_config_entry_mock.return_value.test = AsyncMock() - yield teslemetry_config_entry_mock + "homeassistant.components.teslemetry.Teslemetry.test", return_value=True + ) as mock_test: + yield mock_test async def test_form( @@ -60,16 +59,14 @@ async def test_form( (TeslaFleetError, {"base": "unknown"}), ], ) -async def test_form_errors( - hass: HomeAssistant, side_effect, error, teslemetry_config_entry_mock -) -> None: +async def test_form_errors(hass: HomeAssistant, side_effect, error, mock_test) -> None: """Test errors are handled.""" result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - teslemetry_config_entry_mock.return_value.test.side_effect = side_effect + mock_test.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], CONFIG, @@ -79,7 +76,7 @@ async def test_form_errors( assert result2["errors"] == error # Complete the flow - teslemetry_config_entry_mock.return_value.test.side_effect = None + mock_test.side_effect = None result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], CONFIG, diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py new file mode 100644 index 0000000000000000000000000000000000000000..28440094becf59d7d9baf41b0126bd2d6c0f720e --- /dev/null +++ b/tests/components/teslemetry/test_init.py @@ -0,0 +1,118 @@ +"""Test the Tessie init.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory +from tesla_fleet_api.exceptions import ( + InvalidToken, + PaymentRequired, + TeslaFleetError, + VehicleOffline, +) + +from homeassistant.components.teslemetry.coordinator import SYNC_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_platform +from .const import WAKE_UP_ASLEEP, WAKE_UP_ONLINE + +from tests.common import async_fire_time_changed + + +async def test_load_unload(hass: HomeAssistant) -> None: + """Test load and unload.""" + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_auth_failure(hass: HomeAssistant, mock_products) -> None: + """Test init with an authentication error.""" + + mock_products.side_effect = InvalidToken + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_subscription_failure(hass: HomeAssistant, mock_products) -> None: + """Test init with an client response error.""" + + mock_products.side_effect = PaymentRequired + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_other_failure(hass: HomeAssistant, mock_products) -> None: + """Test init with an client response error.""" + + mock_products.side_effect = TeslaFleetError + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +# Coordinator + + +async def test_first_refresh( + hass: HomeAssistant, + mock_wake_up, + mock_vehicle_data, + mock_products, + freezer: FrozenDateTimeFactory, +) -> None: + """Test first coordinator refresh but vehicle is asleep.""" + + # Mock vehicle is asleep + mock_wake_up.return_value = WAKE_UP_ASLEEP + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + mock_wake_up.assert_called_once() + + # Reset mock and set vehicle to online + mock_wake_up.reset_mock() + mock_wake_up.return_value = WAKE_UP_ONLINE + + # Wait for the retry + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Verify we have loaded + assert entry.state is ConfigEntryState.LOADED + mock_wake_up.assert_called_once() + mock_vehicle_data.assert_called_once() + + +async def test_first_refresh_error(hass: HomeAssistant, mock_wake_up) -> None: + """Test first coordinator refresh with an error.""" + mock_wake_up.side_effect = TeslaFleetError + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_refresh_offline( + hass: HomeAssistant, mock_vehicle_data, freezer: FrozenDateTimeFactory +) -> None: + """Test coordinator refresh with an error.""" + entry = await setup_platform(hass, [Platform.CLIMATE]) + assert entry.state is ConfigEntryState.LOADED + mock_vehicle_data.assert_called_once() + mock_vehicle_data.reset_mock() + + mock_vehicle_data.side_effect = VehicleOffline + freezer.tick(timedelta(seconds=SYNC_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_vehicle_data.assert_called_once() + + +async def test_refresh_error(hass: HomeAssistant, mock_vehicle_data) -> None: + """Test coordinator refresh with an error.""" + mock_vehicle_data.side_effect = TeslaFleetError + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.SETUP_RETRY