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)