Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds OAuth (Bosch SingleKey ID support) #117

Merged
merged 21 commits into from
Apr 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,10 @@ pyIndego.egg-info/
_build.cmd

# vscode
.vscode/
.vscode/

# PyCharm
.idea/

# Python virtual environment
env/
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
5 changes: 1 addition & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 13 additions & 9 deletions pyIndego/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
}


Expand Down
142 changes: 47 additions & 95 deletions pyIndego/indego_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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,
):
Expand All @@ -481,105 +460,78 @@ 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.

"""
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,
data=data,
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

Expand Down
Loading