Skip to content

Commit

Permalink
Merge branch 'dev'
Browse files Browse the repository at this point in the history
  • Loading branch information
xaviml committed Dec 19, 2020
2 parents fd580f6 + 6d4f4ae commit 5f60518
Show file tree
Hide file tree
Showing 36 changed files with 377 additions and 331 deletions.
7 changes: 6 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ _Note: I recommend working with Python 3.6 since is the minimum version supporte

New controllers need to be added into the `apps/controllerx/devices/` and you will need to define the mapping for the integration you are adding support to.

Note that this project will only accept the mapping that the original controller would follow with its original hub.
Also, the controller will need to be added to the documentation. You will need to create:
- YAML file in `docs/_data/controllers`
- MarkDown file in `docs/controllers`
- JPEG image in `docs/assets/img`

Note that this project will only accept the mapping that the original controller would follow with its original hub, or the closest behaviour we can get.

## Imports

Expand Down
10 changes: 5 additions & 5 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ verify_ssl = true

[dev-packages]
black = "==20.8b1"
pytest = "==6.1.2"
pytest = "==6.2.1"
pytest-asyncio = "==0.14.0"
pytest-cov = "==2.10.1"
pytest-mock = "==3.3.1"
mock = "==4.0.2"
pre-commit = "==2.9.2"
commitizen = "==2.8.2"
pytest-mock = "==3.4.0"
mock = "==4.0.3"
pre-commit = "==2.9.3"
commitizen = "==2.11.1"
mypy = "==0.790"
flake8 = "==3.8.4"
isort = "==5.6.4"
Expand Down
1 change: 1 addition & 0 deletions apps/controllerx/controllerx.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@
from cx_devices.phillips import *
from cx_devices.smartthings import *
from cx_devices.sonoff import *
from cx_devices.terncy import *
from cx_devices.trust import *
3 changes: 2 additions & 1 deletion apps/controllerx/cx_core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from cx_core.controller import Controller, ReleaseHoldController, action
from cx_core.controller import Controller, action
from cx_core.custom_controller import (
CallServiceController,
CustomCoverController,
CustomLightController,
CustomMediaPlayerController,
CustomSwitchController,
)
from cx_core.release_hold_controller import ReleaseHoldController
from cx_core.type.cover_controller import CoverController
from cx_core.type.light_controller import LightController
from cx_core.type.media_player_controller import MediaPlayerController
Expand Down
64 changes: 4 additions & 60 deletions apps/controllerx/cx_core/controller.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import abc
import asyncio
import time
from asyncio.futures import Future
Expand Down Expand Up @@ -30,7 +29,6 @@
Services = List[Service]


DEFAULT_DELAY = 350 # In milliseconds
DEFAULT_ACTION_DELTA = 300 # In milliseconds
DEFAULT_MULTIPLE_CLICK_DELAY = 500 # In milliseconds
MULTIPLE_CLICK_TOKEN = "$"
Expand Down Expand Up @@ -65,7 +63,7 @@ async def inner() -> None:
return task


class Controller(Hass, Mqtt, abc.ABC):
class Controller(Hass, Mqtt):
"""
This is the parent Controller, all controllers must extend from this class.
"""
Expand Down Expand Up @@ -228,7 +226,7 @@ async def call_service(self, service: str, **attributes) -> None:
if isinstance(value, float):
value = f"{value:.2f}"
self.log(f" - {attribute}: {value}", level="INFO", ascii_encode=False)
return await Hass.call_service(self, service, **attributes)
return await Hass.call_service(self, service, **attributes) # type: ignore

async def handle_action(self, action_key: str) -> None:
if (
Expand Down Expand Up @@ -293,15 +291,15 @@ async def call_action(self, action_key: ActionEvent) -> None:
if delay > 0:
handle = self.action_delay_handles[action_key]
if handle is not None:
await self.cancel_timer(handle)
await self.cancel_timer(handle) # type: ignore
self.log(
f"🕒 Running `{self.actions_key_mapping[action_key]}` in {delay} seconds",
level="INFO",
ascii_encode=False,
)
new_handle = await self.run_in(
self.action_timer_callback, delay, action_key=action_key
)
) # type: ignore
self.action_delay_handles[action_key] = new_handle
else:
await self.action_timer_callback({"action_key": action_key})
Expand Down Expand Up @@ -393,57 +391,3 @@ def get_zha_action(self, data: EventData) -> Optional[str]:

def get_type_actions_mapping(self) -> TypeActionsMapping:
return {}


class ReleaseHoldController(Controller, abc.ABC):
DEFAULT_MAX_LOOPS = 50

async def initialize(self):
self.on_hold = False
self.delay = self.args.get("delay", self.default_delay())
self.max_loops = self.args.get(
"max_loops", ReleaseHoldController.DEFAULT_MAX_LOOPS
)
self.hold_release_toggle: bool = self.args.get("hold_release_toggle", False)
await super().initialize()

@action
async def release(self) -> None:
self.on_hold = False

@action
async def hold(self, *args) -> None:
loops = 0
self.on_hold = True
stop = False
while self.on_hold and not stop:
stop = await self.hold_loop(*args)
# Stop the iteration if we either stop from the hold_loop
# or we reached the max loop number
stop = stop or loops >= self.max_loops
await self.sleep(self.delay / 1000)
loops += 1
self.on_hold = False

async def before_action(self, action: str, *args, **kwargs) -> bool:
super_before_action = await super().before_action(action, *args, **kwargs)
to_return = not (action == "hold" and self.on_hold)
if action == "hold" and self.on_hold and self.hold_release_toggle:
self.on_hold = False
return super_before_action and to_return

@abc.abstractmethod
async def hold_loop(self, *args) -> bool:
"""
This function is called by the ReleaseHoldController depending on the settings.
It stops calling the function once release action is called or when this function
returns True.
"""
raise NotImplementedError

def default_delay(self) -> int:
"""
This function can be overwritten for each device to indeicate the delay
for the specific device, by default it returns the default delay from the app
"""
return DEFAULT_DELAY
39 changes: 6 additions & 33 deletions apps/controllerx/cx_core/feature_support/__init__.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,15 @@
from typing import TYPE_CHECKING, List, Optional, Set, Type, TypeVar
from typing import TYPE_CHECKING, Optional

if TYPE_CHECKING:
from cx_core.type_controller import TypeController

Features = List[int]
SupportedFeatures = Set[int]
FeatureSupportType = TypeVar("FeatureSupportType", bound="FeatureSupport")


class FeatureSupport:

entity_id: str
controller: "TypeController"
features: Features = []
update_supported_features: bool
_supported_features: Optional[SupportedFeatures]

@staticmethod
def encode(supported_features: SupportedFeatures) -> int:
number = 0
for supported_feature in supported_features:
number |= supported_feature
return number

@staticmethod
def decode(number: int, features: Features) -> SupportedFeatures:
return {number & feature for feature in features if number & feature != 0}
_supported_features: Optional[int]

def __init__(
self,
Expand All @@ -38,33 +22,22 @@ def __init__(
self._supported_features = None
self.update_supported_features = update_supported_features

@classmethod
def instantiate(
cls: Type[FeatureSupportType],
entity_id: str,
controller: "TypeController",
update_supported_features=False,
) -> FeatureSupportType:
return cls(entity_id, controller, update_supported_features)

@property
async def supported_features(self) -> SupportedFeatures:
async def supported_features(self) -> int:
if self._supported_features is None or self.update_supported_features:
bitfield: str = await self.controller.get_entity_state(
self.entity_id, attribute="supported_features"
)
if bitfield is not None:
self._supported_features = FeatureSupport.decode(
int(bitfield), self.features
)
self._supported_features = int(bitfield)
else:
raise ValueError(
f"`supported_features` could not be read from `{self.entity_id}`. Entity might not be available."
)
return self._supported_features

async def is_supported(self, feature: int) -> bool:
return feature in await self.supported_features
return feature & await self.supported_features != 0

async def not_supported(self, feature: int) -> bool:
return feature not in await self.supported_features
return not await self.is_supported(feature)
17 changes: 1 addition & 16 deletions apps/controllerx/cx_core/feature_support/cover.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
from cx_core.feature_support import FeatureSupport


class CoverSupport(FeatureSupport):

class CoverSupport:
OPEN = 1
CLOSE = 2
SET_COVER_POSITION = 4
Expand All @@ -11,14 +7,3 @@ class CoverSupport(FeatureSupport):
CLOSE_TILT = 32
STOP_TILT = 64
SET_TILT_POSITION = 128

features = [
OPEN,
CLOSE,
SET_COVER_POSITION,
STOP,
OPEN_TILT,
CLOSE_TILT,
STOP_TILT,
SET_TILT_POSITION,
]
15 changes: 1 addition & 14 deletions apps/controllerx/cx_core/feature_support/light.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,8 @@
from cx_core.feature_support import FeatureSupport


class LightSupport(FeatureSupport):
class LightSupport:
BRIGHTNESS = 1
COLOR_TEMP = 2
EFFECT = 4
FLASH = 8
COLOR = 16
TRANSITION = 32
WHITE_VALUE = 128

features = [
BRIGHTNESS,
COLOR_TEMP,
EFFECT,
FLASH,
COLOR,
TRANSITION,
WHITE_VALUE,
]
26 changes: 3 additions & 23 deletions apps/controllerx/cx_core/feature_support/media_player.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
from cx_core.feature_support import FeatureSupport


class MediaPlayerSupport(FeatureSupport):
class MediaPlayerSupport:
PAUSE = 1
SEEK = 2
VOLUME_SET = 4
Expand All @@ -18,22 +15,5 @@ class MediaPlayerSupport(FeatureSupport):
PLAY = 16384
SHUFFLE_SET = 32768
SELECT_SOUND_MODE = 65536

features = [
PAUSE,
SEEK,
VOLUME_SET,
VOLUME_MUTE,
PREVIOUS_TRACK,
NEXT_TRACK,
TURN_ON,
TURN_OFF,
PLAY_MEDIA,
VOLUME_STEP,
SELECT_SOURCE,
STOP,
CLEAR_PLAYLIST,
PLAY,
SHUFFLE_SET,
SELECT_SOUND_MODE,
]
SUPPORT_BROWSE_MEDIA = 131072
SUPPORT_REPEAT_SET = 262144
59 changes: 59 additions & 0 deletions apps/controllerx/cx_core/release_hold_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import abc

from cx_core import Controller, action

DEFAULT_DELAY = 350 # In milliseconds


class ReleaseHoldController(Controller, abc.ABC):
DEFAULT_MAX_LOOPS = 50

async def initialize(self):
self.on_hold = False
self.delay = self.args.get("delay", self.default_delay())
self.max_loops = self.args.get(
"max_loops", ReleaseHoldController.DEFAULT_MAX_LOOPS
)
self.hold_release_toggle: bool = self.args.get("hold_release_toggle", False)
await super().initialize()

@action
async def release(self) -> None:
self.on_hold = False

@action
async def hold(self, *args) -> None:
loops = 0
self.on_hold = True
stop = False
while self.on_hold and not stop:
stop = await self.hold_loop(*args)
# Stop the iteration if we either stop from the hold_loop
# or we reached the max loop number
stop = stop or loops >= self.max_loops
await self.sleep(self.delay / 1000)
loops += 1
self.on_hold = False

async def before_action(self, action: str, *args, **kwargs) -> bool:
super_before_action = await super().before_action(action, *args, **kwargs)
to_return = not (action == "hold" and self.on_hold)
if action == "hold" and self.on_hold and self.hold_release_toggle:
self.on_hold = False
return super_before_action and to_return

@abc.abstractmethod
async def hold_loop(self, *args) -> bool:
"""
This function is called by the ReleaseHoldController depending on the settings.
It stops calling the function once release action is called or when this function
returns True.
"""
raise NotImplementedError

def default_delay(self) -> int:
"""
This function can be overwritten for each device to indeicate the delay
for the specific device, by default it returns the default delay from the app
"""
return DEFAULT_DELAY
5 changes: 1 addition & 4 deletions apps/controllerx/cx_core/type/cover_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from cx_core.type_controller import Entity, TypeController


class CoverController(TypeController[Entity, CoverSupport]):
class CoverController(TypeController[Entity]):
"""
This is the main class that controls the coveres for different devices.
Type of actions:
Expand Down Expand Up @@ -35,9 +35,6 @@ async def initialize(self) -> None:
def _get_entity_type(self) -> Type[Entity]:
return Entity

def _get_feature_support_type(self) -> Type[CoverSupport]:
return CoverSupport

def get_type_actions_mapping(self) -> TypeActionsMapping:
return {
Cover.OPEN: self.open,
Expand Down
Loading

0 comments on commit 5f60518

Please sign in to comment.