From bae6aa074459347240052222f35f92b113f534a1 Mon Sep 17 00:00:00 2001 From: Joachim Buyse Date: Sat, 16 Nov 2024 23:07:16 +0100 Subject: [PATCH 1/4] feat: add support for private charge points --- Makefile | 4 +- example-user.py | 24 +++++ pyproject.toml | 1 + shellrecharge/__init__.py | 7 ++ shellrecharge/decorators.py | 19 ++++ shellrecharge/models.py | 1 + shellrecharge/user.py | 200 ++++++++++++++++++++++++++++++++++++ shellrecharge/usermodels.py | 146 ++++++++++++++++++++++++++ 8 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 example-user.py create mode 100644 shellrecharge/decorators.py create mode 100644 shellrecharge/user.py create mode 100644 shellrecharge/usermodels.py diff --git a/Makefile b/Makefile index c8b0d8a..9df6e61 100644 --- a/Makefile +++ b/Makefile @@ -26,12 +26,12 @@ rebuild-lockfiles: .pdm format: .pdm pdm run isort $(sources) pdm run black -l 79 $(sources) - pdm run ruff --fix $(sources) + pdm run ruff format $(sources) .PHONY: lint ## Lint python source files lint: .pdm pdm run isort --check-only $(sources) - pdm run ruff $(sources) + pdm run ruff check $(sources) pdm run black -l 79 $(sources) --check --diff pdm run mypy $(sources) diff --git a/example-user.py b/example-user.py new file mode 100644 index 0000000..c0717ce --- /dev/null +++ b/example-user.py @@ -0,0 +1,24 @@ +from asyncio import new_event_loop +from logging import basicConfig, DEBUG, error +from os import getenv +from sys import stdout + +from aiohttp import ClientSession +from shellrecharge import Api +from shellrecharge.user import LoginFailedError + +async def main(): + async with ClientSession() as session: + try: + api = Api(session) + usr = await api.get_user(getenv("SHELL_USER"), getenv("SHELL_PWD")) + [print(card) async for card in usr.get_cards()] + [print(charger) async for charger in usr.get_chargers()] + except LoginFailedError: + error("Login failed, check your credentials") + + +if __name__ == "__main__": + basicConfig(stream=stdout, level=DEBUG) + loop = new_event_loop() + loop.run_until_complete(main()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 72186d2..7fa7871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "aiohttp_retry", "yarl", "pydantic>=1.0.0, <3.0.0", + "bs4", ] requires-python = ">=3.10" classifiers = [ diff --git a/shellrecharge/__init__.py b/shellrecharge/__init__.py index 756bb0d..c098a51 100644 --- a/shellrecharge/__init__.py +++ b/shellrecharge/__init__.py @@ -1,4 +1,5 @@ """The shellrecharge API code.""" + import logging from asyncio import CancelledError, TimeoutError @@ -10,6 +11,7 @@ from yarl import URL from .models import Location +from .user import User class Api: @@ -65,6 +67,11 @@ async def location_by_id(self, location_id: str) -> Location | None: return location + async def get_user(self, email: str, pwd: str) -> User: + user = User(email, pwd, self.websession) + await user.authenticate() + return user + class LocationEmptyError(Exception): """Raised when returned Location API data is empty.""" diff --git a/shellrecharge/decorators.py b/shellrecharge/decorators.py new file mode 100644 index 0000000..c80331d --- /dev/null +++ b/shellrecharge/decorators.py @@ -0,0 +1,19 @@ +from functools import wraps +from typing import Callable + + +def retry_on_401(func: Callable): + """Decorator to handle 401 errors""" + + @wraps(func) + async def wrapper(self, *args, **kwargs): + if not hasattr(self, "cookies"): + await self.authenticate() + response = await func(self, *args, **kwargs) + + if response.status == 401: + await self.authenticate() + response = await func(*args, **kwargs) + return response + + return wrapper diff --git a/shellrecharge/models.py b/shellrecharge/models.py index cfa639a..973ed84 100644 --- a/shellrecharge/models.py +++ b/shellrecharge/models.py @@ -1,4 +1,5 @@ """Models for pydantic parsing.""" + from typing import Literal, Optional from pydantic import BaseModel, Field diff --git a/shellrecharge/user.py b/shellrecharge/user.py new file mode 100644 index 0000000..22f6698 --- /dev/null +++ b/shellrecharge/user.py @@ -0,0 +1,200 @@ +from logging import getLogger +from re import compile, search +from typing import AsyncGenerator, Literal + +import pydantic +from aiohttp import ClientResponse, ClientSession +from bs4 import BeautifulSoup +from bs4.element import Tag +from pydantic import ValidationError + +from .decorators import retry_on_401 +from .usermodels import Assets, ChargeToken, DetailedChargePoint + + +class User: + """Class bundling all user requests""" + + accountUrl = "https://account.shellrecharge.com" + assetUrl = "https://ui-chargepoints.shellrecharge.com" + userAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0" + + def __init__(self, email: str, pwd: str, session: ClientSession): + """Initialize user""" + self.logger = getLogger("user") + self.session = session + self.__email = email + self.__pwd = pwd + + async def authenticate(self) -> None: + """Authenticate using email and password and retrieve an api key""" + headers = {"User-Agent": self.userAgent} + async with self.session.get(self.accountUrl, headers=headers) as r: + page = await r.text() + + # Make soup + soup = BeautifulSoup(page, "html.parser") + + # Find field values + login_email = soup.find("input", attrs={"id": "login-email"}) + if type(login_email) is not Tag: + raise ShellPageChangedError() + login_pwd = soup.find(attrs={"id": "login-pwd"}) + if type(login_pwd) is not Tag: + raise ShellPageChangedError() + login_hidden = soup.find("input", {"type": "hidden"}) + if type(login_hidden) is not Tag: + raise ShellPageChangedError() + + # Find the var declaration for lift_page + script_text = soup.find( + "script", string=compile(r"var\s+lift_page\s*=\s*") + ) + if type(script_text) is not Tag: + raise ShellPageChangedError() + lift_page_match = search( + r'var\s+lift_page\s*=\s*["\']([^"\']+)["\'];', + script_text.string or "", + ) + if not lift_page_match: + raise ShellPageChangedError() + lift_page = lift_page_match.group(1) + + form_data = { + login_email.get("name"): self.__email, + login_pwd.get("name"): self.__pwd, + login_hidden.get("name"): True, + } + + async with self.session.post( + f"{self.accountUrl}/ajax_request/{lift_page}-00", + headers=headers, + data=form_data, + ) as key: + cookie = key.cookies.get("tnm_api") + if not cookie: + raise LoginFailedError() + self.cookies = {"tnm_api": cookie.value} + + @retry_on_401 + async def __get_request(self, url: str) -> ClientResponse: + """Get request that reauthenticates when getting a 401""" + return await self.session.get(url, cookies=self.cookies) + + @retry_on_401 + async def __post_request( + self, url: str, headers: dict, data: str + ) -> ClientResponse: + """Post request that reauthenticates when getting a 401""" + return await self.session.post( + url, headers=headers, cookies=self.cookies, data=data + ) + + async def get_cards(self) -> AsyncGenerator[ChargeToken, None]: + """Get the user's charging cards""" + async with await self.__get_request( + f"{self.assetUrl}/api/facade/v1/me/asset-overview" + ) as response: + assets = await response.json() + + if not assets: + raise AssetsEmptyError() + + try: + if pydantic.version.VERSION.startswith("1"): + Assets.parse_obj(assets) + else: + Assets.model_validate(assets) + except ValidationError as err: + raise AssetsValidationError(err) + + for token in assets["chargeTokens"]: + yield token + + async def get_chargers(self) -> AsyncGenerator[DetailedChargePoint, None]: + """Get the user's private charge points""" + async with await self.__get_request( + f"{self.assetUrl}/api/facade/v1/me/asset-overview" + ) as response: + assets = await response.json() + + if not assets: + raise AssetsEmptyError() + + try: + if pydantic.version.VERSION.startswith("1"): + Assets.parse_obj(assets) + else: + Assets.model_validate(assets) + except ValidationError as err: + raise AssetsValidationError(err) + + for charger in assets["chargePoints"]: + async with await self.__get_request( + f"{self.assetUrl}/api/facade/v1/charge-points/{charger['uuid']}" + ) as r: + details = await r.json() + + if not details: + raise DetailedChargePointEmptyError() + + try: + if pydantic.version.VERSION.startswith("1"): + DetailedChargePoint.parse_obj(details) + else: + DetailedChargePoint.model_validate(details) + except ValidationError as err: + raise DetailedChargePointValidationError(err) + + yield details + + async def toggle_charger( + self, + charger_id: str, + card_rfid: str, + action: Literal["start", "stop"] = "start", + ) -> bool: + body = f'{{"rfid":"{card_rfid}","evseNo":0}}' + headers = {"Accept": "text/html", "Content-Type": "application/json"} + async with await self.__post_request( + f"{self.assetUrl}/api/facade/v1/charge-points/{charger_id}/remote-control/{action}", + headers=headers, + data=body, + ) as r: + return 202 == r.status + + +class ShellPageChangedError(Exception): + """Raised when Shell changes their website breaking the scraping.""" + + pass + + +class LoginFailedError(Exception): + """Raised when login failed""" + + pass + + +class AssetsEmptyError(Exception): + """Raised when returned assets data is empty.""" + + pass + + +class DetailedChargePointEmptyError(Exception): + """Raised when returned charge point details are empty.""" + + pass + + +class AssetsValidationError(Exception): + """Raised when returned assets are in the wrong format.""" + + pass + + +class DetailedChargePointValidationError(Exception): + """Raised when returned charge point details are in the wrong format.""" + + pass diff --git a/shellrecharge/usermodels.py b/shellrecharge/usermodels.py new file mode 100644 index 0000000..2ff6da5 --- /dev/null +++ b/shellrecharge/usermodels.py @@ -0,0 +1,146 @@ +"""Models for pydantic parsing.""" + +from typing import Literal, Optional + +from pydantic import UUID4, BaseModel, Field + +DateTimeISO8601 = str +ChargePointStatus = Literal["Available", "Unavailable", "Occupied", "Unknown"] +ChargePointDetailedStatus = Literal[ + "available", "preparing", "charging", "suspendedev" +] +Vendor = Literal["NewMotion"] + + +class ChargeToken(BaseModel): + """Charge card.""" + + uuid: Optional[str] = None + rfid: str + printedNumber: str + name: Optional[str] = None + + +class OccupyingToken(ChargeToken): + """Charge card occupying a charger""" + + timestamp: DateTimeISO8601 + + +class Evse(BaseModel): + """Basic EVSE representation for charge points.""" + + evseId: str + number: int + occupyingToken: OccupyingToken + status: ChargePointStatus + + +class ChargePoint(BaseModel): + """Charge point.""" + + evses: list[Evse] + name: str + serial: str + uuid: UUID4 + + +class Assets(BaseModel): + chargePoints: list[ChargePoint] + chargeTokens: list[ChargeToken] + + +class Address(BaseModel): + """Address.""" + + city: str + country: str + number: str + street: str + zip: str + + +class Coordinates(BaseModel): + """Location.""" + + latitude: float = Field(ge=-90, le=90) + longitude: float = Field(ge=-180, le=180) + + +class PlugAndCharge(BaseModel): + """Plug & charge support.""" + + capable: Literal[True, False] + + +class LatestOnlineStatus(BaseModel): + """Last time the charger was online.""" + + lastChanged: DateTimeISO8601 + online: Literal[True, False] + + +class Href(BaseModel): + """API path.""" + + href: str + + +class Links(BaseModel): + """Self-describing links.""" + + self: Href + evses: Optional[Href] = None + + +class Connector(BaseModel): + """Specs of the charger.""" + + connectorType: str + electricCurrentType: Literal["AC", "DC"] + maxCurrentInAmps: int + maxPowerInWatts: int = Field(ge=1000) + number: int + numberOfPhases: Literal[1, 3] + + +class DetailedEvse(BaseModel): + """Evse instance.""" + + _links: Href + connectors: list[Connector] + currentType: Literal["ac", "dc"] + evseId: str + id: UUID4 + maxPower: str + number: str + status: ChargePointDetailedStatus + statusDetails: OccupyingToken + + +class Embedded(BaseModel): + """Embedded charger details.""" + + evses: list[DetailedEvse] + + +class DetailedChargePoint(BaseModel): + """Charge point details.""" + + _embedded: Embedded + _links: Links + address: Address + connectivity: Literal["online", "offline"] + coordinates: Coordinates + firstConnection: DateTimeISO8601 + id: UUID4 + lastConnection: DateTimeISO8601 + lastSession: DateTimeISO8601 + latestOnlineStatus: LatestOnlineStatus + model: str + name: str + plugAndCharge: PlugAndCharge + protocol: Literal["ocpp 1.6-j"] + serial: str + sharing: Literal["private", "public"] + vendor: Vendor From e8e17384d341ca653d6f079fbbc808ebd282daec Mon Sep 17 00:00:00 2001 From: Joachim Buyse Date: Sat, 16 Nov 2024 23:10:40 +0100 Subject: [PATCH 2/4] chore: missing end of file newline --- example-user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example-user.py b/example-user.py index c0717ce..0f2b8ee 100644 --- a/example-user.py +++ b/example-user.py @@ -21,4 +21,4 @@ async def main(): if __name__ == "__main__": basicConfig(stream=stdout, level=DEBUG) loop = new_event_loop() - loop.run_until_complete(main()) \ No newline at end of file + loop.run_until_complete(main()) From accfc40c384731c95a7b06cc0555b6ec9b39301c Mon Sep 17 00:00:00 2001 From: Joachim Buyse Date: Sat, 16 Nov 2024 23:18:17 +0100 Subject: [PATCH 3/4] chore: add toggle example --- example-user.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/example-user.py b/example-user.py index 0f2b8ee..e19f9fa 100644 --- a/example-user.py +++ b/example-user.py @@ -1,5 +1,5 @@ from asyncio import new_event_loop -from logging import basicConfig, DEBUG, error +from logging import basicConfig, DEBUG, error, info from os import getenv from sys import stdout @@ -12,8 +12,14 @@ async def main(): try: api = Api(session) usr = await api.get_user(getenv("SHELL_USER"), getenv("SHELL_PWD")) - [print(card) async for card in usr.get_cards()] - [print(charger) async for charger in usr.get_chargers()] + cards = [card async for card in usr.get_cards()] + chargers = [charger async for charger in usr.get_chargers()] + + info(cards[0]) + info(chargers[0]) + + await usr.toggle_charger(chargers[0]["id"], cards[0]["rfid"], "start") + except LoginFailedError: error("Login failed, check your credentials") From ad4f0188e40d935d1bab76acb0e904562bed5e5f Mon Sep 17 00:00:00 2001 From: Joachim Buyse Date: Sat, 16 Nov 2024 23:22:15 +0100 Subject: [PATCH 4/4] chore: ruff the example --- example-user.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/example-user.py b/example-user.py index e19f9fa..e4e8055 100644 --- a/example-user.py +++ b/example-user.py @@ -1,12 +1,14 @@ from asyncio import new_event_loop -from logging import basicConfig, DEBUG, error, info +from logging import DEBUG, basicConfig, error, info from os import getenv from sys import stdout from aiohttp import ClientSession + from shellrecharge import Api from shellrecharge.user import LoginFailedError + async def main(): async with ClientSession() as session: try: