diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index b62725e5b1b..403627147f0 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -33,13 +33,14 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_VEHICLES, DOMAIN +from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_REGION, DATA_VEHICLES, DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CLIMATE, Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SENSOR, @@ -161,6 +162,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: vehicle["evStatus"] = await with_timeout( mazda_client.get_ev_vehicle_status(vehicle["id"]) ) + vehicle["hvacSetting"] = await with_timeout( + mazda_client.get_hvac_setting(vehicle["id"]) + ) hass.data[DOMAIN][entry.entry_id][DATA_VEHICLES] = vehicles @@ -185,6 +189,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = { DATA_CLIENT: mazda_client, DATA_COORDINATOR: coordinator, + DATA_REGION: region, DATA_VEHICLES: [], } diff --git a/homeassistant/components/mazda/climate.py b/homeassistant/components/mazda/climate.py new file mode 100644 index 00000000000..e2028885c34 --- /dev/null +++ b/homeassistant/components/mazda/climate.py @@ -0,0 +1,187 @@ +"""Platform for Mazda climate integration.""" +from __future__ import annotations + +from typing import Any + +from pymazda import Client as MazdaAPIClient + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_WHOLE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.temperature import convert as convert_temperature + +from . import MazdaEntity +from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_REGION, DOMAIN + +PRESET_DEFROSTER_OFF = "Defroster Off" +PRESET_DEFROSTER_FRONT = "Front Defroster" +PRESET_DEFROSTER_REAR = "Rear Defroster" +PRESET_DEFROSTER_FRONT_AND_REAR = "Front and Rear Defroster" + + +def _front_defroster_enabled(preset_mode: str | None) -> bool: + return preset_mode in [ + PRESET_DEFROSTER_FRONT_AND_REAR, + PRESET_DEFROSTER_FRONT, + ] + + +def _rear_defroster_enabled(preset_mode: str | None) -> bool: + return preset_mode in [ + PRESET_DEFROSTER_FRONT_AND_REAR, + PRESET_DEFROSTER_REAR, + ] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the climate platform.""" + entry_data = hass.data[DOMAIN][config_entry.entry_id] + client = entry_data[DATA_CLIENT] + coordinator = entry_data[DATA_COORDINATOR] + region = entry_data[DATA_REGION] + + async_add_entities( + MazdaClimateEntity(client, coordinator, index, region) + for index, data in enumerate(coordinator.data) + if data["isElectric"] + ) + + +class MazdaClimateEntity(MazdaEntity, ClimateEntity): + """Class for a Mazda climate entity.""" + + _attr_name = "Climate" + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] + _attr_preset_modes = [ + PRESET_DEFROSTER_OFF, + PRESET_DEFROSTER_FRONT, + PRESET_DEFROSTER_REAR, + PRESET_DEFROSTER_FRONT_AND_REAR, + ] + + def __init__( + self, + client: MazdaAPIClient, + coordinator: DataUpdateCoordinator, + index: int, + region: str, + ) -> None: + """Initialize Mazda climate entity.""" + super().__init__(client, coordinator, index) + + self.region = region + self._attr_unique_id = self.vin + + if self.data["hvacSetting"]["temperatureUnit"] == "F": + self._attr_precision = PRECISION_WHOLE + self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + self._attr_min_temp = 61.0 + self._attr_max_temp = 83.0 + else: + self._attr_precision = PRECISION_HALVES + self._attr_temperature_unit = UnitOfTemperature.CELSIUS + if region == "MJO": + self._attr_min_temp = 18.5 + self._attr_max_temp = 31.5 + else: + self._attr_min_temp = 15.5 + self._attr_max_temp = 28.5 + + self._update_state_attributes() + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator data updates.""" + self._update_state_attributes() + + super()._handle_coordinator_update() + + def _update_state_attributes(self) -> None: + # Update the HVAC mode + hvac_on = self.client.get_assumed_hvac_mode(self.vehicle_id) + self._attr_hvac_mode = HVACMode.HEAT_COOL if hvac_on else HVACMode.OFF + + # Update the target temperature + hvac_setting = self.client.get_assumed_hvac_setting(self.vehicle_id) + self._attr_target_temperature = hvac_setting.get("temperature") + + # Update the current temperature + current_temperature_celsius = self.data["evStatus"]["hvacInfo"][ + "interiorTemperatureCelsius" + ] + if self.data["hvacSetting"]["temperatureUnit"] == "F": + self._attr_current_temperature = convert_temperature( + current_temperature_celsius, + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + ) + else: + self._attr_current_temperature = current_temperature_celsius + + # Update the preset mode based on the state of the front and rear defrosters + front_defroster = hvac_setting.get("frontDefroster") + rear_defroster = hvac_setting.get("rearDefroster") + if front_defroster and rear_defroster: + self._attr_preset_mode = PRESET_DEFROSTER_FRONT_AND_REAR + elif front_defroster: + self._attr_preset_mode = PRESET_DEFROSTER_FRONT + elif rear_defroster: + self._attr_preset_mode = PRESET_DEFROSTER_REAR + else: + self._attr_preset_mode = PRESET_DEFROSTER_OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set a new HVAC mode.""" + if hvac_mode == HVACMode.HEAT_COOL: + await self.client.turn_on_hvac(self.vehicle_id) + elif hvac_mode == HVACMode.OFF: + await self.client.turn_off_hvac(self.vehicle_id) + + self._handle_coordinator_update() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set a new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: + precision = self.precision + rounded_temperature = round(temperature / precision) * precision + + await self.client.set_hvac_setting( + self.vehicle_id, + rounded_temperature, + self.data["hvacSetting"]["temperatureUnit"], + _front_defroster_enabled(self._attr_preset_mode), + _rear_defroster_enabled(self._attr_preset_mode), + ) + + self._handle_coordinator_update() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Turn on/off the front/rear defrosters according to the chosen preset mode.""" + await self.client.set_hvac_setting( + self.vehicle_id, + self._attr_target_temperature, + self.data["hvacSetting"]["temperatureUnit"], + _front_defroster_enabled(preset_mode), + _rear_defroster_enabled(preset_mode), + ) + + self._handle_coordinator_update() diff --git a/homeassistant/components/mazda/const.py b/homeassistant/components/mazda/const.py index 58ca2183a56..ebfa7f05301 100644 --- a/homeassistant/components/mazda/const.py +++ b/homeassistant/components/mazda/const.py @@ -4,6 +4,7 @@ DOMAIN = "mazda" DATA_CLIENT = "mazda_client" DATA_COORDINATOR = "coordinator" +DATA_REGION = "region" DATA_VEHICLES = "vehicles" MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"} diff --git a/tests/components/mazda/__init__.py b/tests/components/mazda/__init__.py index 9d20f78bc00..59b1d474140 100644 --- a/tests/components/mazda/__init__.py +++ b/tests/components/mazda/__init__.py @@ -1,7 +1,7 @@ """Tests for the Mazda Connected Services integration.""" import json -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from pymazda import Client as MazdaAPI @@ -35,6 +35,7 @@ async def init_integration( get_ev_vehicle_status_fixture = json.loads( load_fixture("mazda/get_ev_vehicle_status.json") ) + get_hvac_setting_fixture = json.loads(load_fixture("mazda/get_hvac_setting.json")) config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) config_entry.add_to_hass(hass) @@ -61,6 +62,13 @@ async def init_integration( client_mock.stop_engine = AsyncMock() client_mock.turn_off_hazard_lights = AsyncMock() client_mock.turn_on_hazard_lights = AsyncMock() + client_mock.refresh_vehicle_status = AsyncMock() + client_mock.get_hvac_setting = AsyncMock(return_value=get_hvac_setting_fixture) + client_mock.get_assumed_hvac_setting = Mock(return_value=get_hvac_setting_fixture) + client_mock.get_assumed_hvac_mode = Mock(return_value=True) + client_mock.set_hvac_setting = AsyncMock() + client_mock.turn_on_hvac = AsyncMock() + client_mock.turn_off_hvac = AsyncMock() with patch( "homeassistant.components.mazda.config_flow.MazdaAPI", diff --git a/tests/components/mazda/fixtures/get_ev_vehicle_status.json b/tests/components/mazda/fixtures/get_ev_vehicle_status.json index ee9825fcbe0..a577cab3054 100644 --- a/tests/components/mazda/fixtures/get_ev_vehicle_status.json +++ b/tests/components/mazda/fixtures/get_ev_vehicle_status.json @@ -1,6 +1,6 @@ { + "lastUpdatedTimestamp": "20210807083956", "chargeInfo": { - "lastUpdatedTimestamp": "20210807083956", "batteryLevelPercentage": 80, "drivingRangeKm": 218, "pluggedIn": true, diff --git a/tests/components/mazda/fixtures/get_hvac_setting.json b/tests/components/mazda/fixtures/get_hvac_setting.json new file mode 100644 index 00000000000..3b95832ba65 --- /dev/null +++ b/tests/components/mazda/fixtures/get_hvac_setting.json @@ -0,0 +1,6 @@ +{ + "temperature": 20, + "temperatureUnit": "C", + "frontDefroster": true, + "rearDefroster": false +} diff --git a/tests/components/mazda/test_climate.py b/tests/components/mazda/test_climate.py new file mode 100644 index 00000000000..ee98b1e8f6c --- /dev/null +++ b/tests/components/mazda/test_climate.py @@ -0,0 +1,327 @@ +"""The climate tests for the Mazda Connected Services integration.""" + +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.components.climate.const import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODES, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.mazda.climate import ( + PRESET_DEFROSTER_FRONT, + PRESET_DEFROSTER_FRONT_AND_REAR, + PRESET_DEFROSTER_OFF, + PRESET_DEFROSTER_REAR, +) +from homeassistant.components.mazda.const import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + CONF_EMAIL, + CONF_PASSWORD, + CONF_REGION, + UnitOfTemperature, +) +from homeassistant.helpers import entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from . import init_integration + +from tests.common import MockConfigEntry, load_fixture + + +async def test_climate_setup(hass): + """Test the setup of the climate entity.""" + await init_integration(hass, electric_vehicle=True) + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("climate.my_mazda3_climate") + assert entry + assert entry.unique_id == "JM000000000000000" + + state = hass.states.get("climate.my_mazda3_climate") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Climate" + + +@pytest.mark.parametrize( + "region, hvac_on, target_temperature, temperature_unit, front_defroster, rear_defroster, current_temperature_celsius, expected_hvac_mode, expected_preset_mode, expected_min_temp, expected_max_temp", + [ + # Test with HVAC off + ( + "MNAO", + False, + 20, + "C", + False, + False, + 22, + HVACMode.OFF, + PRESET_DEFROSTER_OFF, + 15.5, + 28.5, + ), + # Test with HVAC on + ( + "MNAO", + True, + 20, + "C", + False, + False, + 22, + HVACMode.HEAT_COOL, + PRESET_DEFROSTER_OFF, + 15.5, + 28.5, + ), + # Test with front defroster on + ( + "MNAO", + False, + 20, + "C", + True, + False, + 22, + HVACMode.OFF, + PRESET_DEFROSTER_FRONT, + 15.5, + 28.5, + ), + # Test with rear defroster on + ( + "MNAO", + False, + 20, + "C", + False, + True, + 22, + HVACMode.OFF, + PRESET_DEFROSTER_REAR, + 15.5, + 28.5, + ), + # Test with front and rear defrosters on + ( + "MNAO", + False, + 20, + "C", + True, + True, + 22, + HVACMode.OFF, + PRESET_DEFROSTER_FRONT_AND_REAR, + 15.5, + 28.5, + ), + # Test with temperature unit F + ( + "MNAO", + False, + 70, + "F", + False, + False, + 22, + HVACMode.OFF, + PRESET_DEFROSTER_OFF, + 61.0, + 83.0, + ), + # Test with Japan region (uses different min/max temp settings) + ( + "MJO", + False, + 20, + "C", + False, + False, + 22, + HVACMode.OFF, + PRESET_DEFROSTER_OFF, + 18.5, + 31.5, + ), + ], +) +async def test_climate_state( + hass, + region, + hvac_on, + target_temperature, + temperature_unit, + front_defroster, + rear_defroster, + current_temperature_celsius, + expected_hvac_mode, + expected_preset_mode, + expected_min_temp, + expected_max_temp, +): + """Test getting the state of the climate entity.""" + if temperature_unit == "F": + hass.config.units = US_CUSTOMARY_SYSTEM + + get_vehicles_fixture = json.loads(load_fixture("mazda/get_vehicles.json")) + get_vehicles_fixture[0]["isElectric"] = True + get_vehicle_status_fixture = json.loads( + load_fixture("mazda/get_vehicle_status.json") + ) + get_ev_vehicle_status_fixture = json.loads( + load_fixture("mazda/get_ev_vehicle_status.json") + ) + get_ev_vehicle_status_fixture["hvacInfo"][ + "interiorTemperatureCelsius" + ] = current_temperature_celsius + get_hvac_setting_fixture = { + "temperature": target_temperature, + "temperatureUnit": temperature_unit, + "frontDefroster": front_defroster, + "rearDefroster": rear_defroster, + } + + 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, + ), patch( + "homeassistant.components.mazda.MazdaAPI.get_ev_vehicle_status", + return_value=get_ev_vehicle_status_fixture, + ), patch( + "homeassistant.components.mazda.MazdaAPI.get_assumed_hvac_mode", + return_value=hvac_on, + ), patch( + "homeassistant.components.mazda.MazdaAPI.get_assumed_hvac_setting", + return_value=get_hvac_setting_fixture, + ), patch( + "homeassistant.components.mazda.MazdaAPI.get_hvac_setting", + return_value=get_hvac_setting_fixture, + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "example@example.com", + CONF_PASSWORD: "password", + CONF_REGION: region, + }, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.my_mazda3_climate") + assert state + assert state.state == expected_hvac_mode + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Climate" + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + assert state.attributes.get(ATTR_HVAC_MODES) == [HVACMode.HEAT_COOL, HVACMode.OFF] + assert state.attributes.get(ATTR_PRESET_MODES) == [ + PRESET_DEFROSTER_OFF, + PRESET_DEFROSTER_FRONT, + PRESET_DEFROSTER_REAR, + PRESET_DEFROSTER_FRONT_AND_REAR, + ] + assert state.attributes.get(ATTR_MIN_TEMP) == expected_min_temp + assert state.attributes.get(ATTR_MAX_TEMP) == expected_max_temp + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == round( + hass.config.units.temperature( + current_temperature_celsius, UnitOfTemperature.CELSIUS + ) + ) + assert state.attributes.get(ATTR_TEMPERATURE) == target_temperature + assert state.attributes.get(ATTR_PRESET_MODE) == expected_preset_mode + + +@pytest.mark.parametrize( + "hvac_mode, api_method", + [ + (HVACMode.HEAT_COOL, "turn_on_hvac"), + (HVACMode.OFF, "turn_off_hvac"), + ], +) +async def test_set_hvac_mode(hass, hvac_mode, api_method): + """Test turning on and off the HVAC system.""" + client_mock = await init_integration(hass, electric_vehicle=True) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.my_mazda3_climate", ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + await hass.async_block_till_done() + + getattr(client_mock, api_method).assert_called_once_with(12345) + + +async def test_set_target_temperature(hass): + """Test setting the target temperature of the climate entity.""" + client_mock = await init_integration(hass, electric_vehicle=True) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.my_mazda3_climate", ATTR_TEMPERATURE: 22}, + blocking=True, + ) + await hass.async_block_till_done() + + client_mock.set_hvac_setting.assert_called_once_with(12345, 22, "C", True, False) + + +@pytest.mark.parametrize( + "preset_mode, front_defroster, rear_defroster", + [ + (PRESET_DEFROSTER_OFF, False, False), + (PRESET_DEFROSTER_FRONT, True, False), + (PRESET_DEFROSTER_REAR, False, True), + (PRESET_DEFROSTER_FRONT_AND_REAR, True, True), + ], +) +async def test_set_preset_mode(hass, preset_mode, front_defroster, rear_defroster): + """Test turning on and off the front and rear defrosters.""" + client_mock = await init_integration(hass, electric_vehicle=True) + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: "climate.my_mazda3_climate", + ATTR_PRESET_MODE: preset_mode, + }, + blocking=True, + ) + await hass.async_block_till_done() + + client_mock.set_hvac_setting.assert_called_once_with( + 12345, 20, "C", front_defroster, rear_defroster + )