From 62d4e1a32b87045e079fa089eb595a72c42b79f6 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Tue, 14 Feb 2023 12:33:20 +0100 Subject: [PATCH 01/21] Split login logic --- pyoverkiz/client.py | 428 ++---------------------- pyoverkiz/models.py | 10 - pyoverkiz/servers/atlantic_cozytouch.py | 64 ++++ pyoverkiz/servers/default.py | 18 + pyoverkiz/servers/nexity.py | 79 +++++ pyoverkiz/servers/overkiz_server.py | 166 +++++++++ pyoverkiz/servers/somfy.py | 130 +++++++ 7 files changed, 488 insertions(+), 407 deletions(-) create mode 100644 pyoverkiz/servers/atlantic_cozytouch.py create mode 100644 pyoverkiz/servers/default.py create mode 100644 pyoverkiz/servers/nexity.py create mode 100644 pyoverkiz/servers/overkiz_server.py create mode 100644 pyoverkiz/servers/somfy.py diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index a573aaf0..42a68a23 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -1,57 +1,29 @@ """ Python wrapper for the OverKiz API """ from __future__ import annotations -import asyncio import datetime import urllib.parse from collections.abc import Mapping -from json import JSONDecodeError from types import TracebackType from typing import Any, cast import backoff -import boto3 import humps -from aiohttp import ClientResponse, ClientSession, FormData, ServerDisconnectedError -from botocore.config import Config -from warrant_lite import WarrantLite from pyoverkiz.const import ( - COZYTOUCH_ATLANTIC_API, - COZYTOUCH_CLIENT_ID, - NEXITY_API, - NEXITY_COGNITO_CLIENT_ID, - NEXITY_COGNITO_REGION, - NEXITY_COGNITO_USER_POOL, SOMFY_API, SOMFY_CLIENT_ID, SOMFY_CLIENT_SECRET, SUPPORTED_SERVERS, ) from pyoverkiz.exceptions import ( - AccessDeniedToGatewayException, - BadCredentialsException, - CozyTouchBadCredentialsException, - CozyTouchServiceException, - InvalidCommandException, InvalidEventListenerIdException, - InvalidTokenException, - MaintenanceException, - MissingAuthorizationTokenException, - NexityBadCredentialsException, - NexityServiceException, NoRegisteredEventListenerException, NotAuthenticatedException, - NotSuchTokenException, - SessionAndBearerInSameRequestException, SomfyBadCredentialsException, SomfyServiceException, - TooManyAttemptsBannedException, TooManyConcurrentRequestsException, TooManyExecutionsException, - TooManyRequestsException, - UnknownObjectException, - UnknownUserException, ) from pyoverkiz.models import ( Command, @@ -61,13 +33,18 @@ Gateway, HistoryExecution, LocalToken, - OverkizServer, Place, Scenario, Setup, State, ) from pyoverkiz.obfuscate import obfuscate_sensitive_data +from pyoverkiz.servers.overkiz_server import ( + ClientSession, + FormData, + OverkizServer, + ServerDisconnectedError, +) from pyoverkiz.types import JSON @@ -153,211 +130,11 @@ async def login( Authenticate and create an API session allowing access to the other operations. Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] """ - # Local authentication - if "/enduser-mobile-web/1/enduserAPI/" in self.server.endpoint: - if register_event_listener: - await self.register_event_listener() - else: - # Call a simple endpoint to verify if our token is correct - await self.get_gateways() - - return True - - # Somfy TaHoma authentication using access_token - if self.server == SUPPORTED_SERVERS["somfy_europe"]: - await self.somfy_tahoma_get_access_token() - - if register_event_listener: - await self.register_event_listener() - - return True - - # CozyTouch authentication using jwt - if self.server == SUPPORTED_SERVERS["atlantic_cozytouch"]: - jwt = await self.cozytouch_login() - payload = {"jwt": jwt} - - # Nexity authentication using ssoToken - elif self.server == SUPPORTED_SERVERS["nexity"]: - sso_token = await self.nexity_login() - user_id = self.username.replace("@", "_-_") # Replace @ for _-_ - payload = {"ssoToken": sso_token, "userId": user_id} - - # Regular authentication using userId+userPassword - else: - payload = {"userId": self.username, "userPassword": self.password} - - response = await self.__post("login", data=payload) - - if response.get("success"): + if await self.server.login(self.username, self.password): if register_event_listener: await self.register_event_listener() - return True - return False - async def somfy_tahoma_get_access_token(self) -> str: - """ - Authenticate via Somfy identity and acquire access_token. - """ - # Request access token - async with self.session.post( - SOMFY_API + "/oauth/oauth/v2/token", - data=FormData( - { - "grant_type": "password", - "username": self.username, - "password": self.password, - "client_id": SOMFY_CLIENT_ID, - "client_secret": SOMFY_CLIENT_SECRET, - } - ), - headers={ - "Content-Type": "application/x-www-form-urlencoded", - }, - ) as response: - token = await response.json() - - # { "message": "error.invalid.grant", "data": [], "uid": "xxx" } - if "message" in token and token["message"] == "error.invalid.grant": - raise SomfyBadCredentialsException(token["message"]) - - if "access_token" not in token: - raise SomfyServiceException("No Somfy access token provided.") - - self._access_token = cast(str, token["access_token"]) - self._refresh_token = token["refresh_token"] - self._expires_in = datetime.datetime.now() + datetime.timedelta( - seconds=token["expires_in"] - 5 - ) - - return self._access_token - - async def refresh_token(self) -> None: - """ - Update the access and the refresh token. The refresh token will be valid 14 days. - """ - if self.server != SUPPORTED_SERVERS["somfy_europe"]: - return - - if not self._refresh_token: - raise ValueError("No refresh token provided. Login method must be used.") - - # &grant_type=refresh_token&refresh_token=REFRESH_TOKEN - # Request access token - async with self.session.post( - SOMFY_API + "/oauth/oauth/v2/token", - data=FormData( - { - "grant_type": "refresh_token", - "refresh_token": self._refresh_token, - "client_id": SOMFY_CLIENT_ID, - "client_secret": SOMFY_CLIENT_SECRET, - } - ), - headers={ - "Content-Type": "application/x-www-form-urlencoded", - }, - ) as response: - token = await response.json() - # { "message": "error.invalid.grant", "data": [], "uid": "xxx" } - if "message" in token and token["message"] == "error.invalid.grant": - raise SomfyBadCredentialsException(token["message"]) - - if "access_token" not in token: - raise SomfyServiceException("No Somfy access token provided.") - - self._access_token = cast(str, token["access_token"]) - self._refresh_token = token["refresh_token"] - self._expires_in = datetime.datetime.now() + datetime.timedelta( - seconds=token["expires_in"] - 5 - ) - - async def cozytouch_login(self) -> str: - """ - Authenticate via CozyTouch identity and acquire JWT token. - """ - # Request access token - async with self.session.post( - COZYTOUCH_ATLANTIC_API + "/token", - data=FormData( - { - "grant_type": "password", - "username": "GA-PRIVATEPERSON/" + self.username, - "password": self.password, - } - ), - headers={ - "Authorization": f"Basic {COZYTOUCH_CLIENT_ID}", - "Content-Type": "application/x-www-form-urlencoded", - }, - ) as response: - token = await response.json() - - # {'error': 'invalid_grant', - # 'error_description': 'Provided Authorization Grant is invalid.'} - if "error" in token and token["error"] == "invalid_grant": - raise CozyTouchBadCredentialsException(token["error_description"]) - - if "token_type" not in token: - raise CozyTouchServiceException("No CozyTouch token provided.") - - # Request JWT - async with self.session.get( - COZYTOUCH_ATLANTIC_API + "/magellan/accounts/jwt", - headers={"Authorization": f"Bearer {token['access_token']}"}, - ) as response: - jwt = await response.text() - - if not jwt: - raise CozyTouchServiceException("No JWT token provided.") - - jwt = jwt.strip('"') # Remove surrounding quotes - - return jwt - - async def nexity_login(self) -> str: - """ - Authenticate via Nexity identity and acquire SSO token. - """ - loop = asyncio.get_event_loop() - - def _get_client() -> boto3.session.Session.client: - return boto3.client( - "cognito-idp", config=Config(region_name=NEXITY_COGNITO_REGION) - ) - - # Request access token - client = await loop.run_in_executor(None, _get_client) - - aws = WarrantLite( - username=self.username, - password=self.password, - pool_id=NEXITY_COGNITO_USER_POOL, - client_id=NEXITY_COGNITO_CLIENT_ID, - client=client, - ) - - try: - tokens = await loop.run_in_executor(None, aws.authenticate_user) - except Exception as error: - raise NexityBadCredentialsException() from error - - id_token = tokens["AuthenticationResult"]["IdToken"] - - async with self.session.get( - NEXITY_API + "/deploy/api/v1/domotic/token", - headers={ - "Authorization": id_token, - }, - ) as response: - token = await response.json() - - if "token" not in token: - raise NexityServiceException("No Nexity SSO token provided.") - - return cast(str, token["token"]) - @backoff.on_exception( backoff.expo, (NotAuthenticatedException, ServerDisconnectedError), @@ -384,7 +161,7 @@ async def get_setup(self, refresh: bool = False) -> Setup: if self.setup and not refresh: return self.setup - response = await self.__get("setup") + response = await self.server.get("setup") setup = Setup(**humps.decamelize(response)) @@ -411,7 +188,7 @@ async def get_diagnostic_data(self) -> JSON: This data will be masked to not return any confidential or PII data. """ - response = await self.__get("setup") + response = await self.server.get("setup") return obfuscate_sensitive_data(response) @@ -429,7 +206,7 @@ async def get_devices(self, refresh: bool = False) -> list[Device]: if self.devices and not refresh: return self.devices - response = await self.__get("setup/devices") + response = await self.server.get("setup/devices") devices = [Device(**d) for d in humps.decamelize(response)] # Cache response @@ -453,7 +230,7 @@ async def get_gateways(self, refresh: bool = False) -> list[Gateway]: if self.gateways and not refresh: return self.gateways - response = await self.__get("setup/gateways") + response = await self.server.get("setup/gateways") gateways = [Gateway(**g) for g in humps.decamelize(response)] # Cache response @@ -473,7 +250,7 @@ async def get_execution_history(self) -> list[HistoryExecution]: """ List execution history """ - response = await self.__get("history/executions") + response = await self.server.get("history/executions") execution_history = [HistoryExecution(**h) for h in humps.decamelize(response)] return execution_history @@ -485,7 +262,7 @@ async def get_device_definition(self, deviceurl: str) -> JSON | None: """ Retrieve a particular setup device definition """ - response: dict = await self.__get( + response: dict = await self.server.get( f"setup/devices/{urllib.parse.quote_plus(deviceurl)}" ) @@ -498,7 +275,7 @@ async def get_state(self, deviceurl: str) -> list[State]: """ Retrieve states of requested device """ - response = await self.__get( + response = await self.server.get( f"setup/devices/{urllib.parse.quote_plus(deviceurl)}/states" ) state = [State(**s) for s in humps.decamelize(response)] @@ -512,7 +289,7 @@ async def refresh_states(self) -> None: """ Ask the box to refresh all devices states for protocols supporting that operation """ - await self.__post("setup/devices/states/refresh") + await self.server.post("setup/devices/states/refresh") @backoff.on_exception(backoff.expo, TooManyConcurrentRequestsException, max_tries=5) async def register_event_listener(self) -> str: @@ -525,7 +302,7 @@ async def register_event_listener(self) -> str: timeout : listening sessions are expected to call the /events/{listenerId}/fetch API on a regular basis. """ - response = await self.__post("events/register") + response = await self.server.post("events/register") listener_id = cast(str, response.get("id")) self.event_listener_id = listener_id @@ -548,8 +325,7 @@ async def fetch_events(self) -> list[Event]: Per-session rate-limit : 1 calls per 1 SECONDS period for this particular operation (polling) """ - await self._refresh_token_if_expired() - response = await self.__post(f"events/{self.event_listener_id}/fetch") + response = await self.server.post(f"events/{self.event_listener_id}/fetch") events = [Event(**e) for e in humps.decamelize(response)] return events @@ -559,8 +335,7 @@ async def unregister_event_listener(self) -> None: Unregister an event listener. API response status is always 200, even on unknown listener ids. """ - await self._refresh_token_if_expired() - await self.__post(f"events/{self.event_listener_id}/unregister") + await self.server.post(f"events/{self.event_listener_id}/unregister") self.event_listener_id = None @backoff.on_exception( @@ -568,7 +343,7 @@ async def unregister_event_listener(self) -> 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.server.get(f"exec/current/{exec_id}") execution = Execution(**humps.decamelize(response)) return execution @@ -578,7 +353,7 @@ async def get_current_execution(self, exec_id: str) -> 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.server.get("exec/current") executions = [Execution(**e) for e in humps.decamelize(response)] return executions @@ -588,7 +363,7 @@ async def get_current_executions(self) -> list[Execution]: ) async def get_api_version(self) -> str: """Get the API version (local only)""" - response = await self.__get("apiVersion") + response = await self.server.get("apiVersion") return cast(str, response["protocolVersion"]) @@ -618,7 +393,7 @@ async def execute_command( ) async def cancel_command(self, exec_id: str) -> None: """Cancel a running setup-level execution""" - await self.__delete(f"/exec/current/setup/{exec_id}") + await self.server.delete(f"/exec/current/setup/{exec_id}") @backoff.on_exception( backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin @@ -634,7 +409,7 @@ async def execute_commands( "label": label, "actions": [{"deviceURL": device_url, "commands": commands}], } - response: dict = await self.__post("exec/apply", payload) + response: dict = await self.server.post("exec/apply", payload) return cast(str, response["execId"]) @backoff.on_exception( @@ -645,7 +420,7 @@ async def execute_commands( ) async def get_scenarios(self) -> list[Scenario]: """List the scenarios""" - response = await self.__get("actionGroups") + response = await self.server.get("actionGroups") return [Scenario(**scenario) for scenario in response] @backoff.on_exception( @@ -656,7 +431,7 @@ async def get_scenarios(self) -> list[Scenario]: ) async def get_places(self) -> Place: """List the places""" - response = await self.__get("setup/places") + response = await self.server.get("setup/places") places = Place(**humps.decamelize(response)) return places @@ -671,7 +446,7 @@ async def generate_local_token(self, gateway_id: str) -> str: Generates a new token Access scope : Full enduser API access (enduser/*) """ - response = await self.__get(f"config/{gateway_id}/local/tokens/generate") + response = await self.server.get(f"config/{gateway_id}/local/tokens/generate") return cast(str, response["token"]) @@ -688,7 +463,7 @@ async def activate_local_token( Create a token Access scope : Full enduser API access (enduser/*) """ - response = await self.__post( + response = await self.server.post( f"config/{gateway_id}/local/tokens", {"label": label, "token": token, "scope": scope}, ) @@ -708,7 +483,7 @@ async def get_local_tokens( Get all gateway tokens with the given scope Access scope : Full enduser API access (enduser/*) """ - response = await self.__get(f"config/{gateway_id}/local/tokens/{scope}") + response = await self.server.get(f"config/{gateway_id}/local/tokens/{scope}") local_tokens = [LocalToken(**lt) for lt in humps.decamelize(response)] return local_tokens @@ -724,7 +499,7 @@ async def delete_local_token(self, gateway_id: str, uuid: str) -> bool: Delete a token Access scope : Full enduser API access (enduser/*) """ - await self.__delete(f"config/{gateway_id}/local/tokens/{uuid}") + await self.server.delete(f"config/{gateway_id}/local/tokens/{uuid}") return True @@ -733,7 +508,7 @@ async def delete_local_token(self, gateway_id: str, uuid: str) -> bool: ) async def execute_scenario(self, oid: str) -> str: """Execute a scenario""" - response = await self.__post(f"exec/{oid}") + response = await self.server.post(f"exec/{oid}") return cast(str, response["execId"]) @backoff.on_exception( @@ -744,146 +519,5 @@ async def execute_scenario(self, oid: str) -> str: ) async def execute_scheduled_scenario(self, oid: str, timestamp: int) -> str: """Execute a scheduled scenario""" - response = await self.__post(f"exec/schedule/{oid}/{timestamp}") + response = await self.server.post(f"exec/schedule/{oid}/{timestamp}") return cast(str, response["triggerId"]) - - async def __get(self, path: str) -> Any: - """Make a GET request to the OverKiz API""" - headers = {} - - await self._refresh_token_if_expired() - if self._access_token: - headers["Authorization"] = f"Bearer {self._access_token}" - - async with self.session.get( - f"{self.server.endpoint}{path}", - headers=headers, - ) as response: - await self.check_response(response) - return await response.json() - - async def __post( - self, path: str, payload: JSON | None = None, data: JSON | None = None - ) -> Any: - """Make a POST request to the OverKiz API""" - headers = {} - - if path != "login" and self._access_token: - await self._refresh_token_if_expired() - headers["Authorization"] = f"Bearer {self._access_token}" - - async with self.session.post( - f"{self.server.endpoint}{path}", data=data, json=payload, headers=headers - ) as response: - await self.check_response(response) - return await response.json() - - async def __delete(self, path: str) -> None: - """Make a DELETE request to the OverKiz API""" - headers = {} - - await self._refresh_token_if_expired() - - if self._access_token: - headers["Authorization"] = f"Bearer {self._access_token}" - - async with self.session.delete( - f"{self.server.endpoint}{path}", headers=headers - ) as response: - await self.check_response(response) - - @staticmethod - async def check_response(response: ClientResponse) -> None: - """Check the response returned by the OverKiz API""" - if response.status in [200, 204]: - return - - try: - result = await response.json(content_type=None) - except JSONDecodeError as error: - result = await response.text() - if "Server is down for maintenance" in result: - raise MaintenanceException("Server is down for maintenance") from error - raise Exception( - f"Unknown error while requesting {response.url}. {response.status} - {result}" - ) from error - - if result.get("errorCode"): - message = result.get("error") - - # {"errorCode": "AUTHENTICATION_ERROR", - # "error": "Too many requests, try again later : login with xxx@xxx.tld"} - if "Too many requests" in message: - raise TooManyRequestsException(message) - - # {"errorCode": "AUTHENTICATION_ERROR", "error": "Bad credentials"} - if message == "Bad credentials": - raise BadCredentialsException(message) - - # {"errorCode": "RESOURCE_ACCESS_DENIED", "error": "Not authenticated"} - if message == "Not authenticated": - raise NotAuthenticatedException(message) - - # {"error":"Missing authorization token.","errorCode":"RESOURCE_ACCESS_DENIED"} - if message == "Missing authorization token.": - raise MissingAuthorizationTokenException(message) - - # {"error": "Server busy, please try again later. (Too many executions)"} - if message == "Server busy, please try again later. (Too many executions)": - raise TooManyExecutionsException(message) - - # {"error": "UNSUPPORTED_OPERATION", "error": "No such command : ..."} - if "No such command" in message: - raise InvalidCommandException(message) - - # {'errorCode': 'UNSPECIFIED_ERROR', 'error': 'Invalid event listener id : ...'} - if "Invalid event listener id" in message: - raise InvalidEventListenerIdException(message) - - # {'errorCode': 'UNSPECIFIED_ERROR', 'error': 'No registered event listener'} - if message == "No registered event listener": - raise NoRegisteredEventListenerException(message) - - # {"errorCode": "RESOURCE_ACCESS_DENIED", "error": "too many concurrent requests"} - if message == "too many concurrent requests": - raise TooManyConcurrentRequestsException(message) - - if message == "Cannot use JSESSIONID and bearer token in same request": - raise SessionAndBearerInSameRequestException(message) - - if ( - message - == "Too many attempts with an invalid token, temporarily banned." - ): - raise TooManyAttemptsBannedException(message) - - if "Invalid token : " in message: - raise InvalidTokenException(message) - - if "Not such token with UUID: " in message: - raise NotSuchTokenException(message) - - if "Unknown user :" in message: - raise UnknownUserException(message) - - # {"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"} - if message == "Unknown object.": - raise UnknownObjectException(message) - - # {'errorCode': 'RESOURCE_ACCESS_DENIED', 'error': 'Access denied to gateway #1234-5678-1234 for action ADD_TOKEN'} - if "Access denied to gateway" in message: - raise AccessDeniedToGatewayException(message) - - raise Exception(message if message else result) - - async def _refresh_token_if_expired(self) -> None: - """Check if token is expired and request a new one.""" - if ( - self._expires_in - and self._refresh_token - and self._expires_in <= datetime.datetime.now() - ): - await self.refresh_token() - - if self.event_listener_id: - await self.register_event_listener() diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index b8abfcc7..a7956e58 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -779,16 +779,6 @@ def __init__( self.oid = oid -@define(kw_only=True) -class OverkizServer: - """Class to describe an Overkiz server.""" - - name: str - endpoint: str - manufacturer: str - configuration_url: str | None - - @define(kw_only=True) class LocalToken: label: str diff --git a/pyoverkiz/servers/atlantic_cozytouch.py b/pyoverkiz/servers/atlantic_cozytouch.py new file mode 100644 index 00000000..bf2e02b0 --- /dev/null +++ b/pyoverkiz/servers/atlantic_cozytouch.py @@ -0,0 +1,64 @@ +from __future__ import annotations + + +from aiohttp import FormData +from pyoverkiz.const import COZYTOUCH_ATLANTIC_API, COZYTOUCH_CLIENT_ID +from pyoverkiz.exceptions import ( + CozyTouchBadCredentialsException, + CozyTouchServiceException, +) + +from pyoverkiz.servers.overkiz_server import OverkizServer + + +class AtlanticCozytouch(OverkizServer): + async def login(self, username: str, password: str) -> bool: + """ + Authenticate and create an API session allowing access to the other operations. + Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] + """ + + async with self.session.post( + COZYTOUCH_ATLANTIC_API + "/token", + data=FormData( + { + "grant_type": "password", + "username": "GA-PRIVATEPERSON/" + username, + "password": password, + } + ), + headers={ + "Authorization": f"Basic {COZYTOUCH_CLIENT_ID}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) as response: + token = await response.json() + + # {'error': 'invalid_grant', + # 'error_description': 'Provided Authorization Grant is invalid.'} + if "error" in token and token["error"] == "invalid_grant": + raise CozyTouchBadCredentialsException(token["error_description"]) + + if "token_type" not in token: + raise CozyTouchServiceException("No CozyTouch token provided.") + + # Request JWT + async with self.session.get( + COZYTOUCH_ATLANTIC_API + "/magellan/accounts/jwt", + headers={"Authorization": f"Bearer {token['access_token']}"}, + ) as response: + jwt = await response.text() + + if not jwt: + raise CozyTouchServiceException("No JWT token provided.") + + jwt = jwt.strip('"') # Remove surrounding quotes + + payload = {"jwt": jwt} + + response = await self.post("login", data=payload) + + if response.get("success"): + return True + + return False diff --git a/pyoverkiz/servers/default.py b/pyoverkiz/servers/default.py new file mode 100644 index 00000000..f18b211b --- /dev/null +++ b/pyoverkiz/servers/default.py @@ -0,0 +1,18 @@ +from pyoverkiz.servers.overkiz_server import OverkizServer + + +class NexityServer(OverkizServer): + async def login(self, username: str, password: str) -> 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": username, "userPassword": password} + + response = await self.post("login", data=payload) + + if response.get("success"): + return True + + return False diff --git a/pyoverkiz/servers/nexity.py b/pyoverkiz/servers/nexity.py new file mode 100644 index 00000000..90518671 --- /dev/null +++ b/pyoverkiz/servers/nexity.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import asyncio + +from typing import cast + +import boto3 + +from botocore.config import Config +from warrant_lite import WarrantLite + +from pyoverkiz.const import ( + NEXITY_API, + NEXITY_COGNITO_CLIENT_ID, + NEXITY_COGNITO_REGION, + NEXITY_COGNITO_USER_POOL, +) +from pyoverkiz.exceptions import ( + NexityBadCredentialsException, + NexityServiceException, +) + +from pyoverkiz.servers.overkiz_server import OverkizServer + + +class NexityServer(OverkizServer): + async def login(self, username: str, password: str) -> bool: + """ + Authenticate and create an API session allowing access to the other operations. + Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] + """ + + loop = asyncio.get_event_loop() + + def _get_client() -> boto3.session.Session.client: + return boto3.client( + "cognito-idp", config=Config(region_name=NEXITY_COGNITO_REGION) + ) + + # Request access token + client = await loop.run_in_executor(None, _get_client) + + aws = WarrantLite( + username=username, + password=password, + pool_id=NEXITY_COGNITO_USER_POOL, + client_id=NEXITY_COGNITO_CLIENT_ID, + client=client, + ) + + try: + tokens = await loop.run_in_executor(None, aws.authenticate_user) + except Exception as error: + raise NexityBadCredentialsException() from error + + id_token = tokens["AuthenticationResult"]["IdToken"] + + async with self.session.get( + NEXITY_API + "/deploy/api/v1/domotic/token", + headers={ + "Authorization": id_token, + }, + ) as response: + token = await response.json() + + if "token" not in token: + raise NexityServiceException("No Nexity SSO token provided.") + + sso_token = cast(str, token["token"]) + + user_id = username.replace("@", "_-_") # Replace @ for _-_ + payload = {"ssoToken": sso_token, "userId": user_id} + + response = await self.server.post("login", data=payload) + + if response.get("success"): + return True + + return False diff --git a/pyoverkiz/servers/overkiz_server.py b/pyoverkiz/servers/overkiz_server.py new file mode 100644 index 00000000..8de4a7ca --- /dev/null +++ b/pyoverkiz/servers/overkiz_server.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from json import JSONDecodeError +from typing import Any + +from aiohttp import ClientResponse, ClientSession +from attr import define + +from pyoverkiz.exceptions import ( + AccessDeniedToGatewayException, + BadCredentialsException, + InvalidCommandException, + InvalidEventListenerIdException, + InvalidTokenException, + MaintenanceException, + MissingAuthorizationTokenException, + NoRegisteredEventListenerException, + NotAuthenticatedException, + NotSuchTokenException, + SessionAndBearerInSameRequestException, + TooManyAttemptsBannedException, + TooManyConcurrentRequestsException, + TooManyExecutionsException, + TooManyRequestsException, + UnknownObjectException, + UnknownUserException, +) +from pyoverkiz.types import JSON + + +@define(kw_only=True) +class OverkizServer(ABC): + """Class to describe an Overkiz server.""" + + name: str + endpoint: str + manufacturer: str + session: ClientSession + + configuration_url: str | None + + @abstractmethod + async def login(self, username: str, password: str) -> bool: + """Login to the server.""" + + @property + def _headers(self): + return {} + + async def get(self, path: str) -> Any: + """Make a GET request to the OverKiz API""" + + async with self.session.get( + f"{self.endpoint}{path}", + headers=self._headers, + ) as response: + await self.check_response(response) + return await response.json() + + async def post( + self, path: str, payload: JSON | None = None, data: JSON | None = None + ) -> Any: + """Make a POST request to the OverKiz API""" + + async with self.session.post( + f"{self.endpoint}{path}", + data=data, + json=payload, + headers=self._headers, + ) as response: + await self.check_response(response) + return await response.json() + + async def delete(self, path: str) -> None: + """Make a DELETE request to the OverKiz API""" + + async with self.session.delete( + f"{self.endpoint}{path}", + headers=self._headers, + ) as response: + await self.check_response(response) + + @staticmethod + async def check_response(response: ClientResponse) -> None: + """Check the response returned by the OverKiz API""" + if response.status in [200, 204]: + return + + try: + result = await response.json(content_type=None) + except JSONDecodeError as error: + result = await response.text() + if "Server is down for maintenance" in result: + raise MaintenanceException("Server is down for maintenance") from error + raise Exception( + f"Unknown error while requesting {response.url}. {response.status} - {result}" + ) from error + + if result.get("errorCode"): + message = result.get("error") + + # {"errorCode": "AUTHENTICATION_ERROR", + # "error": "Too many requests, try again later : login with xxx@xxx.tld"} + if "Too many requests" in message: + raise TooManyRequestsException(message) + + # {"errorCode": "AUTHENTICATION_ERROR", "error": "Bad credentials"} + if message == "Bad credentials": + raise BadCredentialsException(message) + + # {"errorCode": "RESOURCE_ACCESS_DENIED", "error": "Not authenticated"} + if message == "Not authenticated": + raise NotAuthenticatedException(message) + + # {"error":"Missing authorization token.","errorCode":"RESOURCE_ACCESS_DENIED"} + if message == "Missing authorization token.": + raise MissingAuthorizationTokenException(message) + + # {"error": "Server busy, please try again later. (Too many executions)"} + if message == "Server busy, please try again later. (Too many executions)": + raise TooManyExecutionsException(message) + + # {"error": "UNSUPPORTED_OPERATION", "error": "No such command : ..."} + if "No such command" in message: + raise InvalidCommandException(message) + + # {'errorCode': 'UNSPECIFIED_ERROR', 'error': 'Invalid event listener id : ...'} + if "Invalid event listener id" in message: + raise InvalidEventListenerIdException(message) + + # {'errorCode': 'UNSPECIFIED_ERROR', 'error': 'No registered event listener'} + if message == "No registered event listener": + raise NoRegisteredEventListenerException(message) + + # {"errorCode": "RESOURCE_ACCESS_DENIED", "error": "too many concurrent requests"} + if message == "too many concurrent requests": + raise TooManyConcurrentRequestsException(message) + + if message == "Cannot use JSESSIONID and bearer token in same request": + raise SessionAndBearerInSameRequestException(message) + + if ( + message + == "Too many attempts with an invalid token, temporarily banned." + ): + raise TooManyAttemptsBannedException(message) + + if "Invalid token : " in message: + raise InvalidTokenException(message) + + if "Not such token with UUID: " in message: + raise NotSuchTokenException(message) + + if "Unknown user :" in message: + raise UnknownUserException(message) + + # {"error":"Unknown object.","errorCode":"UNSPECIFIED_ERROR"} + if message == "Unknown object.": + raise UnknownObjectException(message) + + # {'errorCode': 'RESOURCE_ACCESS_DENIED', 'error': 'Access denied to gateway #1234-5678-1234 for action ADD_TOKEN'} + if "Access denied to gateway" in message: + raise AccessDeniedToGatewayException(message) + + raise Exception(message if message else result) diff --git a/pyoverkiz/servers/somfy.py b/pyoverkiz/servers/somfy.py new file mode 100644 index 00000000..d689d892 --- /dev/null +++ b/pyoverkiz/servers/somfy.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import datetime +from typing import Any, cast + +from aiohttp import FormData + +from pyoverkiz.const import SOMFY_API, SOMFY_CLIENT_ID, SOMFY_CLIENT_SECRET +from pyoverkiz.exceptions import SomfyBadCredentialsException, SomfyServiceException +from pyoverkiz.servers.overkiz_server import OverkizServer +from pyoverkiz.types import JSON + + +class SomfyServer(OverkizServer): + + _access_token: str | None = None + _refresh_token: str | None = None + _expires_in: datetime.datetime | None = None + + @property + def _headers(self): + return {"Authorization": f"Bearer {self._access_token}"} + + async def get(self, path: str) -> Any: + """Make a GET request to the OverKiz API""" + + await self._refresh_token_if_expired() + return await super().get(path) + + async def post( + self, path: str, payload: JSON | None = None, data: JSON | None = None + ) -> Any: + """Make a POST request to the OverKiz API""" + if path != "login": + await self._refresh_token_if_expired() + return await super().post(path, payload=payload, data=data) + + async def delete(self, path: str) -> None: + """Make a DELETE request to the OverKiz API""" + await self._refresh_token_if_expired() + return await super().delete(path) + + async def login(self, username: str, password: str) -> bool: + """ + Authenticate and create an API session allowing access to the other operations. + Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] + """ + + async with self.session.post( + SOMFY_API + "/oauth/oauth/v2/token", + data=FormData( + { + "grant_type": "password", + "username": username, + "password": password, + "client_id": SOMFY_CLIENT_ID, + "client_secret": SOMFY_CLIENT_SECRET, + } + ), + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + ) as response: + token = await response.json() + + # { "message": "error.invalid.grant", "data": [], "uid": "xxx" } + if "message" in token and token["message"] == "error.invalid.grant": + raise SomfyBadCredentialsException(token["message"]) + + if "access_token" not in token: + raise SomfyServiceException("No Somfy access token provided.") + + self._access_token = cast(str, token["access_token"]) + self._refresh_token = token["refresh_token"] + self._expires_in = datetime.datetime.now() + datetime.timedelta( + seconds=token["expires_in"] - 5 + ) + + return True + + async def refresh_token(self) -> None: + """ + Update the access and the refresh token. The refresh token will be valid 14 days. + """ + + if not self._refresh_token: + raise ValueError("No refresh token provided. Login method must be used.") + + # &grant_type=refresh_token&refresh_token=REFRESH_TOKEN + # Request access token + async with self.session.post( + SOMFY_API + "/oauth/oauth/v2/token", + data=FormData( + { + "grant_type": "refresh_token", + "refresh_token": self._refresh_token, + "client_id": SOMFY_CLIENT_ID, + "client_secret": SOMFY_CLIENT_SECRET, + } + ), + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + ) as response: + token = await response.json() + # { "message": "error.invalid.grant", "data": [], "uid": "xxx" } + if "message" in token and token["message"] == "error.invalid.grant": + raise SomfyBadCredentialsException(token["message"]) + + if "access_token" not in token: + raise SomfyServiceException("No Somfy access token provided.") + + self._access_token = cast(str, token["access_token"]) + self._refresh_token = token["refresh_token"] + self._expires_in = datetime.datetime.now() + datetime.timedelta( + seconds=token["expires_in"] - 5 + ) + + async def _refresh_token_if_expired(self) -> None: + """Check if token is expired and request a new one.""" + if ( + self._expires_in + and self._refresh_token + and self._expires_in <= datetime.datetime.now() + ): + await self.refresh_token() + + # TODO + # if self.event_listener_id: + # await self.register_event_listener() From a68b32528204787b77f07c83e42e88948076d7b2 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Tue, 14 Feb 2023 12:38:37 +0100 Subject: [PATCH 02/21] Move constants --- pyoverkiz/client.py | 16 ++-------------- pyoverkiz/const.py | 16 +--------------- pyoverkiz/servers/atlantic_cozytouch.py | 9 ++++++--- pyoverkiz/servers/nexity.py | 19 ++++++------------- pyoverkiz/servers/somfy.py | 5 ++++- 5 files changed, 19 insertions(+), 46 deletions(-) diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 42a68a23..3e38cb1f 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -9,19 +9,12 @@ import backoff import humps +from aiohttp import ClientSession, ServerDisconnectedError -from pyoverkiz.const import ( - SOMFY_API, - SOMFY_CLIENT_ID, - SOMFY_CLIENT_SECRET, - SUPPORTED_SERVERS, -) from pyoverkiz.exceptions import ( InvalidEventListenerIdException, NoRegisteredEventListenerException, NotAuthenticatedException, - SomfyBadCredentialsException, - SomfyServiceException, TooManyConcurrentRequestsException, TooManyExecutionsException, ) @@ -39,12 +32,7 @@ State, ) from pyoverkiz.obfuscate import obfuscate_sensitive_data -from pyoverkiz.servers.overkiz_server import ( - ClientSession, - FormData, - OverkizServer, - ServerDisconnectedError, -) +from pyoverkiz.servers.overkiz_server import OverkizServer from pyoverkiz.types import JSON diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index 48478e97..cfc6ea2d 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -1,20 +1,6 @@ from __future__ import annotations -from pyoverkiz.models import OverkizServer - -COZYTOUCH_ATLANTIC_API = "https://apis.groupe-atlantic.com" -COZYTOUCH_CLIENT_ID = ( - "Q3RfMUpWeVRtSUxYOEllZkE3YVVOQmpGblpVYToyRWNORHpfZHkzNDJVSnFvMlo3cFNKTnZVdjBh" -) - -NEXITY_API = "https://api.egn.prd.aws-nexity.fr" -NEXITY_COGNITO_CLIENT_ID = "3mca95jd5ase5lfde65rerovok" -NEXITY_COGNITO_USER_POOL = "eu-west-1_wj277ucoI" -NEXITY_COGNITO_REGION = "eu-west-1" - -SOMFY_API = "https://accounts.somfy.com" -SOMFY_CLIENT_ID = "0d8e920c-1478-11e7-a377-02dd59bd3041_1ewvaqmclfogo4kcsoo0c8k4kso884owg08sg8c40sk4go4ksg" -SOMFY_CLIENT_SECRET = "12k73w1n540g8o4cokg0cw84cog840k84cwggscwg884004kgk" +from pyoverkiz.servers.overkiz_server import OverkizServer SUPPORTED_SERVERS: dict[str, OverkizServer] = { "atlantic_cozytouch": OverkizServer( diff --git a/pyoverkiz/servers/atlantic_cozytouch.py b/pyoverkiz/servers/atlantic_cozytouch.py index bf2e02b0..246f0072 100644 --- a/pyoverkiz/servers/atlantic_cozytouch.py +++ b/pyoverkiz/servers/atlantic_cozytouch.py @@ -1,15 +1,18 @@ from __future__ import annotations - from aiohttp import FormData -from pyoverkiz.const import COZYTOUCH_ATLANTIC_API, COZYTOUCH_CLIENT_ID + from pyoverkiz.exceptions import ( CozyTouchBadCredentialsException, CozyTouchServiceException, ) - from pyoverkiz.servers.overkiz_server import OverkizServer +COZYTOUCH_ATLANTIC_API = "https://apis.groupe-atlantic.com" +COZYTOUCH_CLIENT_ID = ( + "Q3RfMUpWeVRtSUxYOEllZkE3YVVOQmpGblpVYToyRWNORHpfZHkzNDJVSnFvMlo3cFNKTnZVdjBh" +) + class AtlanticCozytouch(OverkizServer): async def login(self, username: str, password: str) -> bool: diff --git a/pyoverkiz/servers/nexity.py b/pyoverkiz/servers/nexity.py index 90518671..c2993973 100644 --- a/pyoverkiz/servers/nexity.py +++ b/pyoverkiz/servers/nexity.py @@ -1,27 +1,20 @@ from __future__ import annotations import asyncio - from typing import cast import boto3 - from botocore.config import Config from warrant_lite import WarrantLite -from pyoverkiz.const import ( - NEXITY_API, - NEXITY_COGNITO_CLIENT_ID, - NEXITY_COGNITO_REGION, - NEXITY_COGNITO_USER_POOL, -) -from pyoverkiz.exceptions import ( - NexityBadCredentialsException, - NexityServiceException, -) - +from pyoverkiz.exceptions import NexityBadCredentialsException, NexityServiceException from pyoverkiz.servers.overkiz_server import OverkizServer +NEXITY_API = "https://api.egn.prd.aws-nexity.fr" +NEXITY_COGNITO_CLIENT_ID = "3mca95jd5ase5lfde65rerovok" +NEXITY_COGNITO_USER_POOL = "eu-west-1_wj277ucoI" +NEXITY_COGNITO_REGION = "eu-west-1" + class NexityServer(OverkizServer): async def login(self, username: str, password: str) -> bool: diff --git a/pyoverkiz/servers/somfy.py b/pyoverkiz/servers/somfy.py index d689d892..e36671ab 100644 --- a/pyoverkiz/servers/somfy.py +++ b/pyoverkiz/servers/somfy.py @@ -5,11 +5,14 @@ from aiohttp import FormData -from pyoverkiz.const import SOMFY_API, SOMFY_CLIENT_ID, SOMFY_CLIENT_SECRET from pyoverkiz.exceptions import SomfyBadCredentialsException, SomfyServiceException from pyoverkiz.servers.overkiz_server import OverkizServer from pyoverkiz.types import JSON +SOMFY_API = "https://accounts.somfy.com" +SOMFY_CLIENT_ID = "0d8e920c-1478-11e7-a377-02dd59bd3041_1ewvaqmclfogo4kcsoo0c8k4kso884owg08sg8c40sk4go4ksg" +SOMFY_CLIENT_SECRET = "12k73w1n540g8o4cokg0cw84cog840k84cwggscwg884004kgk" + class SomfyServer(OverkizServer): From d3f4c6efcec77899598ef74728f0a48755407a35 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Tue, 14 Feb 2023 12:44:59 +0100 Subject: [PATCH 03/21] Fixes --- pyoverkiz/const.py | 32 ++++++++++++++++++-------------- pyoverkiz/servers/default.py | 2 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index cfc6ea2d..4c83ef2f 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -1,87 +1,91 @@ from __future__ import annotations +from pyoverkiz.servers.atlantic_cozytouch import AtlanticCozytouch +from pyoverkiz.servers.default import DefaultServer +from pyoverkiz.servers.nexity import NexityServer from pyoverkiz.servers.overkiz_server import OverkizServer +from pyoverkiz.servers.somfy import SomfyServer SUPPORTED_SERVERS: dict[str, OverkizServer] = { - "atlantic_cozytouch": OverkizServer( + "atlantic_cozytouch": AtlanticCozytouch( name="Atlantic Cozytouch", endpoint="https://ha110-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Atlantic", configuration_url=None, ), - "brandt": OverkizServer( + "brandt": DefaultServer( name="Brandt Smart Control", endpoint="https://ha3-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Brandt", configuration_url=None, ), - "flexom": OverkizServer( + "flexom": DefaultServer( name="Flexom", endpoint="https://ha108-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Bouygues", configuration_url=None, ), - "hexaom_hexaconnect": OverkizServer( + "hexaom_hexaconnect": DefaultServer( name="Hexaom HexaConnect", endpoint="https://ha5-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hexaom", configuration_url=None, ), - "hi_kumo_asia": OverkizServer( + "hi_kumo_asia": DefaultServer( name="Hitachi Hi Kumo (Asia)", endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, ), - "hi_kumo_europe": OverkizServer( + "hi_kumo_europe": DefaultServer( name="Hitachi Hi Kumo (Europe)", endpoint="https://ha117-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, ), - "hi_kumo_oceania": OverkizServer( + "hi_kumo_oceania": DefaultServer( name="Hitachi Hi Kumo (Oceania)", endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, ), - "nexity": OverkizServer( + "nexity": NexityServer( name="Nexity Eugénie", endpoint="https://ha106-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Nexity", configuration_url=None, ), - "rexel": OverkizServer( + "rexel": DefaultServer( name="Rexel Energeasy Connect", endpoint="https://ha112-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Rexel", configuration_url="https://utilisateur.energeasyconnect.com/user/#/zone/equipements", ), - "simu_livein2": OverkizServer( # alias of https://tahomalink.com + "simu_livein2": DefaultServer( # alias of https://tahomalink.com name="SIMU (LiveIn2)", endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, ), - "somfy_europe": OverkizServer( # alias of https://tahomalink.com + "somfy_europe": SomfyServer( # alias of https://tahomalink.com name="Somfy (Europe)", endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url="https://www.tahomalink.com", ), - "somfy_america": OverkizServer( + "somfy_america": DefaultServer( name="Somfy (North America)", endpoint="https://ha401-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, ), - "somfy_oceania": OverkizServer( + "somfy_oceania": DefaultServer( name="Somfy (Oceania)", endpoint="https://ha201-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, ), - "ubiwizz": OverkizServer( + "ubiwizz": DefaultServer( name="Ubiwizz", endpoint="https://ha129-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Decelect", diff --git a/pyoverkiz/servers/default.py b/pyoverkiz/servers/default.py index f18b211b..86774795 100644 --- a/pyoverkiz/servers/default.py +++ b/pyoverkiz/servers/default.py @@ -1,7 +1,7 @@ from pyoverkiz.servers.overkiz_server import OverkizServer -class NexityServer(OverkizServer): +class DefaultServer(OverkizServer): async def login(self, username: str, password: str) -> bool: """ Authenticate and create an API session allowing access to the other operations. From cc895fc3f033404e3ff39ceccd7eed7414ccb576 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Tue, 14 Feb 2023 21:51:49 +0100 Subject: [PATCH 04/21] Fix missing session object --- mypy.ini | 8 +++-- pyoverkiz/client.py | 10 ++---- pyoverkiz/const.py | 48 +++++++++++++++++-------- pyoverkiz/enums/execution.py | 6 ++-- pyoverkiz/enums/gateway.py | 4 +-- pyoverkiz/enums/general.py | 4 +-- pyoverkiz/enums/protocol.py | 2 +- pyoverkiz/enums/ui.py | 4 +-- pyoverkiz/servers/atlantic_cozytouch.py | 7 ++-- pyoverkiz/servers/nexity.py | 11 ++---- pyoverkiz/servers/overkiz_server.py | 5 ++- pyoverkiz/servers/somfy.py | 2 +- 12 files changed, 62 insertions(+), 49 deletions(-) diff --git a/mypy.ini b/mypy.ini index 13ea0941..0bb1e81d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,18 +3,22 @@ python_version = 3.8 show_error_codes = true follow_imports = silent ignore_missing_imports = true +local_partial_types = true strict_equality = true +no_implicit_optional = true warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = true +enable_error_code = ignore-without-code, redundant-self, truthy-iterable +disable_error_code = annotation-unchecked +strict_concatenate = false check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true disallow_untyped_calls = true -#disallow_untyped_decorators = true +disallow_untyped_decorators = true disallow_untyped_defs = true -no_implicit_optional = true warn_return_any = true warn_unreachable = true diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 3e38cb1f..59c40f2f 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -44,12 +44,11 @@ async def refresh_listener(invocation: Mapping[str, Any]) -> None: await invocation["args"][0].register_event_listener() -# pylint: disable=too-many-instance-attributes, too-many-branches - - class OverkizClient: """Interface class for the Overkiz API""" + # pylint: disable=too-many-instance-attributes + username: str password: str server: OverkizServer @@ -69,7 +68,6 @@ def __init__( password: str, server: OverkizServer, token: str | None = None, - session: ClientSession | None = None, ) -> None: """ Constructor @@ -90,8 +88,6 @@ def __init__( self.gateways: list[Gateway] = [] self.event_listener_id: str | None = None - self.session = session if session else ClientSession() - async def __aenter__(self) -> OverkizClient: return self @@ -108,7 +104,7 @@ async def close(self) -> None: if self.event_listener_id: await self.unregister_event_listener() - await self.session.close() + await self.server.session.close() async def login( self, diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index 4c83ef2f..e6fa08c4 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -1,94 +1,112 @@ from __future__ import annotations +from typing import Callable + +from aiohttp import ClientSession + from pyoverkiz.servers.atlantic_cozytouch import AtlanticCozytouch from pyoverkiz.servers.default import DefaultServer from pyoverkiz.servers.nexity import NexityServer from pyoverkiz.servers.overkiz_server import OverkizServer from pyoverkiz.servers.somfy import SomfyServer -SUPPORTED_SERVERS: dict[str, OverkizServer] = { - "atlantic_cozytouch": AtlanticCozytouch( +SUPPORTED_SERVERS: dict[str, Callable[[ClientSession], OverkizServer]] = { + "atlantic_cozytouch": lambda session: AtlanticCozytouch( name="Atlantic Cozytouch", endpoint="https://ha110-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Atlantic", configuration_url=None, + session=session, ), - "brandt": DefaultServer( + "brandt": lambda session: DefaultServer( name="Brandt Smart Control", endpoint="https://ha3-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Brandt", configuration_url=None, + session=session, ), - "flexom": DefaultServer( + "flexom": lambda session: DefaultServer( name="Flexom", endpoint="https://ha108-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Bouygues", configuration_url=None, + session=session, ), - "hexaom_hexaconnect": DefaultServer( + "hexaom_hexaconnect": lambda session: DefaultServer( name="Hexaom HexaConnect", endpoint="https://ha5-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hexaom", configuration_url=None, + session=session, ), - "hi_kumo_asia": DefaultServer( + "hi_kumo_asia": lambda session: DefaultServer( name="Hitachi Hi Kumo (Asia)", endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, + session=session, ), - "hi_kumo_europe": DefaultServer( + "hi_kumo_europe": lambda session: DefaultServer( name="Hitachi Hi Kumo (Europe)", endpoint="https://ha117-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, + session=session, ), - "hi_kumo_oceania": DefaultServer( + "hi_kumo_oceania": lambda session: DefaultServer( name="Hitachi Hi Kumo (Oceania)", endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, + session=session, ), - "nexity": NexityServer( + "nexity": lambda session: NexityServer( name="Nexity Eugénie", endpoint="https://ha106-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Nexity", configuration_url=None, + session=session, ), - "rexel": DefaultServer( + "rexel": lambda session: DefaultServer( name="Rexel Energeasy Connect", endpoint="https://ha112-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Rexel", configuration_url="https://utilisateur.energeasyconnect.com/user/#/zone/equipements", + session=session, ), - "simu_livein2": DefaultServer( # alias of https://tahomalink.com + "simu_livein2": lambda session: DefaultServer( # alias of https://tahomalink.com name="SIMU (LiveIn2)", endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, + session=session, ), - "somfy_europe": SomfyServer( # alias of https://tahomalink.com + "somfy_europe": lambda session: SomfyServer( # alias of https://tahomalink.com name="Somfy (Europe)", endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url="https://www.tahomalink.com", + session=session, ), - "somfy_america": DefaultServer( + "somfy_america": lambda session: DefaultServer( name="Somfy (North America)", endpoint="https://ha401-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, + session=session, ), - "somfy_oceania": DefaultServer( + "somfy_oceania": lambda session: DefaultServer( name="Somfy (Oceania)", endpoint="https://ha201-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, + session=session, ), - "ubiwizz": DefaultServer( + "ubiwizz": lambda session: DefaultServer( name="Ubiwizz", endpoint="https://ha129-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Decelect", configuration_url=None, + session=session, ), } diff --git a/pyoverkiz/enums/execution.py b/pyoverkiz/enums/execution.py index f5feb482..201c9030 100644 --- a/pyoverkiz/enums/execution.py +++ b/pyoverkiz/enums/execution.py @@ -15,7 +15,7 @@ class ExecutionType(str, Enum): RAW_TRIGGER_GATEWAY = "Raw trigger (Gateway)" @classmethod - def _missing_(cls, value): # type: ignore + def _missing_(cls, value): # type: ignore[no-untyped-def] _LOGGER.warning(f"Unsupported value {value} has been returned for {cls}") return cls.UNKNOWN @@ -33,7 +33,7 @@ class ExecutionState(str, Enum): QUEUED_SERVER_SIDE = "QUEUED_SERVER_SIDE" @classmethod - def _missing_(cls, value): # type: ignore + def _missing_(cls, value): # type: ignore[no-untyped-def] _LOGGER.warning(f"Unsupported value {value} has been returned for {cls}") return cls.UNKNOWN @@ -56,6 +56,6 @@ class ExecutionSubType(str, Enum): TIME_TRIGGER = "TIME_TRIGGER" @classmethod - def _missing_(cls, value): # type: ignore + def _missing_(cls, value): # type: ignore[no-untyped-def] _LOGGER.warning(f"Unsupported value {value} has been returned for {cls}") return cls.UNKNOWN diff --git a/pyoverkiz/enums/gateway.py b/pyoverkiz/enums/gateway.py index 41476034..c5a487ba 100644 --- a/pyoverkiz/enums/gateway.py +++ b/pyoverkiz/enums/gateway.py @@ -51,7 +51,7 @@ class GatewayType(IntEnum): TAHOMA_RAIL_DIN_S = 108 @classmethod - def _missing_(cls, value): # type: ignore + def _missing_(cls, value): # type: ignore[no-untyped-def] _LOGGER.warning(f"Unsupported value {value} has been returned for {cls}") return cls.UNKNOWN @@ -85,7 +85,7 @@ class GatewaySubType(IntEnum): # TAHOMA_BOX_C_IO = 12 That’s probably 17, but tahomalink.com says it’s 12 @classmethod - def _missing_(cls, value): # type: ignore + def _missing_(cls, value): # type: ignore[no-untyped-def] _LOGGER.warning(f"Unsupported value {value} has been returned for {cls}") return cls.UNKNOWN diff --git a/pyoverkiz/enums/general.py b/pyoverkiz/enums/general.py index 42dc4355..f36c24ec 100644 --- a/pyoverkiz/enums/general.py +++ b/pyoverkiz/enums/general.py @@ -234,7 +234,7 @@ class FailureType(IntEnum): TIME_OUT_ON_COMMAND_PROGRESS = 20003 @classmethod - def _missing_(cls, value): # type: ignore + def _missing_(cls, value): # type: ignore[no-untyped-def] _LOGGER.warning(f"Unsupported value {value} has been returned for {cls}") return cls.UNKNOWN @@ -416,6 +416,6 @@ class EventName(str, Enum): ZONE_UPDATED = "ZoneUpdatedEvent" @classmethod - def _missing_(cls, value): # type: ignore + def _missing_(cls, value): # type: ignore[no-untyped-def] _LOGGER.warning(f"Unsupported value {value} has been returned for {cls}") return cls.UNKNOWN diff --git a/pyoverkiz/enums/protocol.py b/pyoverkiz/enums/protocol.py index 40146371..3ebfbe2a 100644 --- a/pyoverkiz/enums/protocol.py +++ b/pyoverkiz/enums/protocol.py @@ -42,6 +42,6 @@ class Protocol(str, Enum): RTN = "rtn" @classmethod - def _missing_(cls, value): # type: ignore + def _missing_(cls, value): # type: ignore[no-untyped-def] _LOGGER.warning(f"Unsupported protocol {value} has been returned for {cls}") return cls.UNKNOWN diff --git a/pyoverkiz/enums/ui.py b/pyoverkiz/enums/ui.py index 0f55e7a1..f022e353 100644 --- a/pyoverkiz/enums/ui.py +++ b/pyoverkiz/enums/ui.py @@ -76,7 +76,7 @@ class UIClass(str, Enum): UNKNOWN = "unknown" @classmethod - def _missing_(cls, value): # type: ignore + def _missing_(cls, value): # type: ignore[no-untyped-def] _LOGGER.warning(f"Unsupported value {value} has been returned for {cls}") return cls.UNKNOWN @@ -408,6 +408,6 @@ class UIWidget(str, Enum): UNKNOWN = "unknown" @classmethod - def _missing_(cls, value): # type: ignore + def _missing_(cls, value): # type: ignore[no-untyped-def] _LOGGER.warning(f"Unsupported value {value} has been returned for {cls}") return cls.UNKNOWN diff --git a/pyoverkiz/servers/atlantic_cozytouch.py b/pyoverkiz/servers/atlantic_cozytouch.py index 246f0072..396b9337 100644 --- a/pyoverkiz/servers/atlantic_cozytouch.py +++ b/pyoverkiz/servers/atlantic_cozytouch.py @@ -59,9 +59,6 @@ async def login(self, username: str, password: str) -> bool: payload = {"jwt": jwt} - response = await self.post("login", data=payload) + post_response = await self.post("login", data=payload) - if response.get("success"): - return True - - return False + return "success" in post_response diff --git a/pyoverkiz/servers/nexity.py b/pyoverkiz/servers/nexity.py index c2993973..819addef 100644 --- a/pyoverkiz/servers/nexity.py +++ b/pyoverkiz/servers/nexity.py @@ -46,12 +46,10 @@ def _get_client() -> boto3.session.Session.client: except Exception as error: raise NexityBadCredentialsException() from error - id_token = tokens["AuthenticationResult"]["IdToken"] - async with self.session.get( NEXITY_API + "/deploy/api/v1/domotic/token", headers={ - "Authorization": id_token, + "Authorization": tokens["AuthenticationResult"]["IdToken"], }, ) as response: token = await response.json() @@ -64,9 +62,6 @@ def _get_client() -> boto3.session.Session.client: user_id = username.replace("@", "_-_") # Replace @ for _-_ payload = {"ssoToken": sso_token, "userId": user_id} - response = await self.server.post("login", data=payload) - - if response.get("success"): - return True + post_response = await self.post("login", data=payload) - return False + return "success" in post_response diff --git a/pyoverkiz/servers/overkiz_server.py b/pyoverkiz/servers/overkiz_server.py index 8de4a7ca..7d3e199c 100644 --- a/pyoverkiz/servers/overkiz_server.py +++ b/pyoverkiz/servers/overkiz_server.py @@ -45,7 +45,7 @@ async def login(self, username: str, password: str) -> bool: """Login to the server.""" @property - def _headers(self): + def _headers(self) -> dict[str, str]: return {} async def get(self, path: str) -> Any: @@ -84,6 +84,9 @@ async def delete(self, path: str) -> None: @staticmethod async def check_response(response: ClientResponse) -> None: """Check the response returned by the OverKiz API""" + + # pylint: disable=too-many-branches + if response.status in [200, 204]: return diff --git a/pyoverkiz/servers/somfy.py b/pyoverkiz/servers/somfy.py index e36671ab..1aac37e8 100644 --- a/pyoverkiz/servers/somfy.py +++ b/pyoverkiz/servers/somfy.py @@ -21,7 +21,7 @@ class SomfyServer(OverkizServer): _expires_in: datetime.datetime | None = None @property - def _headers(self): + def _headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self._access_token}"} async def get(self, path: str) -> Any: From b632423126d396dd886eea381a46d5a45120b8da Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Tue, 14 Feb 2023 22:35:32 +0100 Subject: [PATCH 05/21] Rework event listener logic --- pyoverkiz/client.py | 19 +++++++------------ pyoverkiz/servers/overkiz_server.py | 29 +++++++++++++++++++++++++++-- pyoverkiz/servers/somfy.py | 5 ++--- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 59c40f2f..2057089d 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -55,7 +55,6 @@ class OverkizClient: setup: Setup | None devices: list[Device] gateways: list[Gateway] - event_listener_id: str | None session: ClientSession _refresh_token: str | None = None @@ -86,7 +85,6 @@ def __init__( self.setup: Setup | None = None self.devices: list[Device] = [] self.gateways: list[Gateway] = [] - self.event_listener_id: str | None = None async def __aenter__(self) -> OverkizClient: return self @@ -101,8 +99,8 @@ async def __aexit__( async def close(self) -> None: """Close the session.""" - if self.event_listener_id: - await self.unregister_event_listener() + if self.server.event_listener_id: + await self.server.unregister_event_listener() await self.server.session.close() @@ -286,11 +284,7 @@ async def register_event_listener(self) -> str: timeout : listening sessions are expected to call the /events/{listenerId}/fetch API on a regular basis. """ - response = await self.server.post("events/register") - listener_id = cast(str, response.get("id")) - self.event_listener_id = listener_id - - return listener_id + return await self.server.register_event_listener() @backoff.on_exception(backoff.expo, TooManyConcurrentRequestsException, max_tries=5) @backoff.on_exception( @@ -309,7 +303,9 @@ async def fetch_events(self) -> list[Event]: Per-session rate-limit : 1 calls per 1 SECONDS period for this particular operation (polling) """ - response = await self.server.post(f"events/{self.event_listener_id}/fetch") + response = await self.server.post( + f"events/{self.server.event_listener_id}/fetch" + ) events = [Event(**e) for e in humps.decamelize(response)] return events @@ -319,8 +315,7 @@ async def unregister_event_listener(self) -> None: Unregister an event listener. API response status is always 200, even on unknown listener ids. """ - await self.server.post(f"events/{self.event_listener_id}/unregister") - self.event_listener_id = None + return await self.server.unregister_event_listener() @backoff.on_exception( backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin diff --git a/pyoverkiz/servers/overkiz_server.py b/pyoverkiz/servers/overkiz_server.py index 7d3e199c..17ce93cd 100644 --- a/pyoverkiz/servers/overkiz_server.py +++ b/pyoverkiz/servers/overkiz_server.py @@ -2,10 +2,11 @@ from abc import ABC, abstractmethod from json import JSONDecodeError -from typing import Any +from typing import Any, cast from aiohttp import ClientResponse, ClientSession from attr import define +from attrs import field from pyoverkiz.exceptions import ( AccessDeniedToGatewayException, @@ -37,8 +38,8 @@ class OverkizServer(ABC): endpoint: str manufacturer: str session: ClientSession - configuration_url: str | None + event_listener_id: str | None = field(default=None, init=False) @abstractmethod async def login(self, username: str, password: str) -> bool: @@ -167,3 +168,27 @@ async def check_response(response: ClientResponse) -> None: raise AccessDeniedToGatewayException(message) raise Exception(message if message else result) + + 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.post("events/register") + listener_id = cast(str, response.get("id")) + self.event_listener_id = listener_id + + return listener_id + + 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/{self.event_listener_id}/unregister") + self.event_listener_id = None diff --git a/pyoverkiz/servers/somfy.py b/pyoverkiz/servers/somfy.py index 1aac37e8..1d81de47 100644 --- a/pyoverkiz/servers/somfy.py +++ b/pyoverkiz/servers/somfy.py @@ -128,6 +128,5 @@ async def _refresh_token_if_expired(self) -> None: ): await self.refresh_token() - # TODO - # if self.event_listener_id: - # await self.register_event_listener() + if self.event_listener_id: + await self.register_event_listener() From 835e6bbb849a31991d64a82538b5e15de19de67a Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Tue, 14 Feb 2023 22:36:21 +0100 Subject: [PATCH 06/21] Update example --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3033de79..6405512e 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,17 @@ import time from pyoverkiz.const import SUPPORTED_SERVERS from pyoverkiz.client import OverkizClient +from aiohttp import ClientSession USERNAME = "" PASSWORD = "" + async def main() -> None: - async with OverkizClient(USERNAME, PASSWORD, server=SUPPORTED_SERVERS["somfy_europe"]) as client: + + session = ClientSession() + server = SUPPORTED_SERVERS["somfy_europe"](session) + async with OverkizClient(USERNAME, PASSWORD, server=server) as client: try: await client.login() except Exception as exception: # pylint: disable=broad-except @@ -63,6 +68,8 @@ async def main() -> None: time.sleep(2) + +asyncio.run(main()) asyncio.run(main()) ``` From f5b01285766089580550cf01d8c501cd5ecf4539 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Wed, 15 Feb 2023 08:36:37 +0100 Subject: [PATCH 07/21] Remove unused variables --- pyoverkiz/client.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py index 2057089d..320989d9 100644 --- a/pyoverkiz/client.py +++ b/pyoverkiz/client.py @@ -1,7 +1,6 @@ """ Python wrapper for the OverKiz API """ from __future__ import annotations -import datetime import urllib.parse from collections.abc import Mapping from types import TracebackType @@ -9,7 +8,7 @@ import backoff import humps -from aiohttp import ClientSession, ServerDisconnectedError +from aiohttp import ServerDisconnectedError from pyoverkiz.exceptions import ( InvalidEventListenerIdException, @@ -55,18 +54,12 @@ class OverkizClient: setup: Setup | None devices: list[Device] gateways: list[Gateway] - session: ClientSession - - _refresh_token: str | None = None - _expires_in: datetime.datetime | None = None - _access_token: str | None = None def __init__( self, username: str, password: str, server: OverkizServer, - token: str | None = None, ) -> None: """ Constructor @@ -74,13 +67,11 @@ def __init__( :param username: the username :param password: the password :param server: OverkizServer - :param session: optional ClientSession """ self.username = username self.password = password self.server = server - self._access_token = token self.setup: Setup | None = None self.devices: list[Device] = [] From fb85fc9ef50e0506b0a5a8bdc7e21aca49a048ee Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Wed, 15 Feb 2023 09:55:34 +0100 Subject: [PATCH 08/21] Move all commands to OverkizServer --- README.md | 6 +- pyoverkiz/client.py | 493 ------------------------ pyoverkiz/servers/atlantic_cozytouch.py | 2 +- pyoverkiz/servers/default.py | 10 +- pyoverkiz/servers/nexity.py | 2 +- pyoverkiz/servers/overkiz_server.py | 489 +++++++++++++++++++++-- pyoverkiz/servers/somfy.py | 2 +- tests/test_client.py | 3 +- 8 files changed, 469 insertions(+), 538 deletions(-) delete mode 100644 pyoverkiz/client.py diff --git a/README.md b/README.md index 6405512e..d5c256bf 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,6 @@ import asyncio import time from pyoverkiz.const import SUPPORTED_SERVERS -from pyoverkiz.client import OverkizClient from aiohttp import ClientSession USERNAME = "" @@ -49,9 +48,9 @@ async def main() -> None: session = ClientSession() server = SUPPORTED_SERVERS["somfy_europe"](session) - async with OverkizClient(USERNAME, PASSWORD, server=server) as client: + async with server as client: try: - await client.login() + await client.login(USERNAME, PASSWORD) except Exception as exception: # pylint: disable=broad-except print(exception) return @@ -69,7 +68,6 @@ async def main() -> None: time.sleep(2) -asyncio.run(main()) asyncio.run(main()) ``` diff --git a/pyoverkiz/client.py b/pyoverkiz/client.py deleted file mode 100644 index 320989d9..00000000 --- a/pyoverkiz/client.py +++ /dev/null @@ -1,493 +0,0 @@ -""" Python wrapper for the OverKiz API """ -from __future__ import annotations - -import urllib.parse -from collections.abc import Mapping -from types import TracebackType -from typing import Any, cast - -import backoff -import humps -from aiohttp import ServerDisconnectedError - -from pyoverkiz.exceptions import ( - InvalidEventListenerIdException, - NoRegisteredEventListenerException, - NotAuthenticatedException, - TooManyConcurrentRequestsException, - TooManyExecutionsException, -) -from pyoverkiz.models import ( - Command, - Device, - Event, - Execution, - Gateway, - HistoryExecution, - LocalToken, - Place, - Scenario, - Setup, - State, -) -from pyoverkiz.obfuscate import obfuscate_sensitive_data -from pyoverkiz.servers.overkiz_server import OverkizServer -from pyoverkiz.types import JSON - - -async def relogin(invocation: Mapping[str, Any]) -> None: - await invocation["args"][0].login() - - -async def refresh_listener(invocation: Mapping[str, Any]) -> None: - await invocation["args"][0].register_event_listener() - - -class OverkizClient: - """Interface class for the Overkiz API""" - - # pylint: disable=too-many-instance-attributes - - username: str - password: str - server: OverkizServer - setup: Setup | None - devices: list[Device] - gateways: list[Gateway] - - def __init__( - self, - username: str, - password: str, - server: OverkizServer, - ) -> None: - """ - Constructor - - :param username: the username - :param password: the password - :param server: OverkizServer - """ - - self.username = username - self.password = password - self.server = server - - self.setup: Setup | None = None - self.devices: list[Device] = [] - self.gateways: list[Gateway] = [] - - async def __aenter__(self) -> OverkizClient: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - await self.close() - - async def close(self) -> None: - """Close the session.""" - if self.server.event_listener_id: - await self.server.unregister_event_listener() - - await self.server.session.close() - - async def login( - self, - register_event_listener: bool | None = 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] - """ - if await self.server.login(self.username, self.password): - if register_event_listener: - await self.register_event_listener() - return False - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def get_setup(self, refresh: bool = False) -> Setup: - """ - Get all data about the connected user setup - -> gateways data (serial number, activation state, ...): - -> setup location: - -> house places (rooms and floors): - -> setup devices: - - A gateway may be in different modes (mode) regarding to the activated functions (functions). - A house may be composed of several floors and rooms. The house, floors and rooms are viewed as a place. - Devices in the house are grouped by type called uiClass. Each device has an associated widget. - The widget is used to control or to know the device state, whatever the device protocol (controllable): IO, RTS, X10, ... . - A device can be either an actuator (type=1) or a sensor (type=2). - Data of one or several devices can be also get by setting the device(s) url as request parameter. - - Per-session rate-limit : 1 calls per 1d period for this particular operation (bulk-load) - """ - if self.setup and not refresh: - return self.setup - - response = await self.server.get("setup") - - setup = Setup(**humps.decamelize(response)) - - # Cache response - self.setup = setup - self.gateways = setup.gateways - self.devices = setup.devices - - return setup - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def get_diagnostic_data(self) -> JSON: - """ - Get all data about the connected user setup - -> gateways data (serial number, activation state, ...): - -> setup location: - -> house places (rooms and floors): - -> setup devices: - - This data will be masked to not return any confidential or PII data. - """ - response = await self.server.get("setup") - - return obfuscate_sensitive_data(response) - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def get_devices(self, refresh: bool = False) -> list[Device]: - """ - List devices - Per-session rate-limit : 1 calls per 1d period for this particular operation (bulk-load) - """ - if self.devices and not refresh: - return self.devices - - response = await self.server.get("setup/devices") - devices = [Device(**d) for d in humps.decamelize(response)] - - # Cache response - self.devices = devices - if self.setup: - self.setup.devices = devices - - return devices - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def get_gateways(self, refresh: bool = False) -> list[Gateway]: - """ - Get every gateways of a connected user setup - Per-session rate-limit : 1 calls per 1d period for this particular operation (bulk-load) - """ - if self.gateways and not refresh: - return self.gateways - - response = await self.server.get("setup/gateways") - gateways = [Gateway(**g) for g in humps.decamelize(response)] - - # Cache response - self.gateways = gateways - if self.setup: - self.setup.gateways = gateways - - return gateways - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def get_execution_history(self) -> list[HistoryExecution]: - """ - List execution history - """ - response = await self.server.get("history/executions") - execution_history = [HistoryExecution(**h) for h in humps.decamelize(response)] - - return execution_history - - @backoff.on_exception( - backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin - ) - async def get_device_definition(self, deviceurl: str) -> JSON | None: - """ - Retrieve a particular setup device definition - """ - response: dict = await self.server.get( - f"setup/devices/{urllib.parse.quote_plus(deviceurl)}" - ) - - return response.get("definition") - - @backoff.on_exception( - backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin - ) - async def get_state(self, deviceurl: str) -> list[State]: - """ - Retrieve states of requested device - """ - response = await self.server.get( - f"setup/devices/{urllib.parse.quote_plus(deviceurl)}/states" - ) - state = [State(**s) for s in humps.decamelize(response)] - - return state - - @backoff.on_exception( - backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin - ) - async def refresh_states(self) -> None: - """ - Ask the box to refresh all devices states for protocols supporting that operation - """ - await self.server.post("setup/devices/states/refresh") - - @backoff.on_exception(backoff.expo, TooManyConcurrentRequestsException, max_tries=5) - 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. - """ - return await self.server.register_event_listener() - - @backoff.on_exception(backoff.expo, TooManyConcurrentRequestsException, max_tries=5) - @backoff.on_exception( - backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin - ) - @backoff.on_exception( - backoff.expo, - (InvalidEventListenerIdException, NoRegisteredEventListenerException), - max_tries=2, - on_backoff=refresh_listener, - ) - async def fetch_events(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.server.post( - f"events/{self.server.event_listener_id}/fetch" - ) - events = [Event(**e) for e in humps.decamelize(response)] - - return events - - async def unregister_event_listener(self) -> None: - """ - Unregister an event listener. - API response status is always 200, even on unknown listener ids. - """ - return await self.server.unregister_event_listener() - - @backoff.on_exception( - backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin - ) - async def get_current_execution(self, exec_id: str) -> Execution: - """Get an action group execution currently running""" - response = await self.server.get(f"exec/current/{exec_id}") - execution = Execution(**humps.decamelize(response)) - - return execution - - @backoff.on_exception( - backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin - ) - async def get_current_executions(self) -> list[Execution]: - """Get all action groups executions currently running""" - response = await self.server.get("exec/current") - executions = [Execution(**e) for e in humps.decamelize(response)] - - return executions - - @backoff.on_exception( - backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin - ) - async def get_api_version(self) -> str: - """Get the API version (local only)""" - response = await self.server.get("apiVersion") - - return cast(str, response["protocolVersion"]) - - @backoff.on_exception(backoff.expo, TooManyExecutionsException, max_tries=10) - @backoff.on_exception( - backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin - ) - async def execute_command( - self, - device_url: str, - command: Command | str, - label: str | None = "python-overkiz-api", - ) -> str: - """Send a command""" - if isinstance(command, str): - command = Command(command) - - response: str = await self.execute_commands(device_url, [command], label) - - return response - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def cancel_command(self, exec_id: str) -> None: - """Cancel a running setup-level execution""" - await self.server.delete(f"/exec/current/setup/{exec_id}") - - @backoff.on_exception( - backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin - ) - async def execute_commands( - self, - device_url: str, - commands: list[Command], - label: str | None = "python-overkiz-api", - ) -> str: - """Send several commands in one call""" - payload = { - "label": label, - "actions": [{"deviceURL": device_url, "commands": commands}], - } - response: dict = await self.server.post("exec/apply", payload) - return cast(str, response["execId"]) - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def get_scenarios(self) -> list[Scenario]: - """List the scenarios""" - response = await self.server.get("actionGroups") - return [Scenario(**scenario) for scenario in response] - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def get_places(self) -> Place: - """List the places""" - response = await self.server.get("setup/places") - places = Place(**humps.decamelize(response)) - return places - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def generate_local_token(self, gateway_id: str) -> str: - """ - Generates a new token - Access scope : Full enduser API access (enduser/*) - """ - response = await self.server.get(f"config/{gateway_id}/local/tokens/generate") - - return cast(str, response["token"]) - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def activate_local_token( - self, gateway_id: str, token: str, label: str, scope: str = "devmode" - ) -> str: - """ - Create a token - Access scope : Full enduser API access (enduser/*) - """ - response = await self.server.post( - f"config/{gateway_id}/local/tokens", - {"label": label, "token": token, "scope": scope}, - ) - - return cast(str, response["requestId"]) - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def get_local_tokens( - self, gateway_id: str, scope: str = "devmode" - ) -> list[LocalToken]: - """ - Get all gateway tokens with the given scope - Access scope : Full enduser API access (enduser/*) - """ - response = await self.server.get(f"config/{gateway_id}/local/tokens/{scope}") - local_tokens = [LocalToken(**lt) for lt in humps.decamelize(response)] - - return local_tokens - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def delete_local_token(self, gateway_id: str, uuid: str) -> bool: - """ - Delete a token - Access scope : Full enduser API access (enduser/*) - """ - await self.server.delete(f"config/{gateway_id}/local/tokens/{uuid}") - - return True - - @backoff.on_exception( - backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin - ) - async def execute_scenario(self, oid: str) -> str: - """Execute a scenario""" - response = await self.server.post(f"exec/{oid}") - return cast(str, response["execId"]) - - @backoff.on_exception( - backoff.expo, - (NotAuthenticatedException, ServerDisconnectedError), - max_tries=2, - on_backoff=relogin, - ) - async def execute_scheduled_scenario(self, oid: str, timestamp: int) -> str: - """Execute a scheduled scenario""" - response = await self.server.post(f"exec/schedule/{oid}/{timestamp}") - return cast(str, response["triggerId"]) diff --git a/pyoverkiz/servers/atlantic_cozytouch.py b/pyoverkiz/servers/atlantic_cozytouch.py index 396b9337..c37557c9 100644 --- a/pyoverkiz/servers/atlantic_cozytouch.py +++ b/pyoverkiz/servers/atlantic_cozytouch.py @@ -15,7 +15,7 @@ class AtlanticCozytouch(OverkizServer): - async def login(self, username: str, password: str) -> bool: + async def _login(self, username: str, password: str) -> bool: """ Authenticate and create an API session allowing access to the other operations. Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] diff --git a/pyoverkiz/servers/default.py b/pyoverkiz/servers/default.py index 86774795..e585a648 100644 --- a/pyoverkiz/servers/default.py +++ b/pyoverkiz/servers/default.py @@ -2,17 +2,11 @@ class DefaultServer(OverkizServer): - async def login(self, username: str, password: str) -> bool: + async def _login(self, username: str, password: str) -> 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": username, "userPassword": password} - response = await self.post("login", data=payload) - - if response.get("success"): - return True - - return False + return "success" in response diff --git a/pyoverkiz/servers/nexity.py b/pyoverkiz/servers/nexity.py index 819addef..73e80c27 100644 --- a/pyoverkiz/servers/nexity.py +++ b/pyoverkiz/servers/nexity.py @@ -17,7 +17,7 @@ class NexityServer(OverkizServer): - async def login(self, username: str, password: str) -> bool: + async def _login(self, username: str, password: str) -> bool: """ Authenticate and create an API session allowing access to the other operations. Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] diff --git a/pyoverkiz/servers/overkiz_server.py b/pyoverkiz/servers/overkiz_server.py index 17ce93cd..207ca3b6 100644 --- a/pyoverkiz/servers/overkiz_server.py +++ b/pyoverkiz/servers/overkiz_server.py @@ -1,10 +1,16 @@ +""" Python wrapper for the OverKiz API """ + from __future__ import annotations +import urllib.parse from abc import ABC, abstractmethod from json import JSONDecodeError -from typing import Any, cast +from types import TracebackType +from typing import Any, Mapping, cast -from aiohttp import ClientResponse, ClientSession +import backoff +import humps +from aiohttp import ClientResponse, ClientSession, ServerDisconnectedError from attr import define from attrs import field @@ -27,24 +33,70 @@ UnknownObjectException, UnknownUserException, ) +from pyoverkiz.models import ( + Command, + Device, + Event, + Execution, + Gateway, + HistoryExecution, + LocalToken, + Place, + Scenario, + Setup, + State, +) +from pyoverkiz.obfuscate import obfuscate_sensitive_data from pyoverkiz.types import JSON +async def relogin(invocation: Mapping[str, Any]) -> None: + await invocation["args"][0].login() + + +async def refresh_listener(invocation: Mapping[str, Any]) -> None: + await invocation["args"][0].register_event_listener() + + @define(kw_only=True) class OverkizServer(ABC): - """Class to describe an Overkiz server.""" + """Interface class for the Overkiz API""" + # username: str + # password: str = field(repr=lambda _: "***") name: str endpoint: str manufacturer: str session: ClientSession configuration_url: str | None event_listener_id: str | None = field(default=None, init=False) + setup: Setup | None = field(default=None, init=False) + devices: list[Device] = field(factory=list, init=False) + gateways: list[Gateway] = field(factory=list, init=False) + + # TODO: Add support for registering event listener + # @abstractmethod - async def login(self, username: str, password: str) -> bool: + async def _login( + self, + username: str, + password: str, + ) -> bool: """Login to the server.""" + async def login( + self, username: str, password: str, register_event_listener: 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] + """ + if await self._login(username, password): + if register_event_listener: + await self.register_event_listener() + return False + @property def _headers(self) -> dict[str, str]: return {} @@ -82,6 +134,411 @@ async def delete(self, path: str) -> None: ) as response: await self.check_response(response) + async def __aenter__(self) -> OverkizServer: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.close() + + async def close(self) -> None: + """Close the session.""" + if self.event_listener_id: + await self.unregister_event_listener() + + await self.session.close() + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def get_setup(self, refresh: bool = False) -> Setup: + """ + Get all data about the connected user setup + -> gateways data (serial number, activation state, ...): + -> setup location: + -> house places (rooms and floors): + -> setup devices: + + A gateway may be in different modes (mode) regarding to the activated functions (functions). + A house may be composed of several floors and rooms. The house, floors and rooms are viewed as a place. + Devices in the house are grouped by type called uiClass. Each device has an associated widget. + The widget is used to control or to know the device state, whatever the device protocol (controllable): IO, RTS, X10, ... . + A device can be either an actuator (type=1) or a sensor (type=2). + Data of one or several devices can be also get by setting the device(s) url as request parameter. + + Per-session rate-limit : 1 calls per 1d period for this particular operation (bulk-load) + """ + if self.setup and not refresh: + return self.setup + + response = await self.get("setup") + + setup = Setup(**humps.decamelize(response)) + + # Cache response + self.setup = setup + self.gateways = setup.gateways + self.devices = setup.devices + + return setup + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def get_diagnostic_data(self) -> JSON: + """ + Get all data about the connected user setup + -> gateways data (serial number, activation state, ...): + -> setup location: + -> house places (rooms and floors): + -> setup devices: + + This data will be masked to not return any confidential or PII data. + """ + response = await self.get("setup") + + return obfuscate_sensitive_data(response) + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def get_devices(self, refresh: bool = False) -> list[Device]: + """ + List devices + Per-session rate-limit : 1 calls per 1d period for this particular operation (bulk-load) + """ + if self.devices and not refresh: + return self.devices + + response = await self.get("setup/devices") + devices = [Device(**d) for d in humps.decamelize(response)] + + # Cache response + self.devices = devices + if self.setup: + self.setup.devices = devices + + return devices + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def get_gateways(self, refresh: bool = False) -> list[Gateway]: + """ + Get every gateways of a connected user setup + Per-session rate-limit : 1 calls per 1d period for this particular operation (bulk-load) + """ + if self.gateways and not refresh: + return self.gateways + + response = await self.get("setup/gateways") + gateways = [Gateway(**g) for g in humps.decamelize(response)] + + # Cache response + self.gateways = gateways + if self.setup: + self.setup.gateways = gateways + + return gateways + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def get_execution_history(self) -> list[HistoryExecution]: + """ + List execution history + """ + response = await self.get("history/executions") + execution_history = [HistoryExecution(**h) for h in humps.decamelize(response)] + + return execution_history + + @backoff.on_exception( + backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin + ) + async def get_device_definition(self, deviceurl: str) -> JSON | None: + """ + Retrieve a particular setup device definition + """ + response: dict = await self.get( + f"setup/devices/{urllib.parse.quote_plus(deviceurl)}" + ) + + return response.get("definition") + + @backoff.on_exception( + backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin + ) + async def get_state(self, deviceurl: str) -> list[State]: + """ + Retrieve states of requested device + """ + response = await self.get( + f"setup/devices/{urllib.parse.quote_plus(deviceurl)}/states" + ) + state = [State(**s) for s in humps.decamelize(response)] + + return state + + @backoff.on_exception( + backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin + ) + async def refresh_states(self) -> None: + """ + Ask the box to refresh all devices states for protocols supporting that operation + """ + await self.post("setup/devices/states/refresh") + + @backoff.on_exception(backoff.expo, TooManyConcurrentRequestsException, max_tries=5) + @backoff.on_exception( + backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin + ) + @backoff.on_exception( + backoff.expo, + (InvalidEventListenerIdException, NoRegisteredEventListenerException), + max_tries=2, + on_backoff=refresh_listener, + ) + async def fetch_events(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/{self.event_listener_id}/fetch") + events = [Event(**e) for e in humps.decamelize(response)] + + return events + + @backoff.on_exception(backoff.expo, TooManyConcurrentRequestsException, max_tries=5) + 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.post("events/register") + listener_id = cast(str, response.get("id")) + self.event_listener_id = listener_id + + return listener_id + + 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/{self.event_listener_id}/unregister") + self.event_listener_id = None + + @backoff.on_exception( + backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin + ) + 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}") + execution = Execution(**humps.decamelize(response)) + + return execution + + @backoff.on_exception( + backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin + ) + async def get_current_executions(self) -> list[Execution]: + """Get all action groups executions currently running""" + response = await self.get("exec/current") + executions = [Execution(**e) for e in humps.decamelize(response)] + + return executions + + @backoff.on_exception( + backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin + ) + async def get_api_version(self) -> str: + """Get the API version (local only)""" + response = await self.get("apiVersion") + + return cast(str, response["protocolVersion"]) + + @backoff.on_exception(backoff.expo, TooManyExecutionsException, max_tries=10) + @backoff.on_exception( + backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin + ) + async def execute_command( + self, + device_url: str, + command: Command | str, + label: str | None = "python-overkiz-api", + ) -> str: + """Send a command""" + if isinstance(command, str): + command = Command(command) + + response: str = await self.execute_commands(device_url, [command], label) + + return response + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def cancel_command(self, exec_id: str) -> None: + """Cancel a running setup-level execution""" + await self.delete(f"/exec/current/setup/{exec_id}") + + @backoff.on_exception( + backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin + ) + async def execute_commands( + self, + device_url: str, + commands: list[Command], + label: str | None = "python-overkiz-api", + ) -> str: + """Send several commands in one call""" + payload = { + "label": label, + "actions": [{"deviceURL": device_url, "commands": commands}], + } + response: dict = await self.post("exec/apply", payload) + return cast(str, response["execId"]) + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def get_scenarios(self) -> list[Scenario]: + """List the scenarios""" + response = await self.get("actionGroups") + return [Scenario(**scenario) for scenario in response] + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def get_places(self) -> Place: + """List the places""" + response = await self.get("setup/places") + places = Place(**humps.decamelize(response)) + return places + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def generate_local_token(self, gateway_id: str) -> str: + """ + Generates a new token + Access scope : Full enduser API access (enduser/*) + """ + response = await self.get(f"config/{gateway_id}/local/tokens/generate") + + return cast(str, response["token"]) + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def activate_local_token( + self, gateway_id: str, token: str, label: str, scope: str = "devmode" + ) -> str: + """ + Create a token + Access scope : Full enduser API access (enduser/*) + """ + response = await self.post( + f"config/{gateway_id}/local/tokens", + {"label": label, "token": token, "scope": scope}, + ) + + return cast(str, response["requestId"]) + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def get_local_tokens( + self, gateway_id: str, scope: str = "devmode" + ) -> list[LocalToken]: + """ + Get all gateway tokens with the given scope + Access scope : Full enduser API access (enduser/*) + """ + response = await self.get(f"config/{gateway_id}/local/tokens/{scope}") + local_tokens = [LocalToken(**lt) for lt in humps.decamelize(response)] + + return local_tokens + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def delete_local_token(self, gateway_id: str, uuid: str) -> bool: + """ + Delete a token + Access scope : Full enduser API access (enduser/*) + """ + await self.delete(f"config/{gateway_id}/local/tokens/{uuid}") + + return True + + @backoff.on_exception( + backoff.expo, NotAuthenticatedException, max_tries=2, on_backoff=relogin + ) + async def execute_scenario(self, oid: str) -> str: + """Execute a scenario""" + response = await self.post(f"exec/{oid}") + return cast(str, response["execId"]) + + @backoff.on_exception( + backoff.expo, + (NotAuthenticatedException, ServerDisconnectedError), + max_tries=2, + on_backoff=relogin, + ) + async def execute_scheduled_scenario(self, oid: str, timestamp: int) -> str: + """Execute a scheduled scenario""" + response = await self.post(f"exec/schedule/{oid}/{timestamp}") + return cast(str, response["triggerId"]) + @staticmethod async def check_response(response: ClientResponse) -> None: """Check the response returned by the OverKiz API""" @@ -168,27 +625,3 @@ async def check_response(response: ClientResponse) -> None: raise AccessDeniedToGatewayException(message) raise Exception(message if message else result) - - 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.post("events/register") - listener_id = cast(str, response.get("id")) - self.event_listener_id = listener_id - - return listener_id - - 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/{self.event_listener_id}/unregister") - self.event_listener_id = None diff --git a/pyoverkiz/servers/somfy.py b/pyoverkiz/servers/somfy.py index 1d81de47..4a0e2b72 100644 --- a/pyoverkiz/servers/somfy.py +++ b/pyoverkiz/servers/somfy.py @@ -43,7 +43,7 @@ async def delete(self, path: str) -> None: await self._refresh_token_if_expired() return await super().delete(path) - async def login(self, username: str, password: str) -> bool: + async def _login(self, username: str, password: str) -> bool: """ Authenticate and create an API session allowing access to the other operations. Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] diff --git a/tests/test_client.py b/tests/test_client.py index db999a85..5ecb8597 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,7 +6,6 @@ import pytest from pytest import fixture -from pyoverkiz.client import OverkizClient from pyoverkiz.const import SUPPORTED_SERVERS from pyoverkiz.enums import DataType @@ -16,7 +15,7 @@ class TestOverkizClient: @fixture def client(self): - return OverkizClient("username", "password", SUPPORTED_SERVERS["somfy_europe"]) + return SUPPORTED_SERVERS["somfy_europe"](aiohttp.ClientSession()) @pytest.mark.asyncio async def test_get_devices_basic(self, client): From 812ce78145af806220bff2dba15407cf16693c03 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Wed, 15 Feb 2023 10:04:58 +0100 Subject: [PATCH 09/21] Rename server as client --- README.md | 5 ++- .../atlantic_cozytouch.py | 4 +- pyoverkiz/{servers => clients}/default.py | 4 +- pyoverkiz/{servers => clients}/nexity.py | 4 +- .../overkiz_server.py => clients/overkiz.py} | 8 ++-- pyoverkiz/{servers => clients}/somfy.py | 4 +- pyoverkiz/const.py | 40 +++++++++---------- 7 files changed, 35 insertions(+), 34 deletions(-) rename pyoverkiz/{servers => clients}/atlantic_cozytouch.py (95%) rename pyoverkiz/{servers => clients}/default.py (81%) rename pyoverkiz/{servers => clients}/nexity.py (95%) rename pyoverkiz/{servers/overkiz_server.py => clients/overkiz.py} (99%) rename pyoverkiz/{servers => clients}/somfy.py (98%) diff --git a/README.md b/README.md index d5c256bf..e7c7dd4f 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ PASSWORD = "" async def main() -> None: session = ClientSession() - server = SUPPORTED_SERVERS["somfy_europe"](session) - async with server as client: + client = SUPPORTED_SERVERS["somfy_europe"](session) + async with client as client: try: await client.login(USERNAME, PASSWORD) except Exception as exception: # pylint: disable=broad-except @@ -69,6 +69,7 @@ async def main() -> None: asyncio.run(main()) + ``` ## Development diff --git a/pyoverkiz/servers/atlantic_cozytouch.py b/pyoverkiz/clients/atlantic_cozytouch.py similarity index 95% rename from pyoverkiz/servers/atlantic_cozytouch.py rename to pyoverkiz/clients/atlantic_cozytouch.py index c37557c9..65a26cce 100644 --- a/pyoverkiz/servers/atlantic_cozytouch.py +++ b/pyoverkiz/clients/atlantic_cozytouch.py @@ -2,11 +2,11 @@ from aiohttp import FormData +from pyoverkiz.clients.overkiz import OverkizClient from pyoverkiz.exceptions import ( CozyTouchBadCredentialsException, CozyTouchServiceException, ) -from pyoverkiz.servers.overkiz_server import OverkizServer COZYTOUCH_ATLANTIC_API = "https://apis.groupe-atlantic.com" COZYTOUCH_CLIENT_ID = ( @@ -14,7 +14,7 @@ ) -class AtlanticCozytouch(OverkizServer): +class AtlanticCozytouchClient(OverkizClient): async def _login(self, username: str, password: str) -> bool: """ Authenticate and create an API session allowing access to the other operations. diff --git a/pyoverkiz/servers/default.py b/pyoverkiz/clients/default.py similarity index 81% rename from pyoverkiz/servers/default.py rename to pyoverkiz/clients/default.py index e585a648..9827f3e7 100644 --- a/pyoverkiz/servers/default.py +++ b/pyoverkiz/clients/default.py @@ -1,7 +1,7 @@ -from pyoverkiz.servers.overkiz_server import OverkizServer +from pyoverkiz.clients.overkiz import OverkizClient -class DefaultServer(OverkizServer): +class DefaultClient(OverkizClient): async def _login(self, username: str, password: str) -> bool: """ Authenticate and create an API session allowing access to the other operations. diff --git a/pyoverkiz/servers/nexity.py b/pyoverkiz/clients/nexity.py similarity index 95% rename from pyoverkiz/servers/nexity.py rename to pyoverkiz/clients/nexity.py index 73e80c27..48d1ac5c 100644 --- a/pyoverkiz/servers/nexity.py +++ b/pyoverkiz/clients/nexity.py @@ -7,8 +7,8 @@ from botocore.config import Config from warrant_lite import WarrantLite +from pyoverkiz.clients.overkiz import OverkizClient from pyoverkiz.exceptions import NexityBadCredentialsException, NexityServiceException -from pyoverkiz.servers.overkiz_server import OverkizServer NEXITY_API = "https://api.egn.prd.aws-nexity.fr" NEXITY_COGNITO_CLIENT_ID = "3mca95jd5ase5lfde65rerovok" @@ -16,7 +16,7 @@ NEXITY_COGNITO_REGION = "eu-west-1" -class NexityServer(OverkizServer): +class NexityClient(OverkizClient): async def _login(self, username: str, password: str) -> bool: """ Authenticate and create an API session allowing access to the other operations. diff --git a/pyoverkiz/servers/overkiz_server.py b/pyoverkiz/clients/overkiz.py similarity index 99% rename from pyoverkiz/servers/overkiz_server.py rename to pyoverkiz/clients/overkiz.py index 207ca3b6..e0c518bc 100644 --- a/pyoverkiz/servers/overkiz_server.py +++ b/pyoverkiz/clients/overkiz.py @@ -1,4 +1,4 @@ -""" Python wrapper for the OverKiz API """ +""" Python wrapper for the OverKiz API""" from __future__ import annotations @@ -59,8 +59,8 @@ async def refresh_listener(invocation: Mapping[str, Any]) -> None: @define(kw_only=True) -class OverkizServer(ABC): - """Interface class for the Overkiz API""" +class OverkizClient(ABC): + """Abstract class for the Overkiz API""" # username: str # password: str = field(repr=lambda _: "***") @@ -134,7 +134,7 @@ async def delete(self, path: str) -> None: ) as response: await self.check_response(response) - async def __aenter__(self) -> OverkizServer: + async def __aenter__(self) -> OverkizClient: return self async def __aexit__( diff --git a/pyoverkiz/servers/somfy.py b/pyoverkiz/clients/somfy.py similarity index 98% rename from pyoverkiz/servers/somfy.py rename to pyoverkiz/clients/somfy.py index 4a0e2b72..7bceba97 100644 --- a/pyoverkiz/servers/somfy.py +++ b/pyoverkiz/clients/somfy.py @@ -5,8 +5,8 @@ from aiohttp import FormData +from pyoverkiz.clients.overkiz import OverkizClient from pyoverkiz.exceptions import SomfyBadCredentialsException, SomfyServiceException -from pyoverkiz.servers.overkiz_server import OverkizServer from pyoverkiz.types import JSON SOMFY_API = "https://accounts.somfy.com" @@ -14,7 +14,7 @@ SOMFY_CLIENT_SECRET = "12k73w1n540g8o4cokg0cw84cog840k84cwggscwg884004kgk" -class SomfyServer(OverkizServer): +class SomfyClient(OverkizClient): _access_token: str | None = None _refresh_token: str | None = None diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index e6fa08c4..f635187b 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -4,105 +4,105 @@ from aiohttp import ClientSession -from pyoverkiz.servers.atlantic_cozytouch import AtlanticCozytouch -from pyoverkiz.servers.default import DefaultServer -from pyoverkiz.servers.nexity import NexityServer -from pyoverkiz.servers.overkiz_server import OverkizServer -from pyoverkiz.servers.somfy import SomfyServer +from pyoverkiz.clients.atlantic_cozytouch import AtlanticCozytouchClient +from pyoverkiz.clients.default import DefaultClient +from pyoverkiz.clients.nexity import NexityClient +from pyoverkiz.clients.overkiz import OverkizClient +from pyoverkiz.clients.somfy import SomfyClient -SUPPORTED_SERVERS: dict[str, Callable[[ClientSession], OverkizServer]] = { - "atlantic_cozytouch": lambda session: AtlanticCozytouch( +SUPPORTED_SERVERS: dict[str, Callable[[ClientSession], OverkizClient]] = { + "atlantic_cozytouch": lambda session: AtlanticCozytouchClient( name="Atlantic Cozytouch", endpoint="https://ha110-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Atlantic", configuration_url=None, session=session, ), - "brandt": lambda session: DefaultServer( + "brandt": lambda session: DefaultClient( name="Brandt Smart Control", endpoint="https://ha3-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Brandt", configuration_url=None, session=session, ), - "flexom": lambda session: DefaultServer( + "flexom": lambda session: DefaultClient( name="Flexom", endpoint="https://ha108-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Bouygues", configuration_url=None, session=session, ), - "hexaom_hexaconnect": lambda session: DefaultServer( + "hexaom_hexaconnect": lambda session: DefaultClient( name="Hexaom HexaConnect", endpoint="https://ha5-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hexaom", configuration_url=None, session=session, ), - "hi_kumo_asia": lambda session: DefaultServer( + "hi_kumo_asia": lambda session: DefaultClient( name="Hitachi Hi Kumo (Asia)", endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, session=session, ), - "hi_kumo_europe": lambda session: DefaultServer( + "hi_kumo_europe": lambda session: DefaultClient( name="Hitachi Hi Kumo (Europe)", endpoint="https://ha117-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, session=session, ), - "hi_kumo_oceania": lambda session: DefaultServer( + "hi_kumo_oceania": lambda session: DefaultClient( name="Hitachi Hi Kumo (Oceania)", endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, session=session, ), - "nexity": lambda session: NexityServer( + "nexity": lambda session: NexityClient( name="Nexity Eugénie", endpoint="https://ha106-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Nexity", configuration_url=None, session=session, ), - "rexel": lambda session: DefaultServer( + "rexel": lambda session: DefaultClient( name="Rexel Energeasy Connect", endpoint="https://ha112-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Rexel", configuration_url="https://utilisateur.energeasyconnect.com/user/#/zone/equipements", session=session, ), - "simu_livein2": lambda session: DefaultServer( # alias of https://tahomalink.com + "simu_livein2": lambda session: DefaultClient( # alias of https://tahomalink.com name="SIMU (LiveIn2)", endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, session=session, ), - "somfy_europe": lambda session: SomfyServer( # alias of https://tahomalink.com + "somfy_europe": lambda session: SomfyClient( # alias of https://tahomalink.com name="Somfy (Europe)", endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url="https://www.tahomalink.com", session=session, ), - "somfy_america": lambda session: DefaultServer( + "somfy_america": lambda session: DefaultClient( name="Somfy (North America)", endpoint="https://ha401-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, session=session, ), - "somfy_oceania": lambda session: DefaultServer( + "somfy_oceania": lambda session: DefaultClient( name="Somfy (Oceania)", endpoint="https://ha201-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, session=session, ), - "ubiwizz": lambda session: DefaultServer( + "ubiwizz": lambda session: DefaultClient( name="Ubiwizz", endpoint="https://ha129-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Decelect", From d2a1feec56b0a5ef58c8c7fffb0c296900d149c4 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Wed, 15 Feb 2023 21:01:08 +0100 Subject: [PATCH 10/21] Rollback username and password to constructor --- pyoverkiz/clients/overkiz.py | 13 +++----- pyoverkiz/const.py | 58 ++++++++++++++++++++++++++---------- tests/test_client.py | 2 +- 3 files changed, 48 insertions(+), 25 deletions(-) diff --git a/pyoverkiz/clients/overkiz.py b/pyoverkiz/clients/overkiz.py index e0c518bc..0b73b039 100644 --- a/pyoverkiz/clients/overkiz.py +++ b/pyoverkiz/clients/overkiz.py @@ -62,8 +62,8 @@ async def refresh_listener(invocation: Mapping[str, Any]) -> None: class OverkizClient(ABC): """Abstract class for the Overkiz API""" - # username: str - # password: str = field(repr=lambda _: "***") + username: str + password: str = field(repr=lambda _: "***") name: str endpoint: str manufacturer: str @@ -74,9 +74,6 @@ class OverkizClient(ABC): devices: list[Device] = field(factory=list, init=False) gateways: list[Gateway] = field(factory=list, init=False) - # TODO: Add support for registering event listener - # - @abstractmethod async def _login( self, @@ -85,14 +82,12 @@ async def _login( ) -> bool: """Login to the server.""" - async def login( - self, username: str, password: str, register_event_listener: bool = True - ) -> bool: + async def login(self, register_event_listener: 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] """ - if await self._login(username, password): + if await self._login(self.username, self.password): if register_event_listener: await self.register_event_listener() return False diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index f635187b..a4cc3c69 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -10,103 +10,131 @@ from pyoverkiz.clients.overkiz import OverkizClient from pyoverkiz.clients.somfy import SomfyClient -SUPPORTED_SERVERS: dict[str, Callable[[ClientSession], OverkizClient]] = { - "atlantic_cozytouch": lambda session: AtlanticCozytouchClient( +SUPPORTED_SERVERS: dict[str, Callable[[str, str, ClientSession], OverkizClient]] = { + "atlantic_cozytouch": lambda username, password, session: AtlanticCozytouchClient( name="Atlantic Cozytouch", endpoint="https://ha110-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Atlantic", configuration_url=None, session=session, + username=username, + password=password, ), - "brandt": lambda session: DefaultClient( + "brandt": lambda username, password, session: DefaultClient( name="Brandt Smart Control", endpoint="https://ha3-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Brandt", configuration_url=None, session=session, + username=username, + password=password, ), - "flexom": lambda session: DefaultClient( + "flexom": lambda username, password, session: DefaultClient( name="Flexom", endpoint="https://ha108-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Bouygues", configuration_url=None, session=session, + username=username, + password=password, ), - "hexaom_hexaconnect": lambda session: DefaultClient( + "hexaom_hexaconnect": lambda username, password, session: DefaultClient( name="Hexaom HexaConnect", endpoint="https://ha5-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hexaom", configuration_url=None, session=session, + username=username, + password=password, ), - "hi_kumo_asia": lambda session: DefaultClient( + "hi_kumo_asia": lambda username, password, session: DefaultClient( name="Hitachi Hi Kumo (Asia)", endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, session=session, + username=username, + password=password, ), - "hi_kumo_europe": lambda session: DefaultClient( + "hi_kumo_europe": lambda username, password, session: DefaultClient( name="Hitachi Hi Kumo (Europe)", endpoint="https://ha117-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, session=session, + username=username, + password=password, ), - "hi_kumo_oceania": lambda session: DefaultClient( + "hi_kumo_oceania": lambda username, password, session: DefaultClient( name="Hitachi Hi Kumo (Oceania)", endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Hitachi", configuration_url=None, session=session, + username=username, + password=password, ), - "nexity": lambda session: NexityClient( + "nexity": lambda username, password, session: NexityClient( name="Nexity Eugénie", endpoint="https://ha106-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Nexity", configuration_url=None, session=session, + username=username, + password=password, ), - "rexel": lambda session: DefaultClient( + "rexel": lambda username, password, session: DefaultClient( name="Rexel Energeasy Connect", endpoint="https://ha112-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Rexel", configuration_url="https://utilisateur.energeasyconnect.com/user/#/zone/equipements", session=session, + username=username, + password=password, ), - "simu_livein2": lambda session: DefaultClient( # alias of https://tahomalink.com + "simu_livein2": lambda username, password, session: DefaultClient( # alias of https://tahomalink.com name="SIMU (LiveIn2)", endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, session=session, + username=username, + password=password, ), - "somfy_europe": lambda session: SomfyClient( # alias of https://tahomalink.com + "somfy_europe": lambda username, password, session: SomfyClient( # alias of https://tahomalink.com name="Somfy (Europe)", endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url="https://www.tahomalink.com", session=session, + username=username, + password=password, ), - "somfy_america": lambda session: DefaultClient( + "somfy_america": lambda username, password, session: DefaultClient( name="Somfy (North America)", endpoint="https://ha401-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, session=session, + username=username, + password=password, ), - "somfy_oceania": lambda session: DefaultClient( + "somfy_oceania": lambda username, password, session: DefaultClient( name="Somfy (Oceania)", endpoint="https://ha201-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Somfy", configuration_url=None, session=session, + username=username, + password=password, ), - "ubiwizz": lambda session: DefaultClient( + "ubiwizz": lambda username, password, session: DefaultClient( name="Ubiwizz", endpoint="https://ha129-1.overkiz.com/enduser-mobile-web/enduserAPI/", manufacturer="Decelect", configuration_url=None, session=session, + username=username, + password=password, ), } diff --git a/tests/test_client.py b/tests/test_client.py index 5ecb8597..eee3e690 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -15,7 +15,7 @@ class TestOverkizClient: @fixture def client(self): - return SUPPORTED_SERVERS["somfy_europe"](aiohttp.ClientSession()) + return SUPPORTED_SERVERS["somfy_europe"]("foo", "pass", aiohttp.ClientSession()) @pytest.mark.asyncio async def test_get_devices_basic(self, client): From 5c57e7dd94a59c49302514411568e58bbfa1857e Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Wed, 15 Feb 2023 22:04:33 +0100 Subject: [PATCH 11/21] Add local support for Somfy Europe --- pyoverkiz/clients/overkiz.py | 26 +++++++++++++------ pyoverkiz/clients/somfy.py | 10 +++++--- pyoverkiz/clients/somfy_local.py | 43 ++++++++++++++++++++++++++++++++ pyoverkiz/const.py | 10 ++++++++ 4 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 pyoverkiz/clients/somfy_local.py diff --git a/pyoverkiz/clients/overkiz.py b/pyoverkiz/clients/overkiz.py index 0b73b039..32c5f802 100644 --- a/pyoverkiz/clients/overkiz.py +++ b/pyoverkiz/clients/overkiz.py @@ -96,18 +96,25 @@ async def login(self, register_event_listener: bool = True) -> bool: def _headers(self) -> dict[str, str]: return {} - async def get(self, path: str) -> Any: + async def get( + self, + path: str, + ssl: bool = True, + ) -> Any: """Make a GET request to the OverKiz API""" async with self.session.get( - f"{self.endpoint}{path}", - headers=self._headers, + f"{self.endpoint}{path}", headers=self._headers, ssl=ssl ) as response: await self.check_response(response) return await response.json() async def post( - self, path: str, payload: JSON | None = None, data: JSON | None = None + self, + path: str, + payload: JSON | None = None, + data: JSON | None = None, + ssl: bool = True, ) -> Any: """Make a POST request to the OverKiz API""" @@ -116,16 +123,20 @@ async def post( data=data, json=payload, headers=self._headers, + ssl=ssl, ) as response: await self.check_response(response) return await response.json() - async def delete(self, path: str) -> None: + async def delete( + self, + path: str, + ssl: bool = True, + ) -> None: """Make a DELETE request to the OverKiz API""" async with self.session.delete( - f"{self.endpoint}{path}", - headers=self._headers, + f"{self.endpoint}{path}", headers=self._headers, ssl=ssl ) as response: await self.check_response(response) @@ -459,6 +470,7 @@ async def generate_local_token(self, gateway_id: str) -> str: Access scope : Full enduser API access (enduser/*) """ response = await self.get(f"config/{gateway_id}/local/tokens/generate") + print(response) return cast(str, response["token"]) diff --git a/pyoverkiz/clients/somfy.py b/pyoverkiz/clients/somfy.py index 7bceba97..595082c7 100644 --- a/pyoverkiz/clients/somfy.py +++ b/pyoverkiz/clients/somfy.py @@ -24,21 +24,25 @@ class SomfyClient(OverkizClient): def _headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self._access_token}"} - async def get(self, path: str) -> Any: + async def get(self, path: str, ssl: bool = True) -> Any: """Make a GET request to the OverKiz API""" await self._refresh_token_if_expired() return await super().get(path) async def post( - self, path: str, payload: JSON | None = None, data: JSON | None = None + self, + path: str, + payload: JSON | None = None, + data: JSON | None = None, + ssl: bool = True, ) -> Any: """Make a POST request to the OverKiz API""" if path != "login": await self._refresh_token_if_expired() return await super().post(path, payload=payload, data=data) - async def delete(self, path: str) -> None: + async def delete(self, path: str, ssl: bool = True) -> None: """Make a DELETE request to the OverKiz API""" await self._refresh_token_if_expired() return await super().delete(path) diff --git a/pyoverkiz/clients/somfy_local.py b/pyoverkiz/clients/somfy_local.py new file mode 100644 index 00000000..95e11eb7 --- /dev/null +++ b/pyoverkiz/clients/somfy_local.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import Any + +from pyoverkiz.clients.overkiz import OverkizClient +from pyoverkiz.types import JSON + + +class SomfyLocalClient(OverkizClient): + async def _login(self, username: str, password: str) -> bool: + """There is no login for Somfy Local""" + return True + + @property + def _headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.password}"} + + async def get( + self, + path: str, + _ssl: bool = False, + ) -> Any: + """Make a GET request to the OverKiz API""" + + return await super().get(path, ssl=False) + + async def post( + self, + path: str, + payload: JSON | None = None, + data: JSON | None = None, + _ssl: bool = False, + ) -> Any: + """Make a POST request to the OverKiz API""" + return await super().post(path, payload=payload, data=data, ssl=False) + + async def delete( + self, + path: str, + _ssl: bool = False, + ) -> None: + """Make a DELETE request to the OverKiz API""" + return await super().delete(path, ssl=False) diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index a4cc3c69..bfa2cd45 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -9,6 +9,7 @@ from pyoverkiz.clients.nexity import NexityClient from pyoverkiz.clients.overkiz import OverkizClient from pyoverkiz.clients.somfy import SomfyClient +from pyoverkiz.clients.somfy_local import SomfyLocalClient SUPPORTED_SERVERS: dict[str, Callable[[str, str, ClientSession], OverkizClient]] = { "atlantic_cozytouch": lambda username, password, session: AtlanticCozytouchClient( @@ -110,6 +111,15 @@ username=username, password=password, ), + "somfy_europe_local": lambda gateway_id, token, session: SomfyLocalClient( + name="Somfy Local(Europe)", + endpoint=f"https://gateway-{gateway_id}.local:8443/enduser-mobile-web/1/enduserAPI/", + manufacturer="Somfy", + configuration_url=None, + session=session, + username=gateway_id, # not used + password=token, + ), "somfy_america": lambda username, password, session: DefaultClient( name="Somfy (North America)", endpoint="https://ha401-1.overkiz.com/enduser-mobile-web/enduserAPI/", From 36b163d4d609cb6e9076f9ef2d98f8ed6713aad4 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Thu, 16 Feb 2023 09:34:46 +0100 Subject: [PATCH 12/21] Move ssl to attribute --- pyoverkiz/clients/overkiz.py | 10 ++++----- pyoverkiz/clients/somfy.py | 5 ++--- pyoverkiz/clients/somfy_local.py | 35 +++++--------------------------- 3 files changed, 11 insertions(+), 39 deletions(-) diff --git a/pyoverkiz/clients/overkiz.py b/pyoverkiz/clients/overkiz.py index 32c5f802..f8363dd3 100644 --- a/pyoverkiz/clients/overkiz.py +++ b/pyoverkiz/clients/overkiz.py @@ -73,6 +73,7 @@ class OverkizClient(ABC): setup: Setup | None = field(default=None, init=False) devices: list[Device] = field(factory=list, init=False) gateways: list[Gateway] = field(factory=list, init=False) + _ssl: bool = field(default=True, init=False) @abstractmethod async def _login( @@ -99,12 +100,11 @@ def _headers(self) -> dict[str, str]: async def get( self, path: str, - ssl: bool = True, ) -> Any: """Make a GET request to the OverKiz API""" async with self.session.get( - f"{self.endpoint}{path}", headers=self._headers, ssl=ssl + f"{self.endpoint}{path}", headers=self._headers, ssl=self._ssl ) as response: await self.check_response(response) return await response.json() @@ -114,7 +114,6 @@ async def post( path: str, payload: JSON | None = None, data: JSON | None = None, - ssl: bool = True, ) -> Any: """Make a POST request to the OverKiz API""" @@ -123,7 +122,7 @@ async def post( data=data, json=payload, headers=self._headers, - ssl=ssl, + ssl=self._ssl, ) as response: await self.check_response(response) return await response.json() @@ -131,12 +130,11 @@ async def post( async def delete( self, path: str, - ssl: bool = True, ) -> None: """Make a DELETE request to the OverKiz API""" async with self.session.delete( - f"{self.endpoint}{path}", headers=self._headers, ssl=ssl + f"{self.endpoint}{path}", headers=self._headers, ssl=self._ssl ) as response: await self.check_response(response) diff --git a/pyoverkiz/clients/somfy.py b/pyoverkiz/clients/somfy.py index 595082c7..639bf67c 100644 --- a/pyoverkiz/clients/somfy.py +++ b/pyoverkiz/clients/somfy.py @@ -24,7 +24,7 @@ class SomfyClient(OverkizClient): def _headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self._access_token}"} - async def get(self, path: str, ssl: bool = True) -> Any: + async def get(self, path: str) -> Any: """Make a GET request to the OverKiz API""" await self._refresh_token_if_expired() @@ -35,14 +35,13 @@ async def post( path: str, payload: JSON | None = None, data: JSON | None = None, - ssl: bool = True, ) -> Any: """Make a POST request to the OverKiz API""" if path != "login": await self._refresh_token_if_expired() return await super().post(path, payload=payload, data=data) - async def delete(self, path: str, ssl: bool = True) -> None: + async def delete(self, path: str) -> None: """Make a DELETE request to the OverKiz API""" await self._refresh_token_if_expired() return await super().delete(path) diff --git a/pyoverkiz/clients/somfy_local.py b/pyoverkiz/clients/somfy_local.py index 95e11eb7..558ff2b4 100644 --- a/pyoverkiz/clients/somfy_local.py +++ b/pyoverkiz/clients/somfy_local.py @@ -1,43 +1,18 @@ from __future__ import annotations -from typing import Any +from attr import field from pyoverkiz.clients.overkiz import OverkizClient -from pyoverkiz.types import JSON class SomfyLocalClient(OverkizClient): - async def _login(self, username: str, password: str) -> bool: + + _ssl: bool = field(default=False, init=False) + + async def _login(self, _username: str, _password: str) -> bool: """There is no login for Somfy Local""" return True @property def _headers(self) -> dict[str, str]: return {"Authorization": f"Bearer {self.password}"} - - async def get( - self, - path: str, - _ssl: bool = False, - ) -> Any: - """Make a GET request to the OverKiz API""" - - return await super().get(path, ssl=False) - - async def post( - self, - path: str, - payload: JSON | None = None, - data: JSON | None = None, - _ssl: bool = False, - ) -> Any: - """Make a POST request to the OverKiz API""" - return await super().post(path, payload=payload, data=data, ssl=False) - - async def delete( - self, - path: str, - _ssl: bool = False, - ) -> None: - """Make a DELETE request to the OverKiz API""" - return await super().delete(path, ssl=False) From 9bea32d16cdbbdf056f3a82a12ad0124900f52f4 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Fri, 17 Feb 2023 10:49:25 +0100 Subject: [PATCH 13/21] Create a single entrypoint to create client --- pyoverkiz/clients/overkiz.py | 19 ---- pyoverkiz/const.py | 164 ++++------------------------------- pyoverkiz/overkiz.py | 161 ++++++++++++++++++++++++++++++++++ tests/test_client.py | 7 +- 4 files changed, 184 insertions(+), 167 deletions(-) create mode 100644 pyoverkiz/overkiz.py diff --git a/pyoverkiz/clients/overkiz.py b/pyoverkiz/clients/overkiz.py index f8363dd3..143fc2fa 100644 --- a/pyoverkiz/clients/overkiz.py +++ b/pyoverkiz/clients/overkiz.py @@ -5,7 +5,6 @@ import urllib.parse from abc import ABC, abstractmethod from json import JSONDecodeError -from types import TracebackType from typing import Any, Mapping, cast import backoff @@ -138,24 +137,6 @@ async def delete( ) as response: await self.check_response(response) - async def __aenter__(self) -> OverkizClient: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - await self.close() - - async def close(self) -> None: - """Close the session.""" - if self.event_listener_id: - await self.unregister_event_listener() - - await self.session.close() - @backoff.on_exception( backoff.expo, (NotAuthenticatedException, ServerDisconnectedError), diff --git a/pyoverkiz/const.py b/pyoverkiz/const.py index bfa2cd45..0ca9d572 100644 --- a/pyoverkiz/const.py +++ b/pyoverkiz/const.py @@ -1,150 +1,22 @@ from __future__ import annotations -from typing import Callable +from enum import Enum, unique -from aiohttp import ClientSession -from pyoverkiz.clients.atlantic_cozytouch import AtlanticCozytouchClient -from pyoverkiz.clients.default import DefaultClient -from pyoverkiz.clients.nexity import NexityClient -from pyoverkiz.clients.overkiz import OverkizClient -from pyoverkiz.clients.somfy import SomfyClient -from pyoverkiz.clients.somfy_local import SomfyLocalClient - -SUPPORTED_SERVERS: dict[str, Callable[[str, str, ClientSession], OverkizClient]] = { - "atlantic_cozytouch": lambda username, password, session: AtlanticCozytouchClient( - name="Atlantic Cozytouch", - endpoint="https://ha110-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Atlantic", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "brandt": lambda username, password, session: DefaultClient( - name="Brandt Smart Control", - endpoint="https://ha3-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Brandt", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "flexom": lambda username, password, session: DefaultClient( - name="Flexom", - endpoint="https://ha108-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Bouygues", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "hexaom_hexaconnect": lambda username, password, session: DefaultClient( - name="Hexaom HexaConnect", - endpoint="https://ha5-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Hexaom", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "hi_kumo_asia": lambda username, password, session: DefaultClient( - name="Hitachi Hi Kumo (Asia)", - endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Hitachi", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "hi_kumo_europe": lambda username, password, session: DefaultClient( - name="Hitachi Hi Kumo (Europe)", - endpoint="https://ha117-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Hitachi", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "hi_kumo_oceania": lambda username, password, session: DefaultClient( - name="Hitachi Hi Kumo (Oceania)", - endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Hitachi", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "nexity": lambda username, password, session: NexityClient( - name="Nexity Eugénie", - endpoint="https://ha106-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Nexity", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "rexel": lambda username, password, session: DefaultClient( - name="Rexel Energeasy Connect", - endpoint="https://ha112-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Rexel", - configuration_url="https://utilisateur.energeasyconnect.com/user/#/zone/equipements", - session=session, - username=username, - password=password, - ), - "simu_livein2": lambda username, password, session: DefaultClient( # alias of https://tahomalink.com - name="SIMU (LiveIn2)", - endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Somfy", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "somfy_europe": lambda username, password, session: SomfyClient( # alias of https://tahomalink.com - name="Somfy (Europe)", - endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Somfy", - configuration_url="https://www.tahomalink.com", - session=session, - username=username, - password=password, - ), - "somfy_europe_local": lambda gateway_id, token, session: SomfyLocalClient( - name="Somfy Local(Europe)", - endpoint=f"https://gateway-{gateway_id}.local:8443/enduser-mobile-web/1/enduserAPI/", - manufacturer="Somfy", - configuration_url=None, - session=session, - username=gateway_id, # not used - password=token, - ), - "somfy_america": lambda username, password, session: DefaultClient( - name="Somfy (North America)", - endpoint="https://ha401-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Somfy", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "somfy_oceania": lambda username, password, session: DefaultClient( - name="Somfy (Oceania)", - endpoint="https://ha201-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Somfy", - configuration_url=None, - session=session, - username=username, - password=password, - ), - "ubiwizz": lambda username, password, session: DefaultClient( - name="Ubiwizz", - endpoint="https://ha129-1.overkiz.com/enduser-mobile-web/enduserAPI/", - manufacturer="Decelect", - configuration_url=None, - session=session, - username=username, - password=password, - ), -} +@unique +class Server(str, Enum): + ATLANTIC_COZYTOUCH = "atlantic_cozytouch" + BRANDT = "brandt" + FLEXOM = "flexom" + HEXAOM_HEXACONNECT = "hexaom_hexaconnect" + HI_KUMO_ASIA = "hi_kumo_asia" + HI_KUMO_EUROPE = "hi_kumo_europe" + HI_KUMO_OCEANIA = "hi_kumo_oceania" + NEXITY = "nexity" + REXEL = "rexel" + SIMU_LIVEIN2 = "simu_livein2" + SOMFY_EUROPE = "somfy_europe" + SOMFY_DEV_MODE = "somfy_dev_mode" + SOMFY_AMERICA = "somfy_america" + SOMFY_OCEANIA = "somfy_oceania" + UBIWIZZ = "ubiwizz" diff --git a/pyoverkiz/overkiz.py b/pyoverkiz/overkiz.py new file mode 100644 index 00000000..dd3926bd --- /dev/null +++ b/pyoverkiz/overkiz.py @@ -0,0 +1,161 @@ +"""Main entropoint for the Overkiz API client.""" +from __future__ import annotations + +from typing import Callable + +from aiohttp import ClientSession + +from pyoverkiz.clients.atlantic_cozytouch import AtlanticCozytouchClient +from pyoverkiz.clients.default import DefaultClient +from pyoverkiz.clients.nexity import NexityClient +from pyoverkiz.clients.overkiz import OverkizClient +from pyoverkiz.clients.somfy import SomfyClient +from pyoverkiz.clients.somfy_local import SomfyLocalClient +from pyoverkiz.const import Server + +SUPPORTED_SERVERS: dict[Server, Callable[[str, str, ClientSession], OverkizClient]] = { + Server.ATLANTIC_COZYTOUCH: lambda username, password, session: AtlanticCozytouchClient( + name="Atlantic Cozytouch", + endpoint="https://ha110-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Atlantic", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.BRANDT: lambda username, password, session: DefaultClient( + name="Brandt Smart Control", + endpoint="https://ha3-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Brandt", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.FLEXOM: lambda username, password, session: DefaultClient( + name="Flexom", + endpoint="https://ha108-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Bouygues", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.HEXAOM_HEXACONNECT: lambda username, password, session: DefaultClient( + name="Hexaom HexaConnect", + endpoint="https://ha5-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Hexaom", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.HI_KUMO_ASIA: lambda username, password, session: DefaultClient( + name="Hitachi Hi Kumo (Asia)", + endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Hitachi", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.HI_KUMO_EUROPE: lambda username, password, session: DefaultClient( + name="Hitachi Hi Kumo (Europe)", + endpoint="https://ha117-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Hitachi", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.HI_KUMO_OCEANIA: lambda username, password, session: DefaultClient( + name="Hitachi Hi Kumo (Oceania)", + endpoint="https://ha203-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Hitachi", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.NEXITY: lambda username, password, session: NexityClient( + name="Nexity Eugénie", + endpoint="https://ha106-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Nexity", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.REXEL: lambda username, password, session: DefaultClient( + name="Rexel Energeasy Connect", + endpoint="https://ha112-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Rexel", + configuration_url="https://utilisateur.energeasyconnect.com/user/#/zone/equipements", + session=session, + username=username, + password=password, + ), + Server.SIMU_LIVEIN2: lambda username, password, session: DefaultClient( # alias of https://tahomalink.com + name="SIMU (LiveIn2)", + endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Somfy", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.SOMFY_EUROPE: lambda username, password, session: SomfyClient( # alias of https://tahomalink.com + name="Somfy (Europe)", + endpoint="https://ha101-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Somfy", + configuration_url="https://www.tahomalink.com", + session=session, + username=username, + password=password, + ), + Server.SOMFY_DEV_MODE: lambda gateway_id, token, session: SomfyLocalClient( + name="Somfy Developer Mode (Local API)", + endpoint=f"https://gateway-{gateway_id}.local:8443/enduser-mobile-web/1/enduserAPI/", + manufacturer="Somfy", + configuration_url=None, + session=session, + username=gateway_id, # not used + password=token, + ), + Server.SOMFY_AMERICA: lambda username, password, session: DefaultClient( + name="Somfy (North America)", + endpoint="https://ha401-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Somfy", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.SOMFY_OCEANIA: lambda username, password, session: DefaultClient( + name="Somfy (Oceania)", + endpoint="https://ha201-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Somfy", + configuration_url=None, + session=session, + username=username, + password=password, + ), + Server.UBIWIZZ: lambda username, password, session: DefaultClient( + name="Ubiwizz", + endpoint="https://ha129-1.overkiz.com/enduser-mobile-web/enduserAPI/", + manufacturer="Decelect", + configuration_url=None, + session=session, + username=username, + password=password, + ), +} + + +class Overkiz: + @staticmethod + def get_client_for( + server: Server, username: str, password: str, session: ClientSession + ) -> OverkizClient: + """Get the client for the given server""" + return SUPPORTED_SERVERS[server](username, password, session) diff --git a/tests/test_client.py b/tests/test_client.py index eee3e690..b4bc8cb2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,8 +6,9 @@ import pytest from pytest import fixture -from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.const import Server from pyoverkiz.enums import DataType +from pyoverkiz.overkiz import Overkiz CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -15,7 +16,9 @@ class TestOverkizClient: @fixture def client(self): - return SUPPORTED_SERVERS["somfy_europe"]("foo", "pass", aiohttp.ClientSession()) + return Overkiz.get_client_for( + Server.SOMFY_EUROPE, "foo", "pass", aiohttp.ClientSession() + ) @pytest.mark.asyncio async def test_get_devices_basic(self, client): From 21dae94ff388ca526531963caa7223c278efd6c1 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Fri, 17 Feb 2023 10:50:27 +0100 Subject: [PATCH 14/21] Update example --- README.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e7c7dd4f..c23f0bea 100644 --- a/README.md +++ b/README.md @@ -37,32 +37,47 @@ pip install pyoverkiz import asyncio import time -from pyoverkiz.const import SUPPORTED_SERVERS from aiohttp import ClientSession +from pyoverkiz.clients.overkiz import OverkizClient +from pyoverkiz.const import Server +from pyoverkiz.overkiz import Overkiz + USERNAME = "" PASSWORD = "" async def main() -> None: - session = ClientSession() - client = SUPPORTED_SERVERS["somfy_europe"](session) - async with client as client: + async with ClientSession() as session: + client = Overkiz.get_client_for( + Server.SOMFY_EUROPE, USERNAME, PASSWORD, session + ) try: - await client.login(USERNAME, PASSWORD) + await client.login() except Exception as exception: # pylint: disable=broad-except print(exception) return - devices = await client.get_devices() + gateways = await client.get_gateways() + token = await client.generate_local_token(gateways[0].id) + print(token) + await client.activate_local_token(gateways[0].id, token, "pyoverkiz") + + local_client: OverkizClient = Overkiz.get_client_for( + Server.SOMFY_DEV_MODE, gateways[0].id, token, session + ) + + devices = await local_client.get_devices() for device in devices: print(f"{device.label} ({device.id}) - {device.controllable_name}") print(f"{device.widget} - {device.ui_class}") + await local_client.register_event_listener() + while True: - events = await client.fetch_events() + events = await local_client.fetch_events() print(events) time.sleep(2) From bcb5c73d2b16ebc750e0f29b11d3c4edc7bb4866 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Wed, 22 Feb 2023 08:42:52 +0100 Subject: [PATCH 15/21] Accept domain as parameter for local api --- README.md | 4 ++-- pyoverkiz/overkiz.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c23f0bea..922cd03a 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,11 @@ async def main() -> None: gateways = await client.get_gateways() token = await client.generate_local_token(gateways[0].id) - print(token) await client.activate_local_token(gateways[0].id, token, "pyoverkiz") + domain = f"gateway-{gateways[0].id}.local" local_client: OverkizClient = Overkiz.get_client_for( - Server.SOMFY_DEV_MODE, gateways[0].id, token, session + Server.SOMFY_DEV_MODE, domain, token, session ) devices = await local_client.get_devices() diff --git a/pyoverkiz/overkiz.py b/pyoverkiz/overkiz.py index dd3926bd..f700d65e 100644 --- a/pyoverkiz/overkiz.py +++ b/pyoverkiz/overkiz.py @@ -113,13 +113,13 @@ username=username, password=password, ), - Server.SOMFY_DEV_MODE: lambda gateway_id, token, session: SomfyLocalClient( + Server.SOMFY_DEV_MODE: lambda domain, token, session: SomfyLocalClient( name="Somfy Developer Mode (Local API)", - endpoint=f"https://gateway-{gateway_id}.local:8443/enduser-mobile-web/1/enduserAPI/", + endpoint=f"https://{domain}:8443/enduser-mobile-web/1/enduserAPI/", manufacturer="Somfy", configuration_url=None, session=session, - username=gateway_id, # not used + username=domain, # not used password=token, ), Server.SOMFY_AMERICA: lambda username, password, session: DefaultClient( From 58959d1a621d70e50115414db57d9b1cf01fad3c Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Wed, 22 Feb 2023 09:11:54 +0100 Subject: [PATCH 16/21] Update documentation --- README.md | 72 +++++++++++++++++++++++++++++------- pyoverkiz/clients/overkiz.py | 1 - 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 922cd03a..9a385d5d 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,11 @@ A fully async and easy to use API client for the (internal) OverKiz API. You can use this client to interact with smart devices connected to the OverKiz platform, used by various vendors like Somfy TaHoma and Atlantic Cozytouch. -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 OverKiz hubs. - -> 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 API client. +This package is written for the Home Assistant [Overkiz](https://www.home-assistant.io/integrations/overkiz/) integration, but could be used by any Python project interacting with OverKiz hubs. ## Supported hubs -- Atlantic Cozytouch -- Bouygues Flexom -- Hitachi Hi Kumo -- Nexity Eugénie -- Rexel Energeasy Connect -- Simu (LiveIn2) -- Somfy Connexoon IO -- Somfy Connexoon RTS -- Somfy TaHoma -- Somfy TaHoma Switch -- Thermor Cozytouch +See [pyoverkiz/const.py](./pyoverkiz/const.py) ## Installation @@ -33,6 +21,62 @@ pip install pyoverkiz ## Getting started +### API Documentation + +A subset of the API is [documented and maintened](https://somfy-developer.github.io/Somfy-TaHoma-Developer-Mode) by Somfy. + +### Local API or Developper mode + +See https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started + +For the moment, only Tahoma and Conexoon hubs from Somfy Europe can enabled this mode. Not all the devices are returned. You can have more details [here](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/20). + +```python +import asyncio +import time + +from aiohttp import ClientSession + +from pyoverkiz.clients.overkiz import OverkizClient +from pyoverkiz.const import Server +from pyoverkiz.overkiz import Overkiz + +USERNAME = "" +PASSWORD = "" + + +async def main() -> None: + + async with ClientSession() as session: + client = Overkiz.get_client_for( + Server.SOMFY_EUROPE, USERNAME, PASSWORD, session + ) + try: + await client.login() + except Exception as exception: # pylint: disable=broad-except + print(exception) + return + + devices = await client.get_devices() + + for device in devices: + print(f"{device.label} ({device.id}) - {device.controllable_name}") + print(f"{device.widget} - {device.ui_class}") + + await client.register_event_listener() + + while True: + events = await client.fetch_events() + print(events) + + time.sleep(2) + + +asyncio.run(main()) +``` + +### Cloud API + ```python import asyncio import time diff --git a/pyoverkiz/clients/overkiz.py b/pyoverkiz/clients/overkiz.py index 143fc2fa..115c23c9 100644 --- a/pyoverkiz/clients/overkiz.py +++ b/pyoverkiz/clients/overkiz.py @@ -449,7 +449,6 @@ async def generate_local_token(self, gateway_id: str) -> str: Access scope : Full enduser API access (enduser/*) """ response = await self.get(f"config/{gateway_id}/local/tokens/generate") - print(response) return cast(str, response["token"]) From cdafe27e50673834ece08e494a31d633b0e28149 Mon Sep 17 00:00:00 2001 From: Thibaut Date: Thu, 23 Feb 2023 15:04:14 +0100 Subject: [PATCH 17/21] Apply suggestions from code review Co-authored-by: Mick Vleeshouwer --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9a385d5d..98d13909 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,11 @@ pip install pyoverkiz A subset of the API is [documented and maintened](https://somfy-developer.github.io/Somfy-TaHoma-Developer-Mode) by Somfy. -### Local API or Developper mode +### Local API or Developer mode See https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started -For the moment, only Tahoma and Conexoon hubs from Somfy Europe can enabled this mode. Not all the devices are returned. You can have more details [here](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/20). +For the moment, only Somfy TaHoma Switch, TaHoma V2 and Connexoon hubs from Somfy Europe can enabled this mode. Not all the devices are returned. You can have more details [here](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/20). ```python import asyncio @@ -49,7 +49,10 @@ async def main() -> None: async with ClientSession() as session: client = Overkiz.get_client_for( - Server.SOMFY_EUROPE, USERNAME, PASSWORD, session + server=Server.SOMFY_EUROPE, + username=USERNAME, + password=PASSWORD, + session=session ) try: await client.login() From 571cdc8f8ed03734fc6585af68dec9ab651f69c3 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Thu, 2 Mar 2023 09:28:09 +0100 Subject: [PATCH 18/21] Fix examples --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 98d13909..5fa347ab 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,7 @@ pip install pyoverkiz A subset of the API is [documented and maintened](https://somfy-developer.github.io/Somfy-TaHoma-Developer-Mode) by Somfy. -### Local API or Developer mode - -See https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started - -For the moment, only Somfy TaHoma Switch, TaHoma V2 and Connexoon hubs from Somfy Europe can enabled this mode. Not all the devices are returned. You can have more details [here](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/20). +### Cloud API ```python import asyncio @@ -66,8 +62,6 @@ async def main() -> None: print(f"{device.label} ({device.id}) - {device.controllable_name}") print(f"{device.widget} - {device.ui_class}") - await client.register_event_listener() - while True: events = await client.fetch_events() print(events) @@ -78,7 +72,13 @@ async def main() -> None: asyncio.run(main()) ``` -### Cloud API +### Local API or Developer mode + + +See https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started + +For the moment, only Somfy TaHoma Switch, TaHoma V2 and Connexoon hubs from Somfy Europe can enabled this mode. Not all the devices are returned. You can have more details [here](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode/issues/20). + ```python import asyncio From 1be84ee5352ada77042a057b9666e8635e5a5f64 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Thu, 2 Mar 2023 09:29:49 +0100 Subject: [PATCH 19/21] Raise an error if we try to call login on the Somfy Local API --- pyoverkiz/clients/somfy_local.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyoverkiz/clients/somfy_local.py b/pyoverkiz/clients/somfy_local.py index 558ff2b4..4583e7db 100644 --- a/pyoverkiz/clients/somfy_local.py +++ b/pyoverkiz/clients/somfy_local.py @@ -10,8 +10,8 @@ class SomfyLocalClient(OverkizClient): _ssl: bool = field(default=False, init=False) async def _login(self, _username: str, _password: str) -> bool: - """There is no login for Somfy Local""" - return True + """There is no login needed for Somfy Local API""" + raise NotImplementedError("There is no login needed for the Somfy Local API") @property def _headers(self) -> dict[str, str]: From a039f808eea5a0cfa1cb0e5ef9f7b35e54ed55e6 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Thu, 2 Mar 2023 09:51:42 +0100 Subject: [PATCH 20/21] =?UTF-8?q?Don=E2=80=99t=20make=20username=20and=20p?= =?UTF-8?q?assword=20mandatories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyoverkiz/clients/atlantic_cozytouch.py | 12 +++++++++--- pyoverkiz/clients/default.py | 11 +++++++++-- pyoverkiz/clients/nexity.py | 14 ++++++++++---- pyoverkiz/clients/overkiz.py | 6 +----- pyoverkiz/clients/somfy.py | 10 +++++++--- pyoverkiz/clients/somfy_local.py | 8 +++++--- pyoverkiz/overkiz.py | 3 +-- 7 files changed, 42 insertions(+), 22 deletions(-) diff --git a/pyoverkiz/clients/atlantic_cozytouch.py b/pyoverkiz/clients/atlantic_cozytouch.py index 65a26cce..7b6c7248 100644 --- a/pyoverkiz/clients/atlantic_cozytouch.py +++ b/pyoverkiz/clients/atlantic_cozytouch.py @@ -1,6 +1,7 @@ from __future__ import annotations from aiohttp import FormData +from attr import define, field from pyoverkiz.clients.overkiz import OverkizClient from pyoverkiz.exceptions import ( @@ -14,8 +15,13 @@ ) +@define(kw_only=True) class AtlanticCozytouchClient(OverkizClient): - async def _login(self, username: str, password: str) -> bool: + + username: str + password: str = field(repr=lambda _: "***") + + async def _login(self) -> bool: """ Authenticate and create an API session allowing access to the other operations. Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] @@ -26,8 +32,8 @@ async def _login(self, username: str, password: str) -> bool: data=FormData( { "grant_type": "password", - "username": "GA-PRIVATEPERSON/" + username, - "password": password, + "username": "GA-PRIVATEPERSON/" + self.username, + "password": self.password, } ), headers={ diff --git a/pyoverkiz/clients/default.py b/pyoverkiz/clients/default.py index 9827f3e7..5ca9145d 100644 --- a/pyoverkiz/clients/default.py +++ b/pyoverkiz/clients/default.py @@ -1,12 +1,19 @@ +from attr import define, field + from pyoverkiz.clients.overkiz import OverkizClient +@define(kw_only=True) class DefaultClient(OverkizClient): - async def _login(self, username: str, password: str) -> bool: + + username: str + password: str = field(repr=lambda _: "***") + + async def _login(self) -> 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": username, "userPassword": password} + payload = {"userId": self.username, "userPassword": self.password} response = await self.post("login", data=payload) return "success" in response diff --git a/pyoverkiz/clients/nexity.py b/pyoverkiz/clients/nexity.py index 48d1ac5c..05f26e7b 100644 --- a/pyoverkiz/clients/nexity.py +++ b/pyoverkiz/clients/nexity.py @@ -4,6 +4,7 @@ from typing import cast import boto3 +from attr import define, field from botocore.config import Config from warrant_lite import WarrantLite @@ -16,8 +17,13 @@ NEXITY_COGNITO_REGION = "eu-west-1" +@define(kw_only=True) class NexityClient(OverkizClient): - async def _login(self, username: str, password: str) -> bool: + + username: str + password: str = field(repr=lambda _: "***") + + async def _login(self) -> bool: """ Authenticate and create an API session allowing access to the other operations. Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] @@ -34,8 +40,8 @@ def _get_client() -> boto3.session.Session.client: client = await loop.run_in_executor(None, _get_client) aws = WarrantLite( - username=username, - password=password, + username=self.username, + password=self.password, pool_id=NEXITY_COGNITO_USER_POOL, client_id=NEXITY_COGNITO_CLIENT_ID, client=client, @@ -59,7 +65,7 @@ def _get_client() -> boto3.session.Session.client: sso_token = cast(str, token["token"]) - user_id = username.replace("@", "_-_") # Replace @ for _-_ + user_id = self.username.replace("@", "_-_") # Replace @ for _-_ payload = {"ssoToken": sso_token, "userId": user_id} post_response = await self.post("login", data=payload) diff --git a/pyoverkiz/clients/overkiz.py b/pyoverkiz/clients/overkiz.py index 115c23c9..4c9a665f 100644 --- a/pyoverkiz/clients/overkiz.py +++ b/pyoverkiz/clients/overkiz.py @@ -61,8 +61,6 @@ async def refresh_listener(invocation: Mapping[str, Any]) -> None: class OverkizClient(ABC): """Abstract class for the Overkiz API""" - username: str - password: str = field(repr=lambda _: "***") name: str endpoint: str manufacturer: str @@ -77,8 +75,6 @@ class OverkizClient(ABC): @abstractmethod async def _login( self, - username: str, - password: str, ) -> bool: """Login to the server.""" @@ -87,7 +83,7 @@ async def login(self, register_event_listener: 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] """ - if await self._login(self.username, self.password): + if await self._login(): if register_event_listener: await self.register_event_listener() return False diff --git a/pyoverkiz/clients/somfy.py b/pyoverkiz/clients/somfy.py index 639bf67c..7d725dba 100644 --- a/pyoverkiz/clients/somfy.py +++ b/pyoverkiz/clients/somfy.py @@ -4,6 +4,7 @@ from typing import Any, cast from aiohttp import FormData +from attr import define, field from pyoverkiz.clients.overkiz import OverkizClient from pyoverkiz.exceptions import SomfyBadCredentialsException, SomfyServiceException @@ -14,8 +15,11 @@ SOMFY_CLIENT_SECRET = "12k73w1n540g8o4cokg0cw84cog840k84cwggscwg884004kgk" +@define(kw_only=True) class SomfyClient(OverkizClient): + username: str + password: str = field(repr=lambda _: "***") _access_token: str | None = None _refresh_token: str | None = None _expires_in: datetime.datetime | None = None @@ -46,7 +50,7 @@ async def delete(self, path: str) -> None: await self._refresh_token_if_expired() return await super().delete(path) - async def _login(self, username: str, password: str) -> bool: + async def _login(self) -> bool: """ Authenticate and create an API session allowing access to the other operations. Caller must provide one of [userId+userPassword, userId+ssoToken, accessToken, jwt] @@ -57,8 +61,8 @@ async def _login(self, username: str, password: str) -> bool: data=FormData( { "grant_type": "password", - "username": username, - "password": password, + "username": self.username, + "password": self.password, "client_id": SOMFY_CLIENT_ID, "client_secret": SOMFY_CLIENT_SECRET, } diff --git a/pyoverkiz/clients/somfy_local.py b/pyoverkiz/clients/somfy_local.py index 4583e7db..b1136d17 100644 --- a/pyoverkiz/clients/somfy_local.py +++ b/pyoverkiz/clients/somfy_local.py @@ -1,18 +1,20 @@ from __future__ import annotations -from attr import field +from attr import define, field from pyoverkiz.clients.overkiz import OverkizClient +@define(kw_only=True) class SomfyLocalClient(OverkizClient): _ssl: bool = field(default=False, init=False) + token: str = field(repr=lambda _: "***") - async def _login(self, _username: str, _password: str) -> bool: + async def _login(self) -> bool: """There is no login needed for Somfy Local API""" raise NotImplementedError("There is no login needed for the Somfy Local API") @property def _headers(self) -> dict[str, str]: - return {"Authorization": f"Bearer {self.password}"} + return {"Authorization": f"Bearer {self.token}"} diff --git a/pyoverkiz/overkiz.py b/pyoverkiz/overkiz.py index f700d65e..01c1f0f8 100644 --- a/pyoverkiz/overkiz.py +++ b/pyoverkiz/overkiz.py @@ -119,8 +119,7 @@ manufacturer="Somfy", configuration_url=None, session=session, - username=domain, # not used - password=token, + token=token, ), Server.SOMFY_AMERICA: lambda username, password, session: DefaultClient( name="Somfy (North America)", From f4cd69fa7551e66ec32cfc8a83dbc6b05a0dbb61 Mon Sep 17 00:00:00 2001 From: Thibaut Etienne Date: Thu, 2 Mar 2023 09:53:58 +0100 Subject: [PATCH 21/21] Rename get_client_for to create_client --- README.md | 6 +++--- pyoverkiz/overkiz.py | 2 +- tests/test_client.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5fa347ab..e0c830b2 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ PASSWORD = "" async def main() -> None: async with ClientSession() as session: - client = Overkiz.get_client_for( + client = Overkiz.create_client( server=Server.SOMFY_EUROPE, username=USERNAME, password=PASSWORD, @@ -97,7 +97,7 @@ PASSWORD = "" async def main() -> None: async with ClientSession() as session: - client = Overkiz.get_client_for( + client = Overkiz.create_client( Server.SOMFY_EUROPE, USERNAME, PASSWORD, session ) try: @@ -111,7 +111,7 @@ async def main() -> None: await client.activate_local_token(gateways[0].id, token, "pyoverkiz") domain = f"gateway-{gateways[0].id}.local" - local_client: OverkizClient = Overkiz.get_client_for( + local_client: OverkizClient = Overkiz.create_client( Server.SOMFY_DEV_MODE, domain, token, session ) diff --git a/pyoverkiz/overkiz.py b/pyoverkiz/overkiz.py index 01c1f0f8..d7f1268b 100644 --- a/pyoverkiz/overkiz.py +++ b/pyoverkiz/overkiz.py @@ -153,7 +153,7 @@ class Overkiz: @staticmethod - def get_client_for( + def create_client( server: Server, username: str, password: str, session: ClientSession ) -> OverkizClient: """Get the client for the given server""" diff --git a/tests/test_client.py b/tests/test_client.py index b4bc8cb2..cd80bec6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,7 +16,7 @@ class TestOverkizClient: @fixture def client(self): - return Overkiz.get_client_for( + return Overkiz.create_client( Server.SOMFY_EUROPE, "foo", "pass", aiohttp.ClientSession() )