diff --git a/readme.md b/readme.md index ae2eb018..ba865437 100644 --- a/readme.md +++ b/readme.md @@ -132,6 +132,8 @@ MyOpenhabRule() - Added CompressedMidnightRotatingFileHandler - Updated dependencies - Small improvement for RGB and HSB types +- Small improvements for openHAB items +- Added toggle for SwitchItem #### 23.11.0 (2023-11-23) - Fix for very small float values (#425) diff --git a/requirements.txt b/requirements.txt index aa857f7a..9b86d6f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,8 +6,8 @@ # ----------------------------------------------------------------------------- # Packages for source formatting # ----------------------------------------------------------------------------- -pre-commit >= 3.6, < 4 -ruff >= 0.1.8, < 0.2 +pre-commit == 3.5.0 +ruff == 0.1.8 # ----------------------------------------------------------------------------- # Packages for other developement tasks diff --git a/src/HABApp/core/const/hints.py b/src/HABApp/core/const/hints.py index 9fe059e1..ffdf34bb 100644 --- a/src/HABApp/core/const/hints.py +++ b/src/HABApp/core/const/hints.py @@ -1,6 +1,7 @@ from typing import Any as __Any from typing import Awaitable as __Awaitable from typing import Callable as __Callable +from typing import Protocol as __Protocol from typing import Type as __Type from .const import PYTHON_310 as __IS_GE_PYTHON_310 @@ -16,3 +17,10 @@ TYPE_FUNC_ASYNC: TypeAlias = __Callable[..., __Awaitable[__Any]] TYPE_EVENT_CALLBACK: TypeAlias = __Callable[[__Any], __Any] + + +# noinspection PyPropertyDefinition +class HasNameAttr(__Protocol): + @property + def name(self) -> str: + ... diff --git a/src/HABApp/core/errors.py b/src/HABApp/core/errors.py index 59128461..fa87be13 100644 --- a/src/HABApp/core/errors.py +++ b/src/HABApp/core/errors.py @@ -1,3 +1,6 @@ +from HABApp.core.const.hints import HasNameAttr as _HasNameAttr + + class HABAppException(Exception): pass @@ -33,3 +36,22 @@ class ContextBoundObjectIsAlreadyLinkedError(HABAppException): class ContextBoundObjectIsAlreadyUnlinkedError(HABAppException): pass + + +# ---------------------------------------------------------------------------------------------------------------------- +# Value errors +# ---------------------------------------------------------------------------------------------------------------------- +class HABAppValueError(ValueError, HABAppException): + pass + + +class ItemValueIsNoneError(HABAppValueError): + @classmethod + def from_item(cls, item: _HasNameAttr): + return cls(f'Item value is None (item "{item.name:s}")') + + +class InvalidItemValue(HABAppValueError): + @classmethod + def from_item(cls, item: _HasNameAttr, value): + return cls(f'Invalid value for {item.__class__.__name__} {item.name:s}: {value}') diff --git a/src/HABApp/openhab/items/base_item.py b/src/HABApp/openhab/items/base_item.py index af772e69..bc855c7d 100644 --- a/src/HABApp/openhab/items/base_item.py +++ b/src/HABApp/openhab/items/base_item.py @@ -1,5 +1,5 @@ import datetime -from typing import Any, FrozenSet, Mapping, NamedTuple, Optional, TypeVar, Type +from typing import Any, FrozenSet, Mapping, NamedTuple, Optional, Type from immutables import Map @@ -110,5 +110,4 @@ def get_persistence_data(self, persistence: Optional[str] = None, ) -HINT_OPENHAB_ITEM = TypeVar('HINT_OPENHAB_ITEM', bound=OpenhabItem) -HINT_TYPE_OPENHAB_ITEM = Type[HINT_OPENHAB_ITEM] +HINT_TYPE_OPENHAB_ITEM = Type[OpenhabItem] diff --git a/src/HABApp/openhab/items/commands.py b/src/HABApp/openhab/items/commands.py index 38fa68a7..5452ef4e 100644 --- a/src/HABApp/openhab/items/commands.py +++ b/src/HABApp/openhab/items/commands.py @@ -1,46 +1,47 @@ +from HABApp.core.const.hints import HasNameAttr as _HasNameAttr from HABApp.openhab.definitions import OnOffValue, UpDownValue from HABApp.openhab.interface_sync import send_command class OnOffCommand: - def is_on(self) -> bool: + def is_on(self: _HasNameAttr) -> bool: """Test value against on-value""" raise NotImplementedError() - def is_off(self) -> bool: + def is_off(self: _HasNameAttr) -> bool: """Test value against off-value""" raise NotImplementedError() - def on(self): + def on(self: _HasNameAttr): """Command item on""" - send_command(self, OnOffValue.ON) + send_command(self.name, OnOffValue.ON) - def off(self): + def off(self: _HasNameAttr): """Command item off""" - send_command(self, OnOffValue.OFF) + send_command(self.name, OnOffValue.OFF) class PercentCommand: - def percent(self, value: float): + def percent(self: _HasNameAttr, value: float): """Command to value (in percent)""" assert 0 <= value <= 100, value - send_command(self, str(value)) + send_command(self.name, str(value)) class UpDownCommand: - def up(self): + def up(self: _HasNameAttr): """Command up""" - send_command(self, UpDownValue.UP) + send_command(self.name, UpDownValue.UP) - def down(self): + def down(self: _HasNameAttr): """Command down""" - send_command(self, UpDownValue.DOWN) + send_command(self.name, UpDownValue.DOWN) - def is_up(self) -> bool: + def is_up(self: _HasNameAttr) -> bool: """Test value against on-value""" raise NotImplementedError() - def is_down(self) -> bool: + def is_down(self: _HasNameAttr) -> bool: """Test value against off-value""" raise NotImplementedError() diff --git a/src/HABApp/openhab/items/contact_item.py b/src/HABApp/openhab/items/contact_item.py index be01e1d5..5714b9fc 100644 --- a/src/HABApp/openhab/items/contact_item.py +++ b/src/HABApp/openhab/items/contact_item.py @@ -5,6 +5,7 @@ from ...core.const import MISSING from ..errors import SendCommandNotSupported from HABApp.openhab.interface_sync import post_update +from ...core.errors import InvalidItemValue if TYPE_CHECKING: Optional = Optional @@ -40,8 +41,9 @@ def set_value(self, new_value) -> bool: if isinstance(new_value, OpenClosedValue): new_value = new_value.value - if new_value is not None and new_value != OPEN and new_value != CLOSED: - raise ValueError(f'Invalid value for ContactItem: {new_value}') + if new_value not in (OPEN, CLOSED, None): + raise InvalidItemValue.from_item(self, new_value) + return super().set_value(new_value) def is_open(self) -> bool: diff --git a/src/HABApp/openhab/items/dimmer_item.py b/src/HABApp/openhab/items/dimmer_item.py index 260a3e34..8e419a11 100644 --- a/src/HABApp/openhab/items/dimmer_item.py +++ b/src/HABApp/openhab/items/dimmer_item.py @@ -1,9 +1,12 @@ -from typing import Union, TYPE_CHECKING, Optional, FrozenSet, Mapping +from typing import TYPE_CHECKING, FrozenSet, Mapping, Optional, Union -from HABApp.openhab.items.base_item import OpenhabItem, MetaData +from HABApp.openhab.items.base_item import MetaData, OpenhabItem from HABApp.openhab.items.commands import OnOffCommand, PercentCommand + +from ...core.errors import InvalidItemValue, ItemValueIsNoneError from ..definitions import OnOffValue, PercentValue + if TYPE_CHECKING: Union = Union Optional = Optional @@ -39,15 +42,13 @@ def set_value(self, new_value) -> bool: new_value = new_value.value # Percent is 0 ... 100 - if isinstance(new_value, (int, float)): - assert 0 <= new_value <= 100, new_value - else: - assert new_value is None, new_value + if isinstance(new_value, (int, float)) and (0 <= new_value <= 100): + return super().set_value(new_value) - return super().set_value(new_value) + if new_value is None: + return super().set_value(new_value) - def __str__(self): - return self.value + raise InvalidItemValue.from_item(self, new_value) def is_on(self) -> bool: """Test value against on-value""" @@ -55,4 +56,12 @@ def is_on(self) -> bool: def is_off(self) -> bool: """Test value against off-value""" - return not bool(self.value) + return self.value is not None and not self.value + + def __str__(self): + return self.value + + def __bool__(self): + if self.value is None: + raise ItemValueIsNoneError.from_item(self) + return self.is_on() diff --git a/src/HABApp/openhab/items/number_item.py b/src/HABApp/openhab/items/number_item.py index a3f37b92..4b46969d 100644 --- a/src/HABApp/openhab/items/number_item.py +++ b/src/HABApp/openhab/items/number_item.py @@ -2,6 +2,7 @@ from HABApp.openhab.items.base_item import OpenhabItem, MetaData from ..definitions import QuantityValue +from ...core.errors import ItemValueIsNoneError, InvalidItemValue if TYPE_CHECKING: Union = Union @@ -41,4 +42,15 @@ def set_value(self, new_value) -> bool: if isinstance(new_value, QuantityValue): return super().set_value(new_value.value) - return super().set_value(new_value) + if isinstance(new_value, (int, float)): + return super().set_value(new_value) + + if new_value is None: + return super().set_value(new_value) + + raise InvalidItemValue.from_item(self, new_value) + + def __bool__(self): + if self.value is None: + raise ItemValueIsNoneError.from_item(self) + return bool(self.value) diff --git a/src/HABApp/openhab/items/rollershutter_item.py b/src/HABApp/openhab/items/rollershutter_item.py index 0d892a6f..71717881 100644 --- a/src/HABApp/openhab/items/rollershutter_item.py +++ b/src/HABApp/openhab/items/rollershutter_item.py @@ -1,8 +1,10 @@ -from typing import TYPE_CHECKING, Optional, FrozenSet, Mapping, Union +from typing import TYPE_CHECKING, FrozenSet, Mapping, Optional, Union + +from HABApp.core.errors import InvalidItemValue +from HABApp.openhab.definitions import PercentValue, UpDownValue +from HABApp.openhab.items.base_item import MetaData, OpenhabItem +from HABApp.openhab.items.commands import PercentCommand, UpDownCommand -from HABApp.openhab.items.base_item import OpenhabItem, MetaData -from HABApp.openhab.items.commands import UpDownCommand, PercentCommand -from ..definitions import UpDownValue, PercentValue if TYPE_CHECKING: Union = Union @@ -38,8 +40,14 @@ def set_value(self, new_value) -> bool: elif isinstance(new_value, PercentValue): new_value = new_value.value - assert isinstance(new_value, (int, float)) or new_value is None, new_value - return super().set_value(new_value) + # Position is 0 ... 100 + if isinstance(new_value, (int, float)) and (0 <= new_value <= 100): + return super().set_value(new_value) + + if new_value is None: + return super().set_value(new_value) + + raise InvalidItemValue.from_item(self, new_value) def is_up(self) -> bool: return self.value <= 0 diff --git a/src/HABApp/openhab/items/switch_item.py b/src/HABApp/openhab/items/switch_item.py index a5687f9f..add0db75 100644 --- a/src/HABApp/openhab/items/switch_item.py +++ b/src/HABApp/openhab/items/switch_item.py @@ -1,9 +1,11 @@ -from typing import TYPE_CHECKING, Tuple, Optional, FrozenSet, Mapping +from typing import TYPE_CHECKING, Final, FrozenSet, Mapping, Optional, Tuple +from HABApp.core.errors import ItemValueIsNoneError, InvalidItemValue from HABApp.openhab.definitions import OnOffValue -from HABApp.openhab.items.base_item import OpenhabItem, MetaData +from HABApp.openhab.items.base_item import MetaData, OpenhabItem from HABApp.openhab.items.commands import OnOffCommand + if TYPE_CHECKING: Tuple = Tuple Optional = Optional @@ -12,8 +14,8 @@ MetaData = MetaData -ON = OnOffValue.ON -OFF = OnOffValue.OFF +ON: Final = OnOffValue.ON +OFF: Final = OnOffValue.OFF class SwitchItem(OpenhabItem, OnOffCommand): @@ -28,7 +30,6 @@ class SwitchItem(OpenhabItem, OnOffCommand): :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ - @staticmethod def _state_from_oh_str(state: str): if state != ON and state != OFF: @@ -40,8 +41,9 @@ def set_value(self, new_value) -> bool: if isinstance(new_value, OnOffValue): new_value = new_value.value - if new_value is not None and new_value != ON and new_value != OFF: - raise ValueError(f'Invalid value for SwitchItem {self.name}: {new_value}') + if new_value not in (ON, OFF, None): + raise InvalidItemValue.from_item(self, new_value) + return super().set_value(new_value) def is_on(self) -> bool: @@ -52,15 +54,26 @@ def is_off(self) -> bool: """Test value against off-value""" return self.value == OFF + def toggle(self): + """Toggle the switch. Turns the switch on when off or off when currently on.""" + if self.value == ON: + self.off() + elif self.value == OFF: + self.on() + elif self.value is None: + raise ItemValueIsNoneError.from_item(self) + else: + raise InvalidItemValue.from_item(self, self.value) + def __str__(self): return self.value def __eq__(self, other): if isinstance(other, SwitchItem): return self.value == other.value - elif isinstance(other, str): + if isinstance(other, str): return self.value == other - elif isinstance(other, int): + if isinstance(other, int): if other and self.is_on(): return True if not other and self.is_off(): @@ -70,4 +83,6 @@ def __eq__(self, other): return NotImplemented def __bool__(self): - return self.is_on() + if self.value is None: + raise ItemValueIsNoneError.from_item(self) + return self.value == ON diff --git a/src/HABApp/openhab/map_items.py b/src/HABApp/openhab/map_items.py index d08ed681..a65db9ef 100644 --- a/src/HABApp/openhab/map_items.py +++ b/src/HABApp/openhab/map_items.py @@ -3,13 +3,14 @@ from immutables import Map -import HABApp from HABApp.core.wrapper import process_exception from HABApp.openhab.definitions.values import QuantityValue -from HABApp.openhab.items import ColorItem, ContactItem, DatetimeItem, DimmerItem, GroupItem, ImageItem, LocationItem, \ - NumberItem, PlayerItem, RollershutterItem, StringItem, SwitchItem, CallItem -from HABApp.openhab.items.base_item import HINT_TYPE_OPENHAB_ITEM -from HABApp.openhab.items.base_item import MetaData +from HABApp.openhab.items import ( + CallItem, ColorItem, ContactItem, DatetimeItem, DimmerItem, GroupItem, ImageItem, + LocationItem, NumberItem, PlayerItem, RollershutterItem, StringItem, SwitchItem, +) +from HABApp.openhab.items.base_item import HINT_TYPE_OPENHAB_ITEM, MetaData, OpenhabItem + log = logging.getLogger('HABApp.openhab.items') @@ -34,7 +35,7 @@ def map_item(name: str, type: str, value: Optional[str], label: Optional[str], tags: FrozenSet[str], groups: FrozenSet[str], metadata: Optional[Dict[str, Dict[str, Any]]]) -> \ - Optional['HABApp.openhab.items.OpenhabItem']: + Optional[OpenhabItem]: try: assert isinstance(type, str) assert value is None or isinstance(value, str) diff --git a/tests/test_openhab/test_items/test_commands.py b/tests/test_openhab/test_items/test_commands.py index 599517ca..88031b67 100644 --- a/tests/test_openhab/test_items/test_commands.py +++ b/tests/test_openhab/test_items/test_commands.py @@ -2,15 +2,20 @@ import pytest -from HABApp.openhab.definitions import OnOffValue, UpDownValue, OpenClosedValue +from HABApp import __version__ +from HABApp.openhab.definitions import OnOffValue, OpenClosedValue, UpDownValue from HABApp.openhab.items import ContactItem -from HABApp.openhab.items.commands import UpDownCommand, OnOffCommand +from HABApp.openhab.items.commands import OnOffCommand, UpDownCommand from HABApp.openhab.map_items import _items as item_dict @pytest.mark.parametrize("cls", [cls for cls in item_dict.values() if issubclass(cls, OnOffCommand)]) def test_OnOff(cls): c = cls('item_name') + assert not c.is_on() + if not __version__.startswith('23.12.0'): + assert not c.is_off() + c.set_value(OnOffValue('ON')) assert c.is_on() assert not c.is_off() @@ -37,6 +42,9 @@ def test_UpDown(cls): @pytest.mark.parametrize("cls", (ContactItem, )) def test_OpenClosed(cls: typing.Type[ContactItem]): c = cls('item_name') + assert not c.is_closed() + assert not c.is_open() + c.set_value(OpenClosedValue.OPEN) assert c.is_open() assert not c.is_closed() diff --git a/tests/test_openhab/test_items/test_contact.py b/tests/test_openhab/test_items/test_contact.py index f551a4f8..638e4661 100644 --- a/tests/test_openhab/test_items/test_contact.py +++ b/tests/test_openhab/test_items/test_contact.py @@ -1,5 +1,6 @@ import pytest +from HABApp.core.errors import InvalidItemValue from HABApp.openhab.errors import SendCommandNotSupported from HABApp.openhab.items import ContactItem @@ -11,3 +12,12 @@ def test_send_command(): c.oh_send_command('asdf') assert str(e.value) == 'ContactItem does not support send command! See openHAB documentation for details.' + + +def test_switch_set_value(): + ContactItem('').set_value(None) + ContactItem('').set_value('OPEN') + ContactItem('').set_value('CLOSED') + + with pytest.raises(InvalidItemValue): + ContactItem('item_name').set_value('asdf') diff --git a/tests/test_openhab/test_items/test_dimmer.py b/tests/test_openhab/test_items/test_dimmer.py new file mode 100644 index 00000000..fee6a32b --- /dev/null +++ b/tests/test_openhab/test_items/test_dimmer.py @@ -0,0 +1,22 @@ +import pytest + +from HABApp.core.errors import InvalidItemValue, ItemValueIsNoneError +from HABApp.openhab.items import DimmerItem + + +def test_dimmer_item_bool(): + with pytest.raises(ItemValueIsNoneError): + assert not DimmerItem('asdf') + + assert not DimmerItem('asdf', 0) + assert DimmerItem('asdf', 1) + + +def test_dimmer_set_value(): + DimmerItem('').set_value(None) + DimmerItem('').set_value(0) + DimmerItem('').set_value(100) + DimmerItem('').set_value(55.55) + + with pytest.raises(InvalidItemValue): + DimmerItem('item_name').set_value('asdf') diff --git a/tests/test_openhab/test_items/test_number.py b/tests/test_openhab/test_items/test_number.py index efecef75..a9b75a51 100644 --- a/tests/test_openhab/test_items/test_number.py +++ b/tests/test_openhab/test_items/test_number.py @@ -1,5 +1,7 @@ +import pytest from immutables import Map +from HABApp.core.errors import InvalidItemValue, ItemValueIsNoneError from HABApp.openhab.items import NumberItem from HABApp.openhab.items.base_item import MetaData @@ -7,3 +9,20 @@ def test_number_item_unit(): assert NumberItem('test', 1).unit is None assert NumberItem('test', 1, metadata=Map(unit=MetaData('°C'))).unit == '°C' + + +def test_number_item_bool(): + with pytest.raises(ItemValueIsNoneError): + assert not NumberItem('asdf') + + assert not NumberItem('asdf', 0) + assert NumberItem('asdf', 1) + + +def test_number_set_value(): + NumberItem('').set_value(None) + NumberItem('').set_value(1) + NumberItem('').set_value(-3.3) + + with pytest.raises(InvalidItemValue): + NumberItem('item_name').set_value('asdf') diff --git a/tests/test_openhab/test_items/test_rollershutter.py b/tests/test_openhab/test_items/test_rollershutter.py new file mode 100644 index 00000000..a6b0938c --- /dev/null +++ b/tests/test_openhab/test_items/test_rollershutter.py @@ -0,0 +1,14 @@ +import pytest + +from HABApp.core.errors import InvalidItemValue +from HABApp.openhab.items import RollershutterItem + + +def test_dimmer_set_value(): + RollershutterItem('').set_value(None) + RollershutterItem('').set_value(0) + RollershutterItem('').set_value(100) + RollershutterItem('').set_value(55.55) + + with pytest.raises(InvalidItemValue): + RollershutterItem('item_name').set_value('asdf') diff --git a/tests/test_openhab/test_items/test_switch.py b/tests/test_openhab/test_items/test_switch.py new file mode 100644 index 00000000..6bc7c9f1 --- /dev/null +++ b/tests/test_openhab/test_items/test_switch.py @@ -0,0 +1,21 @@ +import pytest + +from HABApp.core.errors import InvalidItemValue, ItemValueIsNoneError +from HABApp.openhab.items import SwitchItem + + +def test_switch_item_bool(): + with pytest.raises(ItemValueIsNoneError): + assert SwitchItem('test') + + assert not SwitchItem('test', 'OFF') + assert SwitchItem('test', 'ON') + + +def test_switch_set_value(): + SwitchItem('').set_value(None) + SwitchItem('').set_value('ON') + SwitchItem('').set_value('OFF') + + with pytest.raises(InvalidItemValue): + SwitchItem('item_name').set_value('asdf')