Skip to content

Commit

Permalink
feat: implement support for weather sensors; related to #72
Browse files Browse the repository at this point in the history
BREAKING CHANGE: weather sensors are untested due to lack of device
  • Loading branch information
muhlba91 committed Nov 16, 2023
1 parent f988e53 commit a49a31d
Show file tree
Hide file tree
Showing 29 changed files with 1,709 additions and 999 deletions.
1 change: 1 addition & 0 deletions .flakeheaven_cache/34f0a451a6f9a93edd9319e7ea3b4799.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"results": [["pycodestyle", "W503", 83, 12, "line break before binary operator", " | SUPPORT_CLOSE\n"], ["pycodestyle", "W503", 84, 12, "line break before binary operator", " | SUPPORT_STOP\n"], ["pycodestyle", "W503", 85, 12, "line break before binary operator", " | SUPPORT_SET_POSITION\n"], ["pycodestyle", "W503", 86, 12, "line break before binary operator", " | SUPPORT_SET_TILT_POSITION\n"]], "digest": "c45599c4be9bfa4068ff1f488c7a91fa"}
1 change: 1 addition & 0 deletions .flakeheaven_cache/359a7719b0d3d1733d4ea7585ac85b43.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"results": [["pycodestyle", "W503", 48, 12, "line break before binary operator", " or item[1].device_type == DeviceType.WEATHER,\n"]], "digest": "757cdf44b36b18d79ae7a57cc0720e51"}
1 change: 1 addition & 0 deletions .flakeheaven_cache/4b3eb8a36c0aee37871a4fb623888e11.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"results": [["pyflakes", "F401", 11, 0, "'onyx_client.enum.action.Action' imported but unused", "from onyx_client.enum.action import Action\n"]], "digest": "8b3ccf97c1f64be4907c107eca2e6f1c"}
1 change: 1 addition & 0 deletions .flakeheaven_cache/6518247dceaba6364fa1df5d0ea938df.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"results": [["pycodestyle", "W503", 260, 16, "line break before binary operator", " + position_keyframe.duration\n"], ["pycodestyle", "W503", 261, 16, "line break before binary operator", " + position_keyframe.delay\n"], ["pycodestyle", "W503", 284, 16, "line break before binary operator", " or (position_end_time is None and current_time > angle_end_time)\n"], ["pycodestyle", "W503", 285, 16, "line break before binary operator", " or (current_time > position_end_time and current_time > angle_end_time)\n"]], "digest": "4d64e826b79067d7b67495f87f725b3f"}
2 changes: 1 addition & 1 deletion .flakeheaven_cache/975e7a37850e323f5cb88e2c904ec06e.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"results": [["pycodestyle", "W503", 282, 16, "line break before binary operator", " + position_keyframe.duration\n"], ["pycodestyle", "W503", 283, 16, "line break before binary operator", " + position_keyframe.delay\n"], ["pycodestyle", "W503", 306, 16, "line break before binary operator", " or (position_end_time is None and current_time > angle_end_time)\n"], ["pycodestyle", "W503", 307, 16, "line break before binary operator", " or (current_time > position_end_time and current_time > angle_end_time)\n"]], "digest": "dd31060492c965047fa90dcd5a336b37"}
{"results": [["pyflakes", "F401", 3, 0, "'typing.Any' imported but unused", "from typing import Any, Callable, Optional\n"]], "digest": "3b8c1e5c9fa3df334fee8ed34220d9f8"}
2 changes: 1 addition & 1 deletion .flakeheaven_cache/e04f20f45df6269780bfca68615a220e.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"results": [["pycodestyle", "W503", 156, 12, "line break before binary operator", " | SUPPORT_CLOSE\n"], ["pycodestyle", "W503", 157, 12, "line break before binary operator", " | SUPPORT_STOP\n"], ["pycodestyle", "W503", 158, 12, "line break before binary operator", " | SUPPORT_SET_POSITION\n"], ["pycodestyle", "W503", 159, 12, "line break before binary operator", " | SUPPORT_SET_TILT_POSITION\n"]], "digest": "c77c271510873caa3a064cd768028129"}
{"results": [["pycodestyle", "W503", 159, 12, "line break before binary operator", " | SUPPORT_CLOSE\n"], ["pycodestyle", "W503", 160, 12, "line break before binary operator", " | SUPPORT_STOP\n"], ["pycodestyle", "W503", 161, 12, "line break before binary operator", " | SUPPORT_SET_POSITION\n"], ["pycodestyle", "W503", 162, 12, "line break before binary operator", " | SUPPORT_SET_TILT_POSITION\n"]], "digest": "76e1ee761d561c52097f5d15562473ec"}
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
[![](https://img.shields.io/github/actions/workflow/status/muhlba91/onyx-homeassistant-integration/release.yml?style=for-the-badge)](https://github.com/muhlba91/onyx-homeassistant-integration/actions/workflows/release.yml)
[![](https://img.shields.io/coveralls/github/muhlba91/onyx-homeassistant-integration?style=for-the-badge)](https://github.com/muhlba91/onyx-homeassistant-integration/)
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs)
[![Known Vulnerabilities](https://snyk.io/test/github/muhlba91/onyx-homeassistant-integration/badge.svg)](https://snyk.io/test/github/muhlba91/onyx-homeassistant-integration/)
[![Known Vulnerabilities](https://snyk.io/test/github/muhlba91/onyx-homeassistant-integration/badge.svg)](https://snyk.io/test/github/muhlba91/onyx-homeassistant-integration)
<a href="https://www.buymeacoffee.com/muhlba91" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="28" width="150"></a>

This component creates an integration that provides **raffstore/shutter entities** to control
[Hella's ONYX.CENTER](https://www.hella.info/) via Home Assistant.
This component creates an integration that provides the following entities to control [Hella's ONYX.CENTER](https://www.hella.info/) via Home Assistant:

- **raffstore/shutter** entities
- **weather station** sensor entities

---

Expand Down
310 changes: 4 additions & 306 deletions custom_components/hella_onyx/cover.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,14 @@
"""The ONYX shutter entity."""
import asyncio
import logging
import time
from datetime import timedelta
from math import ceil
from typing import Any, Callable, Optional
from typing import Callable, Optional

from homeassistant.components.cover import (
ATTR_POSITION,
ATTR_TILT_POSITION,
CoverDeviceClass,
CoverEntity,
SUPPORT_CLOSE,
SUPPORT_OPEN,
SUPPORT_SET_POSITION,
SUPPORT_SET_TILT_POSITION,
SUPPORT_STOP,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.event import (
track_point_in_utc_time,
)
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import utcnow
from onyx_client.data.animation_value import AnimationValue
from onyx_client.enum.action import Action
from onyx_client.enum.device_type import DeviceType

from . import APIConnector, DOMAIN, ONYX_TIMEZONE
from .const import INCREASED_INTERVAL_DELTA, ONYX_API, ONYX_COORDINATOR
from .moving_state import MovingState
from .onyx_entity import OnyxEntity
from custom_components.hella_onyx import DOMAIN, ONYX_TIMEZONE
from custom_components.hella_onyx.const import ONYX_API, ONYX_COORDINATOR
from custom_components.hella_onyx.sensors.shutter import OnyxShutter

_LOGGER = logging.getLogger(__name__)

Expand All @@ -59,281 +35,3 @@ async def async_setup_entry(
]
_LOGGER.info("adding %s hella_onyx shutter entities", len(shutters))
async_add_entities(shutters, True)


class OnyxShutter(OnyxEntity, CoverEntity):
"""A shutter entity."""

def __init__(
self,
api: APIConnector,
timezone: str,
coordinator: DataUpdateCoordinator,
name: str,
device_type: DeviceType,
uuid: str,
):
"""Initialize a shutter entity."""
super().__init__(api, timezone, coordinator, name, device_type, uuid)
self._moving_state = MovingState.STILL

@property
def name(self) -> str:
"""Return the display name of the light."""
return self._name

@property
def unique_id(self) -> str:
"""Return the unique id of the light."""
return f"{self._uuid}/Shutter"

@property
def device_class(self) -> Optional[str]:
"""Return the class of this device, from component device class."""
return CoverDeviceClass.SHUTTER

@property
def supported_features(self):
"""Flag supported features."""
supported_features = (
SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION
)

if (
self._type
in (DeviceType.RAFFSTORE_90, DeviceType.RAFFSTORE_180)
is not None
):
supported_features |= SUPPORT_SET_TILT_POSITION

return supported_features

@property
def current_cover_position(self) -> int:
"""Return current position of cover.
None is unknown, 0 is closed, 100 is fully open.
"""
position = self._device.actual_position
_LOGGER.debug(
"received position fo device %s: %s (%s/%s)",
self._uuid,
position.value,
position.minimum,
position.maximum,
)
if position.animation is not None and len(position.animation.keyframes) > 0:
self._start_moving_device(position.animation)
return 100 - int(position.value / position.maximum * 100)

@property
def current_cover_tilt_position(self) -> int:
"""Return current position of cover tilt.
None is unknown, 0 is closed, 100 is fully open.
"""
position = self._device.actual_angle
_LOGGER.debug(
"received tilt position fo device %s: %s (%s/%s)",
self._uuid,
position.value,
position.minimum,
position.maximum,
)
if position.animation is not None and len(position.animation.keyframes) > 0:
self._start_moving_device(position.animation)
return int(position.value / self._max_angle * 100)

@property
def is_opening(self) -> bool:
"""Return if the cover is opening or not."""
return self._moving_state == MovingState.OPENING

@property
def is_closing(self) -> bool:
"""Return if the cover is closing or not."""
return self._moving_state == MovingState.CLOSING

@property
def is_closed(self) -> bool:
"""Return if the cover is closed or not."""
position = self._device.actual_position
return position.value == position.maximum

def open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
self._set_state(MovingState.OPENING)
asyncio.run_coroutine_threadsafe(
self.api.send_device_command_action(self._uuid, Action.OPEN), self.hass.loop
)

def close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
self._set_state(MovingState.CLOSING)
asyncio.run_coroutine_threadsafe(
self.api.send_device_command_action(self._uuid, Action.CLOSE),
self.hass.loop,
)

def set_cover_position(self, **kwargs):
"""Move the cover to a specific position."""
if ATTR_POSITION in kwargs:
position = 100 - int(kwargs.get(ATTR_POSITION))
hella_position = ceil(
position * (self._device.target_position.maximum / 100)
)
self._calculate_and_set_state(self._device.actual_position.value, position)
asyncio.run_coroutine_threadsafe(
self.api.send_device_command_properties(
self._uuid,
{
"target_position": hella_position,
"target_angle": self._device.target_angle.value,
},
),
self.hass.loop,
)

def stop_cover(self, **kwargs):
"""Stop the cover."""
self._set_state(MovingState.STILL)
asyncio.run_coroutine_threadsafe(
self.api.send_device_command_action(self._uuid, Action.STOP), self.hass.loop
)

def open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
raise NotImplementedError()

def close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
raise NotImplementedError()

def set_cover_tilt_position(self, **kwargs):
"""Move the cover tilt to a specific position."""
if ATTR_TILT_POSITION in kwargs:
angle = int(kwargs.get(ATTR_TILT_POSITION))
hella_angle = ceil(angle * (self._max_angle / 100))
self._calculate_and_set_state(
self._device.actual_angle.value,
hella_angle,
)
asyncio.run_coroutine_threadsafe(
self.api.send_device_command_properties(
self._uuid, {"target_angle": hella_angle}
),
self.hass.loop,
)

def stop_cover_tilt(self, **kwargs):
"""Stop the cover."""
self._set_state(MovingState.STILL)
asyncio.run_coroutine_threadsafe(
self.api.send_device_command_action(self._uuid, Action.STOP), self.hass.loop
)

def _set_state(self, state: MovingState):
"""Set the new moving state."""
self._moving_state = state

def _start_moving_device(self, animation: AnimationValue):
"""Start the update loop."""
if self._moving_state == MovingState.STILL:
_LOGGER.debug("not moving still device %s", self._uuid)
return

keyframes = len(animation.keyframes)
keyframe = animation.keyframes[keyframes - 1]

current_time = time.time()
end_time = animation.start + keyframe.duration + keyframe.delay
delta = end_time - current_time
moving = current_time < end_time

_LOGGER.debug(
"moving device %s with current_time %s and end_time %s: %s",
self._uuid,
current_time,
end_time,
moving,
)

if moving:
track_point_in_utc_time(
self.hass,
self._end_moving_device,
utcnow() + timedelta(seconds=delta + INCREASED_INTERVAL_DELTA),
)
else:
_LOGGER.debug("end moving device %s due to too old data", self._uuid)
self._end_moving_device()

def _end_moving_device(self, *args: Any):
"""Call STOP to update the device values on ONYX."""
position_animation = self._device.actual_position.animation
position_keyframe = (
position_animation.keyframes[len(position_animation.keyframes) - 1]
if position_animation is not None and len(position_animation.keyframes) > 0
else None
)
position_end_time = (
(
position_animation.start
+ position_keyframe.duration
+ position_keyframe.delay
)
if position_keyframe is not None
else None
)

angle_animation = self._device.actual_angle.animation
angle_keyframe = (
angle_animation.keyframes[len(angle_animation.keyframes) - 1]
if angle_animation is not None and len(angle_animation.keyframes) > 0
else None
)
angle_end_time = (
(angle_animation.start + angle_keyframe.duration + angle_keyframe.delay)
if angle_keyframe is not None
else None
)

current_time = time.time()

if self._moving_state != MovingState.STILL:
if (
(angle_end_time is None and current_time > position_end_time)
or (position_end_time is None and current_time > angle_end_time)
or (current_time > position_end_time and current_time > angle_end_time)
):
self.stop_cover()
else:
_LOGGER.debug(
"not ending moving device %s. overlapping angle and positioning",
self._uuid,
)

def _calculate_and_set_state(self, actual: int, new_value: int):
"""Calculate and set the new moving state."""
new_state = self._calculate_state(actual, new_value)
self._set_state(new_state)

@property
def _max_angle(self) -> int:
"""Maximum angle depending on raffstore type."""
if self._type == DeviceType.RAFFSTORE_90:
return 90
elif self._type == DeviceType.RAFFSTORE_180:
return 180
else:
return 100

@staticmethod
def _calculate_state(actual: int, new_value: int) -> MovingState:
"""Calculate the new moving state."""
if new_value < actual:
return MovingState.OPENING
elif new_value > actual:
return MovingState.CLOSING
else:
return MovingState.STILL
1 change: 1 addition & 0 deletions custom_components/hella_onyx/enum/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The ONYX.CENTER Home Assistant enums."""
2 changes: 1 addition & 1 deletion custom_components/hella_onyx/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"documentation": "https://github.com/muhlba91/onyx-homeassistant-integration",
"issue_tracker": "https://github.com/muhlba91/onyx-homeassistant-integration/issues",
"requirements": [
"onyx-client==7.1.1"
"onyx-client==7.2.1"
],
"ssdp": [],
"zeroconf": [],
Expand Down
Loading

0 comments on commit a49a31d

Please sign in to comment.