Skip to content

Commit

Permalink
Add oauth
Browse files Browse the repository at this point in the history
  • Loading branch information
Santobert committed Nov 16, 2020
1 parent 547f45d commit 59a663e
Show file tree
Hide file tree
Showing 10 changed files with 402 additions and 124 deletions.
48 changes: 40 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,54 @@ robot.schedule_enabled = False
```

### Account
If the serial and secret is unknown, they can be retrieved using the Account class.
If the serial and secret are unknown, they can be retrieved using the Account class.
You need a session instance to create an account.
There are three different types of sessions available.
It depends on your provider which session is suitable for you.

* **PasswordSession** lets you authenticate via E-Mail and Password. Even though this works fine, it is not recommended.
* **OAuthSession** lets you authenticate via OAuth2. You have to create an application [here](https://developers.neatorobotics.com/applications) in order to generate `client_id`, `client_secret` and `redirect_url`.
* **PasswordlessSession** is known to work for users of the new MyKobold App. The only known `client_id` is `KY4YbVAvtgB7lp8vIbWQ7zLk3hssZlhR`.

```python
>>> from pybotvac import Account
>>> # List all robots associated with account
>>> for robot in Account('[email protected]', 'sample_password').robots:
... print(robot)
Name: my_robot_name, Serial: OPS01234-0123456789AB, Secret: 0123456789ABCDEF0123456789ABCDEF, Traits: ['maps']
from pybotvac import Account, Neato, OAuthSession, PasswordlessSession, PasswordSession, Vorwerk

email = "Your email"
password = "Your password"
client_id = "Your client it"
client_secret = "Your client secret"
redirect_uri = "Your redirect URI"

# Authenticate via Email and Password
password_session = PasswordSession(email=email, password=password, vendor=Neato())

# Authenticate via OAuth2
oauth_session = OAuthSession(client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, vendor=Neato())
authorization_url = oauth_session.get_authorization_url()
print("Visit: " + authorization_url)
authorization_response = input("Enter the full callback URL: ")
token = oauth_session.fetch_token(authorization_response)

# Authenticate via One Time Password
passwordless_session = PasswordlessSession(client_id=client_id, vendor=Vorwerk())
passwordless_session.send_email_otp(email)
code = input("Enter the code: ")
passwordless_session.fetch_token_passwordless(email, code)

# Create an account with one of the generated sessions
account = Account(password_session)

# List all robots associated with account
for robot in account.robots:
print(robot)
```

Information about maps and download of maps can be done from the Account class:

```python
>>> from pybotvac import Account
>>> # List all maps associated with a specific robot
>>> for map_info in Account('[email protected]', 'sample_password').maps:
>>> for map_info in Account(PasswordSession('[email protected]', 'sample_password')).maps:
... print(map_info)
```

Expand All @@ -93,7 +125,7 @@ You need the url from the map output to do that:
```python
>>> from pybotvac import Account
>>> # List all maps associated with a specific robot
>>> map = Account('[email protected]', 'sample_password').maps
>>> map = Account(PasswordSession('[email protected]', 'sample_password')).maps
>>> download_link = map['robot_serial']['maps'][0]['url']
>>> Account('[email protected]', 'sample_password').get_map_image(download_link)
```
3 changes: 2 additions & 1 deletion pybotvac/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .account import Account
from .neato import Neato
from .robot import Robot
from .session import OAuthSession, PasswordlessSession, PasswordSession
from .version import __version__
from .vorwerk import Vorwerk
from .neato import Neato
118 changes: 21 additions & 97 deletions pybotvac/account.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
"""Account access and data handling for beehive endpoint."""

import binascii
import logging
import os
import shutil
import requests

try:
from urllib.parse import urljoin
except ImportError:
from urlparse import urljoin
import requests

from .exceptions import NeatoRobotException
from .robot import Robot
from .neato import Neato # For default Account argument
from .exceptions import NeatoLoginException, NeatoRobotException
from .session import Session

_LOGGER = logging.getLogger(__name__)

Expand All @@ -27,55 +22,13 @@ class Account:
"""

def __init__(self, email, password, vendor=Neato):
def __init__(self, session: Session):
"""Initialize the account data."""
self._robots = set()
self.robot_serials = {}
self._vendor = vendor
self._endpoint = vendor.endpoint
self._maps = {}
self._headers = {"Accept": vendor.headers}
self._login(email, password)
self._persistent_maps = {}

def _login(self, email, password):
"""
Login to pybotvac account using provided email and password.
:param email: email for pybotvac account
:param password: Password for pybotvac account
:return:
"""

try:
response = requests.post(
urljoin(self._endpoint, "sessions"),
json={
"email": email,
"password": password,
"platform": "ios",
"token": binascii.hexlify(os.urandom(64)).decode("utf8"),
},
headers=self._headers,
)

response.raise_for_status()
access_token = response.json()["access_token"]

self._headers["Authorization"] = "Token token=%s" % access_token
except (
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
) as ex:
if (
isinstance(ex, requests.exceptions.HTTPError)
and ex.response.status_code == 403
):
raise NeatoLoginException(
"Unable to login to neato, check account credentials."
) from ex
else:
raise NeatoRobotException("Unable to connect to Neato API.") from ex
self._session = session

@property
def robots(self):
Expand Down Expand Up @@ -107,21 +60,9 @@ def refresh_maps(self):
:return:
"""

try:
for robot in self.robots:
resp2 = requests.get(
urljoin(
self._endpoint, "users/me/robots/{}/maps".format(robot.serial)
),
headers=self._headers,
)
resp2.raise_for_status()
self._maps.update({robot.serial: resp2.json()})
except (
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
) as ex:
raise NeatoRobotException("Unable to refresh robot maps") from ex
for robot in self.robots:
resp2 = self._session.get("users/me/robots/{}/maps".format(robot.serial))
self._maps.update({robot.serial: resp2.json()})

def refresh_robots(self):
"""
Expand All @@ -130,26 +71,18 @@ def refresh_robots(self):
:return:
"""

try:
resp = requests.get(
urljoin(self._endpoint, "dashboard"), headers=self._headers
)
resp.raise_for_status()
except (
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
) as ex:
raise NeatoRobotException("Unable to refresh robots") from ex
resp = self._session.get("users/me/robots")

for robot in resp.json()["robots"]:
if robot["mac_address"] is None:
continue # Ignore robots without mac-address
for robot in resp.json():
# TODO: how to detect robots that are not online?
if robot["serial"] is None:
continue # Ignore robots without serial

try:
self._robots.add(
Robot(
name=robot["name"],
vendor=self._vendor,
vendor=self._session.vendor,
serial=robot["serial"],
secret=robot["secret_key"],
traits=robot["traits"],
Expand Down Expand Up @@ -190,6 +123,7 @@ def get_map_image(url, dest_path=None, file_name=None):
except (
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
requests.exceptions.Timeout,
) as ex:
raise NeatoRobotException("Unable to get robot map") from ex

Expand All @@ -213,19 +147,9 @@ def refresh_persistent_maps(self):
:return:
"""

try:
for robot in self._robots:
resp2 = requests.get(
urljoin(
self._endpoint,
"users/me/robots/{}/persistent_maps".format(robot.serial),
),
headers=self._headers,
)
resp2.raise_for_status()
self._persistent_maps.update({robot.serial: resp2.json()})
except (
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
) as ex:
raise NeatoRobotException("Unable to refresh persistent maps") from ex
for robot in self._robots:
resp2 = self._session.get(
"users/me/robots/{}/persistent_maps".format(robot.serial)
)

self._persistent_maps.update({robot.serial: resp2.json()})
27 changes: 22 additions & 5 deletions pybotvac/neato.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import os
from dataclasses import dataclass
from typing import List, Union


class Neato:
@dataclass(init=False, frozen=True)
class Vendor:
name: str
endpoint: str
auth_endpoint: str
passwordless_endpoint: str
token_endpoint: str
scope: List[str]
audience: str
source: str
cert_path: Union[str, bool] = False
beehive_version: str = "application/vnd.neato.beehive.v1+json"
nucleo_version: str = "application/vnd.neato.nucleo.v1"


class Neato(Vendor):
name = "neato"
endpoint = "https://beehive.neatocloud.com/"
headers = "application/vnd.neato.nucleo.v1"
cert_path = cert_path = os.path.join(
os.path.dirname(__file__), "cert", "neatocloud.com.crt"
)
auth_endpoint = "https://apps.neatorobotics.com/oauth2/authorize"
token_endpoint = "https://beehive.neatocloud.com/oauth2/token"
scope = ["public_profile", "control_robots", "maps"]
cert_path = os.path.join(os.path.dirname(__file__), "cert", "neatocloud.com.crt")
3 changes: 1 addition & 2 deletions pybotvac/robot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import hashlib
import hmac
import os.path
import re
import urllib3
import requests
Expand Down Expand Up @@ -54,7 +53,7 @@ def __init__(
vendor_name=vendor.name,
serial=self.serial,
)
self._headers = {"Accept": "application/vnd.neato.nucleo.v1"}
self._headers = {"Accept": vendor.nucleo_version}

if self.service_version not in SUPPORTED_SERVICES:
raise NeatoUnsupportedDevice(
Expand Down
Loading

0 comments on commit 59a663e

Please sign in to comment.