Add climate platform to Mazda integration (#75037)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Brandon Rothweiler 2022-12-27 15:13:36 -05:00 committed by GitHub
parent 7ef145d4ce
commit f5c56152d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 537 additions and 3 deletions

View File

@ -33,13 +33,14 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed, 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__) _LOGGER = logging.getLogger(__name__)
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,
Platform.CLIMATE,
Platform.DEVICE_TRACKER, Platform.DEVICE_TRACKER,
Platform.LOCK, Platform.LOCK,
Platform.SENSOR, Platform.SENSOR,
@ -161,6 +162,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
vehicle["evStatus"] = await with_timeout( vehicle["evStatus"] = await with_timeout(
mazda_client.get_ev_vehicle_status(vehicle["id"]) 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 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] = { hass.data[DOMAIN][entry.entry_id] = {
DATA_CLIENT: mazda_client, DATA_CLIENT: mazda_client,
DATA_COORDINATOR: coordinator, DATA_COORDINATOR: coordinator,
DATA_REGION: region,
DATA_VEHICLES: [], DATA_VEHICLES: [],
} }

View File

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

View File

@ -4,6 +4,7 @@ DOMAIN = "mazda"
DATA_CLIENT = "mazda_client" DATA_CLIENT = "mazda_client"
DATA_COORDINATOR = "coordinator" DATA_COORDINATOR = "coordinator"
DATA_REGION = "region"
DATA_VEHICLES = "vehicles" DATA_VEHICLES = "vehicles"
MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"} MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"}

View File

@ -1,7 +1,7 @@
"""Tests for the Mazda Connected Services integration.""" """Tests for the Mazda Connected Services integration."""
import json import json
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
from pymazda import Client as MazdaAPI from pymazda import Client as MazdaAPI
@ -35,6 +35,7 @@ async def init_integration(
get_ev_vehicle_status_fixture = json.loads( get_ev_vehicle_status_fixture = json.loads(
load_fixture("mazda/get_ev_vehicle_status.json") 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 = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT)
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
@ -61,6 +62,13 @@ async def init_integration(
client_mock.stop_engine = AsyncMock() client_mock.stop_engine = AsyncMock()
client_mock.turn_off_hazard_lights = AsyncMock() client_mock.turn_off_hazard_lights = AsyncMock()
client_mock.turn_on_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( with patch(
"homeassistant.components.mazda.config_flow.MazdaAPI", "homeassistant.components.mazda.config_flow.MazdaAPI",

View File

@ -1,6 +1,6 @@
{ {
"lastUpdatedTimestamp": "20210807083956",
"chargeInfo": { "chargeInfo": {
"lastUpdatedTimestamp": "20210807083956",
"batteryLevelPercentage": 80, "batteryLevelPercentage": 80,
"drivingRangeKm": 218, "drivingRangeKm": 218,
"pluggedIn": true, "pluggedIn": true,

View File

@ -0,0 +1,6 @@
{
"temperature": 20,
"temperatureUnit": "C",
"frontDefroster": true,
"rearDefroster": false
}

View File

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