diff --git a/.coveragerc b/.coveragerc index 0d53cb2ea22..378e7df4e9c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -593,10 +593,6 @@ omit = homeassistant/components/keyboard_remote/* homeassistant/components/kira/* homeassistant/components/kiwi/lock.py - homeassistant/components/knx/__init__.py - homeassistant/components/knx/climate.py - homeassistant/components/knx/cover.py - homeassistant/components/knx/notify.py homeassistant/components/kodi/__init__.py homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/const.py diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index f15979cc909..5690722ef2b 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -151,12 +151,6 @@ class KNXClimate(KnxEntity, ClimateEntity): ) self.default_hvac_mode: str = config[ClimateSchema.CONF_DEFAULT_CONTROLLER_MODE] - async def async_update(self) -> None: - """Request a state update from KNX bus.""" - await self._device.sync() - if self._device.mode is not None: - await self._device.mode.sync() - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -181,10 +175,10 @@ class KNXClimate(KnxEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - return - await self._device.set_target_temperature(temperature) - self.async_write_ha_state() + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is not None: + await self._device.set_target_temperature(temperature) + self.async_write_ha_state() @property def hvac_mode(self) -> str: diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index cc595197a95..b3096a75df5 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -160,10 +160,10 @@ class KNXCover(KnxEntity, CoverEntity): @property def current_cover_tilt_position(self) -> int | None: """Return current tilt position of cover.""" - if not self._device.supports_angle: - return None - ang = self._device.current_angle() - return 100 - ang if ang is not None else None + if self._device.supports_angle: + ang = self._device.current_angle() + return 100 - ang if ang is not None else None + return None async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py new file mode 100644 index 00000000000..c77e9c58398 --- /dev/null +++ b/tests/components/knx/test_climate.py @@ -0,0 +1,245 @@ +"""Test KNX climate.""" +from homeassistant.components.climate import HVAC_MODE_HEAT, HVAC_MODE_OFF +from homeassistant.components.climate.const import PRESET_ECO, PRESET_SLEEP +from homeassistant.components.knx.schema import ClimateSchema +from homeassistant.const import CONF_NAME, STATE_IDLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component + +from .conftest import KNXTestKit + +from tests.common import async_capture_events + + +async def test_climate_basic_temperature_set(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX climate basic.""" + events = async_capture_events(hass, "state_changed") + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + } + } + ) + assert len(hass.states.async_all()) == 1 + assert len(events) == 1 + events.pop() + + # read temperature + await knx.assert_read("1/2/3") + # read target temperature + await knx.assert_read("1/2/5") + + # set new temperature + await hass.services.async_call( + "climate", + "set_temperature", + {"entity_id": "climate.test", "temperature": 20}, + blocking=True, + ) + await knx.assert_write("1/2/4", (7, 208)) + assert len(events) == 1 + events.pop() + + +async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX climate hvac mode.""" + events = async_capture_events(hass, "state_changed") + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_CONTROLLER_MODE_ADDRESS: "1/2/6", + ClimateSchema.CONF_CONTROLLER_MODE_STATE_ADDRESS: "1/2/7", + ClimateSchema.CONF_ON_OFF_ADDRESS: "1/2/8", + ClimateSchema.CONF_OPERATION_MODES: ["Auto"], + } + } + ) + assert len(hass.states.async_all()) == 1 + assert len(events) == 1 + events.pop() + + await hass.async_block_till_done() + # read states state updater + await knx.assert_read("1/2/7") + await knx.assert_read("1/2/3") + # StateUpdater initialize state + await knx.receive_response("1/2/7", True) + await knx.receive_response("1/2/3", (0x21,)) + # StateUpdater semaphore allows 2 concurrent requests + # read target temperature state + await knx.assert_read("1/2/5") + + # turn hvac off + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.test", "hvac_mode": HVAC_MODE_OFF}, + blocking=True, + ) + await knx.assert_write("1/2/8", False) + + # turn hvac on + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": "climate.test", "hvac_mode": HVAC_MODE_HEAT}, + blocking=True, + ) + await knx.assert_write("1/2/8", True) + await knx.assert_write("1/2/6", (0x01,)) + + +async def test_climate_preset_mode(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX climate preset mode.""" + events = async_capture_events(hass, "state_changed") + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_OPERATION_MODE_ADDRESS: "1/2/6", + ClimateSchema.CONF_OPERATION_MODE_STATE_ADDRESS: "1/2/7", + } + } + ) + assert len(hass.states.async_all()) == 1 + assert len(events) == 1 + events.pop() + + await hass.async_block_till_done() + # read states state updater + await knx.assert_read("1/2/7") + await knx.assert_read("1/2/3") + # StateUpdater initialize state + await knx.receive_response("1/2/7", True) + await knx.receive_response("1/2/3", (0x01,)) + # StateUpdater semaphore allows 2 concurrent requests + # read target temperature state + await knx.assert_read("1/2/5") + + # set preset mode + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": "climate.test", "preset_mode": PRESET_ECO}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x04,)) + assert len(events) == 1 + events.pop() + + # set preset mode + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": "climate.test", "preset_mode": PRESET_SLEEP}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x03,)) + assert len(events) == 1 + events.pop() + + assert len(knx.xknx.devices) == 2 + assert len(knx.xknx.devices[0].device_updated_cbs) == 2 + assert len(knx.xknx.devices[1].device_updated_cbs) == 2 + # test removing also removes hooks + er = entity_registry.async_get(hass) + er.async_remove("climate.test") + await hass.async_block_till_done() + + assert len(knx.xknx.devices) == 2 + assert len(knx.xknx.devices[0].device_updated_cbs) == 1 + assert len(knx.xknx.devices[1].device_updated_cbs) == 1 + + +async def test_update_entity(hass: HomeAssistant, knx: KNXTestKit): + """Test update climate entity for KNX.""" + events = async_capture_events(hass, "state_changed") + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_OPERATION_MODE_ADDRESS: "1/2/6", + ClimateSchema.CONF_OPERATION_MODE_STATE_ADDRESS: "1/2/7", + } + } + ) + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + assert len(events) == 1 + events.pop() + + await hass.async_block_till_done() + # read states state updater + await knx.assert_read("1/2/7") + await knx.assert_read("1/2/3") + # StateUpdater initialize state + await knx.receive_response("1/2/7", True) + await knx.receive_response("1/2/3", (0x01,)) + # StateUpdater semaphore allows 2 concurrent requests + await knx.assert_read("1/2/5") + + # verify update entity retriggers group value reads to the bus + await hass.services.async_call( + "homeassistant", + "update_entity", + target={"entity_id": "climate.test"}, + blocking=True, + ) + + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + await knx.assert_read("1/2/7") + + +async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX climate command_value.""" + events = async_capture_events(hass, "state_changed") + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_COMMAND_VALUE_STATE_ADDRESS: "1/2/6", + } + } + ) + assert len(hass.states.async_all()) == 1 + assert len(events) == 1 + events.pop() + + await hass.async_block_till_done() + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + # StateUpdater initialize state + await knx.receive_response("1/2/6", (0x32,)) + await knx.receive_response("1/2/3", (0x0C, 0x1A)) + + assert len(events) == 2 + events.pop() + + knx.assert_state("climate.test", HVAC_MODE_HEAT, command_value=20) + + await knx.receive_write("1/2/6", (0x00,)) + + knx.assert_state( + "climate.test", HVAC_MODE_HEAT, command_value=0, hvac_action=STATE_IDLE + ) diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py new file mode 100644 index 00000000000..906b637cffd --- /dev/null +++ b/tests/components/knx/test_cover.py @@ -0,0 +1,112 @@ +"""Test KNX cover.""" +from homeassistant.components.knx.schema import CoverSchema +from homeassistant.const import CONF_NAME, STATE_CLOSING +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + +from tests.common import async_capture_events + + +async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX cover basic.""" + events = async_capture_events(hass, "state_changed") + await knx.setup_integration( + { + CoverSchema.PLATFORM: { + CONF_NAME: "test", + CoverSchema.CONF_MOVE_LONG_ADDRESS: "1/0/0", + CoverSchema.CONF_MOVE_SHORT_ADDRESS: "1/0/1", + CoverSchema.CONF_POSITION_STATE_ADDRESS: "1/0/2", + CoverSchema.CONF_POSITION_ADDRESS: "1/0/3", + CoverSchema.CONF_ANGLE_STATE_ADDRESS: "1/0/4", + CoverSchema.CONF_ANGLE_ADDRESS: "1/0/5", + } + } + ) + assert len(hass.states.async_all()) == 1 + assert len(events) == 1 + events.pop() + + # read position state address and angle state address + await knx.assert_read("1/0/2") + await knx.assert_read("1/0/4") + + # open cover + await hass.services.async_call( + "cover", "open_cover", target={"entity_id": "cover.test"}, blocking=True + ) + await knx.assert_write("1/0/0", False) + + assert len(events) == 1 + events.pop() + + # close cover + await hass.services.async_call( + "cover", "close_cover", target={"entity_id": "cover.test"}, blocking=True + ) + await knx.assert_write("1/0/0", True) + + assert len(events) == 1 + events.pop() + + # stop cover + await hass.services.async_call( + "cover", "stop_cover", target={"entity_id": "cover.test"}, blocking=True + ) + await knx.assert_write("1/0/1", True) + + assert len(events) == 1 + events.pop() + + # set cover position + await hass.services.async_call( + "cover", + "set_cover_position", + {"position": 25}, + target={"entity_id": "cover.test"}, + blocking=True, + ) + + # in KNX this will result in a payload of 191, percent values are encoded from 0 to 255 + # We need to transpile the position by using 100 - position due to the way KNX actuators work + await knx.assert_write("1/0/3", (0xBF,)) + + knx.assert_state( + "cover.test", + STATE_CLOSING, + ) + + assert len(events) == 1 + events.pop() + + # set cover tilt position + await hass.services.async_call( + "cover", + "set_cover_tilt_position", + {"tilt_position": 25}, + target={"entity_id": "cover.test"}, + blocking=True, + ) + + # in KNX this will result in a payload of 191, percent values are encoded from 0 to 255 + # We need to transpile the position by using 100 - position due to the way KNX actuators work + await knx.assert_write("1/0/5", (0xBF,)) + + assert len(events) == 1 + events.pop() + + # close cover tilt + await hass.services.async_call( + "cover", "close_cover_tilt", target={"entity_id": "cover.test"}, blocking=True + ) + await knx.assert_write("1/0/1", True) + + assert len(events) == 1 + events.pop() + + # open cover tilt + await hass.services.async_call( + "cover", "open_cover_tilt", target={"entity_id": "cover.test"}, blocking=True + ) + await knx.assert_write("1/0/1", False) diff --git a/tests/components/knx/test_notify.py b/tests/components/knx/test_notify.py new file mode 100644 index 00000000000..ad8da5f2cc0 --- /dev/null +++ b/tests/components/knx/test_notify.py @@ -0,0 +1,137 @@ +"""Test KNX notify.""" + +from homeassistant.components.knx.const import KNX_ADDRESS +from homeassistant.components.knx.schema import NotifySchema +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + + +async def test_notify_simple(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX notify can send to one device.""" + await knx.setup_integration( + { + NotifySchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/0/0", + } + } + ) + await hass.async_block_till_done() + + await hass.services.async_call( + "notify", "notify", {"target": "test", "message": "I love KNX"}, blocking=True + ) + + await knx.assert_write( + "1/0/0", + ( + 0x49, + 0x20, + 0x6C, + 0x6F, + 0x76, + 0x65, + 0x20, + 0x4B, + 0x4E, + 0x58, + 0x0, + 0x0, + 0x0, + 0x0, + ), + ) + + await hass.services.async_call( + "notify", + "notify", + { + "target": "test", + "message": "I love KNX, but this text is too long for KNX, poor KNX", + }, + blocking=True, + ) + + await knx.assert_write( + "1/0/0", + ( + 0x49, + 0x20, + 0x6C, + 0x6F, + 0x76, + 0x65, + 0x20, + 0x4B, + 0x4E, + 0x58, + 0x2C, + 0x20, + 0x62, + 0x75, + ), + ) + + +async def test_notify_multiple_sends_to_all(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX notify can send to all devices.""" + await knx.setup_integration( + { + NotifySchema.PLATFORM: [ + { + CONF_NAME: "test", + KNX_ADDRESS: "1/0/0", + }, + { + CONF_NAME: "test2", + KNX_ADDRESS: "1/0/1", + }, + ] + } + ) + await hass.async_block_till_done() + + await hass.services.async_call( + "notify", "notify", {"message": "I love KNX"}, blocking=True + ) + + await knx.assert_write( + "1/0/0", + ( + 0x49, + 0x20, + 0x6C, + 0x6F, + 0x76, + 0x65, + 0x20, + 0x4B, + 0x4E, + 0x58, + 0x0, + 0x0, + 0x0, + 0x0, + ), + ) + await knx.assert_write( + "1/0/1", + ( + 0x49, + 0x20, + 0x6C, + 0x6F, + 0x76, + 0x65, + 0x20, + 0x4B, + 0x4E, + 0x58, + 0x0, + 0x0, + 0x0, + 0x0, + ), + )