From c7544d3b447c4e9e55bd04f18ffa5fe0ed5264a0 Mon Sep 17 00:00:00 2001 From: Rainer Staude Date: Sat, 21 Jan 2023 17:52:22 +0100 Subject: [PATCH 1/7] update copyright --- LICENSE | 1 + 1 file changed, 1 insertion(+) diff --git a/LICENSE b/LICENSE index f63f90c..f4019b4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2020 Nicolas Berthel +Copyright (c) 2023 Rainer Staude Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 35b11400f3310f2a96c1aa377702512ad9ffd150 Mon Sep 17 00:00:00 2001 From: Rainer Staude Date: Sat, 11 Mar 2023 08:57:24 +0100 Subject: [PATCH 2/7] minor code and documentation improvements --- README.md | 38 ++++++++++++++++++++++++++------------ manifest.json | 2 +- pybecker/becker.py | 6 +++--- pybecker/database.py | 2 ++ 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3c7d655..56a7eee 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,13 @@ cover: travelling_time_down: 26.5 ``` -### Position by value template +## Position by value template In some cases it might be useful to add a value template to determine the position of your cover. For example for a roof window with rain sensor. In case of rain, the roof window will close, but you cannot determine this without an additional sensor. -Every time the template generates a new result, the position of the cover is overwritten by the result of the template. +Every time the template generates a new result, the position of the cover is overwritten +by the result of the template. The following results are valid: - any number between `0` and `100` where `0` is `closed` and `100` is `open` - logic values where @@ -107,8 +108,9 @@ The following results are valid: - `'open'`, `'true'`, `True` are `open` - unknown values `'unknown'`, `'unavailable'`, `'none'`, `None` The unknown values are useful to set the position only to confirm one specific -position, like closed in the example above. For any other values the position -will not changed. This allows to use the value template in conjunction with the position by travel time. +position, like closed in the example below. For any other values the position +will not changed. This allows to use the value template in conjunction with the position +by travel time. ```yaml cover: - platform: becker @@ -117,11 +119,11 @@ cover: friendly_name: "Roof window" channel: "3:1" travelling_time_up: 15 - # Set position to closed (False) if sensor.roof_window is closed, otherwise keep value + # Set position to closed (0) if sensor.roof_window is closed, otherwise keep value value_template: "{{ 0 if is_state('sensor.roof_window', 'closed') else None }}" ``` -### Position tracking for cover commands from Becker remotes +## Position tracking for cover commands from Becker remotes Usually there is at least one remote, the master remote, used to control the cover. The remote communicates directly with the cover. It is possible to receive and track all remote commands within home assistant. Therefore the position of the cover @@ -144,7 +146,7 @@ cover: remote_id: "12345:2" ``` -### Intermediate cover position +## Intermediate cover position Becker covers supports two intermediate positions. One when opening the cover and one when closing the cover. Please see the manual of your cover to see how to program these intermediate positions in your cover. @@ -177,7 +179,7 @@ disable the intermediate cover position. intermediate_position: off ``` -### Tilt intermediate +## Tilt intermediate The Becker integration provide the ability to control the intermediate position from Home Assistant user interface. Therefore the tilt functionality of Home Assistant is used to issue the commands to drive to intermediate positions. @@ -194,7 +196,7 @@ This will also disable the service `cover.close_cover_tilt` and `cover.open_cove ``` Note: You still need to set the intermediate cover position appropriately! -### Tilt blind +## Tilt blind The Becker integration provides support for blinds. The Becker blinds allow to control their tilt position by short press of the UP or DOWN button on their master remote. A long press of the UP or DOWN button fully open or closes the blinds. @@ -212,7 +214,7 @@ This time can be adapted to your needs. tilt_time_blind: 0.5 ``` -## Pairing the Becker USB Stick with your covers +# Pairing the Becker USB Stick with your covers To use your cover in HA you need to pair it first with the Becker USB stick. The pairing is always between your remote and the shutter. The shutter will react on the commands of all paired remotes. @@ -237,7 +239,7 @@ data: unit: 1 ``` -## Troubleshooting +# Troubleshooting If you have any trouble follow these steps: - Restart Home Assistant after you have plugged in the USB stick - Enable debug log for becker. @@ -250,7 +252,17 @@ logger: # This must correspond to the folder name of your /config/custom_components/becker folder custom_components.becker: debug ``` -This enable DEBUG messages to the home-assistant.log file in your config folder. + +You can also change the log configuration dynamically by calling the `logger.set_level` service. +This method allows you to enable debug logging only for a limited time: + +```yaml +service: logger.set_level +data: + custom_components.becker: debug +``` + +All messages are logged to the home-assistant.log file in your config folder. It is also helpful to find out the Remote ID of your Becker Remote. The message will be something like below every time you press a key on your Remote: `... DEBUG ... \[custom_components.becker.pybecker.becker_helper]` Received packet: @@ -263,3 +275,5 @@ Becker integration: [Integrating Becker Motors](https://community.home-assistant.io/t/integrating-becker-motors-in-to-hassio/151705) Another way is to open a new issue on [GitHub](https://github.com/RainerStaude/hass-becker-component-plus-pybecker/issues). + +To disable debug log for becker set the level back from `debug` to `info`. \ No newline at end of file diff --git a/manifest.json b/manifest.json index dadf1ae..2165755 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "domain": "becker", "name": "Becker", "documentation": "", - "version": "0.2.0", + "version": "0.3.1", "requirements": [ "xknx==0.18.15" ], diff --git a/pybecker/becker.py b/pybecker/becker.py index 7aa4a1d..0686fca 100644 --- a/pybecker/becker.py +++ b/pybecker/becker.py @@ -2,7 +2,7 @@ # pylint: disable=missing-class-docstring import logging import re -import time +import asyncio from random import randrange from .becker_helper import finalize_code @@ -147,7 +147,7 @@ async def run_codes(self, channel, unit, cmd, test): unit[1] += 1 await self.write([code]) - time.sleep(int(mt.group(2))) + await asyncio.sleep(int(mt.group(2))) # stop moving code = generate_code(channel, unit, COMMAND_HALT) @@ -276,4 +276,4 @@ async def init_unconfigured_unit(self, channel, name=None): "Init call to %s:%s #%d", un, 1, init_call_count) await self.stop(':'.join((str(un), '1'))) # 0.5 to 0.9 seconds (works with my Roto cover) - time.sleep(randrange(5, 10, 1) / 10) + await asyncio.sleep(randrange(5, 10, 1) / 10) diff --git a/pybecker/database.py b/pybecker/database.py index 4433828..43b5c66 100644 --- a/pybecker/database.py +++ b/pybecker/database.py @@ -1,3 +1,5 @@ +# pylint: disable=missing-function-docstring +# pylint: disable=missing-class-docstring import logging import os import time From 3e849a9040854133bdffb6cb918011590065b8bf Mon Sep 17 00:00:00 2001 From: Rainer Staude Date: Mon, 1 May 2023 18:32:33 +0200 Subject: [PATCH 3/7] correct intermediate behaviour --- README.md | 21 +++++++++++++-------- cover.py | 18 ++---------------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 56a7eee..6146da9 100644 --- a/README.md +++ b/README.md @@ -152,13 +152,18 @@ and one when closing the cover. Please see the manual of your cover to see how to program these intermediate positions in your cover. Your cover will travel to the corresponding intermediate position if your double tab the UP or DOWN button on your remote. -The default intermediate positions in the Becker integraten are `25` for UP -direction and `75` for DOWN direction. If the cover already passed the intermediate -position it will close instead. -This behaviour is imitated by the Becker integration in Home Assistant. To imitate -the cover movement properly in Home Assistant it is required to set the positions properly. -You can calculate the `intermediate_position_up` by dividing the measured runtime from -closed position to the intermediate position in direction UP by the `travelling_time_up`. +The default intermediate positions in the Becker integration are `25` for UP +direction and `75` for DOWN direction, where `0` is `closed` and `100` is `open`. +This behavior is imitated by the Becker integration in Home Assistant. To imitate +the cover movement properly in Home Assistant it is required to set the positions properly. +You can calculate the `intermediate_position_up`. You need to measure the runtime from +closed position to the intermediate position in direction UP (double tap UP +on your remote). Divide the measured time by the `travelling_time_up` and +multiply the result by `100`. +You can do the same for the `intermediate_position_up`. Measure the runtime from +closed position to the intermediate position in direction DOWN (double tap DOWN on +your remote). Divide the measured time by the `travelling_time_up` and multiply +the result by `100`. ```yaml - platform: becker covers: @@ -168,7 +173,7 @@ closed position to the intermediate position in direction UP by the `travelling_ intermediate_position_up: 70 intermediate_position_down: 40 ``` -If you have not programmed any intermediate positions in your cover you should +If you have not programmed any intermediate positions in your cover, you should disable the intermediate cover position. ```yaml - platform: becker diff --git a/cover.py b/cover.py index 6718bfb..d8e0186 100644 --- a/cover.py +++ b/cover.py @@ -350,7 +350,7 @@ async def async_open_cover_tilt(self, **kwargs): await self.async_open_cover() self._update_scheduled_stop_travel_callback(self._tilt_time_blind) if self._tilt_intermediate: - self._travel_up_intermediate() + self._travel_to_position(self._intermediate_pos_up) await self._becker.move_up_intermediate(self._channel) async def async_close_cover(self, **kwargs): @@ -365,7 +365,7 @@ async def async_close_cover_tilt(self, **kwargs): await self.async_close_cover() self._update_scheduled_stop_travel_callback(self._tilt_time_blind) if self._tilt_intermediate: - self._travel_down_intermediate() + self._travel_to_position(self._intermediate_pos_down) await self._becker.move_down_intermediate(self._channel) async def async_stop_cover(self, **kwargs): @@ -409,20 +409,6 @@ def _travel_stop(self): _LOGGER.debug("%s stopped at position %s", self.name, self.current_cover_position) self._update_scheduled_ha_state_callback(0) - def _travel_up_intermediate(self): - pos = self.current_cover_position - if pos >= self._intermediate_pos_up or not self._intermediate_position: - self._travel_to_position(OPEN_POSITION) - else: - self._travel_to_position(self._intermediate_pos_up) - - def _travel_down_intermediate(self): - pos = self.current_cover_position - if pos <= self._intermediate_pos_down or not self._intermediate_position: - self._travel_to_position(CLOSED_POSITION) - else: - self._travel_to_position(self._intermediate_pos_down) - def _update_scheduled_ha_state_callback(self, delay=None): """ Update ha-state callback From 5b5df1fbf955f680068219903f1dbc02f1a1b5f1 Mon Sep 17 00:00:00 2001 From: Rainer Staude Date: Mon, 1 May 2023 18:53:20 +0200 Subject: [PATCH 4/7] correct intermediate behaviour --- cover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cover.py b/cover.py index d8e0186..6957673 100644 --- a/cover.py +++ b/cover.py @@ -467,12 +467,12 @@ async def _async_message_received(self, packet): if self._tilt_blind and (self.is_opening or self.is_closing): self._travel_stop() elif cmd_arg == COMMANDS['up_intermediate'] and self._intermediate_position: - self._travel_up_intermediate() + self._travel_to_position(self._intermediate_pos_up) elif command == COMMANDS['up']: self._travel_to_position(OPEN_POSITION) self._tilt_timeout = time.time() + TILT_RECEIVE_TIMEOUT elif cmd_arg == COMMANDS['down_intermediate'] and self._intermediate_position: - self._travel_down_intermediate() + self._travel_to_position(self._intermediate_pos_down) elif command == COMMANDS['down']: self._travel_to_position(CLOSED_POSITION) self._tilt_timeout = time.time() + TILT_RECEIVE_TIMEOUT From a1858171228c51be63406f28729109dff9f15e2a Mon Sep 17 00:00:00 2001 From: Rainer Staude Date: Wed, 3 May 2023 22:20:51 +0200 Subject: [PATCH 5/7] fix xknx travelcalculator issue --- cover.py | 10 +-- travelcalculator.py | 171 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 travelcalculator.py diff --git a/cover.py b/cover.py index 6957673..50aa770 100644 --- a/cover.py +++ b/cover.py @@ -4,7 +4,7 @@ import time import voluptuous as vol -from xknx.devices import TravelCalculator +from travelcalculator import TravelCalculator from homeassistant.core import callback from homeassistant.exceptions import ( @@ -383,14 +383,14 @@ async def async_set_cover_position(self, **kwargs): await self._becker.move_down(self._channel) elif self._tc.is_opening(): await self._becker.move_up(self._channel) - if 0 < self._tc.travel_to_position < 100: + if 0 < pos < 100: self._update_scheduled_stop_travel_callback(travel_time) def _travel_to_position(self, position): """Start TravelCalculator and update ha-state.""" # In TravelCalculator 0 is open, 100 is closed. - travel_time = self._tc._calculate_travel_time( - self.current_cover_position - position + travel_time = self._tc.calculate_travel_time( + self.current_cover_position, position ) if self._template is None: _LOGGER.debug( @@ -468,11 +468,13 @@ async def _async_message_received(self, packet): self._travel_stop() elif cmd_arg == COMMANDS['up_intermediate'] and self._intermediate_position: self._travel_to_position(self._intermediate_pos_up) + self._tilt_timeout = time.time() # reset timeout elif command == COMMANDS['up']: self._travel_to_position(OPEN_POSITION) self._tilt_timeout = time.time() + TILT_RECEIVE_TIMEOUT elif cmd_arg == COMMANDS['down_intermediate'] and self._intermediate_position: self._travel_to_position(self._intermediate_pos_down) + self._tilt_timeout = time.time() # reset timeout elif command == COMMANDS['down']: self._travel_to_position(CLOSED_POSITION) self._tilt_timeout = time.time() + TILT_RECEIVE_TIMEOUT diff --git a/travelcalculator.py b/travelcalculator.py new file mode 100644 index 0000000..ac1296e --- /dev/null +++ b/travelcalculator.py @@ -0,0 +1,171 @@ +""" +Module TravelCalculator provides functionality for predicting the current position of a Cover. + +E.g.: + +* Given a Cover that takes 100 seconds to travel from top to bottom. +* Starting from position 90, directed to position 60 at time 0. +* At time 10 TravelCalculator will return position 80 (final position not reached). +* At time 20 TravelCalculator will return position 70 (final position not reached). +* At time 30 TravelCalculator will return position 60 (final position reached). + +From https://github.com/XKNX +""" +from __future__ import annotations + +from enum import Enum +import time + + +class TravelStatus(Enum): + """Enum class for travel status.""" + + DIRECTION_UP = 1 + DIRECTION_DOWN = 2 + STOPPED = 3 + + +class TravelCalculator: + """Class for calculating the current position of a cover.""" + + def __init__(self, travel_time_down: float, travel_time_up: float) -> None: + """Initialize TravelCalculator class.""" + self.travel_direction = TravelStatus.STOPPED + self.travel_time_down = travel_time_down + self.travel_time_up = travel_time_up + + self._last_known_position: int | None = None + self._last_known_position_timestamp: float = 0.0 + self._position_confirmed: bool = False + self._travel_to_position: int | None = None + + # 100 is closed, 0 is fully open + self.position_closed: int = 100 + self.position_open: int = 0 + + def set_position(self, position: int) -> None: + """Set position and target of cover.""" + self._travel_to_position = position + self.update_position(position) + + def update_position(self, position: int) -> None: + """Update known position of cover.""" + self._last_known_position = position + self._last_known_position_timestamp = time.time() + if position == self._travel_to_position: + self._position_confirmed = True + + def stop(self) -> None: + """Stop traveling.""" + stop_position = self.current_position() + if stop_position is None: + return + self._last_known_position = stop_position + self._travel_to_position = stop_position + self._position_confirmed = False + self.travel_direction = TravelStatus.STOPPED + + def start_travel(self, _travel_to_position: int) -> None: + """Start traveling to position.""" + if self._last_known_position is None: + self.set_position(_travel_to_position) + return + self.stop() + self._last_known_position_timestamp = time.time() + self._travel_to_position = _travel_to_position + self._position_confirmed = False + + self.travel_direction = ( + TravelStatus.DIRECTION_DOWN + if _travel_to_position > self._last_known_position + else TravelStatus.DIRECTION_UP + ) + + def start_travel_up(self) -> None: + """Start traveling up.""" + self.start_travel(self.position_open) + + def start_travel_down(self) -> None: + """Start traveling down.""" + self.start_travel(self.position_closed) + + def current_position(self) -> int | None: + """Return current (calculated or known) position.""" + if not self._position_confirmed: + return self._calculate_position() + return self._last_known_position + + def is_traveling(self) -> bool: + """Return if cover is traveling.""" + return self.current_position() != self._travel_to_position + + def is_opening(self) -> bool: + """Return if the cover is opening.""" + return ( + self.is_traveling() and self.travel_direction == TravelStatus.DIRECTION_UP + ) + + def is_closing(self) -> bool: + """Return if the cover is closing.""" + return ( + self.is_traveling() and self.travel_direction == TravelStatus.DIRECTION_DOWN + ) + + def position_reached(self) -> bool: + """Return if cover has reached designated position.""" + return self.current_position() == self._travel_to_position + + def is_open(self) -> bool: + """Return if cover is (fully) open.""" + return self.current_position() == self.position_open + + def is_closed(self) -> bool: + """Return if cover is (fully) closed.""" + return self.current_position() == self.position_closed + + def _calculate_position(self) -> int | None: + """Return calculated position.""" + if self._travel_to_position is None or self._last_known_position is None: + return self._last_known_position + relative_position = self._travel_to_position - self._last_known_position + + def position_reached_or_exceeded(relative_position: int) -> bool: + """Return if designated position was reached.""" + if ( + relative_position <= 0 + and self.travel_direction == TravelStatus.DIRECTION_DOWN + ): + return True + if ( + relative_position >= 0 + and self.travel_direction == TravelStatus.DIRECTION_UP + ): + return True + return False + + if position_reached_or_exceeded(relative_position): + return self._travel_to_position + + 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_position_timestamp + remaining_travel_time: + return self._travel_to_position + + progress = ( + time.time() - self._last_known_position_timestamp + ) / remaining_travel_time + return int(self._last_known_position + relative_position * progress) + + 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_down if travel_range > 0 else self.travel_time_up + ) + return travel_time_full * abs(travel_range) / self.position_closed + + def __eq__(self, other: object | None) -> bool: + """Equal operator.""" + return self.__dict__ == other.__dict__ \ No newline at end of file From 7fe537a933b5a5d75e83f2c1b9f336ffed69db12 Mon Sep 17 00:00:00 2001 From: Rainer Staude Date: Wed, 3 May 2023 22:21:53 +0200 Subject: [PATCH 6/7] fix xknx issue --- manifest.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index 5d1dce1..7376280 100644 --- a/manifest.json +++ b/manifest.json @@ -2,10 +2,8 @@ "domain": "becker", "name": "Becker", "documentation": "", - "version": "0.3.1", - "requirements": [ - "xknx" - ], + "version": "0.3.2", + "requirements": [], "dependencies": [], "codeowners": ["@nicolasberthel", "@RainerStaude"] } From 70f2c8d7503bc64c5ea5273eb61aae6e9f7bb4d6 Mon Sep 17 00:00:00 2001 From: Rainer Staude Date: Wed, 3 May 2023 22:33:25 +0200 Subject: [PATCH 7/7] correct local import for travelcalculator --- cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cover.py b/cover.py index 50aa770..5a0c182 100644 --- a/cover.py +++ b/cover.py @@ -4,7 +4,7 @@ import time import voluptuous as vol -from travelcalculator import TravelCalculator +from .travelcalculator import TravelCalculator from homeassistant.core import callback from homeassistant.exceptions import (