From 11082655af71ff658f3dfdb877a25622ed29ba2b Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Sat, 3 Dec 2022 13:57:48 +0100 Subject: [PATCH 01/18] Store the list of mowers detected during login. --- .gitignore | 5 ++++- pyIndego/indego_async_client.py | 15 +++++++++++---- pyIndego/indego_base_client.py | 7 +++++++ pyIndego/indego_client.py | 14 ++++++++++---- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index b9d827d..ce22c28 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ pyIndego.egg-info/ _build.cmd # vscode -.vscode/ \ No newline at end of file +.vscode/ + +# PyCharm +.idea/ \ No newline at end of file diff --git a/pyIndego/indego_async_client.py b/pyIndego/indego_async_client.py index 15b34d0..e8f7116 100644 --- a/pyIndego/indego_async_client.py +++ b/pyIndego/indego_async_client.py @@ -457,11 +457,18 @@ async def login(self, attempts: int = 0): self._login(response) if response is not None: _LOGGER.debug("Logged in") - if not self._serial: - list_of_mowers = await self.get("alms") - self._serial = list_of_mowers[0].get("alm_sn") - _LOGGER.debug("Serial added") + if self._serial is not None: + return True + + self._mowers_in_account = await self.get("alms") + if len(self._mowers_in_account) == 0: + _LOGGER.warning("No mowers found in account") + return False + + self._serial = self._mowers_in_account[0].get("alm_sn") + _LOGGER.debug("First mower added") return True + return False async def _request( # noqa: C901 diff --git a/pyIndego/indego_base_client.py b/pyIndego/indego_base_client.py index bde26bf..6293ec8 100644 --- a/pyIndego/indego_base_client.py +++ b/pyIndego/indego_base_client.py @@ -56,6 +56,7 @@ def __init__( self._username = username self._password = password self._serial = serial + self._mowers_in_account = None self.map_filename = map_filename self._api_url = api_url self._logged_in = False @@ -91,6 +92,11 @@ def serial(self): _LOGGER.warning("Serial not yet set, please login first") return None + @property + def mowers_in_account(self): + """Return the list of mower detected during login.""" + return self._mowers_in_account + @property def alerts_count(self): """Return the count of alerts.""" @@ -413,6 +419,7 @@ def _login(self, login): self._logged_in = True else: self._logged_in = False + self._mowers_in_account = None @abstractmethod def _request( diff --git a/pyIndego/indego_client.py b/pyIndego/indego_client.py index 18670ed..e9d3b25 100644 --- a/pyIndego/indego_client.py +++ b/pyIndego/indego_client.py @@ -396,10 +396,16 @@ def login(self, attempts: int = 0): self._login(response) if response is not None: _LOGGER.debug("Logged in") - if not self._serial: - list_of_mowers = self.get("alms") - self._serial = list_of_mowers[0].get("alm_sn") - _LOGGER.debug("Serial added") + if self._serial is not None: + return True + + self._mowers_in_account = self.get("alms") + if len(self._mowers_in_account) == 0: + _LOGGER.warning("No mowers found in account") + return False + + self._serial = self._mowers_in_account[0].get("alm_sn") + _LOGGER.debug("First mower added") return True return False From 8f5b0214c8270abc0edab3349225e6c400dd3d1e Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Sat, 3 Dec 2022 14:14:05 +0100 Subject: [PATCH 02/18] Updated version nr. --- CHANGELOG.md | 5 ++++- setup.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d200c8..6a83daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ # Changelog +## 2.0.33 +- Added storage of mowers list during login + ## 2.0.30 -- Adjusted python code for somw changes in the Bosch API +- Adjusted python code for some changes in the Bosch API ## 2.0.29 - Added new mower model S+ 700 gen 2 #120 diff --git a/setup.py b/setup.py index 7254343..8ab9b35 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="pyIndego", - version="2.0.32", + version="2.0.33", author="jm-73", author_email="jens@myretyr.se", description="API for Bosch Indego mower", From 23193558b01d575212693e2d444884f36e482aef Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Mon, 13 Mar 2023 23:52:35 +0100 Subject: [PATCH 03/18] Implemented OAuth authentication using Bosch SingleKey ID. Merged some re-usable code to base client. Added User-Agent header to fix block by Microsoft Azure proxy. Added API call to retrieve the available mowers/serials in the account. --- .gitignore | 5 +- CHANGELOG.md | 4 + README.md | 5 +- pyIndego/const.py | 17 ++-- pyIndego/indego_async_client.py | 134 ++++++++------------------------ pyIndego/indego_base_client.py | 76 ++++++++++++------ pyIndego/indego_client.py | 108 +++++++------------------ setup.py | 4 +- test_new.py | 25 +++--- 9 files changed, 145 insertions(+), 233 deletions(-) diff --git a/.gitignore b/.gitignore index ce22c28..dcbfda6 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,7 @@ _build.cmd .vscode/ # PyCharm -.idea/ \ No newline at end of file +.idea/ + +# Python virtual environment +env/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a83daf..d3ac2b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 2.1.0 +- Replaced old Bosch Indego authentication by Bosch SingleKey ID #171 +- Changed to new Bosch Indego API server #171 + ## 2.0.33 - Added storage of mowers list during login diff --git a/README.md b/README.md index 58353da..cf38505 100644 --- a/README.md +++ b/README.md @@ -380,13 +380,10 @@ Get the automatic update settings # API CALLS -https://api.indego.iot.bosch-si.com:443/api/v1 +https://api.indego-cloud.iot.bosch-si.com/api/v1/ ```python -post -/authenticate - get /alerts /alms diff --git a/pyIndego/const.py b/pyIndego/const.py index 1ce6d07..7ba1840 100644 --- a/pyIndego/const.py +++ b/pyIndego/const.py @@ -14,20 +14,17 @@ class Methods(Enum): HEAD = "HEAD" -DEFAULT_URL = "https://api.indego.iot.bosch-si.com/api/v1/" +DEFAULT_URL = "https://api.indego-cloud.iot.bosch-si.com/api/v1/" CONTENT_TYPE_JSON = "application/json" CONTENT_TYPE = "Content-Type" -DEFAULT_BODY = { - "accept_tc_id": "202012", - "device": "", - "os_type": "Android", - "os_version": "4.0", - "dvc_manuf": "unknown", - "dvc_type": "unknown", -} COMMANDS = ("mow", "pause", "returnToDock") -DEFAULT_HEADER = {CONTENT_TYPE: CONTENT_TYPE_JSON} +DEFAULT_HEADER = { + CONTENT_TYPE: CONTENT_TYPE_JSON, + # We need to change the user-agent! + # The Microsoft Azure proxy seems to block all requests (HTTP 403) for the default 'python-requests' user-agent. + "User-Agent": "pyIndego" +} DEFAULT_LOOKUP_VALUE = "Not in database." DEFAULT_CALENDAR = { diff --git a/pyIndego/indego_async_client.py b/pyIndego/indego_async_client.py index e8f7116..eec8aab 100644 --- a/pyIndego/indego_async_client.py +++ b/pyIndego/indego_async_client.py @@ -2,7 +2,7 @@ import asyncio import logging from socket import error as SocketError -from typing import Any +from typing import Any, Optional, Callable, Awaitable import aiohttp from aiohttp import ( @@ -11,20 +11,18 @@ ServerTimeoutError, TooManyRedirects, ) -from aiohttp.helpers import BasicAuth from aiohttp.web_exceptions import HTTPGatewayTimeout from . import __version__ from .const import ( COMMANDS, CONTENT_TYPE_JSON, - DEFAULT_BODY, DEFAULT_CALENDAR, DEFAULT_HEADER, DEFAULT_URL, Methods, ) -from .indego_base_client import IndegoBaseClient +from .indego_base_client import IndegoBaseClient, BearerAuth from .states import Calendar _LOGGER = logging.getLogger(__name__) @@ -35,8 +33,8 @@ class IndegoAsyncClient(IndegoBaseClient): def __init__( self, - username: str, - password: str, + token: str, + token_refresh_method: Optional[Callable[[], Awaitable[str]]] = None, serial: str = None, map_filename: str = None, api_url: str = DEFAULT_URL, @@ -45,37 +43,34 @@ def __init__( """Initialize the Async Client. Args: - username (str): username for Indego Account - password (str): password for Indego Account + token (str): Bosch SingleKey ID OAuth token + token_refresh_method (callback): Callback method to request an OAuth token refresh serial (str): serial number of the mower map_filename (str, optional): Filename to store maps in. Defaults to None. api_url (str, optional): url for the api, defaults to DEFAULT_URL. """ - super().__init__(username, password, serial, map_filename, api_url) + super().__init__(token, token_refresh_method, serial, map_filename, api_url) if session: self._session = session else: self._session = aiohttp.ClientSession(raise_for_status=False) - async def __aenter__(self): - """Enter for async with.""" - await self.start() - return self - async def __aexit__(self, exc_type, exc_value, traceback): """Exit for async with.""" await self.close() - async def start(self): - """Login if not done.""" - if not self._logged_in: - await self.login() - async def close(self): """Close the aiohttp session.""" await self._session.close() + async def get_mowers(self): + """Get a list of the available mowers (serials) in the account.""" + result = await self.get("alms") + if result is None: + return [] + return [mower['alm_sn'] for mower in result] + async def delete_alert(self, alert_index: int): """Delete the alert with the specified index. @@ -443,41 +438,12 @@ async def get_user(self): await self.update_user() return self.user - async def login(self, attempts: int = 0): - """Login to the api and store the context.""" - response = await self._request( - method=Methods.GET, - path="authenticate/check", - data=DEFAULT_BODY, - headers=DEFAULT_HEADER, - auth=BasicAuth(self._username, self._password), - timeout=30, - attempts=attempts, - ) - self._login(response) - if response is not None: - _LOGGER.debug("Logged in") - if self._serial is not None: - return True - - self._mowers_in_account = await self.get("alms") - if len(self._mowers_in_account) == 0: - _LOGGER.warning("No mowers found in account") - return False - - self._serial = self._mowers_in_account[0].get("alm_sn") - _LOGGER.debug("First mower added") - return True - - return False - async def _request( # noqa: C901 self, method: Methods, path: str, data: dict = None, headers: dict = None, - auth: BasicAuth = None, timeout: int = 30, attempts: int = 0, ): @@ -488,7 +454,6 @@ async def _request( # noqa: C901 path (str): url to call on top of base_url. data (dict, optional): if applicable, data to be sent, defaults to None. headers (dict, optional): headers to be included, defaults to None, which should be filled by the method. - auth (BasicAuth or HTTPBasicAuth, optional): login specific attribute, defaults to None. timeout (int, optional): Timeout for the api call. Defaults to 30. attempts (int, optional): Number to keep track of retries, after three starts delaying, after five quites. @@ -496,78 +461,43 @@ async def _request( # noqa: C901 if 3 <= attempts < 5: _LOGGER.info("Three or four attempts done, waiting 30 seconds") await asyncio.sleep(30) + if attempts == 5: _LOGGER.warning("Five attempts done, please try again later") return None + + if self._token_refresh_method is not None: + self.token = await self._token_refresh_method() + url = f"{self._api_url}{path}" + if not headers: headers = DEFAULT_HEADER.copy() - headers["x-im-context-id"] = self._contextid - _LOGGER.debug("Sending %s to %s", method.value, url) + try: + _LOGGER.debug("%s call to API endpoint %s", method.value, url) async with self._session.request( method=method.value, url=url, - json=data if data else DEFAULT_BODY, + json=data, headers=headers, - auth=auth, + auth=BearerAuth(self._token), timeout=timeout, ) as response: status = response.status - _LOGGER.debug("status: %s", status) + _LOGGER.debug("HTTP status code: %i", status) if status == 200: if response.content_type == CONTENT_TYPE_JSON: resp = await response.json() _LOGGER.debug("Response: %s", resp) - return resp # await response.json() + return resp return await response.content.read() - if status == 204: - _LOGGER.debug("204: No content in response from server") - return None - if status == 400: - _LOGGER.warning( - "400: Bad Request: won't retry. Message: %s", - (await response.content.read()).decode("UTF-8"), - ) - return None - if status == 401: - if path == "authenticate/check": - _LOGGER.info( - "401: Unauthorized, credentials are wrong, won't retry" - ) - return None - _LOGGER.info("401: Unauthorized: logging in again") - login_result = await self.login() - if login_result: - return await self._request( - method=method, - path=path, - data=data, - timeout=timeout, - attempts=attempts + 1, - ) - return None - if status == 403: - _LOGGER.error("403: Forbidden: won't retry") - return None - if status == 405: - _LOGGER.error( - "405: Method not allowed: %s is used but not allowed, try a different method for path %s, won't retry", - method, - path, - ) - return None - if status == 500: - _LOGGER.debug("500: Internal Server Error") - return None - if status == 501: - _LOGGER.debug("501: Not implemented yet") + + if self._log_request_result(status, url, path): return None - if status == 504: - if url.find("longpoll=true") > 0: - _LOGGER.debug("504: longpoll stopped, no updates") - return None + response.raise_for_status() + except (asyncio.TimeoutError, ServerTimeoutError, HTTPGatewayTimeout) as exc: _LOGGER.info("%s: Timeout on Bosch servers, retrying", exc) return await self._request( @@ -577,15 +507,19 @@ async def _request( # noqa: C901 timeout=timeout, attempts=attempts + 1, ) + except ClientOSError as exc: _LOGGER.debug("%s: Failed to update Indego status, longpoll timeout", exc) return None + except (TooManyRedirects, ClientResponseError, SocketError) as exc: _LOGGER.error("%s: Failed %s to Indego, won't retry", exc, method.value) return None + except asyncio.CancelledError: _LOGGER.debug("Task cancelled by task runner") return None + except Exception as exc: _LOGGER.error("Request to %s gave a unhandled error: %s", url, exc) return None diff --git a/pyIndego/indego_base_client.py b/pyIndego/indego_base_client.py index 6293ec8..01d0510 100644 --- a/pyIndego/indego_base_client.py +++ b/pyIndego/indego_base_client.py @@ -1,9 +1,10 @@ """Base class for indego.""" import logging from abc import ABC, abstractmethod -from typing import Any +from typing import Any, Optional, Callable, Awaitable import pytz +import requests from .const import ( DEFAULT_CALENDAR, @@ -32,13 +33,22 @@ _LOGGER = logging.getLogger(__name__) +class BearerAuth(requests.auth.AuthBase): + def __init__(self, token): + self.token = token + + def __call__(self, r): + r.headers["Authorization"] = "Bearer " + self.token + return r + + class IndegoBaseClient(ABC): """Indego base client class.""" def __init__( self, - username: str, - password: str, + token: str, + token_refresh_method: Optional[Callable[[], Awaitable[str]]] = None, serial: str = None, map_filename: str = None, api_url: str = DEFAULT_URL, @@ -46,15 +56,15 @@ def __init__( """Abstract class for the Indego Clent, only use the Indego Client or Indego Async Client. Args: - username (str): username for Indego Account - password (str): password for Indego Account + token (str): Bosch SingleKey ID OAuth token + token_refresh_method (callback): Callback method to request an OAuth token refresh serial (str): serial number of the mower map_filename (str, optional): Filename to store maps in. Defaults to None. api_url (str, optional): url for the api, defaults to DEFAULT_URL. """ - self._username = username - self._password = password + self._token = token + self._token_refresh_method = token_refresh_method self._serial = serial self._mowers_in_account = None self.map_filename = map_filename @@ -407,20 +417,6 @@ def _update_user(self, new): if new: self.user = generate_update(self.user, new, User) - @abstractmethod - def login(self, attempts: int = 0): - """Login to the Indego API.""" - - def _login(self, login): - """Login to the Indego API.""" - if login: - self._contextid = login["contextId"] - self._userid = login["userId"] - self._logged_in = True - else: - self._logged_in = False - self._mowers_in_account = None - @abstractmethod def _request( self, @@ -428,12 +424,48 @@ def _request( path: str, data: dict = None, headers: dict = None, - auth: Any = None, timeout: int = 30, attempts: int = 0, ): """Request implemented by the subclasses either synchronously or asynchronously.""" + def _log_request_result(self, status: int, url: str, path: str) -> bool: + """Log the API request result for certain status codes.""" + + if status == 204: + _LOGGER.info("204: No content in response from server") + return True + + if status == 400: + _LOGGER.error("400: Bad Request, won't retry") + return True + + if status == 401: + _LOGGER.error("401: Unauthorized, OAuth token is wrong, won't retry") + return True + + if status == 403: + _LOGGER.error("403: Forbidden, won't retry") + return True + + if status == 405: + _LOGGER.error("405: Method not allowed: Get is used but not allowed, try a different method for path %s, won't retry", path) + return True + + if status == 500: + _LOGGER.info("500: Internal Server Error") + return True + + if status == 501: + _LOGGER.info("501: Not implemented yet") + return True + + if status == 504 and url.find("longpoll=true") > 0: + _LOGGER.info("504: longpoll stopped, no updates") + return True + + return False + @abstractmethod def get(self, path: str, timeout: int): """Get implemented by the subclasses either synchronously or asynchronously.""" diff --git a/pyIndego/indego_client.py b/pyIndego/indego_client.py index e9d3b25..b90c4aa 100644 --- a/pyIndego/indego_client.py +++ b/pyIndego/indego_client.py @@ -4,7 +4,6 @@ import typing import requests -from requests.auth import HTTPBasicAuth from requests.exceptions import RequestException, Timeout, TooManyRedirects from . import __version__ @@ -12,12 +11,11 @@ COMMANDS, CONTENT_TYPE, CONTENT_TYPE_JSON, - DEFAULT_BODY, DEFAULT_CALENDAR, DEFAULT_HEADER, Methods, ) -from .indego_base_client import IndegoBaseClient +from .indego_base_client import IndegoBaseClient, BearerAuth from .states import Calendar _LOGGER = logging.getLogger(__name__) @@ -28,17 +26,18 @@ class IndegoClient(IndegoBaseClient): def __enter__(self): """Enter for with.""" - self.start() return self def __exit__(self, exc_type, exc_value, traceback): """Exit for with.""" pass - def start(self): - """Login if not done.""" - if not self._logged_in: - self.login() + def get_mowers(self): + """Get a list of the available mowers (serials) in the account.""" + result = self.get("alms") + if result is None: + return [] + return [mower['alm_sn'] for mower in result] def delete_alert(self, alert_index: int): """Delete the alert with the specified index. @@ -382,40 +381,12 @@ def get_user(self): self.update_user() return self.user - def login(self, attempts: int = 0): - """Login to the api and store the context.""" - response = self._request( - method=Methods.POST, - path="authenticate", - data=DEFAULT_BODY, - headers=DEFAULT_HEADER, - auth=HTTPBasicAuth(self._username, self._password), - timeout=30, - attempts=attempts, - ) - self._login(response) - if response is not None: - _LOGGER.debug("Logged in") - if self._serial is not None: - return True - - self._mowers_in_account = self.get("alms") - if len(self._mowers_in_account) == 0: - _LOGGER.warning("No mowers found in account") - return False - - self._serial = self._mowers_in_account[0].get("alm_sn") - _LOGGER.debug("First mower added") - return True - return False - def _request( # noqa: C901 self, method: Methods, path: str, data: dict = None, - headers=None, - auth=None, + headers: dict = None, timeout: int = 30, attempts: int = 0, ): @@ -423,73 +394,45 @@ def _request( # noqa: C901 if attempts >= 3: _LOGGER.warning("Three attempts done, waiting 30 seconds.") time.sleep(30) + if attempts >= 5: _LOGGER.warning("Five attempts done, please try again manually.") return None + + if self._token_refresh_method is not None: + self.token = self._token_refresh_method() + url = f"{self._api_url}{path}" + if not headers: headers = DEFAULT_HEADER.copy() - headers["x-im-context-id"] = self._contextid + try: + _LOGGER.debug("%s call to API endpoint %s", method.value, url) response = requests.request( method=method.value, url=url, json=data, headers=headers, - auth=auth, + auth=BearerAuth(self._token), timeout=timeout, ) status = response.status_code + _LOGGER.debug("HTTP status code: %i", status) + if status == 200: if method in (Methods.DELETE, Methods.PATCH, Methods.PUT): return True if CONTENT_TYPE_JSON in response.headers[CONTENT_TYPE].split(";"): return response.json() return response.content - if status == 204: - _LOGGER.info("204: No content in response from server") - return None - if status == 400: - _LOGGER.error("400: Bad Request: won't retry.") - return None - if status == 401: - if path == "authenticate": - _LOGGER.info( - "401: Unauthorized, credentials are wrong, won't retry" - ) - return None - _LOGGER.info("401: Unauthorized: logging in again") - login_result = self.login() - if login_result: - return self._request( - method=method, - path=path, - data=data, - timeout=timeout, - attempts=attempts + 1, - ) - return None - if status == 403: - _LOGGER.error("403: Forbidden: won't retry.") - return None - if status == 405: - _LOGGER.error( - "405: Method not allowed: Get is used but not allowerd, try a different method for path %s, won't retry.", - path, - ) - return None - if status == 500: - _LOGGER.info("500: Internal Server Error") - return None - if status == 501: - _LOGGER.info("501: Not implemented yet") + + if self._log_request_result(status, url, path): return None - if status == 504: - if url.find("longpoll=true") > 0: - _LOGGER.info("504: longpoll stopped, no updates.") - return None + response.raise_for_status() - except (Timeout) as exc: + + except Timeout as exc: _LOGGER.error("%s: Timeout on Bosch servers, retrying.", exc) return self._request( method=method, @@ -498,10 +441,13 @@ def _request( # noqa: C901 timeout=timeout, attempts=attempts + 1, ) + except (TooManyRedirects, RequestException) as exc: _LOGGER.error("%s: Failed to update Indego status.", exc) + except Exception as exc: _LOGGER.error("Get gave a unhandled error: %s", exc) + return None def get(self, path: str, timeout: int = 30): diff --git a/setup.py b/setup.py index 8ab9b35..21899c0 100644 --- a/setup.py +++ b/setup.py @@ -6,8 +6,8 @@ setup( name="pyIndego", - version="2.0.33", - author="jm-73", + version="2.1.0", + author="jm-73, sander1988", author_email="jens@myretyr.se", description="API for Bosch Indego mower", long_description=long_description, diff --git a/test_new.py b/test_new.py index 558c642..a281674 100644 --- a/test_new.py +++ b/test_new.py @@ -19,15 +19,17 @@ def main(config): # indego.download_map() # print("map: ", indego.map_filename) - print(" ") - print("=[indego.update_state]===") - indego.update_state() - print(indego.state) - print(f"State: {indego.state_description}") - print(f"State detail: {indego.state_description_detail}") + # print(" ") + # print("=[indego.update_state]===") + # indego.update_state() + # print(indego.state) + # print(f"State: {indego.state_description}") + # print(f"State detail: {indego.state_description_detail}") + # + # print(f"xPos: {indego.state.xPos}") + # print(f"yPos: {indego.state.yPos}") - print(f"xPos: {indego.state.xPos}") - print(f"yPos: {indego.state.yPos}") + print(f"Mowers: {indego.get_mowers()}") print(f"Serial: {indego.serial}") #print(" ") @@ -67,8 +69,8 @@ def main(config): print(" ") print("=[indego.update_last_mow]====") - indego.update_last_mow() - print(indego.last_mow) + indego.update_last_completed_mow() + print(indego.last_completed_mow) #print(" ") #print("=[update_location]====") @@ -131,7 +133,4 @@ def main(config): print(" ") print("Sleep for 60 seconds") - time.sleep(60) - - From 5f7bbf847aed12c756f1a7eee1c6277ad4da5ae9 Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Tue, 14 Mar 2023 00:14:11 +0100 Subject: [PATCH 04/18] Custom BearerAuth object didn't work with aiohttp session ; so different implementation. --- pyIndego/indego_async_client.py | 4 ++-- pyIndego/indego_base_client.py | 9 --------- pyIndego/indego_client.py | 6 +++--- 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/pyIndego/indego_async_client.py b/pyIndego/indego_async_client.py index eec8aab..7d248b2 100644 --- a/pyIndego/indego_async_client.py +++ b/pyIndego/indego_async_client.py @@ -22,7 +22,7 @@ DEFAULT_URL, Methods, ) -from .indego_base_client import IndegoBaseClient, BearerAuth +from .indego_base_client import IndegoBaseClient from .states import Calendar _LOGGER = logging.getLogger(__name__) @@ -473,6 +473,7 @@ async def _request( # noqa: C901 if not headers: headers = DEFAULT_HEADER.copy() + headers["Authorization"] = "Bearer %s" % self._token try: _LOGGER.debug("%s call to API endpoint %s", method.value, url) @@ -481,7 +482,6 @@ async def _request( # noqa: C901 url=url, json=data, headers=headers, - auth=BearerAuth(self._token), timeout=timeout, ) as response: status = response.status diff --git a/pyIndego/indego_base_client.py b/pyIndego/indego_base_client.py index 01d0510..ec676c5 100644 --- a/pyIndego/indego_base_client.py +++ b/pyIndego/indego_base_client.py @@ -33,15 +33,6 @@ _LOGGER = logging.getLogger(__name__) -class BearerAuth(requests.auth.AuthBase): - def __init__(self, token): - self.token = token - - def __call__(self, r): - r.headers["Authorization"] = "Bearer " + self.token - return r - - class IndegoBaseClient(ABC): """Indego base client class.""" diff --git a/pyIndego/indego_client.py b/pyIndego/indego_client.py index b90c4aa..efbd0f7 100644 --- a/pyIndego/indego_client.py +++ b/pyIndego/indego_client.py @@ -15,7 +15,7 @@ DEFAULT_HEADER, Methods, ) -from .indego_base_client import IndegoBaseClient, BearerAuth +from .indego_base_client import IndegoBaseClient from .states import Calendar _LOGGER = logging.getLogger(__name__) @@ -406,6 +406,7 @@ def _request( # noqa: C901 if not headers: headers = DEFAULT_HEADER.copy() + headers["Authorization"] = "Bearer %s" % self._token try: _LOGGER.debug("%s call to API endpoint %s", method.value, url) @@ -414,7 +415,6 @@ def _request( # noqa: C901 url=url, json=data, headers=headers, - auth=BearerAuth(self._token), timeout=timeout, ) status = response.status_code @@ -446,7 +446,7 @@ def _request( # noqa: C901 _LOGGER.error("%s: Failed to update Indego status.", exc) except Exception as exc: - _LOGGER.error("Get gave a unhandled error: %s", exc) + _LOGGER.error("Request to %s gave a unhandled error: %s", url, exc) return None From e8d9227791ee65b28e0d831838f55bcfd07e82b9 Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Tue, 14 Mar 2023 19:33:49 +0100 Subject: [PATCH 05/18] Improved logging output. Fixed issue where the lib closes the HA HTTP session. --- pyIndego/indego_async_client.py | 9 +++++++-- pyIndego/indego_base_client.py | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pyIndego/indego_async_client.py b/pyIndego/indego_async_client.py index 7d248b2..28d48e2 100644 --- a/pyIndego/indego_async_client.py +++ b/pyIndego/indego_async_client.py @@ -53,8 +53,12 @@ def __init__( super().__init__(token, token_refresh_method, serial, map_filename, api_url) if session: self._session = session + # We should only close session we own. + # In this case don't own it, probably a reference from HA. + self._should_close_session = False else: self._session = aiohttp.ClientSession(raise_for_status=False) + self._should_close_session = True async def __aexit__(self, exc_type, exc_value, traceback): """Exit for async with.""" @@ -62,7 +66,8 @@ async def __aexit__(self, exc_type, exc_value, traceback): async def close(self): """Close the aiohttp session.""" - await self._session.close() + if self._should_close_session: + await self._session.close() async def get_mowers(self): """Get a list of the available mowers (serials) in the account.""" @@ -499,7 +504,7 @@ async def _request( # noqa: C901 response.raise_for_status() except (asyncio.TimeoutError, ServerTimeoutError, HTTPGatewayTimeout) as exc: - _LOGGER.info("%s: Timeout on Bosch servers, retrying", exc) + _LOGGER.info("%s: Timeout on Bosch servers (mower offline?), retrying...", exc) return await self._request( method=method, path=path, diff --git a/pyIndego/indego_base_client.py b/pyIndego/indego_base_client.py index ec676c5..d80322b 100644 --- a/pyIndego/indego_base_client.py +++ b/pyIndego/indego_base_client.py @@ -424,7 +424,7 @@ def _log_request_result(self, status: int, url: str, path: str) -> bool: """Log the API request result for certain status codes.""" if status == 204: - _LOGGER.info("204: No content in response from server") + _LOGGER.info("204: No content in response from server, ignoring") return True if status == 400: @@ -444,11 +444,11 @@ def _log_request_result(self, status: int, url: str, path: str) -> bool: return True if status == 500: - _LOGGER.info("500: Internal Server Error") + _LOGGER.info("500: Internal Server Error, won't retry") return True if status == 501: - _LOGGER.info("501: Not implemented yet") + _LOGGER.info("501: Not implemented yet, ignoring") return True if status == 504 and url.find("longpoll=true") > 0: From b2f92ea04351fd1651d35727ddf09f500a984249 Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Tue, 14 Mar 2023 20:01:09 +0100 Subject: [PATCH 06/18] Fixed bug in token refresh. --- pyIndego/indego_async_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyIndego/indego_async_client.py b/pyIndego/indego_async_client.py index 28d48e2..e9510f4 100644 --- a/pyIndego/indego_async_client.py +++ b/pyIndego/indego_async_client.py @@ -472,7 +472,7 @@ async def _request( # noqa: C901 return None if self._token_refresh_method is not None: - self.token = await self._token_refresh_method() + self._token = await self._token_refresh_method() url = f"{self._api_url}{path}" From ce2d2ae479ad8f95bc2a34ac1fb50d552f8575ae Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Tue, 14 Mar 2023 21:13:26 +0100 Subject: [PATCH 07/18] Updated log levels. --- pyIndego/indego_base_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyIndego/indego_base_client.py b/pyIndego/indego_base_client.py index d80322b..95831be 100644 --- a/pyIndego/indego_base_client.py +++ b/pyIndego/indego_base_client.py @@ -424,7 +424,7 @@ def _log_request_result(self, status: int, url: str, path: str) -> bool: """Log the API request result for certain status codes.""" if status == 204: - _LOGGER.info("204: No content in response from server, ignoring") + _LOGGER.debug("204: No content in response from server, ignoring") return True if status == 400: @@ -444,15 +444,15 @@ def _log_request_result(self, status: int, url: str, path: str) -> bool: return True if status == 500: - _LOGGER.info("500: Internal Server Error, won't retry") + _LOGGER.warning("500: Internal Server Error, won't retry") return True if status == 501: - _LOGGER.info("501: Not implemented yet, ignoring") + _LOGGER.debug("501: Not implemented yet, ignoring") return True if status == 504 and url.find("longpoll=true") > 0: - _LOGGER.info("504: longpoll stopped, no updates") + _LOGGER.debug("504: longpoll stopped, no updates") return True return False From e3cbd26979ed3dd4ebad5e0cd4d77e15f75a4268 Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Sat, 1 Apr 2023 12:35:41 +0200 Subject: [PATCH 08/18] Version 2.2.0 : Added option to raise (unexpected) API request exception to improve handling by other applications like HomeAssistant. --- pyIndego/indego_async_client.py | 14 ++++++++----- pyIndego/indego_base_client.py | 35 +++++++++------------------------ pyIndego/indego_client.py | 4 +++- setup.py | 2 +- 4 files changed, 22 insertions(+), 33 deletions(-) diff --git a/pyIndego/indego_async_client.py b/pyIndego/indego_async_client.py index e9510f4..00e7942 100644 --- a/pyIndego/indego_async_client.py +++ b/pyIndego/indego_async_client.py @@ -39,6 +39,7 @@ def __init__( map_filename: str = None, api_url: str = DEFAULT_URL, session: aiohttp.ClientSession = None, + raise_request_exceptions: bool = False, ): """Initialize the Async Client. @@ -48,9 +49,9 @@ def __init__( serial (str): serial number of the mower map_filename (str, optional): Filename to store maps in. Defaults to None. api_url (str, optional): url for the api, defaults to DEFAULT_URL. - + raise_request_exceptions (bool): Should unexpected API request exception be raised or not. Default False to keep things backwards compatible. """ - super().__init__(token, token_refresh_method, serial, map_filename, api_url) + super().__init__(token, token_refresh_method, serial, map_filename, api_url, raise_request_exceptions) if session: self._session = session # We should only close session we own. @@ -385,7 +386,7 @@ async def update_state(self, force=False, longpoll=False, longpoll_timeout=120): """ if not self.serial: - return + return False path = f"alms/{self.serial}/state" if longpoll: if longpoll_timeout > 300: @@ -403,7 +404,8 @@ async def update_state(self, force=False, longpoll=False, longpoll_timeout=120): else: path = f"{path}?forceRefresh=true" - self._update_state(await self.get(path, timeout=longpoll_timeout + 30)) + new_state = await self.get(path, timeout=longpoll_timeout + 30) + return self._update_state(new_state) async def get_state(self, force=False, longpoll=False, longpoll_timeout=120): """Update state and return it. @@ -498,7 +500,7 @@ async def _request( # noqa: C901 return resp return await response.content.read() - if self._log_request_result(status, url, path): + if self._log_request_result(status, url): return None response.raise_for_status() @@ -526,6 +528,8 @@ async def _request( # noqa: C901 return None except Exception as exc: + if self._raise_request_exceptions: + raise _LOGGER.error("Request to %s gave a unhandled error: %s", url, exc) return None diff --git a/pyIndego/indego_base_client.py b/pyIndego/indego_base_client.py index 95831be..c4e7c4b 100644 --- a/pyIndego/indego_base_client.py +++ b/pyIndego/indego_base_client.py @@ -43,6 +43,7 @@ def __init__( serial: str = None, map_filename: str = None, api_url: str = DEFAULT_URL, + raise_request_exceptions: bool = False, ): """Abstract class for the Indego Clent, only use the Indego Client or Indego Async Client. @@ -52,7 +53,7 @@ def __init__( serial (str): serial number of the mower map_filename (str, optional): Filename to store maps in. Defaults to None. api_url (str, optional): url for the api, defaults to DEFAULT_URL. - + raise_request_exceptions (bool): Should unexpected API request exception be raised or not. Default False to keep things backwards compatible. """ self._token = token self._token_refresh_method = token_refresh_method @@ -60,6 +61,7 @@ def __init__( self._mowers_in_account = None self.map_filename = map_filename self._api_url = api_url + self._raise_request_exceptions = raise_request_exceptions self._logged_in = False self._online = False self._contextid = "" @@ -420,41 +422,22 @@ def _request( ): """Request implemented by the subclasses either synchronously or asynchronously.""" - def _log_request_result(self, status: int, url: str, path: str) -> bool: + def _log_request_result(self, status: int, url: str) -> bool: """Log the API request result for certain status codes.""" + """Return False if the status is fatal and should be raised.""" if status == 204: _LOGGER.debug("204: No content in response from server, ignoring") return True - if status == 400: - _LOGGER.error("400: Bad Request, won't retry") - return True - - if status == 401: - _LOGGER.error("401: Unauthorized, OAuth token is wrong, won't retry") - return True - - if status == 403: - _LOGGER.error("403: Forbidden, won't retry") - return True - - if status == 405: - _LOGGER.error("405: Method not allowed: Get is used but not allowed, try a different method for path %s, won't retry", path) - return True - - if status == 500: - _LOGGER.warning("500: Internal Server Error, won't retry") - return True - - if status == 501: - _LOGGER.debug("501: Not implemented yet, ignoring") - return True - if status == 504 and url.find("longpoll=true") > 0: _LOGGER.debug("504: longpoll stopped, no updates") return True + if 400 <= status < 600: + _LOGGER.error("Request to '%s' failed with HTTP status code: %i", url, status) + return not self._raise_request_exceptions + return False @abstractmethod diff --git a/pyIndego/indego_client.py b/pyIndego/indego_client.py index efbd0f7..95efd17 100644 --- a/pyIndego/indego_client.py +++ b/pyIndego/indego_client.py @@ -427,7 +427,7 @@ def _request( # noqa: C901 return response.json() return response.content - if self._log_request_result(status, url, path): + if self._log_request_result(status, url): return None response.raise_for_status() @@ -446,6 +446,8 @@ def _request( # noqa: C901 _LOGGER.error("%s: Failed to update Indego status.", exc) except Exception as exc: + if self._raise_request_exceptions: + raise _LOGGER.error("Request to %s gave a unhandled error: %s", url, exc) return None diff --git a/setup.py b/setup.py index 21899c0..341393a 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="pyIndego", - version="2.1.0", + version="2.2.0", author="jm-73, sander1988", author_email="jens@myretyr.se", description="API for Bosch Indego mower", From 2df0e225246869e04f275ae14e1931a4193e3bcc Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Sat, 1 Apr 2023 12:41:22 +0200 Subject: [PATCH 09/18] Reverted a few changes. --- pyIndego/indego_async_client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyIndego/indego_async_client.py b/pyIndego/indego_async_client.py index 00e7942..a52cad4 100644 --- a/pyIndego/indego_async_client.py +++ b/pyIndego/indego_async_client.py @@ -386,7 +386,7 @@ async def update_state(self, force=False, longpoll=False, longpoll_timeout=120): """ if not self.serial: - return False + return path = f"alms/{self.serial}/state" if longpoll: if longpoll_timeout > 300: @@ -404,8 +404,7 @@ async def update_state(self, force=False, longpoll=False, longpoll_timeout=120): else: path = f"{path}?forceRefresh=true" - new_state = await self.get(path, timeout=longpoll_timeout + 30) - return self._update_state(new_state) + self._update_state(await self.get(path, timeout=longpoll_timeout + 30)) async def get_state(self, force=False, longpoll=False, longpoll_timeout=120): """Update state and return it. From 774f8e72112998f85d15881771d612576dc1840b Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Sat, 15 Apr 2023 19:18:33 +0200 Subject: [PATCH 10/18] Additional logging for #173 --- pyIndego/indego_async_client.py | 3 +++ setup.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pyIndego/indego_async_client.py b/pyIndego/indego_async_client.py index a52cad4..191dfda 100644 --- a/pyIndego/indego_async_client.py +++ b/pyIndego/indego_async_client.py @@ -473,7 +473,10 @@ async def _request( # noqa: C901 return None if self._token_refresh_method is not None: + _LOGGER.debug("Refreshing token") self._token = await self._token_refresh_method() + else: + _LOGGER.warning("Token refresh is not available") url = f"{self._api_url}{path}" diff --git a/setup.py b/setup.py index 341393a..8f32b9b 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="pyIndego", - version="2.2.0", + version="2.2.1", author="jm-73, sander1988", author_email="jens@myretyr.se", description="API for Bosch Indego mower", From 65757287f856c92b9903f3e5491f155ec08d2542 Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Sat, 15 Apr 2023 19:30:22 +0200 Subject: [PATCH 11/18] Debug instead of warning. --- pyIndego/indego_async_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyIndego/indego_async_client.py b/pyIndego/indego_async_client.py index 191dfda..2ec3c23 100644 --- a/pyIndego/indego_async_client.py +++ b/pyIndego/indego_async_client.py @@ -476,7 +476,7 @@ async def _request( # noqa: C901 _LOGGER.debug("Refreshing token") self._token = await self._token_refresh_method() else: - _LOGGER.warning("Token refresh is not available") + _LOGGER.debug("Token refresh is NOT available") url = f"{self._api_url}{path}" From b60d2a02fdb7b2f59ce151d5dde37588251a6202 Mon Sep 17 00:00:00 2001 From: Jens MM Date: Thu, 27 Apr 2023 07:28:39 +0200 Subject: [PATCH 12/18] Preparing release --- CHANGELOG.md | 7 ++++++- setup.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d200c8..6b57705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,12 @@ # Changelog +## 3.0.0 +- Added OAuth (Bosch SingleKey ID support) Doest work anymore: New API / Switch to Bosch Single ID #116. +- Improved logging output. +- Added call to retrieve the available mowers/serial a the account. + ## 2.0.30 -- Adjusted python code for somw changes in the Bosch API +- Adjusted python code for some changes in the Bosch API ## 2.0.29 - Added new mower model S+ 700 gen 2 #120 diff --git a/setup.py b/setup.py index 7254343..2c6f4f7 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="pyIndego", - version="2.0.32", + version="3.0.0", author="jm-73", author_email="jens@myretyr.se", description="API for Bosch Indego mower", From a2b5f542de886d80483b3f1aba41a34f67d03ce2 Mon Sep 17 00:00:00 2001 From: Jens MM Date: Thu, 27 Apr 2023 07:34:16 +0200 Subject: [PATCH 13/18] preparing release --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3e26ce7..93bc5da 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="pyIndego", - version="3.0.0", + version="3.0.1", author="jm-73, sander1988", author_email="jens@myretyr.se", description="API for Bosch Indego mower", From 4606ed1f18daaac2205c35cdd38499a18242ed19 Mon Sep 17 00:00:00 2001 From: Jens MM Date: Thu, 27 Apr 2023 07:38:14 +0200 Subject: [PATCH 14/18] preparing release 3.0.1 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b57705..2a4c86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 3.0.1 +- Bugfixes when I made some major errors when relleasing 3.0.0 + ## 3.0.0 - Added OAuth (Bosch SingleKey ID support) Doest work anymore: New API / Switch to Bosch Single ID #116. - Improved logging output. From 40fe4ee5a6ae5b2995b6b56acf6148171d939767 Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Thu, 1 Jun 2023 13:09:25 +0200 Subject: [PATCH 15/18] Use a unique user agent for each client as a workaround for #119 --- pyIndego/const.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyIndego/const.py b/pyIndego/const.py index 7ba1840..517ace2 100644 --- a/pyIndego/const.py +++ b/pyIndego/const.py @@ -1,6 +1,7 @@ """Constants for pyIndego.""" from enum import Enum - +import random +import string class Methods(Enum): """Enum with HTTP methods.""" @@ -23,7 +24,8 @@ class Methods(Enum): CONTENT_TYPE: CONTENT_TYPE_JSON, # We need to change the user-agent! # The Microsoft Azure proxy seems to block all requests (HTTP 403) for the default 'python-requests' user-agent. - "User-Agent": "pyIndego" + # We also need to use a random agent for each client: https://github.com/jm-73/pyIndego/issues/119 + "User-Agent": "pyIndego-" + ''.join(random.choices(string.ascii_uppercase + string.digits, k=12)) } DEFAULT_LOOKUP_VALUE = "Not in database." From 6e9a8ffb2666ea7a94ddc5906be4ff897cb1c449 Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Thu, 1 Jun 2023 13:11:47 +0200 Subject: [PATCH 16/18] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8f32b9b..afdce57 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="pyIndego", - version="2.2.1", + version="2.2.2", author="jm-73, sander1988", author_email="jens@myretyr.se", description="API for Bosch Indego mower", From c270bf5cf2d9a549c33a960523f8211aec997fac Mon Sep 17 00:00:00 2001 From: Sander Peterse Date: Thu, 1 Jun 2023 13:22:01 +0200 Subject: [PATCH 17/18] User agent needs to be fully unique ; or it's still getting blocked with 403 (#119). --- pyIndego/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyIndego/const.py b/pyIndego/const.py index 517ace2..e27ecf6 100644 --- a/pyIndego/const.py +++ b/pyIndego/const.py @@ -25,7 +25,7 @@ class Methods(Enum): # We need to change the user-agent! # The Microsoft Azure proxy seems to block all requests (HTTP 403) for the default 'python-requests' user-agent. # We also need to use a random agent for each client: https://github.com/jm-73/pyIndego/issues/119 - "User-Agent": "pyIndego-" + ''.join(random.choices(string.ascii_uppercase + string.digits, k=12)) + "User-Agent": ''.join(random.choices(string.ascii_uppercase + string.digits, k=12)) } DEFAULT_LOOKUP_VALUE = "Not in database." From ef8800efb322846a65a46147e06204774683ef64 Mon Sep 17 00:00:00 2001 From: Jens MM Date: Mon, 26 Jun 2023 16:03:15 +0200 Subject: [PATCH 18/18] Preparing release 3.1.0 --- CHANGELOG.md | 3 +++ setup.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a4c86d..179c8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 3.1.0 +- Changed the useragent for communication with the Bosch server API. + ## 3.0.1 - Bugfixes when I made some major errors when relleasing 3.0.0 diff --git a/setup.py b/setup.py index 93bc5da..57efa35 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="pyIndego", - version="3.0.1", + version="3.1.0", author="jm-73, sander1988", author_email="jens@myretyr.se", description="API for Bosch Indego mower",