From ec49fa29b4c46f13c0b569c5a9b5f9a9f97fb0ea Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 6 Jul 2020 19:18:06 +0200 Subject: [PATCH 1/5] Add first client version --- tahoma_api/client.py | 72 +++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/tahoma_api/client.py b/tahoma_api/client.py index 61ec7b7e..341e76a3 100644 --- a/tahoma_api/client.py +++ b/tahoma_api/client.py @@ -27,6 +27,10 @@ def __init__(self, username, password): self.username = username self.password = password + + self._devices = None + + self.__roles = [] async def login(self): @@ -43,60 +47,58 @@ async def login(self): # 401 # {'errorCode': 'AUTHENTICATION_ERROR', 'error': 'Bad credentials'} + # {'errorCode': 'AUTHENTICATION_ERROR', 'error': 'Your setup cannot be accessed through this application'} + if response.status == 401: + if result['errorCode'] == 'AUTHENTICATION_ERROR': + + if 'Too many requests' in result['error']: + print(result['error']) + + if 'Your setup cannot be accessed through this application' in result['error']: + print(result['error']) + + if 'Bad credentials' in result['error']: + print(result['error']) + + print(result['error']) + return False # todo throw error + # 401 # {'errorCode': 'AUTHENTICATION_ERROR', 'error': 'Too many requests, try again later : login with xxx@xxx.tld'} # TODO Add retry logic on too many requests + for debug, log requests + timespans - + # 200 # {'success': True, 'roles': [{'name': 'ENDUSER'}]} - if (response.status is 200): + if response.status == 200: if result['success'] == True: self.__roles = result['roles'] self.__cookies = response.cookies return True + # Temp fallbacks print(response.status) print(result) - async def get_devices(self): - - cookies = self.__cookies - - async with aiohttp.ClientSession() as session: - async with session.get(API_URL + 'setup/devices', cookies=cookies) as response: - - print(response.status) - print(response) - - result = await response.json() - - print(result) - # 401 - # {'errorCode': 'AUTHENTICATION_ERROR', 'error': 'Bad credentials'} - - # {'success': True, 'roles': [{'name': 'ENDUSER'}]} - if (response.status is 200): - if result["success"] == True: - print(result) - - # TODO Save cookies + async def get_devices(self, refresh=False): - async def get_states(self): - - cookies = self.__cookies + if self._devices is None or refresh == True: - async with aiohttp.ClientSession() as session: - async with session.get(API_URL + 'setup/devices/states', cookies=cookies) as response: + cookies = self.__cookies - print(response.status) - result = await response.json() - - print(result) - + async with aiohttp.ClientSession() as session: + async with session.get(API_URL + 'setup/devices', cookies=cookies) as response: + result = await response.json() - + if (response.status is 200): + self._devices = result + return result + + # TODO add retry logic for unauthorized? + else: + return [] + # TODO Save cookies \ No newline at end of file From 8d9e300698651ea636de95e5f3bc7bcc1faf6fcb Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 6 Jul 2020 19:33:26 +0200 Subject: [PATCH 2/5] Update README --- README.md | 8 +++++++- setup.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 805cab3d..4bff872c 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ -# python-tahoma-api \ No newline at end of file +# Somfy TaHoma + +An updated and async version of the original [tahoma-api](https://github.com/philklei/tahoma-api) by [@philklei](https://github.com/philklei). The aim of this wrapper is to offer an easy to consume Python wrapper for the internal API's used by tahomalink.com. + +Somfy TaHoma has an official API, which can be consumed via the [Somfy-open-api](https://github.com/tetienne/somfy-open-api). Unfortunately only a few device classes are supported via the official API, thus the need for this wrapper. + +This package is written for the Home Assistant [ha-tahoma](https://github.com/iMicknl/ha-tahoma) integration, but could be used by any Python project interacting with Somfy TaHoma devices. diff --git a/setup.py b/setup.py index 4b0eb961..20651f93 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ version="0.1.0", author="Mick Vleeshouwer", author_email="mick@imick.nl", - description="Python wrapper to interact with SagemCom F@st routers via internal API's.", + description="Python wrapper to interact with internal Somfy TaHama API", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/iMicknl/python-tahoma-api", From c7cc039c31154bf98124d0b674c0380dbcbdafd0 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 6 Jul 2020 20:40:54 +0200 Subject: [PATCH 3/5] Add first models --- tahoma_api/client.py | 77 +++++++++++++++++++++++--------------------- tahoma_api/models.py | 73 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 37 deletions(-) create mode 100644 tahoma_api/models.py diff --git a/tahoma_api/client.py b/tahoma_api/client.py index 341e76a3..c35f4a24 100644 --- a/tahoma_api/client.py +++ b/tahoma_api/client.py @@ -10,13 +10,15 @@ import json from .exceptions import * +from .models import * + +API_URL = "https://tahomalink.com/enduser-mobile-web/enduserAPI/" # /doc for API doc -API_URL = 'https://tahomalink.com/enduser-mobile-web/enduserAPI/' # /doc for API doc class TahomaClient(object): """ Interface class for the Tahoma API """ - def __init__(self, username, password): + def __init__(self, username, password, api_url=API_URL): """ Constructor @@ -26,22 +28,18 @@ def __init__(self, username, password): self.username = username self.password = password - + self.api_url = api_url self._devices = None - self.__roles = [] async def login(self): - - payload = { - 'userId': self.username, - 'userPassword': self.password - } + + payload = {"userId": self.username, "userPassword": self.password} async with aiohttp.ClientSession() as session: - async with session.post(API_URL + 'login', data=payload) as response: + async with session.post(self.api_url + "login", data=payload) as response: result = await response.json() @@ -49,30 +47,33 @@ async def login(self): # {'errorCode': 'AUTHENTICATION_ERROR', 'error': 'Bad credentials'} # {'errorCode': 'AUTHENTICATION_ERROR', 'error': 'Your setup cannot be accessed through this application'} if response.status == 401: - if result['errorCode'] == 'AUTHENTICATION_ERROR': + if result["errorCode"] == "AUTHENTICATION_ERROR": + + if "Too many requests" in result["error"]: + print(result["error"]) - if 'Too many requests' in result['error']: - print(result['error']) + if ( + "Your setup cannot be accessed through this application" + in result["error"] + ): + print(result["error"]) - if 'Your setup cannot be accessed through this application' in result['error']: - print(result['error']) + if "Bad credentials" in result["error"]: + print(result["error"]) - if 'Bad credentials' in result['error']: - print(result['error']) + print(result["error"]) - print(result['error']) + return False # todo throw error - return False # todo throw error - # 401 # {'errorCode': 'AUTHENTICATION_ERROR', 'error': 'Too many requests, try again later : login with xxx@xxx.tld'} # TODO Add retry logic on too many requests + for debug, log requests + timespans - + # 200 # {'success': True, 'roles': [{'name': 'ENDUSER'}]} if response.status == 200: - if result['success'] == True: - self.__roles = result['roles'] + if result["success"] == True: + self.__roles = result["roles"] self.__cookies = response.cookies return True @@ -81,24 +82,26 @@ async def login(self): print(response.status) print(result) - async def get_devices(self, refresh=False): + async def get_devices(self, refresh=False) -> List[Device]: + + if self._devices and refresh == False: + return self._devices - if self._devices is None or refresh == True: + cookies = self.__cookies - cookies = self.__cookies + # TODO add retry logic for unauthorized? + async with aiohttp.ClientSession() as session: + async with session.get( + self.api_url + "setup/devices", cookies=cookies + ) as response: - async with aiohttp.ClientSession() as session: - async with session.get(API_URL + 'setup/devices', cookies=cookies) as response: + result = await response.json() - result = await response.json() + # for device in result.items() - if (response.status is 200): - self._devices = result + if response.status is 200: + devices = [Device(**d) for d in result] + self._devices = devices - return result - - # TODO add retry logic for unauthorized? + return devices - else: - return [] - # TODO Save cookies \ No newline at end of file diff --git a/tahoma_api/models.py b/tahoma_api/models.py new file mode 100644 index 00000000..35ee33fe --- /dev/null +++ b/tahoma_api/models.py @@ -0,0 +1,73 @@ +from typing import Any, Dict, List, Optional, Union + +# TODO Rewrite camelCase to snake_case +class Device: + __slots__ = ( + "creationTime", + "lastUpdateTime", + "label", + "deviceURL", + "shortcut", + "controllableName", + "definition", + "states", + "dataProperties", + "widgetName", + "uiClass", + "qualifiedName", + "type", + ) + + def __init__( + self, + *, + label: str, + deviceURL: str, + controllableName: str, + definition: Dict[List[Any]], + states: List[Dict[str, Any]], + dataProperties: Optional[List[Dict[str, Any]]] = None, + widgetName: Optional[str] = None, + uiClass: str, + qualifiedName: Optional[str] = None, + type: str, + **kwargs: Any + ): + self.deviceURL = deviceURL + self.controllableName = controllableName + self.states = [State(**s) for s in states] + + +class StateDefinition: + __slots__ = ( + "qualifiedName", + "type", + "values", + ) + + def __init__( + self, qualifiedName: str, type: str, values: Optional[str], **kwargs: Any + ): + self.qualifiedName = qualifiedName + self.type = type + self.values = values + + +class CommandDefinition: + __slots__ = ( + "commandName", + "nparams", + ) + + def __init__(self, commandName: str, nparams: int, **kwargs: Any): + self.commandName = commandName + self.nparams = nparams + + +class State: + __slots__ = "name", "value", "type" + + def __init__(self, name: str, value: str, type: str, **kwargs: Any): + self.name = name + self.value = value + self.type = type From f6bd4f5bc41a44c92a2fe83ec1c32e87eeac9b01 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 6 Jul 2020 22:00:04 +0200 Subject: [PATCH 4/5] Add event listeners test --- tahoma_api/client.py | 74 ++++++++++++++++++++++++++++++++++---------- tahoma_api/models.py | 8 ++++- test.py | 15 +++++++-- 3 files changed, 76 insertions(+), 21 deletions(-) diff --git a/tahoma_api/client.py b/tahoma_api/client.py index c35f4a24..6bf1f07c 100644 --- a/tahoma_api/client.py +++ b/tahoma_api/client.py @@ -30,9 +30,9 @@ def __init__(self, username, password, api_url=API_URL): self.password = password self.api_url = api_url - self._devices = None - - self.__roles = [] + self.__cookies = None + self.__devices = None + self.__roles = None async def login(self): @@ -51,6 +51,7 @@ async def login(self): if "Too many requests" in result["error"]: print(result["error"]) + raise Exception if ( "Your setup cannot be accessed through this application" @@ -83,25 +84,64 @@ async def login(self): print(result) async def get_devices(self, refresh=False) -> List[Device]: - - if self._devices and refresh == False: + if self.__devices and refresh == False: return self._devices - cookies = self.__cookies + response = await self.__make_http_request("GET", "setup/devices") - # TODO add retry logic for unauthorized? - async with aiohttp.ClientSession() as session: - async with session.get( - self.api_url + "setup/devices", cookies=cookies - ) as response: + devices = [Device(**d) for d in response] + self.__devices = devices - result = await response.json() + return devices + + async def register_event_listener(self) -> str: + """ + Register a new setup event listener on the current session and return a new listener id. + Only one listener may be registered on a given session. + Registering an new listener will invalidate the previous one if any. + Note that registering an event listener drastically reduces the session timeout : listening sessions are expected to call the /events/{listenerId}/fetch API on a regular basis. + """ + response = await self.__make_http_request("POST", "events/register") + listener_id = response.get("id") - # for device in result.items() + return listener_id - if response.status is 200: - devices = [Device(**d) for d in result] - self._devices = devices + async def fetch_event_listener(self, listener_id: str) -> List[Any]: + """ + 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.__make_http_request("POST", f"events/{listener_id}/fetch") - return devices + return response + + async def __make_http_request( + self, method: str, endpoint: str, payload: Optional[Any] = None + ) -> Any: + """Make a request to the TaHoma API""" + cookies = self.__cookies + supported_methods = ["GET", "POST"] + + if method not in supported_methods: + raise Exception + + async with aiohttp.ClientSession() as session: + if method == "GET": + async with session.get( + self.api_url + endpoint, cookies=cookies + ) as response: + result = await response.json() + + if method == "POST": + async with session.post( + self.api_url + endpoint, cookies=cookies, data=payload + ) as response: + result = await response.json() + + if response.status == 200: + return result + + if response.status > 400 and response.status < 500: + # implement retry logic + print("TODO") diff --git a/tahoma_api/models.py b/tahoma_api/models.py index 35ee33fe..f4810373 100644 --- a/tahoma_api/models.py +++ b/tahoma_api/models.py @@ -3,6 +3,7 @@ # TODO Rewrite camelCase to snake_case class Device: __slots__ = ( + "id", "creationTime", "lastUpdateTime", "label", @@ -12,7 +13,10 @@ class Device: "definition", "states", "dataProperties", + "available", + "enabled", "widgetName", + "widget", "uiClass", "qualifiedName", "type", @@ -24,7 +28,7 @@ def __init__( label: str, deviceURL: str, controllableName: str, - definition: Dict[List[Any]], + # definition: Dict[List[Any]], states: List[Dict[str, Any]], dataProperties: Optional[List[Dict[str, Any]]] = None, widgetName: Optional[str] = None, @@ -33,7 +37,9 @@ def __init__( type: str, **kwargs: Any ): + self.id = deviceURL self.deviceURL = deviceURL + self.label = label self.controllableName = controllableName self.states = [State(**s) for s in states] diff --git a/test.py b/test.py index f5b11946..e037aeba 100644 --- a/test.py +++ b/test.py @@ -1,18 +1,27 @@ import asyncio from tahoma_api import TahomaClient +import time username = "" password = "" + async def main(): client = TahomaClient(username, password) - + try: login = await client.login() - devices = await client.get_states() + devices = await client.get_devices() + + for device in devices: + print(f"{device.label} ({device.id})") - print(devices) + listener_id = await client.register_event_listener() + while True: + events = await client.fetch_event_listener(listener_id) + print(events) + time.sleep(2) except Exception as exception: print(exception) From fa121cd656081bf6567e6a0cefcae40b3b2eb89d Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 6 Jul 2020 22:13:38 +0200 Subject: [PATCH 5/5] Add client --- tahoma_api/client.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tahoma_api/client.py b/tahoma_api/client.py index 6bf1f07c..8e83bf56 100644 --- a/tahoma_api/client.py +++ b/tahoma_api/client.py @@ -115,6 +115,21 @@ async def fetch_event_listener(self, listener_id: str) -> List[Any]: return response + async def execute_action_group( + self, actions: [Command], label="python-tahoma-api", priority=False + ) -> List[Any]: + """ + Execute a non-persistent action group + The executed action group does not have to be persisted on the server before use. + Per-session rate-limit : 50 calls per 24 HOURS period for all operations of the same category (exec) + """ + payload = {"label": label, "actions": actions} + response = await self.__make_http_request("POST", f"exec/apply", payload) + + return response + + apply_actions + async def __make_http_request( self, method: str, endpoint: str, payload: Optional[Any] = None ) -> Any: