diff --git a/README.en.md b/README.en.md index c5cd0216..0307829a 100644 --- a/README.en.md +++ b/README.en.md @@ -41,6 +41,8 @@ This unofficial HACS integration allows you to use [Hilo](https://www.hiloenergi ✅ Thermostat control and temperature reading +✅ Support for 24 V low-voltage thermostats (e.g. Honeywell T10), including mode and fan + ✅ Energy consumption monitoring of Hilo devices ✅ Sensor for Hilo challenges and gateway diff --git a/README.md b/README.md index f1804890..4224f189 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Cette intégration non-officielle HACS permet d'utiliser [Hilo](https://www.hilo ✅ Contrôle des thermostats et lecture des températures +✅ Support des thermostats basse tension 24 V (ex. : Honeywell T10), incluant mode et ventilateur + ✅ Suivi de la consommation énergétique des appareils Hilo ✅ Sensor pour les défis et la passerelle Hilo diff --git a/custom_components/hilo/climate.py b/custom_components/hilo/climate.py index da38173a..63f046a6 100755 --- a/custom_components/hilo/climate.py +++ b/custom_components/hilo/climate.py @@ -4,6 +4,9 @@ from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, ClimateEntityFeature, HVACAction, HVACMode, @@ -15,6 +18,7 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -22,6 +26,25 @@ from .const import CLIMATE_CLASSES, LOG from .entity import HiloEntity +# Hilo mode vocabulary -> Home Assistant HVAC modes. Keys are normalized +# (lowercase, no separator) so a casing change upstream is tolerated: Hilo +# spells them EMERGENCY_HEAT, HEAT, OFF, COOL and AUTO. +HILO_MODE_TO_HVAC = { + "off": HVACMode.OFF, + "heat": HVACMode.HEAT, + "heating": HVACMode.HEAT, + "emergencyheat": HVACMode.HEAT, + "auxheat": HVACMode.HEAT, + "cool": HVACMode.COOL, + "cooling": HVACMode.COOL, + "auto": HVACMode.HEAT_COOL, +} + + +def normalize_mode(value) -> str: + """Normalize a Hilo mode string for lookup purposes.""" + return str(value).strip().lower().replace("_", "").replace(" ", "").replace("-", "") + def validate_reduction_phase(events, tag): """Validate if current time is within a challenge lock reduction phase.""" @@ -62,10 +85,8 @@ async def async_setup_entry( class HiloClimate(HiloEntity, ClimateEntity): """Representation of a Hilo Climate entity.""" - _attr_hvac_modes = [HVACMode.HEAT] _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_precision: float = PRECISION_TENTHS - _attr_supported_features: int = ClimateEntityFeature.TARGET_TEMPERATURE def __init__(self, hilo: Hilo, device): """Initialize the climate entity.""" @@ -85,58 +106,286 @@ def __init__(self, hilo: Hilo, device): self._temperature_entity = None LOG.debug("Setting up Climate entity: %s", self._attr_name) + @property + def _is_low_voltage(self) -> bool: + """Whether the device is a 24 V thermostat reporting a real mode. + + getattr() keeps the platform working against an older python-hilo: the + entity then simply behaves as it did before, heating only. + """ + return getattr(self._device, "is_low_voltage", False) + + # ------------------------------------------------------------------ + # Temperature + # ------------------------------------------------------------------ @property def current_temperature(self): """Return the current temperature.""" return self._device.current_temperature + @property + def current_humidity(self): + """Return the ambient humidity, on devices that report it.""" + if not self._is_low_voltage: + return None + return self._device.current_humidity + @property def target_temperature(self): - """Return the target temperature.""" + """Return the target temperature. + + In cooling mode the relevant setpoint is the cooling one; returning the + heating setpoint would display a value unrelated to what the system is + actually doing. In auto mode both matter, so the range properties are + used instead. + """ + if self.hvac_mode == HVACMode.HEAT_COOL: + return None + if self.hvac_mode == HVACMode.COOL: + cool_setpoint = self._device.cool_setpoint + if cool_setpoint is not None: + return cool_setpoint return self._device.target_temperature + @property + def target_temperature_low(self): + """Heating setpoint, used in auto mode.""" + if self.hvac_mode != HVACMode.HEAT_COOL: + return None + return self._device.target_temperature + + @property + def target_temperature_high(self): + """Cooling setpoint, used in auto mode.""" + if self.hvac_mode != HVACMode.HEAT_COOL: + return None + return self._device.cool_setpoint + @property def max_temp(self): """Return the maximum temperature.""" + if self.hvac_mode == HVACMode.COOL: + max_cool = self._device.max_cool_setpoint + if max_cool is not None: + return max_cool return self._device.max_temp @property def min_temp(self): """Return the minimum temperature.""" + if self.hvac_mode == HVACMode.COOL: + min_cool = self._device.min_cool_setpoint + if min_cool is not None: + return min_cool return self._device.min_temp - def set_hvac_mode(self, hvac_mode): - """Set hvac mode.""" - return + # ------------------------------------------------------------------ + # HVAC mode + # ------------------------------------------------------------------ + @property + def hvac_modes(self): + """Return the HVAC modes advertised by the device.""" + if not self._is_low_voltage: + return [HVACMode.HEAT] + modes = [] + for raw in self._device.allowed_modes: + mode = HILO_MODE_TO_HVAC.get(normalize_mode(raw)) + if mode is None: + LOG.debug( + "%s Unknown Hilo thermostat mode %s, ignoring", + self._device._tag, + raw, + ) + elif mode not in modes: + modes.append(mode) + if not modes: + # An ordinary thermostat advertises an empty list, and a climate + # entity with no mode at all is invalid in Home Assistant. + modes = [HVACMode.HEAT] + current = self.hvac_mode + if current not in modes: + modes.append(current) + return modes @property def hvac_mode(self): - """Return hvac mode.""" - return HVACMode.HEAT + """Return the current HVAC mode reported by Hilo.""" + if not self._is_low_voltage: + return HVACMode.HEAT + raw = self._device.low_voltage_mode + if raw is None: + return HVACMode.HEAT + mode = HILO_MODE_TO_HVAC.get(normalize_mode(raw)) + if mode is None: + LOG.warning( + "%s Unknown Hilo thermostat mode %s, falling back to heat", + self._device._tag, + raw, + ) + return HVACMode.HEAT + return mode @property def hvac_action(self): """Return the current hvac action.""" + if self.hvac_mode == HVACMode.OFF: + return HVACAction.OFF return self._device.hvac_action + # ------------------------------------------------------------------ + # Fan + # ------------------------------------------------------------------ + @property + def fan_modes(self): + """Return the fan modes advertised by the device, if any.""" + if not self._is_low_voltage: + return None + return self._device.allowed_fan_modes or None + + @property + def fan_mode(self): + """Return the current fan mode, if any.""" + if not self._is_low_voltage: + return None + return self._device.fan_mode + + @property + def supported_features(self): + """Return the features supported by this particular device.""" + if self.hvac_mode == HVACMode.HEAT_COOL: + features = ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + else: + features = ClimateEntityFeature.TARGET_TEMPERATURE + if self.fan_modes: + features |= ClimateEntityFeature.FAN_MODE + modes = self.hvac_modes + if HVACMode.OFF in modes: + features |= ClimateEntityFeature.TURN_OFF + if len(modes) > 1: + features |= ClimateEntityFeature.TURN_ON + return features + @property def icon(self): """Return the icon to use in the frontend, based on hvac_action.""" + if self.hvac_mode == HVACMode.COOL: + return "mdi:snowflake" if self._device.hvac_action == HVACAction.HEATING: return "mdi:radiator" return "mdi:radiator-disabled" - async def async_set_temperature(self, **kwargs): - """Set new target temperature.""" - if ATTR_TEMPERATURE in kwargs: - if self._hilo.challenge_lock: - challenge = self._hilo._hass.states.get("sensor.defi_hilo") - validate_reduction_phase( - challenge.attributes.get("next_events", []), self._device._tag - ) - LOG.info( - f"{self._device._tag} Setting temperature to {kwargs[ATTR_TEMPERATURE]}" + # ------------------------------------------------------------------ + # Commands + # ------------------------------------------------------------------ + def _check_challenge_lock(self) -> None: + """Block setpoint changes during a Hilo challenge reduction phase.""" + if self._hilo.challenge_lock: + challenge = self._hilo._hass.states.get("sensor.defi_hilo") + validate_reduction_phase( + challenge.attributes.get("next_events", []), self._device._tag ) - await self._device.set_attribute( - "target_temperature", kwargs[ATTR_TEMPERATURE] + + async def async_set_hvac_mode(self, hvac_mode): + """Set a new HVAC mode. + + The value sent back is taken from the vocabulary the device advertises, + so the exact spelling Hilo expects is preserved. Among the raw strings + that map to the requested mode, the one whose normalized spelling + exactly matches the Home Assistant mode is preferred over any other + match: Hilo maps both "heat" and "emergencyheat" to HVACMode.HEAT, and + picking the first one in allowed_modes order would silently engage the + resistive backup strip (EMERGENCY_HEAT) for a plain Heat request. + """ + if not self._is_low_voltage: + if hvac_mode == HVACMode.HEAT: + # Restoring the only mode this device ever reports is a + # legitimate no-op (e.g. climate.set_hvac_mode: heat from an + # automation), not something worth warning about. + return + LOG.warning( + "%s Only heating is available on this device", self._device._tag ) + return + + self._check_challenge_lock() + + candidates = [ + raw + for raw in self._device.allowed_modes + if HILO_MODE_TO_HVAC.get(normalize_mode(raw)) == hvac_mode + ] + if not candidates: + raise HomeAssistantError( + f"Mode {hvac_mode} is not offered by {self._device.name} " + f"(allowed: {self._device.allowed_modes})" + ) + target = next( + ( + raw + for raw in candidates + if normalize_mode(raw) == normalize_mode(hvac_mode) + ), + candidates[0], + ) + LOG.info("%s Setting mode to %s", self._device._tag, target) + try: + await self._device.async_set_low_voltage_state(mode=target) + except ValueError as err: + raise HomeAssistantError(str(err)) from err + + async def async_set_fan_mode(self, fan_mode): + """Set a new fan mode.""" + if not self._is_low_voltage: + return + LOG.info("%s Setting fan mode to %s", self._device._tag, fan_mode) + try: + await self._device.async_set_low_voltage_state(fan_mode=fan_mode) + except ValueError as err: + raise HomeAssistantError(str(err)) from err + + async def async_set_temperature(self, **kwargs): + """Set new target temperature. + + In heat or cool mode Home Assistant sends a single setpoint, which maps + to the heating or the cooling one depending on the current mode. In auto + mode it sends a range, which maps to both at once. A hvac_mode kwarg is + forwarded by HA's SET_TEMPERATURE_SCHEMA when a service call sets both + the mode and the setpoint together; the mode is switched first so the + setpoint below is routed against the mode being requested rather than + the one the device is still reporting. + """ + low = kwargs.get(ATTR_TARGET_TEMP_LOW) + high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + single = kwargs.get(ATTR_TEMPERATURE) + hvac_mode = kwargs.get(ATTR_HVAC_MODE) + + if low is None and high is None and single is None: + return + + self._check_challenge_lock() + + if hvac_mode is not None: + await self.async_set_hvac_mode(hvac_mode) + + if not self._is_low_voltage: + LOG.info("%s Setting temperature to %s", self._device._tag, single) + await self._device.set_attribute("target_temperature", single) + return + + changes = {} + if low is not None: + changes["target_temperature"] = low + if high is not None: + changes["cool_setpoint"] = high + if single is not None: + target_mode = hvac_mode if hvac_mode is not None else self.hvac_mode + if target_mode == HVACMode.COOL: + changes["cool_setpoint"] = single + else: + changes["target_temperature"] = single + + LOG.info("%s Setting temperature: %s", self._device._tag, changes) + try: + await self._device.async_set_low_voltage_state(**changes) + except ValueError as err: + raise HomeAssistantError(str(err)) from err diff --git a/tests/fixtures/all_devices.json b/tests/fixtures/all_devices.json index bf4aaa59..778d5752 100644 --- a/tests/fixtures/all_devices.json +++ b/tests/fixtures/all_devices.json @@ -126,7 +126,8 @@ "LoadConnected", "LockKeypad", "BackLight" - ] + ], + "Disconnected": false }, { "id": 111001, @@ -403,5 +404,57 @@ "Disconnected": { "value": false } + }, + { + "id": 111008, + "assetId": "d6fd23f0-6c7a-45aa-8d84-1f000e11a4b5", + "hiloId": "urn:hilo:philo:2b3c4d5e6f70:0", + "identifier": "2b3c4d5e6f70", + "gatewayId": 1001, + "gatewayExternalId": "100000", + "gatewayAssetId": "bff2b9f2-760f-4210-b9e6-686e12b407ae", + "name": "Thermostat 24V", + "type": "Thermostat24V", + "groupId": 7833, + "category": "Heating", + "icon": null, + "loadConnected": null, + "modelNumber": "T9-T10", + "locationId": 123, + "parameters": null, + "externalGroup": "LowVoltageTstat", + "provider": 6, + "providerData": null, + "isFavorite": false, + "eTag": "", + "supportedAttributes": "CurrentTemperature,TargetTemperature,Heating,Humidity,Power,Disconnected,MaxTempSetpoint,MinTempSetpoint", + "supportedAttributesList": [ + "CurrentTemperature", + "TargetTemperature", + "Heating", + "Humidity", + "Power", + "Disconnected", + "MaxTempSetpoint", + "MinTempSetpoint" + ], + "settableAttributes": "Disconnected", + "settableAttributesList": ["Disconnected"], + "supportedParameters": "", + "supportedParametersList": [], + "CurrentTemperature": 24, + "TargetTemperature": 19, + "Humidity": 57, + "Heating": 0, + "Thermostat24VMode": "COOL", + "Thermostat24VAllowedMode": ["EMERGENCY_HEAT", "HEAT", "OFF", "COOL", "AUTO"], + "Thermostat24VAllowedFanMode": ["ON", "AUTO", "CIRCULATE"], + "FanMode": "ON", + "FanSpeed": 1, + "CurrentState": "OFF", + "CoolTemperatureSet": 24, + "MinCoolSetpoint": 10, + "MaxCoolSetpoint": 32, + "Disconnected": false } ] diff --git a/tests/test_climate.py b/tests/test_climate.py index 7959f065..b5870bfb 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -1,15 +1,20 @@ """Tests for the Hilo climate platform.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from homeassistant.components.climate.const import ATTR_HVAC_MODE, HVACMode from homeassistant.const import ( + ATTR_TEMPERATURE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from pyhilo.device import HiloDevice import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry +from custom_components.hilo.climate import HiloClimate + from . import setup_with_selected_platforms @@ -25,28 +30,243 @@ async def test_climate( hass, mock_config_entry, [Platform.CLIMATE], mock_api ) - entity_entries = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id + entity_entries = { + entry.unique_id: entry + for entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + } + assert set(entity_entries) == {"1a2b3c4d5e6f-climate", "2b3c4d5e6f70-climate"} + + # TODO: Consider reverting to syrupy snapshot testing once support for + # Python 3.13 (Home Assistant < 2026.3) is dropped. In HA 2026.3+, internal + # EntityRegistryEntry capability dictionary keys changed from strings + # ('hvac_modes') to enum members (ClimateEntityCapabilityAttribute.HVAC_MODES), + # causing snapshot comparisons to diverge across supported HA versions. + # Direct property assertions test the integration behavior reliably across both. + entity_entry = entity_entries["1a2b3c4d5e6f-climate"] + assert entity_entry.domain == "climate" + assert entity_entry.platform == "hilo" + assert entity_entry.original_name == "Thermostat 1" + assert entity_entry.original_icon == "mdi:radiator-disabled" + + state = hass.states.get(entity_entry.entity_id) + assert state is not None + assert state.state == HVACMode.HEAT + assert state.attributes.get("friendly_name") == "Thermostat 1" + assert state.attributes.get("icon") == "mdi:radiator-disabled" + assert state.attributes.get("hvac_action") == "idle" + assert state.attributes.get("hvac_modes") == [HVACMode.HEAT] + assert state.attributes.get("supported_features") == 1 + assert state.attributes.get("current_temperature") == 0 + assert state.attributes.get("temperature") == 0 + assert state.attributes.get("min_temp") == 5.0 + assert state.attributes.get("max_temp") == 36.0 + + entity_entry = entity_entries["2b3c4d5e6f70-climate"] + assert entity_entry.domain == "climate" + assert entity_entry.platform == "hilo" + assert entity_entry.original_name == "Thermostat 24V" + assert entity_entry.original_icon == "mdi:snowflake" + + state = hass.states.get(entity_entry.entity_id) + assert state is not None + assert state.state == HVACMode.COOL + assert state.attributes.get("friendly_name") == "Thermostat 24V" + assert state.attributes.get("icon") == "mdi:snowflake" + assert state.attributes.get("hvac_action") == "idle" + assert state.attributes.get("hvac_modes") == [ + HVACMode.HEAT, + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT_COOL, + ] + assert state.attributes.get("fan_mode") == "ON" + assert state.attributes.get("fan_modes") == ["ON", "AUTO", "CIRCULATE"] + assert state.attributes.get("current_temperature") == 24 + assert state.attributes.get("current_humidity") == 57 + assert state.attributes.get("temperature") == 24.0 + assert state.attributes.get("min_temp") == 10.0 + assert state.attributes.get("max_temp") == 32.0 + + +def _get_climate_entity( + hass: HomeAssistant, config_entry: MockConfigEntry, device_name: str +) -> HiloClimate: + """Return the HiloClimate entity backing the named test fixture device.""" + hilo = config_entry.runtime_data + device: HiloDevice = next(d for d in hilo.devices.all if d.name == device_name) + assert isinstance(device._entity, HiloClimate) + return device._entity + + +@pytest.mark.usefixtures("mock_api") +async def test_async_set_hvac_mode_heat_writes_heat_not_emergency_heat( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """Selecting Heat must never engage the resistive backup strip. + + HILO_MODE_TO_HVAC maps both "heat" and "emergencyheat" to HVACMode.HEAT, + and the fixture's allowed_modes lists EMERGENCY_HEAT before HEAT + (["EMERGENCY_HEAT", "HEAT", "OFF", "COOL", "AUTO"], mirroring what Hilo + actually returns). Picking the first list match therefore used to write + EMERGENCY_HEAT for a plain Heat request, silently engaging the backup + strip at several times the running cost. + """ + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.CLIMATE], mock_api + ) + entity = _get_climate_entity(hass, mock_config_entry, "Thermostat 24V") + + with patch.object( + entity._device, "async_set_low_voltage_state", new=AsyncMock() + ) as mock_set: + await entity.async_set_hvac_mode(HVACMode.HEAT) + + mock_set.assert_awaited_once_with(mode="HEAT") + + +@pytest.mark.usefixtures("mock_api") +async def test_async_set_fan_mode( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """A fan mode change is written through to the device untouched.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.CLIMATE], mock_api + ) + entity = _get_climate_entity(hass, mock_config_entry, "Thermostat 24V") + + with patch.object( + entity._device, "async_set_low_voltage_state", new=AsyncMock() + ) as mock_set: + await entity.async_set_fan_mode("ON") + + mock_set.assert_awaited_once_with(fan_mode="ON") + + +@pytest.mark.usefixtures("mock_api") +async def test_async_set_temperature_in_cool_mode_sets_cool_setpoint( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """A single setpoint while in COOL must land on the cooling setpoint. + + The fixture device reports Thermostat24VMode: COOL, so its entity is + already in HVACMode.COOL without any mode switch. + """ + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.CLIMATE], mock_api + ) + entity = _get_climate_entity(hass, mock_config_entry, "Thermostat 24V") + assert entity.hvac_mode == HVACMode.COOL + + with patch.object( + entity._device, "async_set_low_voltage_state", new=AsyncMock() + ) as mock_set: + await entity.async_set_temperature(**{ATTR_TEMPERATURE: 22}) + + mock_set.assert_awaited_once_with(cool_setpoint=22) + + +@pytest.mark.usefixtures("mock_api") +async def test_async_set_temperature_with_hvac_mode_kwarg_switches_then_routes( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """climate.set_temperature with an hvac_mode kwarg switches mode first. + + A device currently in HEAT receiving hvac_mode: cool, temperature: 22 + must both switch the device to COOL and route 22 to the cooling + setpoint, not the heating one. + """ + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.CLIMATE], mock_api + ) + entity = _get_climate_entity(hass, mock_config_entry, "Thermostat 24V") + + with ( + patch.object( + type(entity._device), + "low_voltage_mode", + new_callable=PropertyMock, + return_value="HEAT", + ), + patch.object( + entity._device, "async_set_low_voltage_state", new=AsyncMock() + ) as mock_set, + ): + assert entity.hvac_mode == HVACMode.HEAT + await entity.async_set_temperature( + **{ATTR_HVAC_MODE: HVACMode.COOL, ATTR_TEMPERATURE: 22} + ) + + assert mock_set.await_args_list[0].kwargs == {"mode": "COOL"} + assert mock_set.await_args_list[-1].kwargs == {"cool_setpoint": 22} + + +@pytest.mark.usefixtures("mock_api") +async def test_async_set_temperature_baseboard_still_uses_set_attribute( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, +) -> None: + """Baseboard thermostats keep writing through set_attribute, unchanged.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.CLIMATE], mock_api + ) + entity = _get_climate_entity(hass, mock_config_entry, "Thermostat 1") + + with patch.object(entity._device, "set_attribute", new=AsyncMock()) as mock_set: + await entity.async_set_temperature(**{ATTR_TEMPERATURE: 21}) + + mock_set.assert_awaited_once_with("target_temperature", 21) + + +@pytest.mark.usefixtures("mock_api") +async def test_async_set_hvac_mode_heat_on_baseboard_is_a_silent_noop( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """climate.set_hvac_mode: heat on a baseboard device must not log. + + HA validates hvac_mode against hvac_modes before calling in, and heat is + the only mode a baseboard thermostat ever advertises, so this is a very + common and perfectly legal automation/scene call. The old class silently + no-opped it; a warning here would be new log spam for existing users. + """ + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.CLIMATE], mock_api + ) + entity = _get_climate_entity(hass, mock_config_entry, "Thermostat 1") + + caplog.clear() + await entity.async_set_hvac_mode(HVACMode.HEAT) + + assert "Only heating is available" not in caplog.text + + +@pytest.mark.usefixtures("mock_api") +async def test_async_set_hvac_mode_cool_on_baseboard_still_warns( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A genuinely unsupported mode on a baseboard device keeps its diagnostic.""" + await setup_with_selected_platforms( + hass, mock_config_entry, [Platform.CLIMATE], mock_api ) + entity = _get_climate_entity(hass, mock_config_entry, "Thermostat 1") - assert entity_entries - for entity_entry in entity_entries: - # TODO: Consider reverting to syrupy snapshot testing once support for - # Python 3.13 (Home Assistant < 2026.3) is dropped. In HA 2026.3+, internal - # EntityRegistryEntry capability dictionary keys changed from strings - # ('hvac_modes') to enum members (ClimateEntityCapabilityAttribute.HVAC_MODES), - # causing snapshot comparisons to diverge across supported HA versions. - # Direct property assertions test the integration behavior reliably across both. - assert entity_entry.domain == "climate" - assert entity_entry.platform == "hilo" - assert entity_entry.unique_id == "1a2b3c4d5e6f-climate" - assert entity_entry.original_name == "Thermostat 1" - assert entity_entry.original_icon == "mdi:radiator-disabled" + caplog.clear() + await entity.async_set_hvac_mode(HVACMode.COOL) - state = hass.states.get(entity_entry.entity_id) - assert state is not None - assert state.state == "unavailable" - assert state.attributes.get("friendly_name") == "Thermostat 1" - assert state.attributes.get("icon") == "mdi:radiator-disabled" - assert state.attributes.get("min_temp") == 5.0 - assert state.attributes.get("max_temp") == 36.0 + assert "Only heating is available" in caplog.text