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
187 changes: 187 additions & 0 deletions 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:
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
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]
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 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
Comment on lines +94 to +101
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need these two cases if the API always reports the temperature data in Celsius? The climate integration will convert between C and F for all the temperature attributes based on the climate entity temperature_unit attribute and the user globally configured temperature unit.

The idea is that the platform shouldn't need to bother with converting.

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()
Copy link
Contributor

Choose a reason for hiding this comment

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

Can this be pushed to MazdaEntity? That way each platform only needs to implement _update_state_attributes() and not have to worry about calling super().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like this idea, but since it would affect several platforms, I would like to do this in a separate follow-up PR after this one is merged.


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":
bdraco marked this conversation as resolved.
Show resolved Hide resolved
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()
Copy link
Member

Choose a reason for hiding this comment

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

It's a bit weird to call this method when there's no coordinator update. I'd create a different helper that we can call that calls self._update_state_attributes and self.async_write_ha_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"],
_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()
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
}