Skip to content

Commit

Permalink
Merge pull request #121 from jm-73/develop
Browse files Browse the repository at this point in the history
Develop
  • Loading branch information
jm-73 authored Jun 26, 2023
2 parents ca6adbf + ef8800e commit cf45b9c
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 224 deletions.
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/
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
# 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

## 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
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
21 changes: 10 additions & 11 deletions pyIndego/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Constants for pyIndego."""
from enum import Enum

import random
import string

class Methods(Enum):
"""Enum with HTTP methods."""
Expand All @@ -14,20 +15,18 @@ 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.
# We also need to use a random agent for each client: https://github.com/jm-73/pyIndego/issues/119
"User-Agent": ''.join(random.choices(string.ascii_uppercase + string.digits, k=12))
}
DEFAULT_LOOKUP_VALUE = "Not in database."

DEFAULT_CALENDAR = {
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

0 comments on commit cf45b9c

Please sign in to comment.