From 2208650fdea8410fc0c214786da9239a7ece2de9 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Wed, 26 Mar 2025 01:25:05 +0100 Subject: [PATCH] Add climate platform to qbus (#139327) * Add climate platform * Add unit tests for climate platform * Use setup_integration fixture * Apply new import order * Undo import order * Code review * Throw an exception on invalid preset mode * Let device response determine state * Remove hvac mode OFF * Remove hvac mode OFF * Setup debouncer when being added to hass * Fix typo --- homeassistant/components/qbus/climate.py | 172 +++++++++++++ homeassistant/components/qbus/const.py | 1 + homeassistant/components/qbus/strings.json | 5 + tests/components/qbus/conftest.py | 25 +- .../qbus/fixtures/payload_config.json | 36 ++- tests/components/qbus/test_climate.py | 228 ++++++++++++++++++ tests/components/qbus/test_light.py | 16 +- tests/components/qbus/test_switch.py | 16 +- 8 files changed, 468 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/qbus/climate.py create mode 100644 tests/components/qbus/test_climate.py diff --git a/homeassistant/components/qbus/climate.py b/homeassistant/components/qbus/climate.py new file mode 100644 index 00000000000..57d97c046b7 --- /dev/null +++ b/homeassistant/components/qbus/climate.py @@ -0,0 +1,172 @@ +"""Support for Qbus thermostat.""" + +import logging +from typing import Any + +from qbusmqttapi.const import KEY_PROPERTIES_REGIME, KEY_PROPERTIES_SET_TEMPERATURE +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttThermoState, StateType + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, add_new_outputs + +PARALLEL_UPDATES = 0 + +STATE_REQUEST_DELAY = 2 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up climate entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + add_new_outputs( + coordinator, + added_outputs, + lambda output: output.type == "thermo", + QbusClimate, + async_add_entities, + ) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusClimate(QbusEntity, ClimateEntity): + """Representation of a Qbus climate entity.""" + + _attr_hvac_modes = [HVACMode.HEAT] + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize climate entity.""" + + super().__init__(mqtt_output) + + self._attr_hvac_action = HVACAction.IDLE + self._attr_hvac_mode = HVACMode.HEAT + + set_temp: dict[str, Any] = mqtt_output.properties.get( + KEY_PROPERTIES_SET_TEMPERATURE, {} + ) + current_regime: dict[str, Any] = mqtt_output.properties.get( + KEY_PROPERTIES_REGIME, {} + ) + + self._attr_min_temp: float = set_temp.get("min", 0) + self._attr_max_temp: float = set_temp.get("max", 35) + self._attr_target_temperature_step: float = set_temp.get("step", 0.5) + self._attr_preset_modes: list[str] = current_regime.get("enumValues", []) + self._attr_preset_mode: str = ( + self._attr_preset_modes[0] if len(self._attr_preset_modes) > 0 else "" + ) + + self._request_state_debouncer: Debouncer | None = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self._request_state_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=STATE_REQUEST_DELAY, + immediate=False, + function=self._async_request_state, + ) + await super().async_added_to_hass() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + + if preset_mode not in self._attr_preset_modes: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_preset", + translation_placeholders={ + "preset": preset_mode, + "options": ", ".join(self._attr_preset_modes), + }, + ) + + state = QbusMqttThermoState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_regime(preset_mode) + + await self._async_publish_output_state(state) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + + if temperature is not None and isinstance(temperature, float): + state = QbusMqttThermoState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_set_temperature(temperature) + + await self._async_publish_output_state(state) + + async def _state_received(self, msg: ReceiveMessage) -> None: + state = self._message_factory.parse_output_state( + QbusMqttThermoState, msg.payload + ) + + if state is None: + return + + if preset_mode := state.read_regime(): + self._attr_preset_mode = preset_mode + + if current_temperature := state.read_current_temperature(): + self._attr_current_temperature = current_temperature + + if target_temperature := state.read_set_temperature(): + self._attr_target_temperature = target_temperature + + self._set_hvac_action() + + # When the state type is "event", the payload only contains the changed + # property. Request the state to get the full payload. However, changing + # temperature step by step could cause a flood of state requests, so we're + # holding off a few seconds before requesting the full state. + if state.type == StateType.EVENT: + assert self._request_state_debouncer is not None + await self._request_state_debouncer.async_call() + + self.async_schedule_update_ha_state() + + def _set_hvac_action(self) -> None: + if self.target_temperature is None or self.current_temperature is None: + self._attr_hvac_action = HVACAction.IDLE + return + + self._attr_hvac_action = ( + HVACAction.HEATING + if self.target_temperature > self.current_temperature + else HVACAction.IDLE + ) + + async def _async_request_state(self) -> None: + request = self._message_factory.create_state_request([self._mqtt_output.id]) + await mqtt.async_publish(self.hass, request.topic, request.payload) diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index b9e42f13766..767a41f48cc 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -6,6 +6,7 @@ from homeassistant.const import Platform DOMAIN: Final = "qbus" PLATFORMS: list[Platform] = [ + Platform.CLIMATE, Platform.LIGHT, Platform.SWITCH, ] diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json index e6df18c393c..f308c5b3519 100644 --- a/homeassistant/components/qbus/strings.json +++ b/homeassistant/components/qbus/strings.json @@ -15,5 +15,10 @@ "error": { "no_controller": "No controllers were found" } + }, + "exceptions": { + "invalid_preset": { + "message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}." + } } } diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py index 8268d091bda..f1fd96c321b 100644 --- a/tests/components/qbus/conftest.py +++ b/tests/components/qbus/conftest.py @@ -1,5 +1,7 @@ """Test fixtures for qbus.""" +import json + import pytest from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN @@ -7,9 +9,13 @@ from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonObjectType -from .const import FIXTURE_PAYLOAD_CONFIG +from .const import FIXTURE_PAYLOAD_CONFIG, TOPIC_CONFIG -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import ( + MockConfigEntry, + async_fire_mqtt_message, + load_json_object_fixture, +) @pytest.fixture @@ -31,3 +37,18 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: def payload_config() -> JsonObjectType: """Return the config topic payload.""" return load_json_object_fixture(FIXTURE_PAYLOAD_CONFIG, DOMAIN) + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + payload_config: JsonObjectType, +) -> None: + """Set up the integration.""" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) + await hass.async_block_till_done() diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json index e2c7f463e4e..fc204c975ad 100644 --- a/tests/components/qbus/fixtures/payload_config.json +++ b/tests/components/qbus/fixtures/payload_config.json @@ -46,7 +46,7 @@ { "id": "UL15", "location": "Media room", - "locationId": 0, + "locationId": 1, "name": "MEDIA ROOM", "originalName": "MEDIA ROOM", "refId": "000001/28", @@ -65,6 +65,40 @@ "write": true } } + }, + { + "id": "UL20", + "location": "Living", + "locationId": 0, + "name": "LIVING TH", + "originalName": "LIVING TH", + "refId": "000001/120", + "type": "thermo", + "actions": {}, + "properties": { + "currRegime": { + "enumValues": ["MANUEEL", "VORST", "ECONOMY", "COMFORT", "NACHT"], + "read": true, + "type": "enumString", + "write": true + }, + "currTemp": { + "max": 35, + "min": 0, + "read": true, + "step": 0.5, + "type": "number", + "write": false + }, + "setTemp": { + "max": 35, + "min": 0, + "read": true, + "step": 0.5, + "type": "number", + "write": true + } + } } ] } diff --git a/tests/components/qbus/test_climate.py b/tests/components/qbus/test_climate.py new file mode 100644 index 00000000000..d521e310984 --- /dev/null +++ b/tests/components/qbus/test_climate.py @@ -0,0 +1,228 @@ +"""Test Qbus light entities.""" + +from datetime import timedelta +from unittest.mock import MagicMock, call + +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + ClimateEntity, + HVACAction, + HVACMode, +) +from homeassistant.components.qbus.climate import STATE_REQUEST_DELAY +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_mqtt_message, async_fire_time_changed +from tests.typing import MqttMockHAClient + +_CURRENT_TEMPERATURE = 21.5 +_SET_TEMPERATURE = 20.5 +_REGIME = "COMFORT" + +_PAYLOAD_CLIMATE_STATE_TEMP = ( + f'{{"id":"UL20","properties":{{"setTemp":{_SET_TEMPERATURE}}},"type":"event"}}' +) +_PAYLOAD_CLIMATE_STATE_TEMP_FULL = f'{{"id":"UL20","properties":{{"currRegime":"MANUEEL","currTemp":{_CURRENT_TEMPERATURE},"setTemp":{_SET_TEMPERATURE}}},"type":"state"}}' + +_PAYLOAD_CLIMATE_STATE_PRESET = ( + f'{{"id":"UL20","properties":{{"currRegime":"{_REGIME}"}},"type":"event"}}' +) +_PAYLOAD_CLIMATE_STATE_PRESET_FULL = f'{{"id":"UL20","properties":{{"currRegime":"{_REGIME}","currTemp":{_CURRENT_TEMPERATURE},"setTemp":22.0}},"type":"state"}}' + +_PAYLOAD_CLIMATE_SET_TEMP = f'{{"id": "UL20", "type": "state", "properties": {{"setTemp": {_SET_TEMPERATURE}}}}}' +_PAYLOAD_CLIMATE_SET_PRESET = ( + '{"id": "UL20", "type": "state", "properties": {"currRegime": "COMFORT"}}' +) + +_TOPIC_CLIMATE_STATE = "cloudapp/QBUSMQTTGW/UL1/UL20/state" +_TOPIC_CLIMATE_SET_STATE = "cloudapp/QBUSMQTTGW/UL1/UL20/setState" +_TOPIC_GET_STATE = "cloudapp/QBUSMQTTGW/getState" + +_CLIMATE_ENTITY_ID = "climate.living_th" + + +async def test_climate( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate temperature & preset.""" + + # Set temperature + mqtt_mock.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_TEMPERATURE: _SET_TEMPERATURE, + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_CLIMATE_SET_STATE, _PAYLOAD_CLIMATE_SET_TEMP, 0, False + ) + + # Simulate a partial state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + + # Check state + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] is None + assert entity.attributes[ATTR_PRESET_MODE] == "MANUEEL" + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # After a delay, a full state request should've been sent + _wait_and_assert_state_request(hass, mqtt_mock) + + # Simulate a full state response + async_fire_mqtt_message( + hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP_FULL + ) + await hass.async_block_till_done() + + # Check state after full state response + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == "MANUEEL" + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # Set preset + mqtt_mock.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_PRESET_MODE: _REGIME, + }, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + _TOPIC_CLIMATE_SET_STATE, _PAYLOAD_CLIMATE_SET_PRESET, 0, False + ) + + # Simulate a partial state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_PRESET) + await hass.async_block_till_done() + + # Check state + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == _SET_TEMPERATURE + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == _REGIME + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE + assert entity.state == HVACMode.HEAT + + # After a delay, a full state request should've been sent + _wait_and_assert_state_request(hass, mqtt_mock) + + # Simulate a full state response + async_fire_mqtt_message( + hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_PRESET_FULL + ) + await hass.async_block_till_done() + + # Check state after full state response + entity = hass.states.get(_CLIMATE_ENTITY_ID) + assert entity + assert entity.attributes[ATTR_TEMPERATURE] == 22.0 + assert entity.attributes[ATTR_CURRENT_TEMPERATURE] == _CURRENT_TEMPERATURE + assert entity.attributes[ATTR_PRESET_MODE] == _REGIME + assert entity.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING + assert entity.state == HVACMode.HEAT + + +async def test_climate_when_invalid_state_received( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate when no valid state is received.""" + + platform: EntityPlatform = hass.data["entity_components"][CLIMATE_DOMAIN] + entity: ClimateEntity = next( + ( + entity + for entity in platform.entities + if entity.entity_id == _CLIMATE_ENTITY_ID + ), + None, + ) + + assert entity + entity.async_schedule_update_ha_state = MagicMock() + + # Simulate state response + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, "") + await hass.async_block_till_done() + + entity.async_schedule_update_ha_state.assert_not_called() + + +async def test_climate_with_fast_subsequent_changes( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate with fast subsequent changes.""" + + # Simulate two subsequent partial state responses + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, _TOPIC_CLIMATE_STATE, _PAYLOAD_CLIMATE_STATE_TEMP) + await hass.async_block_till_done() + + # State request should be requested only once + _wait_and_assert_state_request(hass, mqtt_mock) + + +async def test_climate_with_unknown_preset( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test climate with passing an unknown preset value.""" + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, + ATTR_PRESET_MODE: "What is cooler than being cool?", + }, + blocking=True, + ) + + +def _wait_and_assert_state_request( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + mqtt_mock.reset_mock() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(STATE_REQUEST_DELAY)) + mqtt_mock.async_publish.assert_has_calls( + [call(_TOPIC_GET_STATE, '["UL20"]', 0, False)], + any_order=True, + ) diff --git a/tests/components/qbus/test_light.py b/tests/components/qbus/test_light.py index c64219f1269..2db2c622289 100644 --- a/tests/components/qbus/test_light.py +++ b/tests/components/qbus/test_light.py @@ -1,7 +1,5 @@ """Test Qbus light entities.""" -import json - from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -10,11 +8,8 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.util.json import JsonObjectType -from .const import TOPIC_CONFIG - -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient # 186 = 73% (rounded) @@ -44,17 +39,10 @@ _LIGHT_ENTITY_ID = "light.media_room" async def test_light( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, - mock_config_entry: MockConfigEntry, - payload_config: JsonObjectType, + setup_integration: None, ) -> None: """Test turning on and off.""" - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) - await hass.async_block_till_done() - # Switch ON mqtt_mock.reset_mock() await hass.services.async_call( diff --git a/tests/components/qbus/test_switch.py b/tests/components/qbus/test_switch.py index 83bb667e4eb..ddb63e933da 100644 --- a/tests/components/qbus/test_switch.py +++ b/tests/components/qbus/test_switch.py @@ -1,7 +1,5 @@ """Test Qbus switch entities.""" -import json - from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -9,11 +7,8 @@ from homeassistant.components.switch import ( ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.util.json import JsonObjectType -from .const import TOPIC_CONFIG - -from tests.common import MockConfigEntry, async_fire_mqtt_message +from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient _PAYLOAD_SWITCH_STATE_ON = '{"id":"UL10","properties":{"value":true},"type":"state"}' @@ -34,17 +29,10 @@ _SWITCH_ENTITY_ID = "switch.living" async def test_switch_turn_on_off( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, - mock_config_entry: MockConfigEntry, - payload_config: JsonObjectType, + setup_integration: None, ) -> None: """Test turning on and off.""" - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - async_fire_mqtt_message(hass, TOPIC_CONFIG, json.dumps(payload_config)) - await hass.async_block_till_done() - # Switch ON mqtt_mock.reset_mock() await hass.services.async_call(