Skip to content

Commit

Permalink
Add support for the new Aseko Pool API
Browse files Browse the repository at this point in the history
Removed the code for the old API and added support for the new API.
  • Loading branch information
milanmeu committed Sep 15, 2024
1 parent e71637c commit ec0cc3e
Show file tree
Hide file tree
Showing 13 changed files with 593 additions and 424 deletions.
2 changes: 2 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github: milanmeu
buy_me_a_coffee: milanmeu
52 changes: 18 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,9 @@ An async Python wrapper for the Aseko Pool Live API.

The library supports Aseko ASIN AQUA devices.
The Aseko ASIN Pool is partially supported.
The library is currently limited to a selection of features available on pool.aseko.com.
Mobile-only features are not supported.
The library is currently limited to a selection of features available on aseko.cloud.


## Account
The library provides a `MobileAccount` and `WebAccount` class to make authenticated requests to the mobile and web API, respectively.
In this version of aioAseko, `WebAccount` can only be used to obtain `AccountInfo` and retrieve the account units.
The mobile API does not provide `AccountInfo`, so `MobileAccount.login()` will return `None`.

## Installation
```bash
pip install aioaseko
Expand All @@ -22,43 +16,33 @@ pip install aioaseko
## Usage
### Import
```python
from aioaseko import MobileAccount
```

### Create a `aiohttp.ClientSession` to make requests
```python
from aiohttp import ClientSession
session = ClientSession()
from aioaseko import Aseko
```

### Create a `MobileAccount` instance and login
### Create an `Aseko` instance and login
```python
account = MobileAccount(session, "[email protected]", "passw0rd")
await account.login()
api = Aseko("[email protected]", "passw0rd")
await api.login()
```

## Example
```python
from aiohttp import ClientSession
from asyncio import run

import aioaseko
from aioaseko import Aseko, InvalidCredentials, Unit

async def main():
async with ClientSession() as session:
account = aioaseko.MobileAccount(session, "[email protected]", "passw0rd")
try:
await account.login()
except aioaseko.InvalidAuthCredentials:
print("The username or password you entered is wrong.")
return
units = await account.get_units()
for unit in units:
print(unit.name)
await unit.get_state()
print(f"Water flow: {unit.water_flow}")
for variable in unit.variables:
print(variable.name, variable.current_value, variable.unit)
await account.logout()
api = Aseko("[email protected]", "passw0rd")
try:
await api.login()
except InvalidCredentials:
print("The username or password is wrong.")
return
units = await api.get_units()
for unit in units:
if isinstance(unit, Unit):
print(f"Unit: {unit.name} ({unit.serial_number})")
print(f"Air temperature: {unit.air_temperature}")
print(f"Water flow to probes: {unit.water_flow_to_probes}")
run(main())
```
11 changes: 7 additions & 4 deletions aioaseko/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2021, 2022 Milan Meulemans.
# Copyright 2021, 2022, 2024 Milan Meulemans.
#
# This file is part of aioaseko.
#
Expand All @@ -16,8 +16,11 @@
# along with aioaseko. If not, see <https://www.gnu.org/licenses/>.

"""aioAseko."""

from .aseko import * # noqa: F401, F403
from .consumable import * # noqa: F401, F403
from .exceptions import * # noqa: F401, F403
from .mobile import * # noqa: F401, F403
from .filtration import * # noqa: F401, F403
from .status_value import * # noqa: F401, F403
from .unit import * # noqa: F401, F403
from .variable import * # noqa: F401, F403
from .web import * # noqa: F401, F403
from .user import * # noqa: F401, F403
247 changes: 247 additions & 0 deletions aioaseko/aseko.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
# Copyright 2021, 2022, 2024 Milan Meulemans.
#
# This file is part of aioaseko.
#
# aioaseko is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# aioaseko is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with aioaseko. If not, see <https://www.gnu.org/licenses/>.

"""aioAseko Aseko API."""

from datetime import datetime
import logging
from typing import Any, cast

from aiohttp import ClientSession
from apischema import deserialize
from gql import Client
from gql.dsl import DSLInlineFragment, DSLQuery, DSLSchema, dsl_gql, to_camel_case
from gql.transport.aiohttp import AIOHTTPTransport, log as gql_log
from gql.transport.exceptions import TransportQueryError
from yarl import URL

from .exceptions import AsekoAPIError, AsekoInvalidCredentials, AsekoNotLoggedIn
from .unit import Unit, UnitNeverConnected
from .user import User

AUTH_URL = "https://auth.aseko.acs.aseko.cloud/auth"
GRAPHQL_URL = "https://graphql.acs.prod.aseko.cloud/graphql"

gql_log.setLevel(logging.ERROR)


class Aseko:
"""Aseko API."""

def __init__(self, email: str, password: str) -> None:
"""Initialize the Aseko API."""
self._email = email
self._password = password
self._token: str | None = None
self._refresh_token: str | None = None
self._cached_schema: DSLSchema | None = None

async def login(self) -> User:
"""Login to the Aseko API."""
async with ClientSession() as session:
resp = await session.post(
AUTH_URL + "/login",
json={
"email": self._email,
"password": self._password,
"cloud": "01HXS50KTV7NRSVNHD617J4CKB",
},
)
if resp.status == 401:
raise AsekoInvalidCredentials
try:
resp.raise_for_status()
except Exception as e:
raise AsekoAPIError from e
data = await resp.json()
self._token = data["token"]
self._refresh_token = resp.cookies["refreshToken"].value
return User(
data["user"]["id"],
datetime.fromisoformat(data["user"]["createdAt"]),
datetime.fromisoformat(data["user"]["updatedAt"]),
data["user"]["name"],
data["user"]["surname"],
data["user"]["lang"],
data["user"]["isActive"],
)

async def _token_refresh(self) -> None:
"""Refresh the token."""
assert self._refresh_token is not None
async with ClientSession() as session:
session.cookie_jar.update_cookies(
{"refreshToken": self._refresh_token}, URL(AUTH_URL)
)
resp = await session.post(AUTH_URL + "/refresh-token")
try:
resp.raise_for_status()
except Exception as e:
raise AsekoAPIError from e
data = await resp.json()
self._token = data["token"]

def _client(self) -> Client:
"""Return the Aseko GraphQL client."""
if self._token is None:
raise AsekoNotLoggedIn
transport = AIOHTTPTransport(
url=GRAPHQL_URL, headers={"Authorization": f"Bearer {self._token}"}
)
return Client(transport=transport, fetch_schema_from_transport=True)

async def _schema(self) -> DSLSchema:
"""Return the Aseko GraphQL schema."""
if self._cached_schema is None:
async with self._client() as session:
self._cached_schema = DSLSchema(session.client.schema)
return self._cached_schema

async def _query(self, query: DSLQuery, retry: bool = True) -> dict[str, Any]:
"""Query the Aseko GraphQL API."""
async with self._client() as session:
document = dsl_gql(query)
try:
result = await session.execute(document)
except TransportQueryError as e:
if not retry:
raise AsekoAPIError from e
await self._token_refresh()
result = await self._query(query, False)
return cast(dict[str, Any], result)

async def get_all_units(self) -> list[Unit | UnitNeverConnected]:
"""Get all units, including never connected units."""

def unit_deserializer(data: dict) -> Unit | UnitNeverConnected:
"""Deserialize a unit."""
if "brandName" in data:
return deserialize(Unit, data, aliaser=to_camel_case)
return deserialize(UnitNeverConnected, data, aliaser=to_camel_case)

ds = await self._schema()
query = DSLQuery(
ds.Query.units.select(
ds.UnitList.units.select(
DSLInlineFragment()
.on(ds.Unit)
.select(
ds.Unit.serialNumber,
ds.Unit.name,
ds.Unit.note,
ds.Unit.online,
ds.Unit.hasWarning,
ds.Unit.timeZone,
ds.Unit.position,
ds.Unit.brandName.select(
ds.UnitBrandName.primary,
ds.UnitBrandName.secondary,
),
ds.Unit.consumables.select(
DSLInlineFragment()
.on(ds.LiquidConsumable)
.select(
ds.LiquidConsumable.type,
ds.LiquidConsumable.name,
ds.LiquidConsumable.canister.select(
ds.Canister.remaining,
ds.Canister.hasWarning,
ds.Canister.volume,
),
ds.LiquidConsumable.tube.select(
ds.Tube.remaining,
ds.Tube.hasWarning,
ds.Tube.remainingDays,
),
),
DSLInlineFragment()
.on(ds.ElectrolyzerConsumable)
.select(
ds.ElectrolyzerConsumable.type,
ds.ElectrolyzerConsumable.name,
ds.ElectrolyzerConsumable.electrode.select(
ds.Electrode.remaining,
ds.Electrode.weekChlorineProduction,
ds.Electrode.hasWarning,
),
),
),
ds.Unit.statusValues.select(
ds.StatusValues.primary.select(
ds.StatusValue.type,
ds.StatusValue.center.select(
DSLInlineFragment()
.on(ds.StringValue)
.select(
ds.StringValue.value,
),
DSLInlineFragment()
.on(ds.UpcomingFiltrationPeriodValue)
.select(
ds.UpcomingFiltrationPeriodValue.configuration.select(
ds.FiltrationInterval.period,
ds.FiltrationInterval.name,
),
ds.UpcomingFiltrationPeriodValue.isNext,
),
),
),
ds.StatusValues.secondary.select(
ds.StatusValue.type,
ds.StatusValue.center.select(
DSLInlineFragment()
.on(ds.StringValue)
.select(
ds.StringValue.value,
),
DSLInlineFragment()
.on(ds.UpcomingFiltrationPeriodValue)
.select(
ds.UpcomingFiltrationPeriodValue.configuration.select(
ds.FiltrationInterval.period,
ds.FiltrationInterval.name,
),
ds.UpcomingFiltrationPeriodValue.isNext,
),
),
),
),
),
DSLInlineFragment()
.on(ds.UnitNeverConnected)
.select(
ds.UnitNeverConnected.serialNumber,
ds.UnitNeverConnected.name,
ds.UnitNeverConnected.note,
ds.UnitNeverConnected.position,
ds.UnitNeverConnected.online,
),
),
)
)
result = await self._query(query)
return deserialize(
list[Unit | UnitNeverConnected],
result["units"]["units"],
aliaser=to_camel_case,
conversion=unit_deserializer,
)

async def get_units(self) -> list[Unit]:
"""Get active units."""
units = await self.get_all_units()
return [unit for unit in units if isinstance(unit, Unit)]
Loading

0 comments on commit ec0cc3e

Please sign in to comment.