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
pull/141431/head^2
Thomas D 2025-03-26 01:25:05 +01:00 committed by GitHub
parent e2a3bfca9a
commit 2208650fde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 468 additions and 31 deletions

View File

@ -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)

View File

@ -6,6 +6,7 @@ from homeassistant.const import Platform
DOMAIN: Final = "qbus"
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.LIGHT,
Platform.SWITCH,
]

View File

@ -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}."
}
}
}

View File

@ -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()

View File

@ -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
}
}
}
]
}

View File

@ -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,
)

View File

@ -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(

View File

@ -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(