Repository: XKNX/xknx (python)

Issue:
Cover: Auto-stop Task
For setting a specific position for a cover that has no position address 
https://github.com/XKNX/xknx/blob/785d73b850a97a688a3b1a5e8baf34e82685d1b6/xknx/devices/cover.py#L202
we currently use HA to create an updater hook to call `Cover.auto_stop_if_necessary()` periodically to stop the cover again.
https://github.com/XKNX/xknx/blob/785d73b850a97a688a3b1a5e8baf34e82685d1b6/home-assistant-plugin/custom_components/xknx/cover.py#L144

We should implement the auto-stop functionality as a Task in xknx instead of relying on HA to check for updates and call stop then. We get no benefit from doing this over HA now and it would be easier to test.

Pull request description:
Call cover callbacks periodically when moving
<!--
  You are awesome! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template!.
-->
## Description
<!--
Please include a summary of the change and which issue is fixed.
Please also include relevant motivation and context.
List any dependencies that are required for this change.
-->
Cover: call `device_updated_cb` periodically when cover is moving

Closes #483

## Type of change
<!--
Please tick the applicable options.
NOTE: Ticking multiple options most likely indicates
that your change is to big and it is suggested to split it into several smaller PRs.
-->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

## Checklist:

- [ ] I have performed a self-review of my own code
- [ ] The documentation has been adjusted accordingly
- [ ] The changes generate no new warnings
- [ ] Tests have been added that prove the fix is effective or that the feature works
- [ ] The changes are documented in the changelog
- [ ] The Homeassistant plugin has been adjusted in case of new config options


Patch:
diff --git a/changelog.md b/changelog.md
index b978f734..2883c136 100644
--- a/changelog.md
+++ b/changelog.md
@@ -4,6 +4,7 @@
 
 ### Devices
 
+- Cover: call `device_updated_cb` periodically when cover is moving
 - Cover: add `invert_updown` option to decouple updown from position
 - Cover: fix travel time prediction when receiving updates from bus while moving
 
diff --git a/xknx/devices/cover.py b/xknx/devices/cover.py
index 949922c7..c1a11fc0 100644
--- a/xknx/devices/cover.py
+++ b/xknx/devices/cover.py
@@ -9,9 +9,11 @@ It provides functionality for
 """
 from __future__ import annotations
 
+import asyncio
 import logging
-from typing import TYPE_CHECKING, Any, Iterator
+from typing import TYPE_CHECKING, Any, Final, Iterator
 
+from xknx.core import Task
 from xknx.remote_value import (
     GroupAddressesType,
     RemoteValue,
@@ -31,12 +33,13 @@ if TYPE_CHECKING:
 logger = logging.getLogger("xknx.log")
 
 
+DEFAULT_TRAVEL_TIME: Final = 25
+TRAVELING_CALLBACK_INTERVAL: Final = 1
+
+
 class Cover(Device):
     """Class for managing a cover."""
 
-    DEFAULT_TRAVEL_TIME_DOWN = 22
-    DEFAULT_TRAVEL_TIME_UP = 22
-
     def __init__(
         self,
         xknx: XKNX,
@@ -50,8 +53,8 @@ class Cover(Device):
         group_address_angle_state: GroupAddressesType | None = None,
         group_address_locked_state: GroupAddressesType | None = None,
         sync_state: bool | int | float | str = True,
-        travel_time_down: float = DEFAULT_TRAVEL_TIME_DOWN,
-        travel_time_up: float = DEFAULT_TRAVEL_TIME_UP,
+        travel_time_down: float = DEFAULT_TRAVEL_TIME,
+        travel_time_up: float = DEFAULT_TRAVEL_TIME,
         invert_updown: bool = False,
         invert_position: bool = False,
         invert_angle: bool = False,
@@ -133,9 +136,11 @@ class Cover(Device):
 
         self.travel_time_down = travel_time_down
         self.travel_time_up = travel_time_up
-
         self.travelcalculator = TravelCalculator(travel_time_down, travel_time_up)
-        self.travel_direction_tilt: TravelStatus | None = None
+
+        self._auto_stop_task: Task | None = None
+        self._periodic_update_task: Task | None = None
+        self._travel_direction_tilt: TravelStatus | None = None
 
     def _iter_remote_values(self) -> Iterator[RemoteValue[Any, Any]]:
         """Iterate the devices RemoteValue classes."""
@@ -147,13 +152,19 @@ class Cover(Device):
         yield self.angle
         yield self.locked
 
+    def _iter_tasks(self) -> Iterator[Task | None]:
+        """Iterate the device tasks."""
+        yield self._auto_stop_task
+        yield self._periodic_update_task
+
     async def set_down(self) -> None:
         """Move cover down."""
         if self.updown.writable:
             await self.updown.down()
-            self.travelcalculator.start_travel_down()
-            self.travel_direction_tilt = None
-            await self.after_update()
+            self._travel_direction_tilt = None
+            await self._start_position_update(
+                target_position=self.travelcalculator.position_closed
+            )
         elif self.position_target.writable:
             await self.position_target.set(self.travelcalculator.position_closed)
 
@@ -161,9 +172,10 @@ class Cover(Device):
         """Move cover up."""
         if self.updown.writable:
             await self.updown.up()
-            self.travelcalculator.start_travel_up()
-            self.travel_direction_tilt = None
-            await self.after_update()
+            self._travel_direction_tilt = None
+            await self._start_position_update(
+                target_position=self.travelcalculator.position_open
+            )
         elif self.position_target.writable:
             await self.position_target.set(self.travelcalculator.position_open)
 
@@ -182,64 +194,126 @@ class Cover(Device):
         elif self.step.writable:
             if TravelStatus.DIRECTION_UP in (
                 self.travelcalculator.travel_direction,
-                self.travel_direction_tilt,
+                self._travel_direction_tilt,
             ):
                 await self.step.decrease()
             elif TravelStatus.DIRECTION_DOWN in (
                 self.travelcalculator.travel_direction,
-                self.travel_direction_tilt,
+                self._travel_direction_tilt,
             ):
                 await self.step.increase()
         else:
             logger.warning("Stop not supported for device %s", self.get_name())
             return
-        self.travelcalculator.stop()
-        self.travel_direction_tilt = None
-        await self.after_update()
+        self._travel_direction_tilt = None
+        await self._stop_position_update()
 
     async def set_position(self, position: int) -> None:
         """Move cover to a desginated postion."""
-        if not self.position_target.writable:
-            # No direct positioning group address defined
-            # fully open or close is always possible even if current position is not known
-            current_position = self.current_position()
-            if current_position is None:
-                if position == self.travelcalculator.position_open:
-                    await self.updown.up()
-                elif position == self.travelcalculator.position_closed:
-                    await self.updown.down()
-                else:
-                    logger.warning(
-                        "Current position unknown. Initialize cover by moving to end position."
-                    )
-                    return
-            elif position < current_position:
+        if self.position_target.writable:
+            await self.position_target.set(position)
+            return
+        # No direct positioning group address defined
+        # fully open or close is always possible even if current position is not known
+        current_position = self.travelcalculator.current_position()
+        if current_position is None:
+            if position == self.travelcalculator.position_open:
                 await self.updown.up()
-            elif position > current_position:
+            elif position == self.travelcalculator.position_closed:
                 await self.updown.down()
-            self.travelcalculator.start_travel(position)
-            await self.after_update()
-        else:
-            await self.position_target.set(position)
+            else:
+                logger.warning(
+                    "Current position unknown. Initialize cover by moving to end position."
+                )
+                return
+            await self._start_position_update(target_position=position)
+            return
+        if position < current_position:
+            await self.updown.up()
+        elif position > current_position:
+            await self.updown.down()
+        await self._start_position_update(target_position=position)
+        # If device does not support auto_positioning,
+        # we have to stop the device when position is reached,
+        # unless device was traveling to fully open
+        # or fully closed state.
+        if (
+            self.supports_stop
+            and self.travelcalculator.position_open
+            < position
+            < self.travelcalculator.position_closed
+        ):
+            stop_in_seconds = self.travelcalculator.calculate_travel_time(
+                from_position=current_position, to_position=position
+            )
+
+            async def auto_stopper() -> None:
+                await asyncio.sleep(stop_in_seconds)
+                # stop() calls stop_position_update() which cancels this task
+                asyncio.shield(self.stop())
+
+            self._auto_stop_task = self.xknx.task_registry.register(
+                name=f"cover.auto_stopper_{id(self)}",
+                async_func=auto_stopper,
+            ).start()
+
+    async def _start_position_update(self, target_position: int) -> None:
+        """Start the travel calculator and run device callbacks."""
+        self.travelcalculator.start_travel(target_position)
+        await self.after_update()
+        if self.travelcalculator.is_traveling():
+            self._start_auto_updater()
+
+    def _start_auto_updater(self) -> None:
+        """Start calling callback periodically while traveling."""
+
+        async def periodic_updater() -> None:
+            """Run callback periodically while traveling."""
+            while self.travelcalculator.is_traveling():
+                await asyncio.sleep(TRAVELING_CALLBACK_INTERVAL)
+                if self.travelcalculator.is_traveling():
+                    # else _stop_position_update will call after_update a second time
+                    await self.after_update()
+            asyncio.shield(self._stop_position_update())
+
+        # restarts when already running
+        self._periodic_update_task = self.xknx.task_registry.register(
+            name=f"cover.periodic_update_{id(self)}",
+            async_func=periodic_updater,
+        ).start()
+
+    async def _stop_position_update(self) -> None:
+        """Stop the travel calculator and periodic device callbacks."""
+        if not self.travelcalculator.position_reached():
+            self.travelcalculator.stop()
+        if self._periodic_update_task:
+            self._periodic_update_task.cancel()
+            self._periodic_update_task = None
+        if self._auto_stop_task:
+            self._auto_stop_task.cancel()
+            self._auto_stop_task = None
+        await self.after_update()
 
     async def _target_position_from_rv(self) -> None:
         """Update the target postion from RemoteValue (Callback)."""
-        new_target = self.position_target.value
-        if new_target is not None:
-            self.travelcalculator.start_travel(new_target)
-            await self.after_update()
+        if self.position_target.value is not None:
+            await self._start_position_update(
+                target_position=self.position_target.value
+            )
 
     async def _current_position_from_rv(self) -> None:
         """Update the current postion from RemoteValue (Callback)."""
         position_before_update = self.travelcalculator.current_position()
         new_position = self.position_current.value
-        if new_position is not None:
-            if self.is_traveling():
-                self.travelcalculator.update_position(new_position)
-            else:
-                self.travelcalculator.set_position(new_position)
-            if position_before_update != self.travelcalculator.current_position():
-                await self.after_update()
+        if new_position is None:
+            return
+        if self.is_traveling():
+            self.travelcalculator.update_position(new_position)
+        else:
+            self.travelcalculator.set_position(new_position)
+        if position_before_update != self.travelcalculator.current_position():
+            self._start_auto_updater()  # to restart the periodic updater
+            await self.after_update()
 
     async def set_angle(self, angle: int) -> None:
         """Move cover to designated angle."""
@@ -248,7 +322,7 @@ class Cover(Device):
             return
 
         current_angle = self.current_angle()
-        self.travel_direction_tilt = (
+        self._travel_direction_tilt = (
             TravelStatus.DIRECTION_DOWN
             if current_angle is not None and angle >= current_angle
             else TravelStatus.DIRECTION_UP
@@ -256,21 +330,6 @@ class Cover(Device):
 
         await self.angle.set(angle)
 
-    async def auto_stop_if_necessary(self) -> None:
-        """Do auto stop if necessary."""
-        # If device does not support auto_positioning,
-        # we have to stop the device when position is reached,
-        # unless device was traveling to fully open
-        # or fully closed state.
-        if (
-            self.supports_stop
-            and not self.position_target.writable
-            and self.position_reached()
-            and not self.is_open()
-            and not self.is_closed()
-        ):
-            await self.stop()
-
     async def sync(self, wait_for_result: bool = False) -> None:
         """Read states of device from KNX bus."""
         await self.position_current.read_state(wait_for_result=wait_for_result)
@@ -284,19 +343,20 @@ class Cover(Device):
                 not self.is_opening()
                 and self.updown.value == RemoteValueUpDown.Direction.UP
             ):
-                self.travelcalculator.start_travel_up()
-                await self.after_update()
+                await self._start_position_update(
+                    target_position=self.travelcalculator.position_open
+                )
             elif (
                 not self.is_closing()
                 and self.updown.value == RemoteValueUpDown.Direction.DOWN
             ):
-                self.travelcalculator.start_travel_down()
-                await self.after_update()
+                await self._start_position_update(
+                    target_position=self.travelcalculator.position_closed
+                )
         # stop from bus
         if await self.stop_.process(telegram) or await self.step.process(telegram):
             if self.is_traveling():
-                self.travelcalculator.stop()
-                await self.after_update()
+                await self._stop_position_update()
 
         await self.position_current.process(telegram, always_callback=True)
         await self.position_target.process(telegram)
diff --git a/xknx/devices/travelcalculator.py b/xknx/devices/travelcalculator.py
index edcb4177..528d27d2 100644
--- a/xknx/devices/travelcalculator.py
+++ b/xknx/devices/travelcalculator.py
@@ -144,29 +144,25 @@ class TravelCalculator:
         if position_reached_or_exceeded(relative_position):
             return self._travel_to_position
 
-        travel_time = self._calculate_travel_time(relative_position)
-        if time.time() > self._last_known_postion_timestamp + travel_time:
+        remaining_travel_time = self.calculate_travel_time(
+            from_position=self._last_known_position,
+            to_position=self._travel_to_position,
+        )
+        if time.time() > self._last_known_postion_timestamp + remaining_travel_time:
             return self._travel_to_position
 
-        progress = (time.time() - self._last_known_postion_timestamp) / travel_time
-        position = self._last_known_position + relative_position * progress
-        return int(position)
+        progress = (
+            time.time() - self._last_known_postion_timestamp
+        ) / remaining_travel_time
+        return int(self._last_known_position + relative_position * progress)
 
-    def _calculate_travel_time(self, relative_position: int) -> float:
-        """Calculate time to travel to relative position."""
-        travel_direction = (
-            TravelStatus.DIRECTION_UP
-            if relative_position < 0
-            else TravelStatus.DIRECTION_DOWN
-        )
+    def calculate_travel_time(self, from_position: int, to_position: int) -> float:
+        """Calculate time to travel from one position to another."""
+        travel_range = to_position - from_position
         travel_time_full = (
-            self.travel_time_up
-            if travel_direction == TravelStatus.DIRECTION_UP
-            else self.travel_time_down
+            self.travel_time_down if travel_range > 0 else self.travel_time_up
         )
-        travel_range = self.position_closed - self.position_open
-
-        return travel_time_full * abs(relative_position) / travel_range
+        return travel_time_full * abs(travel_range) / self.position_closed
 
     def __eq__(self, other: object | None) -> bool:
         """Equal operator."""


Test patch:
diff --git a/requirements/testing.txt b/requirements/testing.txt
index 6e29b5fb..e1cb5234 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -4,10 +4,10 @@ isort==5.10.1
 flake8==4.0.1
 flake8-isort==4.1.1
 pydocstyle==6.1.1
-pylint==2.13.8
+pylint==2.13.9
 pytest==7.1.2
 pytest-asyncio==0.18.3
 pytest-cov==3.0.0
-setuptools==62.2.0
+setuptools==62.3.1
 tox==3.25.0
 mypy==0.950
diff --git a/test/devices_tests/cover_test.py b/test/devices_tests/cover_test.py
index 84f8b2e7..2617622e 100644
--- a/test/devices_tests/cover_test.py
+++ b/test/devices_tests/cover_test.py
@@ -125,7 +125,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverSync",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position_state="1/2/3",
@@ -142,7 +142,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverSyncState",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",
@@ -160,7 +160,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverSyncAngle",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position_state="1/2/3",
@@ -182,7 +182,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverSyncAngleState",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_angle="1/2/3",
@@ -203,7 +203,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverSetUp",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",
@@ -226,7 +226,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverShortDown",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",
@@ -248,7 +248,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverSetDownInverted",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",
@@ -271,7 +271,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverSetShortUp",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",
@@ -294,7 +294,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverSetUpInverted",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",
@@ -318,7 +318,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverSetDown",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",
@@ -341,7 +341,7 @@ class TestCover:
         xknx = XKNX()
         cover_short_stop = Cover(
             xknx,
-            "TestCover",
+            "TestCoverStop",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",
@@ -385,7 +385,7 @@ class TestCover:
 
         cover_manual_stop = Cover(
             xknx,
-            "TestCover",
+            "TestCoverManualStop",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_stop="1/2/0",
@@ -405,7 +405,7 @@ class TestCover:
         xknx = XKNX()
         cover_short_stop = Cover(
             xknx,
-            "TestCover",
+            "TestCoverStopAngle",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_angle="1/2/5",
@@ -463,7 +463,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverPosition",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",
@@ -476,13 +476,14 @@ class TestCover:
             destination_address=GroupAddress("1/2/3"),
             payload=GroupValueWrite(DPTArray(0x80)),
         )
+        await cover.stop()  # clean up tasks
 
     async def test_position_without_binary(self):
         """Test moving cover - with no binary positioning supported."""
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverPositionWithoutBinary",
             group_address_position="1/2/3",
         )
         await cover.set_down()
@@ -512,7 +513,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverPWPAD",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position_state="1/2/4",
@@ -533,12 +534,14 @@ class TestCover:
         assert cover.travelcalculator._travel_to_position == 50
         assert xknx.telegrams.qsize() == 0
 
+        await cover.stop()  # clean up tasks
+
     async def test_position_without_position_address_down(self):
         """Test moving cover down - with no absolute positioning supported."""
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverPWPAD",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position_state="1/2/4",
@@ -557,12 +560,14 @@ class TestCover:
         await cover.process(telegram)
         assert cover.travelcalculator._travel_to_position == 80
 
+        await cover.stop()  # clean up tasks
+
     async def test_position_without_position_address_uninitialized_up(self):
         """Test moving uninitialized cover to absolute position - with no absolute positioning supported."""
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverPWPAUU",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position_state="1/2/4",
@@ -582,13 +587,14 @@ class TestCover:
             destination_address=GroupAddress("1/2/1"),
             payload=GroupValueWrite(DPTBinary(0)),
         )
+        await cover.stop()  # clean up tasks
 
     async def test_position_without_position_address_uninitialized_down(self):
         """Test moving uninitialized cover to absolute position - with no absolute positioning supported."""
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverPWPAUD",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position_state="1/2/4",
@@ -608,6 +614,7 @@ class TestCover:
             destination_address=GroupAddress("1/2/1"),
             payload=GroupValueWrite(DPTBinary(1)),
         )
+        await cover.stop()  # clean up tasks
 
     async def test_angle(self):
         """Test changing angle."""
@@ -654,7 +661,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverProcessPosition",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",
@@ -692,12 +699,14 @@ class TestCover:
         assert cover.is_closing()
         assert cover.travelcalculator._travel_to_position == 100
 
+        await cover.stop()  # clean up tasks
+
     async def test_process_angle(self):
         """Test process / reading telegrams from telegram queue. Test if position is processed correctly."""
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverProcessAngle",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_angle="1/2/3",
@@ -714,7 +723,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverProcessLocked",
             group_address_long="1/2/1",
             group_address_locked_state="1/2/4",
         )
@@ -728,7 +737,10 @@ class TestCover:
         """Test process / reading telegrams from telegram queue. Test if up/down is processed correctly."""
         xknx = XKNX()
         cover = Cover(
-            xknx, "TestCover", group_address_long="1/2/1", group_address_short="1/2/2"
+            xknx,
+            "TestCoverProcessUp",
+            group_address_long="1/2/1",
+            group_address_short="1/2/2",
         )
         cover.travelcalculator.set_position(50)
         assert not cover.is_traveling()
@@ -738,11 +750,16 @@ class TestCover:
         await cover.process(telegram)
         assert cover.is_opening()
 
+        await cover.stop()  # clean up tasks
+
     async def test_process_down(self):
         """Test process / reading telegrams from telegram queue. Test if up/down is processed correctly."""
         xknx = XKNX()
         cover = Cover(
-            xknx, "TestCover", group_address_long="1/2/1", group_address_short="1/2/2"
+            xknx,
+            "TestCoverProcessDown",
+            group_address_long="1/2/1",
+            group_address_short="1/2/2",
         )
         cover.travelcalculator.set_position(50)
         assert not cover.is_traveling()
@@ -752,12 +769,14 @@ class TestCover:
         await cover.process(telegram)
         assert cover.is_closing()
 
+        await cover.stop()  # clean up tasks
+
     async def test_process_stop(self):
         """Test process / reading telegrams from telegram queue. Test if stop is processed correctly."""
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverProcessStop",
             group_address_long="1/2/1",
             group_address_stop="1/2/2",
         )
@@ -775,7 +794,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverProcessShortStop",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
         )
@@ -794,7 +813,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverProcessCallback",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_stop="1/2/3",
@@ -832,6 +851,8 @@ class TestCover:
         await cover.process(telegram)
         after_update_callback.assert_called_with(cover)
 
+        await cover.stop()  # clean up tasks
+
     #
     # IS TRAVELING / IS UP / IS DOWN
     #
@@ -840,7 +861,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverIsTraveling",
             group_address_long="1/2/1",
             group_address_stop="1/2/2",
             group_address_position="1/2/3",
@@ -905,36 +926,106 @@ class TestCover:
             assert not cover.is_closing()
 
     #
-    # TEST AUTO STOP
+    # TEST TASKS
     #
-    async def test_auto_stop(self):
+    async def test_auto_stop(self, time_travel):
         """Test auto stop functionality."""
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverAutoStop",
             group_address_long="1/2/1",
             group_address_stop="1/2/2",
             travel_time_down=10,
             travel_time_up=10,
         )
-        with patch(
-            "xknx.devices.Cover.stop", new_callable=AsyncMock
-        ) as mock_stop, patch("time.time") as mock_time:
+        with patch("time.time") as mock_time:
             mock_time.return_value = 1517000000.0
             # we start with state 0 - open covers (up) this is assumed immediately
             await cover.set_position(0)
+            assert xknx.telegrams.qsize() == 1
+            _ = xknx.telegrams.get_nowait()
 
             await cover.set_position(50)
 
+            await time_travel(1)
+            mock_time.return_value = 1517000001.0
+            assert xknx.telegrams.qsize() == 1
+            telegram1 = xknx.telegrams.get_nowait()
+            assert telegram1 == Telegram(
+                destination_address=GroupAddress("1/2/1"),
+                payload=GroupValueWrite(DPTBinary(True)),
+            )
+
+            await time_travel(4)
+            mock_time.return_value = 1517000005.0
+            assert xknx.telegrams.qsize() == 1
+            telegram1 = xknx.telegrams.get_nowait()
+            assert telegram1 == Telegram(
+                destination_address=GroupAddress("1/2/2"),
+                payload=GroupValueWrite(DPTBinary(True)),
+            )
+
+    async def test_periodic_update(self, time_travel):
+        """Test periodic update functionality."""
+        xknx = XKNX()
+        callback_mock = AsyncMock()
+        cover = Cover(
+            xknx,
+            "TestCoverPeriodicUpdate",
+            group_address_long="1/2/1",
+            group_address_stop="1/2/2",
+            group_address_position="1/2/3",
+            group_address_position_state="1/2/4",
+            travel_time_down=10,
+            travel_time_up=10,
+            device_updated_cb=callback_mock,
+        )
+        with patch("time.time") as mock_time:
+            mock_time.return_value = 1517000000.0
+            # state telegram updates current position - we are not moving so this is new state - not moving
+            telegram = Telegram(
+                GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(0))
+            )
+            await cover.process(telegram)
+            assert (
+                callback_mock.call_count == 2
+            )  # 1 additional form _stop_position_update because previous state was None
+            callback_mock.reset_mock()
+            # move to 50%
+            telegram = Telegram(
+                GroupAddress("1/2/3"), payload=GroupValueWrite(DPTArray(125))
+            )
+            await cover.process(telegram)
+            await time_travel(0)
+            assert callback_mock.call_count == 1
+
             mock_time.return_value = 1517000001.0
-            await cover.auto_stop_if_necessary()
-            mock_stop.assert_not_called()
+            await time_travel(1)
+            assert callback_mock.call_count == 2
 
+            # state telegram from bus too early
+            mock_time.return_value = 1517000001.6
+            await time_travel(0.6)
+            assert callback_mock.call_count == 2
+            telegram = Telegram(
+                GroupAddress("1/2/4"), payload=GroupValueWrite(DPTArray(42))
+            )
+            await cover.process(telegram)
+            assert callback_mock.call_count == 3
+            # next update 1 second after last received state telegram
+            mock_time.return_value = 1517000002.0
+            await time_travel(0.4)
+            assert callback_mock.call_count == 3
+            mock_time.return_value = 1517000002.6
+            await time_travel(0.6)
+            assert callback_mock.call_count == 4
+            # last callback - auto updater is removed
             mock_time.return_value = 1517000005.0
-            await cover.auto_stop_if_necessary()
-            mock_stop.assert_called_with()
-            mock_stop.reset_mock()
+            await time_travel(2.4)
+            assert callback_mock.call_count == 5
+            assert cover.position_reached()
+            assert cover._periodic_update_task is None
 
     #
     # HAS GROUP ADDRESS
@@ -944,7 +1035,7 @@ class TestCover:
         xknx = XKNX()
         cover = Cover(
             xknx,
-            "TestCover",
+            "TestCoverHasGroupAddress",
             group_address_long="1/2/1",
             group_address_short="1/2/2",
             group_address_position="1/2/3",