Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add climate platform to Mazda integration #75037

Merged
merged 15 commits into from Dec 27, 2022
7 changes: 6 additions & 1 deletion homeassistant/components/mazda/__init__.py
Expand Up @@ -33,13 +33,14 @@
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,
Expand Down Expand Up @@ -161,6 +162,9 @@ async def async_update_data():
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

Expand All @@ -185,6 +189,7 @@ async def async_update_data():
hass.data[DOMAIN][entry.entry_id] = {
DATA_CLIENT: mazda_client,
DATA_COORDINATOR: coordinator,
DATA_REGION: region,
DATA_VEHICLES: [],
}

Expand Down
221 changes: 221 additions & 0 deletions homeassistant/components/mazda/climate.py
@@ -0,0 +1,221 @@
"""Platform for Mazda climate integration."""
from typing import Any

from pymazda import Client as MazdaAPIClient

from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import ClimateEntityFeature, HVACMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_HALVES,
PRECISION_WHOLE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.core import HomeAssistant
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"


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the switch 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]
Comment on lines +54 to +57
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a bit cleaner if entry_data was a @dataclass

Copy link
Contributor Author

@bdr99 bdr99 Dec 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. We already discussed this previously here: #75037 (comment). I plan to implement this in a follow-up PR after this one is merged.


async_add_entities(
MazdaClimateEntity(client, coordinator, index, region)
for index, data in enumerate(coordinator.data)
if data["isElectric"]
)


class MazdaClimateEntity(MazdaEntity, ClimateEntity):
"""Class for the 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,
]

_hvac_mode: HVACMode
bdraco marked this conversation as resolved.
Show resolved Hide resolved
_target_temperature: float
_preset_mode: str

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

async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
# Whenever a coordinator update happens, update the state of the climate entity.
self.async_on_remove(
self.coordinator.async_add_listener(self._update_hvac_state)
)

# Perform an initial update of the state.
self._update_hvac_state()
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved

def _update_hvac_state(self):
hvac_on = self.client.get_assumed_hvac_mode(self.vehicle_id)
self._hvac_mode = HVACMode.HEAT_COOL if hvac_on else HVACMode.OFF

hvac_setting = self.client.get_assumed_hvac_setting(self.vehicle_id)
self._target_temperature = hvac_setting.get("temperature")

front_defroster = hvac_setting.get("frontDefroster")
rear_defroster = hvac_setting.get("rearDefroster")
if front_defroster and rear_defroster:
self._preset_mode = PRESET_DEFROSTER_FRONT_AND_REAR
elif front_defroster:
self._preset_mode = PRESET_DEFROSTER_FRONT
elif rear_defroster:
self._preset_mode = PRESET_DEFROSTER_REAR
else:
self._preset_mode = PRESET_DEFROSTER_OFF

self.async_write_ha_state()

@property
def hvac_mode(self) -> HVACMode:
"""Return the current HVAC setting."""
return self._hvac_mode

@property
def precision(self) -> float:
bdraco marked this conversation as resolved.
Show resolved Hide resolved
"""Return the precision of the temperature setting."""
if self.data["hvacSetting"]["temperatureUnit"] == "F":
return PRECISION_WHOLE
return PRECISION_HALVES

@property
def current_temperature(self) -> float:
"""Return the current temperature."""
bdraco marked this conversation as resolved.
Show resolved Hide resolved
current_temperature_celsius = self.data["evStatus"]["hvacInfo"][
"interiorTemperatureCelsius"
]
if self.data["hvacSetting"]["temperatureUnit"] == "F":
bdraco marked this conversation as resolved.
Show resolved Hide resolved
return convert_temperature(
current_temperature_celsius, TEMP_CELSIUS, TEMP_FAHRENHEIT
)
return current_temperature_celsius

@property
def target_temperature(self) -> float:
"""Return the temperature we try to reach."""
return self._target_temperature

@property
def temperature_unit(self) -> str:
bdraco marked this conversation as resolved.
Show resolved Hide resolved
"""Return the unit of measurement."""
if self.data["hvacSetting"]["temperatureUnit"] == "F":
return TEMP_FAHRENHEIT
return TEMP_CELSIUS

@property
def min_temp(self) -> float:
"""Return the minimum target temperature."""
if self.data["hvacSetting"]["temperatureUnit"] == "F":
return 61.0
if self.region == "MJO":
return 18.5
return 15.5

@property
def max_temp(self) -> float:
"""Return the maximum target temperature."""
if self.data["hvacSetting"]["temperatureUnit"] == "F":
return 83.0
if self.region == "MJO":
return 31.5
return 28.5

@property
def preset_mode(self) -> str:
"""Return the current preset mode based on the state of the defrosters."""
return self._preset_mode

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._update_hvac_state()

async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be cleaner if you reversed the condition and returned early so you could outdent below

Copy link
Contributor Author

@bdr99 bdr99 Dec 27, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually did it this way because I couldn't find a way to achieve 100% test coverage otherwise. It doesn't seem possible to create a test scenario where kwargs.get(ATTR_TEMPERATURE) is None, but I need to check that case in order for the type checker to pass.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can make a service call for set_temperature that passes low and high target temps instead of ATTR_TEMPERATURE.

precision = self.precision
rounded_temperature = round(temperature / precision) * precision

await self.client.set_hvac_setting(
self.vehicle_id,
rounded_temperature,
self.data["hvacSetting"]["temperatureUnit"],
self._preset_mode
in [
PRESET_DEFROSTER_FRONT_AND_REAR,
PRESET_DEFROSTER_FRONT,
],
self._preset_mode
in [
PRESET_DEFROSTER_FRONT_AND_REAR,
PRESET_DEFROSTER_REAR,
],
)

self._update_hvac_state()

async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Turn on/off the front/rear defrosters according to the chosen preset mode."""
front_defroster = preset_mode in [
bdraco marked this conversation as resolved.
Show resolved Hide resolved
PRESET_DEFROSTER_FRONT_AND_REAR,
PRESET_DEFROSTER_FRONT,
]
rear_defroster = preset_mode in [
PRESET_DEFROSTER_FRONT_AND_REAR,
PRESET_DEFROSTER_REAR,
]

await self.client.set_hvac_setting(
self.vehicle_id,
self._target_temperature,
self.data["hvacSetting"]["temperatureUnit"],
front_defroster,
rear_defroster,
)

self._update_hvac_state()
1 change: 1 addition & 0 deletions homeassistant/components/mazda/const.py
Expand Up @@ -4,6 +4,7 @@

DATA_CLIENT = "mazda_client"
DATA_COORDINATOR = "coordinator"
DATA_REGION = "region"
DATA_VEHICLES = "vehicles"

MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"}
10 changes: 9 additions & 1 deletion 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

Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/components/mazda/fixtures/get_ev_vehicle_status.json
@@ -1,6 +1,6 @@
{
"lastUpdatedTimestamp": "20210807083956",
"chargeInfo": {
"lastUpdatedTimestamp": "20210807083956",
"batteryLevelPercentage": 80,
"drivingRangeKm": 218,
"pluggedIn": true,
Expand Down
6 changes: 6 additions & 0 deletions tests/components/mazda/fixtures/get_hvac_setting.json
@@ -0,0 +1,6 @@
{
"temperature": 20,
"temperatureUnit": "C",
"frontDefroster": true,
"rearDefroster": false
}