mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 07:51:46 -05:00
Add unit_of_measurement to climate and water_heater in ESPHome integration (#168747)
Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: J. Nick Koston <nick+github@koston.org> Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
This commit is contained in:
co-authored by
J. Nick Koston
J. Nick Koston
Simon Lamon
parent
7f8f9517b8
commit
e85b8a256e
@@ -51,7 +51,6 @@ from homeassistant.const import (
|
||||
PRECISION_HALVES,
|
||||
PRECISION_TENTHS,
|
||||
PRECISION_WHOLE,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
@@ -62,6 +61,7 @@ from .entity import (
|
||||
convert_api_error_ha_error,
|
||||
esphome_float_state_property,
|
||||
esphome_state_property,
|
||||
get_temperature_unit,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
@@ -131,7 +131,6 @@ _PRESETS: EsphomeEnumMapper[ClimatePreset, str] = EsphomeEnumMapper(
|
||||
class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEntity):
|
||||
"""A climate implementation for ESPHome."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = "climate"
|
||||
_feature_flags = ClimateFeature(0)
|
||||
|
||||
@@ -144,6 +143,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
self._feature_flags = ClimateFeature(
|
||||
static_info.supported_feature_flags_compat(self._api_version)
|
||||
)
|
||||
self._attr_temperature_unit = get_temperature_unit(static_info)
|
||||
self._attr_precision = self._get_precision()
|
||||
self._attr_hvac_modes = [
|
||||
_CLIMATE_MODES.from_esphome(mode) for mode in static_info.supported_modes
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from aioesphomeapi import TemperatureUnit
|
||||
from awesomeversion import AwesomeVersion
|
||||
|
||||
from homeassistant.components.bluetooth import BluetoothScanningMode
|
||||
from homeassistant.const import __version__ as ha_version
|
||||
from homeassistant.const import UnitOfTemperature, __version__ as ha_version
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -43,3 +44,9 @@ NO_WAKE_WORD: Final[str] = "no_wake_word"
|
||||
|
||||
WAKE_WORDS_DIR_NAME = "custom_wake_words"
|
||||
WAKE_WORDS_API_PATH = "/api/esphome/wake_words"
|
||||
|
||||
TEMPERATURE_UNIT_MAP: dict[TemperatureUnit, UnitOfTemperature] = {
|
||||
TemperatureUnit.CELSIUS: UnitOfTemperature.CELSIUS,
|
||||
TemperatureUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
|
||||
TemperatureUnit.KELVIN: UnitOfTemperature.KELVIN,
|
||||
}
|
||||
|
||||
@@ -8,15 +8,17 @@ from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast, over
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIConnectionError,
|
||||
ClimateInfo,
|
||||
DeviceInfo as EsphomeDeviceInfo,
|
||||
EntityCategory as EsphomeEntityCategory,
|
||||
EntityInfo,
|
||||
EntityState,
|
||||
WaterHeaterInfo,
|
||||
build_device_unique_id,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.const import EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
@@ -29,7 +31,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, TEMPERATURE_UNIT_MAP
|
||||
|
||||
# Import config flow so that it's added to the registry
|
||||
from .entry_data import (
|
||||
@@ -42,6 +44,26 @@ from .enum_mapper import EsphomeEnumMapper
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_temperature_unit(
|
||||
static_info: ClimateInfo | WaterHeaterInfo,
|
||||
) -> UnitOfTemperature:
|
||||
"""Return the HA temperature unit for the given ESPHome static info."""
|
||||
temperature_unit = static_info.temperature_unit
|
||||
if (
|
||||
temperature_unit is not None
|
||||
and (ha_unit := TEMPERATURE_UNIT_MAP.get(temperature_unit)) is not None
|
||||
):
|
||||
return ha_unit
|
||||
_LOGGER.warning(
|
||||
"%s (device_id=%s): Unrecognized ESPHome temperature unit %r, defaulting to Celsius",
|
||||
static_info.name,
|
||||
static_info.device_id,
|
||||
temperature_unit,
|
||||
)
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
|
||||
_InfoT = TypeVar("_InfoT", bound=EntityInfo)
|
||||
_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]")
|
||||
_StateT = TypeVar("_StateT", bound=EntityState)
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.components.water_heater import (
|
||||
WaterHeaterEntity,
|
||||
WaterHeaterEntityFeature,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
|
||||
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .entity import (
|
||||
@@ -24,6 +24,7 @@ from .entity import (
|
||||
convert_api_error_ha_error,
|
||||
esphome_float_state_property,
|
||||
esphome_state_property,
|
||||
get_temperature_unit,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
@@ -49,7 +50,6 @@ class EsphomeWaterHeater(
|
||||
):
|
||||
"""A water heater implementation for ESPHome."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_precision = PRECISION_TENTHS
|
||||
|
||||
@callback
|
||||
@@ -58,6 +58,7 @@ class EsphomeWaterHeater(
|
||||
"""Set attrs from static info."""
|
||||
super()._on_static_info_update(static_info)
|
||||
static_info = self._static_info
|
||||
self._attr_temperature_unit = get_temperature_unit(static_info)
|
||||
self._attr_min_temp = static_info.min_temperature
|
||||
self._attr_max_temp = static_info.max_temperature
|
||||
self._attr_target_temperature_step = static_info.target_temperature_step
|
||||
|
||||
@@ -13,6 +13,7 @@ from aioesphomeapi import (
|
||||
ClimatePreset,
|
||||
ClimateState,
|
||||
ClimateSwingMode,
|
||||
TemperatureUnit,
|
||||
)
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
@@ -24,7 +25,9 @@ from homeassistant.components.climate import (
|
||||
ATTR_HUMIDITY,
|
||||
ATTR_HVAC_MODE,
|
||||
ATTR_MAX_HUMIDITY,
|
||||
ATTR_MAX_TEMP,
|
||||
ATTR_MIN_HUMIDITY,
|
||||
ATTR_MIN_TEMP,
|
||||
ATTR_PRESET_MODE,
|
||||
ATTR_SWING_MODE,
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
@@ -44,6 +47,7 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
|
||||
|
||||
from .conftest import MockGenericDeviceEntryType
|
||||
|
||||
@@ -862,3 +866,123 @@ async def test_climate_entity_requires_two_point_keeps_range(
|
||||
assert ATTR_TEMPERATURE not in state.attributes
|
||||
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 3.5
|
||||
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 4.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("temperature_unit", "expected_temperature"),
|
||||
[
|
||||
pytest.param(TemperatureUnit.CELSIUS, 22.0, id="celsius"),
|
||||
pytest.param(TemperatureUnit.FAHRENHEIT, -5.6, id="fahrenheit"),
|
||||
pytest.param(TemperatureUnit.KELVIN, -251.1, id="kelvin"),
|
||||
pytest.param(None, 22.0, id="none_falls_back_to_celsius"),
|
||||
],
|
||||
)
|
||||
async def test_climate_entity_temperature_unit(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
temperature_unit: TemperatureUnit | int | None,
|
||||
expected_temperature: float,
|
||||
) -> None:
|
||||
"""Test that the temperature unit is passed through correctly."""
|
||||
entity_info = [
|
||||
ClimateInfo(
|
||||
object_id="myclimate",
|
||||
key=1,
|
||||
name="my climate",
|
||||
temperature_unit=temperature_unit,
|
||||
)
|
||||
]
|
||||
states = [ClimateState(key=1, mode=ClimateMode.COOL, target_temperature=22)]
|
||||
await mock_generic_device_entry(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
state = hass.states.get("climate.test_my_climate")
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_TEMPERATURE] == expected_temperature
|
||||
|
||||
|
||||
async def test_climate_entity_fahrenheit_unit(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
) -> None:
|
||||
"""Test that a Fahrenheit climate entity converts temperatures correctly."""
|
||||
entity_info = [
|
||||
ClimateInfo(
|
||||
object_id="myclimate",
|
||||
key=1,
|
||||
name="my climate",
|
||||
temperature_unit=TemperatureUnit.FAHRENHEIT,
|
||||
visual_min_temperature=32.0,
|
||||
visual_max_temperature=212.0,
|
||||
)
|
||||
]
|
||||
states = [ClimateState(key=1, mode=ClimateMode.COOL, target_temperature=32.0)]
|
||||
await mock_generic_device_entry(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
state = hass.states.get("climate.test_my_climate")
|
||||
assert state is not None
|
||||
# 32 °F and 212 °F displayed in the HA system unit (°C)
|
||||
assert state.attributes[ATTR_MIN_TEMP] == 0.0
|
||||
assert state.attributes[ATTR_MAX_TEMP] == 100.0
|
||||
# 32 °F target displayed in °C
|
||||
assert state.attributes[ATTR_TEMPERATURE] == 0.0
|
||||
|
||||
# set_temperature is called in °C; ESPHome must receive the °F equivalent
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 10},
|
||||
blocking=True,
|
||||
)
|
||||
mock_client.climate_command.assert_called_once_with(
|
||||
key=1, target_temperature=50.0, device_id=0
|
||||
)
|
||||
|
||||
|
||||
async def test_climate_entity_fahrenheit_unit_fahrenheit_system(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
) -> None:
|
||||
"""Test a Fahrenheit climate entity under a Fahrenheit HA system passes through unchanged."""
|
||||
hass.config.units = US_CUSTOMARY_SYSTEM
|
||||
entity_info = [
|
||||
ClimateInfo(
|
||||
object_id="myclimate",
|
||||
key=1,
|
||||
name="my climate",
|
||||
temperature_unit=TemperatureUnit.FAHRENHEIT,
|
||||
visual_min_temperature=32.0,
|
||||
visual_max_temperature=212.0,
|
||||
)
|
||||
]
|
||||
states = [ClimateState(key=1, mode=ClimateMode.COOL, target_temperature=72.0)]
|
||||
await mock_generic_device_entry(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
state = hass.states.get("climate.test_my_climate")
|
||||
assert state is not None
|
||||
# No conversion — device and system are both °F
|
||||
assert state.attributes[ATTR_MIN_TEMP] == 32.0
|
||||
assert state.attributes[ATTR_MAX_TEMP] == 212.0
|
||||
assert state.attributes[ATTR_TEMPERATURE] == 72.0
|
||||
|
||||
# set_temperature is called in °F; ESPHome must receive the same value
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 86},
|
||||
blocking=True,
|
||||
)
|
||||
mock_client.climate_command.assert_called_once_with(
|
||||
key=1, target_temperature=86.0, device_id=0
|
||||
)
|
||||
|
||||
@@ -14,12 +14,14 @@ from aioesphomeapi import (
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
SubDeviceInfo,
|
||||
TemperatureUnit,
|
||||
build_device_unique_id,
|
||||
build_unique_id,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.esphome import DOMAIN
|
||||
from homeassistant.components.esphome.const import TEMPERATURE_UNIT_MAP
|
||||
from homeassistant.const import (
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_ICON,
|
||||
@@ -3481,3 +3483,8 @@ async def test_mover_does_not_adopt_other_movers_state(
|
||||
|
||||
assert hass.states.get("binary_sensor.test_sensor_one").state == STATE_UNKNOWN
|
||||
assert hass.states.get("binary_sensor.test_sensor_two").state == STATE_UNKNOWN
|
||||
|
||||
|
||||
def test_temperature_unit_map_covers_all_units() -> None:
|
||||
"""Every aioesphomeapi TemperatureUnit must be mapped."""
|
||||
assert set(TEMPERATURE_UNIT_MAP) == set(TemperatureUnit)
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import call
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
TemperatureUnit,
|
||||
WaterHeaterFeature,
|
||||
WaterHeaterInfo,
|
||||
WaterHeaterMode,
|
||||
@@ -14,6 +15,8 @@ import pytest
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
ATTR_AWAY_MODE,
|
||||
ATTR_MAX_TEMP,
|
||||
ATTR_MIN_TEMP,
|
||||
ATTR_OPERATION_LIST,
|
||||
DOMAIN as WATER_HEATER_DOMAIN,
|
||||
SERVICE_SET_AWAY_MODE,
|
||||
@@ -29,6 +32,7 @@ from homeassistant.const import (
|
||||
ATTR_TEMPERATURE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
|
||||
|
||||
from .conftest import MockGenericDeviceEntryType
|
||||
|
||||
@@ -462,3 +466,125 @@ async def test_water_heater_set_away_mode(
|
||||
mock_client.water_heater_command.assert_has_calls(
|
||||
[call(key=1, away=away_mode, device_id=0)]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("temperature_unit", "expected_temperature"),
|
||||
[
|
||||
pytest.param(TemperatureUnit.CELSIUS, 50.0, id="celsius"),
|
||||
pytest.param(TemperatureUnit.FAHRENHEIT, 10.0, id="fahrenheit"),
|
||||
pytest.param(TemperatureUnit.KELVIN, -223.1, id="kelvin"),
|
||||
pytest.param(None, 50.0, id="none_falls_back_to_celsius"),
|
||||
],
|
||||
)
|
||||
async def test_water_heater_temperature_unit(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
temperature_unit: TemperatureUnit | int | None,
|
||||
expected_temperature: float,
|
||||
) -> None:
|
||||
"""Test that the temperature unit is passed through correctly."""
|
||||
entity_info = [
|
||||
WaterHeaterInfo(
|
||||
object_id="my_boiler",
|
||||
key=1,
|
||||
name="My Boiler",
|
||||
min_temperature=10.0,
|
||||
max_temperature=85.0,
|
||||
temperature_unit=temperature_unit,
|
||||
)
|
||||
]
|
||||
states = [WaterHeaterState(key=1, target_temperature=50.0)]
|
||||
await mock_generic_device_entry(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
state = hass.states.get("water_heater.test_my_boiler")
|
||||
assert state is not None
|
||||
assert state.attributes[ATTR_TEMPERATURE] == expected_temperature
|
||||
|
||||
|
||||
async def test_water_heater_fahrenheit_unit(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
) -> None:
|
||||
"""Test that a Fahrenheit water heater converts temperatures correctly."""
|
||||
entity_info = [
|
||||
WaterHeaterInfo(
|
||||
object_id="my_boiler",
|
||||
key=1,
|
||||
name="My Boiler",
|
||||
min_temperature=32.0,
|
||||
max_temperature=212.0,
|
||||
temperature_unit=TemperatureUnit.FAHRENHEIT,
|
||||
)
|
||||
]
|
||||
states = [WaterHeaterState(key=1, target_temperature=32.0)]
|
||||
await mock_generic_device_entry(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
state = hass.states.get("water_heater.test_my_boiler")
|
||||
assert state is not None
|
||||
# 32 °F and 212 °F displayed in the HA system unit (°C)
|
||||
assert state.attributes[ATTR_MIN_TEMP] == 0.0
|
||||
assert state.attributes[ATTR_MAX_TEMP] == 100.0
|
||||
# 32 °F target displayed in °C
|
||||
assert state.attributes[ATTR_TEMPERATURE] == 0.0
|
||||
|
||||
# set_temperature is called in °C; ESPHome must receive the °F equivalent
|
||||
await hass.services.async_call(
|
||||
WATER_HEATER_DOMAIN,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_ENTITY_ID: "water_heater.test_my_boiler", ATTR_TEMPERATURE: 10},
|
||||
blocking=True,
|
||||
)
|
||||
mock_client.water_heater_command.assert_called_once_with(
|
||||
key=1, target_temperature=50.0, device_id=0
|
||||
)
|
||||
|
||||
|
||||
async def test_water_heater_fahrenheit_unit_fahrenheit_system(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
) -> None:
|
||||
"""Test a Fahrenheit water heater under a Fahrenheit HA system passes through unchanged."""
|
||||
hass.config.units = US_CUSTOMARY_SYSTEM
|
||||
entity_info = [
|
||||
WaterHeaterInfo(
|
||||
object_id="my_boiler",
|
||||
key=1,
|
||||
name="My Boiler",
|
||||
min_temperature=32.0,
|
||||
max_temperature=212.0,
|
||||
temperature_unit=TemperatureUnit.FAHRENHEIT,
|
||||
)
|
||||
]
|
||||
states = [WaterHeaterState(key=1, target_temperature=72.0)]
|
||||
await mock_generic_device_entry(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
state = hass.states.get("water_heater.test_my_boiler")
|
||||
assert state is not None
|
||||
# No conversion — device and system are both °F
|
||||
assert state.attributes[ATTR_MIN_TEMP] == 32.0
|
||||
assert state.attributes[ATTR_MAX_TEMP] == 212.0
|
||||
assert state.attributes[ATTR_TEMPERATURE] == 72.0
|
||||
|
||||
# set_temperature is called in °F; ESPHome must receive the same value
|
||||
await hass.services.async_call(
|
||||
WATER_HEATER_DOMAIN,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_ENTITY_ID: "water_heater.test_my_boiler", ATTR_TEMPERATURE: 86},
|
||||
blocking=True,
|
||||
)
|
||||
mock_client.water_heater_command.assert_called_once_with(
|
||||
key=1, target_temperature=86.0, device_id=0
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user