diff --git a/.gitignore b/.gitignore index b9d827d..dcbfda6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,10 @@ pyIndego.egg-info/ _build.cmd # vscode -.vscode/ \ No newline at end of file +.vscode/ + +# PyCharm +.idea/ + +# Python virtual environment +env/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 625cf93..d3ac2b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 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 + +## 2.0.30 +- 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/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 f7864d0..7ba1840 100644 --- a/pyIndego/const.py +++ b/pyIndego/const.py @@ -14,19 +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 = { - "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 = { @@ -205,9 +203,15 @@ class Methods(Enum): "104": "Stop button pushed", "101": "Mower lifted", "115": "Mower is stuck", + "1008": "Mower is stuck", "149": "Mower outside perimeter cable", "151": "Perimeter cable signal missing", "ntfy_blade_life": "Reminder blade life", + "1005": "Mower has not entered the charging station", + "smartMow.mowerUnreachable": "SmartMowing disabled", + "1108": "Too large tilt angle", + "1138": "Mower needs help", + "firmware.updateComplete": "Software update complete", } diff --git a/pyIndego/indego_async_client.py b/pyIndego/indego_async_client.py index 15b34d0..2ec3c23 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,14 +11,12 @@ 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, @@ -35,46 +33,49 @@ 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, session: aiohttp.ClientSession = None, + raise_request_exceptions: bool = False, ): """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. - + raise_request_exceptions (bool): Should unexpected API request exception be raised or not. Default False to keep things backwards compatible. """ - super().__init__(username, password, 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. + # 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) - - async def __aenter__(self): - """Enter for async with.""" - await self.start() - return self + self._should_close_session = True 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() + 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.""" + 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,34 +444,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 not self._serial: - list_of_mowers = await self.get("alms") - self._serial = list_of_mowers[0].get("alm_sn") - _LOGGER.debug("Serial 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, ): @@ -481,7 +460,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. @@ -489,80 +467,48 @@ 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: + _LOGGER.debug("Refreshing token") + self._token = await self._token_refresh_method() + else: + _LOGGER.debug("Token refresh is NOT available") + 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) + headers["Authorization"] = "Bearer %s" % self._token + 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, 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): 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) + _LOGGER.info("%s: Timeout on Bosch servers (mower offline?), retrying...", exc) return await self._request( method=method, path=path, @@ -570,16 +516,22 @@ 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: + 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 bde26bf..c4e7c4b 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, @@ -37,27 +38,30 @@ class IndegoBaseClient(ABC): 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, + raise_request_exceptions: bool = False, ): """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. - + raise_request_exceptions (bool): Should unexpected API request exception be raised or not. Default False to keep things backwards compatible. """ - 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 self._api_url = api_url + self._raise_request_exceptions = raise_request_exceptions self._logged_in = False self._online = False self._contextid = "" @@ -91,6 +95,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.""" @@ -401,19 +410,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 - @abstractmethod def _request( self, @@ -421,12 +417,29 @@ 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) -> 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 == 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 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 18670ed..95efd17 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,7 +11,6 @@ COMMANDS, CONTENT_TYPE, CONTENT_TYPE_JSON, - DEFAULT_BODY, DEFAULT_CALENDAR, DEFAULT_HEADER, Methods, @@ -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,34 +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 not self._serial: - list_of_mowers = self.get("alms") - self._serial = list_of_mowers[0].get("alm_sn") - _LOGGER.debug("Serial 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, ): @@ -417,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 + headers["Authorization"] = "Bearer %s" % self._token + 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, 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): 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, @@ -492,10 +441,15 @@ 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) + if self._raise_request_exceptions: + raise + _LOGGER.error("Request to %s gave a unhandled error: %s", url, exc) + return None def get(self, path: str, timeout: int = 30): diff --git a/setup.py b/setup.py index 1ca4dcc..8f32b9b 100644 --- a/setup.py +++ b/setup.py @@ -6,8 +6,8 @@ setup( name="pyIndego", - version="2.0.29", - author="jm-73", + version="2.2.1", + 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) - -