diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 713fdab3..bdd8dee0 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -13,5 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - uses: actions/setup-python@v1 + - uses: actions/setup-python@v2 + with: + python-version: '3.7' - uses: pre-commit/action@v2.0.0 diff --git a/.idea/misc.xml b/.idea/misc.xml index f25f36bb..43f9c025 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,4 @@ - + diff --git a/.idea/python-tahoma-api.iml b/.idea/python-tahoma-api.iml index 44e96509..516e6275 100644 --- a/.idea/python-tahoma-api.iml +++ b/.idea/python-tahoma-api.iml @@ -4,7 +4,7 @@ - + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bae6b0ea..bb46428c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,12 +9,12 @@ repos: rev: 19.10b0 hooks: - id: black - language_version: python3.8 + language_version: python3.7 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.4.0 hooks: - id: flake8 - language_version: python3.8 + language_version: python3.7 - repo: https://github.com/pre-commit/mirrors-mypy rev: v0.740 hooks: diff --git a/pyhoma/client.py b/pyhoma/client.py index ac963ba9..392978b1 100644 --- a/pyhoma/client.py +++ b/pyhoma/client.py @@ -14,12 +14,21 @@ NotAuthenticatedException, TooManyRequestsException, ) -from pyhoma.models import Command, Device, Event, Execution, Scenario, State +from pyhoma.models import Command, DataType, Device, Event, Execution, Scenario, State JSON = Union[Dict[str, Any], List[Dict[str, Any]]] API_URL = "https://tahomalink.com/enduser-mobile-web/enduserAPI/" # /doc for API doc +TYPES = { + DataType.NONE: None, + DataType.INTEGER: int, + DataType.DATE: int, + DataType.STRING: str, + DataType.FLOAT: float, + DataType.BOOLEAN: bool, +} + class TahomaClient: """ Interface class for the Tahoma API """ @@ -44,8 +53,7 @@ def __init__( self.password = password self.api_url = api_url - self.devices: List[Device] = [] - self.__roles = None + self._devices: List[Device] = [] self.event_listener_id: Optional[str] = None self.session = session if session else ClientSession() @@ -64,50 +72,83 @@ async def __aexit__( async def close(self) -> None: """Close the session.""" if self.event_listener_id: - await self.unregister_event_listener(self.event_listener_id) + await self.unregister_event_listener() await self.session.close() - async def login(self) -> bool: + async def login(self, register: Optional[bool] = True) -> bool: """ Authenticate and create an API session allowing access to the other operations. Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] """ payload = {"userId": self.username, "userPassword": self.password} response = await self.__post("login", data=payload) - - if response.get("success"): - self.__roles = response.get("roles") - + if "success" in response: + if register: + await self.register_event_listener() return True - return False async def get_devices(self, refresh: bool = False) -> List[Device]: - """ - List devices - """ - if self.devices and not refresh: - return self.devices + """ List devices. """ + if self._devices and not refresh: + return self._devices response = await self.__get("setup/devices") devices = [Device(**d) for d in humps.decamelize(response)] - self.devices = devices + self._devices = devices return devices + async def fetch_devices(self) -> List[Device]: + """ Return the list of devices updated with fetch_event_listener information. """ + if not self.event_listener_id: + await self.register_event_listener() + devices = {device.deviceurl: device for device in await self.get_devices()} + try: + events = await self.fetch_event_listener() + for event in events: + self._update_state(devices, event) + self._update_available(devices, event) + return list(devices.values()) + except NotAuthenticatedException: + await self.login() + return await self.get_devices(refresh=True) + + def _update_state(self, devices: Dict[str, Device], event: Event) -> None: + if event.name != "DeviceStateChangedEvent" or not event.device_states: + return + for state in event.device_states: + if not event.deviceurl: + continue + device = devices[event.deviceurl] + device.states[state.name] = state # type: ignore + device.states[state.name].value = self._cast_state(state) # type: ignore + + @staticmethod + def _update_available(devices: Dict[str, Device], event: Event) -> None: + if event.name == "DeviceAvailableEvent" and event.deviceurl: + devices[event.deviceurl].available = True + + @staticmethod + def _cast_state(state: State) -> Union[float, int, bool, str, None]: + if state.type != DataType.NONE: + caster = TYPES[DataType(state.type)] + return caster(state.value) # type: ignore + return state.value + async def get_state(self, deviceurl: str) -> List[State]: """ Retrieve states of requested device """ - response = await self.__get( + response = await self.__get_and_retry( f"setup/devices/{urllib.parse.quote_plus(deviceurl)}/states" ) state = [State(**s) for s in humps.decamelize(response)] return state - async def register_event_listener(self) -> str: + async def register_event_listener(self) -> None: """ Register a new setup event listener on the current session and return a new listener id. @@ -121,38 +162,36 @@ async def register_event_listener(self) -> str: listener_id = response.get("id") self.event_listener_id = listener_id - return listener_id - - async def fetch_event_listener(self, listener_id: str) -> List[Event]: + async def fetch_event_listener(self) -> List[Event]: """ Fetch new events from a registered event listener. Fetched events are removed from the listener buffer. Return an empty response if no event is available. Per-session rate-limit : 1 calls per 1 SECONDS period for this particular operation (polling) """ - response = await self.__post(f"events/{listener_id}/fetch") + response = await self.__post(f"events/{self.event_listener_id}/fetch") events = [Event(**e) for e in humps.decamelize(response)] return events - async def unregister_event_listener(self, listener_id: str) -> None: + async def unregister_event_listener(self) -> None: """ Unregister an event listener. API response status is always 200, even on unknown listener ids. """ - await self.__post(f"events/{listener_id}/unregister") + await self.__post(f"events/{self.event_listener_id}/unregister") self.event_listener_id = None async def get_current_execution(self, exec_id: str) -> Execution: """ Get an action group execution currently running """ - response = await self.__get(f"exec/current/{exec_id}") + response = await self.__get_and_retry(f"exec/current/{exec_id}") execution = Execution(**humps.decamelize(response)) return execution async def get_current_executions(self) -> List[Execution]: """ Get all action groups executions currently running """ - response = await self.__get("exec/current") + response = await self.__get_and_retry("exec/current") executions = [Execution(**e) for e in humps.decamelize(response)] return executions @@ -183,17 +222,17 @@ async def execute_commands( "label": label, "actions": [{"deviceURL": device_url, "commands": commands}], } - response = await self.__post("exec/apply", payload) + response = await self.__post_and_retry("exec/apply", payload) return response["execId"] async def get_scenarios(self) -> List[Scenario]: """ List the scenarios """ - response = await self.__get("actionGroups") + response = await self.__get_and_retry("actionGroups") return [Scenario(**scenario) for scenario in response] async def execute_scenario(self, oid: str) -> str: """ Execute a scenario """ - response = await self.__post(f"exec/{oid}") + response = await self.__post_and_retry(f"exec/{oid}") return response["execId"] async def __get(self, endpoint: str) -> Any: @@ -202,6 +241,14 @@ async def __get(self, endpoint: str) -> Any: await self.check_response(response) return await response.json() + async def __get_and_retry(self, endpoint: str) -> Any: + """ Make a GET request to the TaHoma API and retry with a login if not authenticated """ + try: + return await self.__get(endpoint) + except NotAuthenticatedException: + await self.login() + return await self.__get(endpoint) + async def __post( self, endpoint: str, payload: Optional[JSON] = None, data: Optional[JSON] = None ) -> Any: @@ -212,6 +259,16 @@ async def __post( await self.check_response(response) return await response.json() + async def __post_and_retry( + self, endpoint: str, payload: Optional[JSON] = None, data: Optional[JSON] = None + ) -> Any: + """ Make a POST request to the TaHoma API and retry with a login if not authenticated """ + try: + return await self.__post(endpoint, payload, data) + except NotAuthenticatedException: + await self.login() + return await self.__post(endpoint, payload, data) + async def __delete(self, endpoint: str) -> None: """ Make a DELETE request to the TaHoma API """ async with self.session.delete(f"{self.api_url}{endpoint}") as response: diff --git a/pyhoma/models.py b/pyhoma/models.py index 644eb8e1..91fc729c 100644 --- a/pyhoma/models.py +++ b/pyhoma/models.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Dict, Iterator, List, Optional +from typing import Any, Dict, Iterator, List, Optional, Union # pylint: disable=unused-argument, too-many-instance-attributes @@ -134,7 +134,13 @@ def __len__(self) -> int: class State: __slots__ = "name", "value", "type" - def __init__(self, name: str, type: int, value: Optional[str] = None, **_: Any): + def __init__( + self, + name: str, + type: int, + value: Union[float, int, bool, str, None] = None, + **_: Any + ): self.name = name self.value = value self.type = DataType(type)