Skip to content

Commit

Permalink
Merge branch 'dev'
Browse files Browse the repository at this point in the history
  • Loading branch information
xaviml committed Feb 7, 2021
2 parents b93bc4f + b3f9620 commit afb2854
Show file tree
Hide file tree
Showing 50 changed files with 705 additions and 182 deletions.
2 changes: 1 addition & 1 deletion .cz.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "4.5.1"
version = "4.6.0b1"
tag_format = "v$major.$minor.$patch$prerelease"
version_files = [
"apps/controllerx/cx_version.py",
Expand Down
8 changes: 4 additions & 4 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ verify_ssl = true

[dev-packages]
black = "==20.8b1"
pytest = "==6.2.1"
pytest = "==6.2.2"
pytest-asyncio = "==0.14.0"
pytest-cov = "==2.11.1"
pytest-mock = "==3.5.1"
pytest-timeout = "==1.4.2"
mock = "==4.0.3"
pre-commit = "==2.9.3"
commitizen = "==2.14.0"
mypy = "==0.790"
pre-commit = "==2.10.1"
commitizen = "==2.14.2"
mypy = "==0.800"
flake8 = "==3.8.4"
isort = "==5.7.0"
controllerx = {path = ".", editable = true}
Expand Down
9 changes: 6 additions & 3 deletions apps/controllerx/cx_const.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Tuple, Union
from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union

ActionFunction = Callable[..., Awaitable[Any]]
ActionFunctionWithParams = Tuple[ActionFunction, Tuple]
TypeAction = Union[ActionFunction, ActionFunctionWithParams]
ActionEvent = Union[str, int]
PredefinedActionsMapping = Dict[str, TypeAction]
DefaultActionsMapping = Mapping[ActionEvent, str]
DefaultActionsMapping = Dict[ActionEvent, str]

CustomAction = Union[str, Dict[str, Any]]
CustomActions = Union[List[CustomAction], CustomAction]
CustomActionsMapping = Mapping[ActionEvent, CustomActions]
CustomActionsMapping = Dict[ActionEvent, CustomActions]


class Light:
Expand Down Expand Up @@ -58,6 +58,8 @@ class Light:
HOLD_XY_COLOR_UP = "hold_xycolor_up"
HOLD_XY_COLOR_DOWN = "hold_xycolor_down"
HOLD_XY_COLOR_TOGGLE = "hold_xycolor_toggle"
XYCOLOR_FROM_CONTROLLER = "xycolor_from_controller"
COLORTEMP_FROM_CONTROLLER = "colortemp_from_controller"


class MediaPlayer:
Expand All @@ -73,6 +75,7 @@ class MediaPlayer:
PREVIOUS_TRACK = "previous_track"
NEXT_SOURCE = "next_source"
PREVIOUS_SOURCE = "previous_source"
MUTE = "mute"


class Switch:
Expand Down
27 changes: 21 additions & 6 deletions apps/controllerx/cx_core/action_type/predefined_action_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ class PredefinedActionType(ActionType):
action_key: str
predefined_actions_mapping: PredefinedActionsMapping

def _raise_action_key_not_found(
self, action_key: str, predefined_actions: PredefinedActionsMapping
) -> None:
raise ValueError(
f"`{action_key}` is not one of the predefined actions. "
f"Available actions are: {list(predefined_actions.keys())}."
"See more in: https://xaviml.github.io/controllerx/advanced/custom-controllers"
)

def initialize(self, **kwargs) -> None:
self.action_key = kwargs["action"]
self.predefined_actions_mapping = (
Expand All @@ -26,15 +35,21 @@ def initialize(self, **kwargs) -> None:
raise ValueError(
f"Cannot use predefined actions for `{self.controller.__class__.__name__}` class."
)
if self.action_key not in self.predefined_actions_mapping:
raise ValueError(
f"`{self.action_key}` is not one of the predefined actions. "
f"Available actions are: {list(self.predefined_actions_mapping.keys())}."
"See more in: https://xaviml.github.io/controllerx/advanced/custom-controllers"
if (
not self.controller.contains_templating(self.action_key)
and self.action_key not in self.predefined_actions_mapping
):
self._raise_action_key_not_found(
self.action_key, self.predefined_actions_mapping
)

async def run(self, extra: Optional[EventData] = None) -> None:
action, args = _get_action(self.predefined_actions_mapping[self.action_key])
action_key = await self.controller.render_value(self.action_key)
if action_key not in self.predefined_actions_mapping:
self._raise_action_key_not_found(
action_key, self.predefined_actions_mapping
)
action, args = _get_action(self.predefined_actions_mapping[action_key])
if "extra" in set(inspect.signature(action).parameters):
await action(*args, extra=extra)
else:
Expand Down
121 changes: 102 additions & 19 deletions apps/controllerx/cx_core/controller.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import asyncio
import re
import time
from ast import literal_eval
from asyncio import CancelledError
from asyncio.futures import Future
from collections import defaultdict
Expand Down Expand Up @@ -41,6 +43,11 @@
DEFAULT_MULTIPLE_CLICK_DELAY = 500 # In milliseconds
MULTIPLE_CLICK_TOKEN = "$"

MODE_SINGLE = "single"
MODE_RESTART = "restart"
MODE_QUEUED = "queued"
MODE_PARALLEL = "parallel"

T = TypeVar("T")


Expand Down Expand Up @@ -82,7 +89,7 @@ class Controller(Hass, Mqtt):
action_delay_handles: Dict[ActionEvent, Optional[float]]
multiple_click_actions: Set[ActionEvent]
action_delay: Dict[ActionEvent, int]
action_delta: int
action_delta: Dict[ActionEvent, int]
action_times: Dict[str, float]
multiple_click_action_times: Dict[str, float]
click_counter: Counter[ActionEvent]
Expand All @@ -105,7 +112,7 @@ async def init(self) -> None:

if custom_mapping is None:
default_actions_mapping = self.get_default_actions_mapping(self.integration)
self.actions_mapping = self.parse_action_mapping(default_actions_mapping)
self.actions_mapping = self.parse_action_mapping(default_actions_mapping) # type: ignore
else:
self.actions_mapping = self.parse_action_mapping(custom_mapping)

Expand All @@ -126,16 +133,18 @@ async def init(self) -> None:
)

# Action delay
default_action_delay = {action_key: 0 for action_key in self.actions_mapping}
self.action_delay = {
**default_action_delay,
**self.args.get("action_delay", {}),
}
self.action_delay = self.get_mapping_per_action(
self.actions_mapping, custom=self.args.get("action_delay"), default=0
)
self.action_delay_handles = defaultdict(lambda: None)
self.action_handles = defaultdict(lambda: None)

# Action delta
self.action_delta = self.args.get("action_delta", DEFAULT_ACTION_DELTA)
self.action_delta = self.get_mapping_per_action(
self.actions_mapping,
custom=self.args.get("action_delta"),
default=DEFAULT_ACTION_DELTA,
)
self.action_times = defaultdict(lambda: 0.0)

# Multiple click
Expand All @@ -149,6 +158,11 @@ async def init(self) -> None:
self.click_counter = Counter()
self.multiple_click_action_delay_tasks = defaultdict(lambda: None)

# Mode
self.mode = self.get_mapping_per_action(
self.actions_mapping, custom=self.args.get("mode"), default=MODE_SINGLE
)

# Listen for device changes
for controller_id in controllers_ids:
self.integration.listen_changes(controller_id)
Expand Down Expand Up @@ -209,6 +223,20 @@ def get_list(self, entities: Union[List[T], T]) -> List[T]:
return list(entities)
return [entities]

def get_mapping_per_action(
self,
actions_mapping: ActionsMapping,
*,
custom: Optional[Union[T, Dict[ActionEvent, T]]],
default: T,
) -> Dict[ActionEvent, T]:
if custom is not None and not isinstance(custom, dict):
default = custom
mapping = {action: default for action in actions_mapping}
if custom is not None and isinstance(custom, dict):
mapping.update(custom)
return mapping

def parse_action_mapping(self, mapping: CustomActionsMapping) -> ActionsMapping:
return {event: parse_actions(self, action) for event, action in mapping.items()}

Expand All @@ -233,17 +261,48 @@ def format_multiple_click_action(
str(action_key) + MULTIPLE_CLICK_TOKEN + str(click_count)
) # e.g. toggle$2

async def call_service(self, service: str, **attributes) -> None:
async def _render_template(self, template: str) -> Any:
result = await self.call_service("template/render", template=template)
if result is None:
raise ValueError(f"Template {template} returned None")
try:
return literal_eval(result)
except (SyntaxError, ValueError):
return result

_TEMPLATE_RE = re.compile(r"\s*\{\{.*\}\}")

def contains_templating(self, template: str) -> bool:
is_template = self._TEMPLATE_RE.search(template) is not None
if not is_template:
self.log(f"`{template}` is not recognized as a template", level="DEBUG")
return is_template

async def render_value(self, value: Any) -> Any:
if isinstance(value, str) and self.contains_templating(value):
return await self._render_template(value)
else:
return value

async def render_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
new_attributes: Dict[str, Any] = {}
for key, value in attributes.items():
new_value = await self.render_value(value)
if isinstance(value, dict):
new_value = await self.render_attributes(value)
new_attributes[key] = new_value
return new_attributes

async def call_service(self, service: str, **attributes) -> Optional[Any]:
service = service.replace(".", "/")
self.log(
f"🤖 Service: \033[1m{service.replace('/', '.')}\033[0m",
level="INFO",
ascii_encode=False,
)
to_log = ["\n", f"🤖 Service: \033[1m{service.replace('/', '.')}\033[0m"]
if service != "template/render":
attributes = await self.render_attributes(attributes)
for attribute, value in attributes.items():
if isinstance(value, float):
value = f"{value:.2f}"
self.log(f" - {attribute}: {value}", level="INFO", ascii_encode=False)
to_log.append(f" - {attribute}: {value}")
self.log("\n".join(to_log), level="INFO", ascii_encode=False)
return await Hass.call_service(self, service, **attributes) # type: ignore

async def handle_action(
Expand All @@ -256,7 +315,7 @@ async def handle_action(
previous_call_time = self.action_times[action_key]
now = time.time() * 1000
self.action_times[action_key] = now
if now - previous_call_time > self.action_delta:
if now - previous_call_time > self.action_delta[action_key]:
await self.call_action(action_key, extra=extra)
elif action_key in self.multiple_click_actions:
now = time.time() * 1000
Expand Down Expand Up @@ -332,14 +391,38 @@ async def call_action(
else:
await self.action_timer_callback({"action_key": action_key, "extra": extra})

async def _apply_mode_strategy(self, action_key: ActionEvent) -> bool:
previous_task = self.action_handles[action_key]
if previous_task is None:
return False
if self.mode[action_key] == MODE_SINGLE:
self.log(
"There is already an action executing for `action_key`. "
"If you want a different behaviour change `mode` parameter.",
level="WARNING",
)
return True
elif self.mode[action_key] == MODE_RESTART:
previous_task.cancel()
elif self.mode[action_key] == MODE_QUEUED:
await previous_task
elif self.mode[action_key] == MODE_PARALLEL:
pass
else:
raise ValueError(
f"`{self.mode[action_key]}` is not a possible value for `mode` parameter."
"Possible values: `single`, `restart`, `queued` and `parallel`."
)
return False

async def action_timer_callback(self, kwargs: Dict[str, Any]):
action_key: ActionEvent = kwargs["action_key"]
extra: EventData = kwargs["extra"]
self.action_delay_handles[action_key] = None
skip = await self._apply_mode_strategy(action_key)
if skip:
return
action_types = self.actions_mapping[action_key]
previous_task = self.action_handles[action_key]
if previous_task is not None:
previous_task.cancel()
task = asyncio.ensure_future(self.call_action_types(action_types, extra))
self.action_handles[action_key] = task
try:
Expand Down
6 changes: 2 additions & 4 deletions apps/controllerx/cx_core/integration/z2m.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,15 @@ async def event_callback(
)
return
if action_group_key in payload and "action_group" in self.kwargs:
action_group = self.kwargs["action_group"]
if isinstance(action_group, str):
action_group = [action_group]
action_group = self.controller.get_list(self.kwargs["action_group"])
if payload["action_group"] not in action_group:
self.controller.log(
f"Action group {payload['action_group']} not found in "
f"action groups: {action_group}",
level="DEBUG",
)
return
await self.controller.handle_action(payload[action_key])
await self.controller.handle_action(payload[action_key], extra=payload)

async def state_callback(
self, entity: Optional[str], attribute: Optional[str], old, new, kwargs
Expand Down
9 changes: 8 additions & 1 deletion apps/controllerx/cx_core/integration/zha.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,12 @@ async def callback(self, event_name: str, data: EventData, kwargs: dict) -> None
if action is None:
# If there is no action extracted from the controller then
# we extract with the standard function
action = self.get_action(data)
try:
action = self.get_action(data)
except Exception:
self.controller.log(
f"The following event could not be parsed: {data}", level="WARNING"
)
return

await self.controller.handle_action(action)

0 comments on commit afb2854

Please sign in to comment.