diff --git a/README.md b/README.md index 5fce1b1..1564d95 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ cease operation at any point. - [Usage](#usage) - [Contributing](#contributing) +# NOTE: Version 5.0.0 + +Version 5.0.0 is a complete re-architecture of `pytile` – as such, the API has changed. +Please read the documentation carefully! + # Python Versions `pytile` is currently supported on: @@ -36,6 +41,32 @@ pip install pytile # Usage +## Getting an API Object + +`pytile` usage starts with an [`aiohttp`](https://github.com/aio-libs/aiohttp) `ClientSession` – +note that this ClientSession is required to properly authenticate the library: + +```python +import asyncio + +from aiohttp import ClientSession + +from pytile import async_login + + +async def main() -> None: + """Run!""" + async with ClientSession() as session: + api = await async_login("", "", session) + + +asyncio.run(main()) +``` + +If for some reason you need to use a specific client UUID (to, say, ensure that the +Tile API sees you as a client it's seen before) or a specific locale, you can do +so easily: + ```python import asyncio @@ -46,20 +77,58 @@ from pytile import async_login async def main() -> None: """Run!""" - client = await async_login("", "") + async with ClientSession() as session: + api = await async_login( + "", "", session, client_uuid="MY_UUID", locale="en-GB" + ) + + +asyncio.run(main()) +``` + +## Getting Tiles + +```python +import asyncio + +from aiohttp import ClientSession + +from pytile import async_login + + +async def main() -> None: + """Run!""" + async with ClientSession() as session: + api = await async_login("", "", session) - # Get all Tiles associated with an account: - await client.tiles.all() + tiles = await api.async_get_tiles() asyncio.run(main()) ``` -By default, the library creates a new connection to Tile with each coroutine. If you are -calling a large number of coroutines (or merely want to squeeze out every second of -runtime savings possible), an -[`aiohttp`](https://github.com/aio-libs/aiohttp) `ClientSession` can be used for connection -pooling: +The `async_get_tiles` coroutine returns a dict with Tile UUIDs as the keys and `Tile` +objects as the values. + +### The `Tile` Object + +The Tile object comes with several properties: + +* `accuracy`: the location accuracy of the Tile +* `altitude`: the altitude of the Tile +* `archetype`: the internal reference string that describes the Tile's "family" +* `dead`: whether the Tile is inactive +* `firmware_version`: the Tile's firmware version +* `hardware_version`: the Tile's hardware version +* `kind`: the kind of Tile (e.g., `TILE`, `PHONE`) +* `last_timestamp`: the timestamp at which the current attributes were received +* `latitude`: the latitude of the Tile +* `longitude`: the latitude of the Tile +* `lost`: whether the Tile has been marked as "lost" +* `lost_timestamp`: the timestamp at which the Tile was last marked as "lost" +* `name`: the name of the Tile +* `uuid`: the Tile UUID +* `visible`: whether the Tile is visible in the mobile app ```python import asyncio @@ -72,18 +141,20 @@ from pytile import async_login async def main() -> None: """Run!""" async with ClientSession() as session: - client = await async_login("", "", session) + api = await async_login("", "", session) - # Get all Tiles associated with an account: - await client.tiles.all() + tiles = await api.async_get_tiles() + + for tile_uuid, tile in tiles.items(): + print(f"The Tile's name is {tile.name}") + # ... asyncio.run(main()) ``` -If for some reason you need to use a specific client UUID (to, say, ensure that the -Tile API sees you as a client it's seen before) or a specific locale, you can do -so easily: +In addition to these properties, the `Tile` object comes with an `async_update` coroutine +which requests new data from the Tile cloud API for this Tile: ```python import asyncio @@ -95,12 +166,13 @@ from pytile import async_login async def main() -> None: """Run!""" - client = await async_login( - "", "", client_uuid="MY_UUID", locale="en-GB" - ) + async with ClientSession() as session: + api = await async_login("", "", session) + + tiles = await api.async_get_tiles() - # Get all Tiles associated with an account: - await client.tiles.all() + for tile_uuid, tile in tiles.items(): + await tile.async_update() asyncio.run(main()) diff --git a/examples/test_api.py b/examples/test_api.py index 4b32e04..9e41b85 100644 --- a/examples/test_api.py +++ b/examples/test_api.py @@ -6,19 +6,30 @@ from pytile import async_login from pytile.errors import TileError +TILE_EMAIL = "bachya1208@gmail.com" +TILE_PASSWORD = "}oeoGpGpVFh8VTFhKDzi" + async def main(): """Run.""" async with ClientSession() as session: try: - # Create a client: - client = await async_login("", "", session=session) - - print("Showing active Tiles:") - print(await client.tiles.all()) - - print("Showing all Tiles:") - print(await client.tiles.all(show_inactive=True)) + api = await async_login(TILE_EMAIL, TILE_PASSWORD, session) + + tiles = await api.async_get_tiles() + print(f"Tile Count: {len(tiles)}") + print() + + tile = tiles["7a60a79e818b1404"] + await tile.async_set_name("Aaron's Wallet 2") + # for tile in tiles.values(): + # print(f"UUID: {tile.uuid}") + # print(f"Name: {tile.name}") + # print(f"Type: {tile.kind}") + # print(f"Latitude: {tile.latitude}") + # print(f"Longitude: {tile.longitude}") + # print(f"Last Timestamp: {tile.last_timestamp}") + # print() except TileError as err: print(err) diff --git a/pytile/__init__.py b/pytile/__init__.py index fb92591..4fb6505 100644 --- a/pytile/__init__.py +++ b/pytile/__init__.py @@ -1,2 +1,2 @@ """Define module-level imports.""" -from .client import async_login # noqa +from .api import async_login # noqa diff --git a/pytile/client.py b/pytile/api.py similarity index 50% rename from pytile/client.py rename to pytile/api.py index 226796c..d3b5618 100644 --- a/pytile/client.py +++ b/pytile/api.py @@ -1,30 +1,35 @@ -"""Define a client to interact with Pollen.com.""" -from typing import Optional +"""Define an object to work directly with the API.""" +import asyncio +import logging +from time import time +from typing import Dict, Optional from uuid import uuid4 -from aiohttp import ClientSession, ClientTimeout +from aiohttp import ClientSession from aiohttp.client_exceptions import ClientError -from .errors import RequestError, SessionExpiredError +from .errors import RequestError from .tile import Tile -from .util import current_epoch_time + +_LOGGER = logging.getLogger(__name__) API_URL_SCAFFOLD: str = "https://production.tile-api.com/api/v1" + DEFAULT_APP_ID: str = "ios-tile-production" -DEFAULT_APP_VERSION: str = "2.55.1.3707" +DEFAULT_APP_VERSION: str = "2.69.0.4123" DEFAULT_LOCALE: str = "en-US" DEFAULT_TIMEOUT: int = 10 -class Client: # pylint: disable=too-few-public-methods,too-many-instance-attributes - """Define the client.""" +class API: # pylint: disable=too-many-instance-attributes + """Define the API management object.""" def __init__( self, email: str, password: str, + session: ClientSession, *, - session: Optional[ClientSession] = None, client_uuid: Optional[str] = None, locale: str = DEFAULT_LOCALE, ) -> None: @@ -35,58 +40,24 @@ def __init__( self._password: str = password self._session: ClientSession = session self._session_expiry: Optional[int] = None - self.tiles: Optional[Tile] = None + self.client_uuid: str = str(uuid4()) if not client_uuid else client_uuid self.user_uuid: Optional[str] = None - self.client_uuid: str - self.client_uuid = str(uuid4()) if not client_uuid else client_uuid + async def async_get_tiles(self) -> Dict[str, Tile]: + """Get all active Tiles from the user's account.""" + states = await self.async_request("get", "tiles/tile_states") - async def _request( - self, - method: str, - endpoint: str, - *, - headers: Optional[dict] = None, - params: Optional[dict] = None, - data: Optional[dict] = None, - ) -> dict: - """Make a request against AirVisual.""" - if self._session_expiry and self._session_expiry <= current_epoch_time(): - raise SessionExpiredError("Session has expired; make a new one!") + details_tasks = { + tile_uuid: self.async_request("get", f"tiles/{tile_uuid}") + for tile_uuid in [tile["tile_id"] for tile in states["result"]] + } - _headers = headers or {} - _headers.update( - { - "Tile_app_id": DEFAULT_APP_ID, - "Tile_app_version": DEFAULT_APP_VERSION, - "Tile_client_uuid": self.client_uuid, - } - ) + results = await asyncio.gather(*details_tasks.values()) - use_running_session = self._session and not self._session.closed - - if use_running_session: - session = self._session - else: - session = ClientSession(timeout=ClientTimeout(total=DEFAULT_TIMEOUT)) - - try: - async with session.request( - method, - f"{API_URL_SCAFFOLD}/{endpoint}", - headers=_headers, - params=params, - data=data, - ) as resp: - resp.raise_for_status() - return await resp.json(content_type=None) - except ClientError as err: - raise RequestError( - f"Error requesting data from {endpoint}: {err}" - ) from None - finally: - if not use_running_session: - await session.close() + return { + tile_uuid: Tile(self.async_request, tile_data["result"]) + for tile_uuid, tile_data, in zip(details_tasks, results) + } async def async_init(self) -> None: """Create a Tile session.""" @@ -95,7 +66,7 @@ async def async_init(self) -> None: self._session_expiry = None if not self._client_established: - await self._request( + await self.async_request( "put", f"clients/{self.client_uuid}", data={ @@ -106,7 +77,7 @@ async def async_init(self) -> None: ) self._client_established = True - resp: dict = await self._request( + resp = await self.async_request( "post", f"clients/{self.client_uuid}/sessions", data={"email": self._email, "password": self._password}, @@ -116,20 +87,45 @@ async def async_init(self) -> None: self.user_uuid = resp["result"]["user"]["user_uuid"] self._session_expiry = resp["result"]["session_expiration_timestamp"] - self.tiles = Tile(self._request, user_uuid=self.user_uuid) + async def async_request(self, method: str, endpoint: str, **kwargs) -> dict: + """Make a request against AirVisual.""" + if self._session_expiry and self._session_expiry <= int(time() * 1000): + await self.async_init() + + kwargs.setdefault("headers", {}) + kwargs["headers"].update( + { + "Tile_app_id": DEFAULT_APP_ID, + "Tile_app_version": DEFAULT_APP_VERSION, + "Tile_client_uuid": self.client_uuid, + } + ) + + async with self._session.request( + method, f"{API_URL_SCAFFOLD}/{endpoint}", **kwargs + ) as resp: + try: + resp.raise_for_status() + data = await resp.json() + except ClientError as err: + raise RequestError( + f"Error requesting data from {endpoint}: {err}" + ) from None + + _LOGGER.debug("Data received from /%s: %s", endpoint, data) + + return data async def async_login( email: str, password: str, + session: ClientSession, *, client_uuid: Optional[str] = None, locale: str = DEFAULT_LOCALE, - session: Optional[ClientSession] = None, -) -> Client: +) -> API: """Return an authenticated client.""" - client = Client( - email, password, client_uuid=client_uuid, locale=locale, session=session - ) - await client.async_init() - return client + api = API(email, password, session, client_uuid=client_uuid, locale=locale) + await api.async_init() + return api diff --git a/pytile/tile.py b/pytile/tile.py index f9dfd29..20e9d6a 100644 --- a/pytile/tile.py +++ b/pytile/tile.py @@ -1,40 +1,117 @@ -"""Define endpoints for interacting with Tiles.""" -from typing import Awaitable, Callable, Dict, List, Optional +"""Define a Tile object.""" +from datetime import datetime +from typing import Awaitable, Callable +TASK_DETAILS = "details" +TASK_MEASUREMENTS = "measurements" -class Tile: # pylint: disable=too-few-public-methods - """Define "Tile" endpoints.""" + +class Tile: + """Define a Tile.""" def __init__( - self, - request: Callable[..., Awaitable[dict]], - *, - user_uuid: Optional[str] = None, + self, async_request: Callable[..., Awaitable], tile_data: dict ) -> None: """Initialize.""" - self._request: Callable[..., Awaitable[dict]] = request - self._user_uuid: Optional[str] = user_uuid - - async def all( - self, whitelist: list = None, show_inactive: bool = False - ) -> Dict[str, dict]: - """Get all Tiles for a user's account.""" - list_data: dict = await self._request( - "get", f"users/{self._user_uuid}/user_tiles" + self._async_request = async_request + self._last_timestamp: datetime = datetime.utcfromtimestamp( + tile_data["last_tile_state"]["timestamp"] / 1000 + ) + self._lost_timestamp: datetime = datetime.utcfromtimestamp( + tile_data["last_tile_state"]["lost_timestamp"] / 1000 ) + self._tile_data = tile_data + + def __str__(self) -> str: + """Return the string representation of the Tile.""" + return f"" + + @property + def accuracy(self) -> float: + """Return the accuracy of the last measurement.""" + return self._tile_data["last_tile_state"]["h_accuracy"] + + @property + def altitude(self) -> float: + """Return the last detected altitude.""" + return self._tile_data["last_tile_state"]["altitude"] + + @property + def archetype(self) -> str: + """Return the archetype.""" + return self._tile_data["archetype"] + + @property + def dead(self) -> bool: + """Return whether the Tile is dead.""" + return self._tile_data["is_dead"] + + @property + def firmware_version(self) -> str: + """Return the firmware version.""" + return self._tile_data["firmware_version"] + + @property + def hardware_version(self) -> str: + """Return the hardware version.""" + return self._tile_data["hw_version"] - tile_uuid_list: List[str] = [ - tile["tile_uuid"] - for tile in list_data["result"] - if not whitelist or tile["tileType"] in whitelist - ] + @property + def kind(self) -> str: + """Return the type of Tile.""" + return self._tile_data["tile_type"] - tile_data: dict = await self._request( - "get", "tiles", params=[("tile_uuids", uuid) for uuid in tile_uuid_list] + @property + def last_timestamp(self) -> datetime: + """Return the timestamp of the last location measurement.""" + return self._last_timestamp + + @property + def latitude(self) -> float: + """Return the last detected latitude.""" + return self._tile_data["last_tile_state"]["latitude"] + + @property + def longitude(self) -> float: + """Return the last detected longitude.""" + return self._tile_data["last_tile_state"]["longitude"] + + @property + def lost(self) -> bool: + """Return whether the Tile is lost.""" + return self._tile_data["last_tile_state"]["is_lost"] + + @property + def lost_timestamp(self) -> datetime: + """Return the timestamp when the Tile was last in a "lost" state.""" + return self._lost_timestamp + + @property + def name(self) -> str: + """Return the name.""" + return self._tile_data["name"] + + @property + def uuid(self) -> str: + """Return the UUID.""" + return self._tile_data["tile_uuid"] + + @property + def visible(self) -> bool: + """Return whether the Tile is visible.""" + return self._tile_data["visible"] + + def _async_save_new_data(self, data: dict) -> None: + """Save new Tile data in this object.""" + self._last_timestamp = datetime.utcfromtimestamp( + data["result"]["last_tile_state"]["timestamp"] / 1000 + ) + self._lost_timestamp = datetime.utcfromtimestamp( + data["result"]["last_tile_state"]["lost_timestamp"] / 1000 ) + self._tile_data = data["result"] - return { - tile_uuid: tile - for tile_uuid, tile in tile_data["result"].items() - if show_inactive or tile["visible"] is True - } + async def async_update(self) -> None: + """Get the latest measurements from the Tile.""" + data = await self._async_request("get", f"tiles/{self.uuid}") + self._async_save_new_data(data) diff --git a/pytile/util.py b/pytile/util.py deleted file mode 100644 index fd2b88f..0000000 --- a/pytile/util.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Define various utility functions.""" -from time import time - - -def current_epoch_time() -> int: - """Return the number of milliseconds since the Epoch.""" - return int(time() * 1000) diff --git a/tests/common.py b/tests/common.py index 7c3ed1b..1357e63 100644 --- a/tests/common.py +++ b/tests/common.py @@ -5,7 +5,7 @@ TILE_EMAIL = "user@email.com" TILE_PASSWORD = "12345" TILE_TILE_NAME = "Wallet" -TILE_TILE_UUID = "86368462-86fa-4a74-b19e-77f176f0095d" +TILE_TILE_UUID = "19264d2dffdbca32" TILE_USER_UUID = "fd0c10a5-d0f7-4619-9bce-5b2cb7a6754b" diff --git a/tests/conftest.py b/tests/conftest.py index 19b9c98..19ce000 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,14 @@ """Define fixtures, constants, etc. available for all tests.""" +import json from time import time import pytest -from .common import TILE_CLIENT_UUID, TILE_EMAIL, TILE_USER_UUID +from .common import TILE_CLIENT_UUID, TILE_EMAIL, TILE_USER_UUID, load_fixture @pytest.fixture() -def fixture_create_session(): +def create_session_response(): """Return a /clients//sessions response.""" return { "version": 1, @@ -40,7 +41,7 @@ def fixture_create_session(): @pytest.fixture() -def fixture_expired_session(): +def expired_session_response(): """Return a /clients//sessions response with an expired session.""" return { "version": 1, @@ -70,3 +71,22 @@ def fixture_expired_session(): "changes": "EXISTING_ACCOUNT", }, } + + +@pytest.fixture() +def tile_details_new_name_response(): + """Define a fixture for a subscription with an ALARM alarm state.""" + raw = load_fixture("tile_details_response.json") + data = json.loads(raw) + data["result"]["name"] = "New Name" + return json.dumps(data) + + +@pytest.fixture() +def tile_details_update_response(): + """Define a fixture for a subscription with an ALARM alarm state.""" + raw = load_fixture("tile_details_response.json") + data = json.loads(raw) + data["result"]["last_tile_state"]["latitude"] = 51.8943631 + data["result"]["last_tile_state"]["longitude"] = -0.4930538 + return json.dumps(data) diff --git a/tests/fixtures/tile_details_response.json b/tests/fixtures/tile_details_response.json index 29454d7..6123158 100644 --- a/tests/fixtures/tile_details_response.json +++ b/tests/fixtures/tile_details_response.json @@ -1,107 +1,94 @@ { "version": 1, "revision": 1, - "timestamp": "2018-06-19T23:04:39.097Z", - "timestamp_ms": 1529449479097, + "timestamp": "2020-08-12T21:46:52.962Z", + "timestamp_ms": 1597268812962, "result_code": 0, "result": { - "86368462-86fa-4a74-b19e-77f176f0095d": { - "thumbnailImage": "https://local-tile-pub.s3.amazonaws.com/..", - "tileState": { - "ringStateCode": 0, - "connectionStateCode": 0, - "uuid": "86368462-86fa-4a74-b19e-77f176f0095d", - "tile_uuid": "86368462-86fa-4a74-b19e-77f176f0095d", - "client_uuid": "2cc56adc-b96a-4293-9b94-eda716e0aa17", - "timestamp": 1512615215149, - "advertised_rssi": 1.4e-45, - "client_rssi": 1.4e-45, - "battery_level": 1.4e-45, - "latitude": 21.9083423, - "longitude": -72.4982138, - "altitude": 1821.129812, - "h_accuracy": 5, - "v_accuracy": 3, - "speed": 1.4e-45, - "course": 1.4e-45, - "authentication": null, - "owned": true, - "has_authentication": null, - "lost_timestamp": -1, - "connection_client_uuid": "2cc56adc-b96a-4293-9b94-eda716e0aa17", - "connection_event_timestamp": 1512615234268, - "last_owner_update": 1512615215149, - "connection_state": "READY", - "ring_state": "STOPPED", - "is_lost": false, - "voip_state": "OFFLINE" - }, - "entityName": "TILE", + "group": null, + "parents": [], + "user_node_relationships": null, + "owner_user_uuid": "fd0c10a5-d0f7-4619-9bce-5b2cb7a6754b", + "node_type": null, + "name": "Wallet", + "description": null, + "image_url": "https://local-tile-pub.s3.amazonaws.com/images/wallet.jpeg", + "product": "DUTCH1", + "archetype": "WALLET", + "visible": true, + "user_node_data": {}, + "permissions_mask": null, + "tile_uuid": "19264d2dffdbca32", + "firmware_version": "01.12.14.0", + "category": null, + "superseded_tile_uuid": null, + "is_dead": false, + "hw_version": "02.09", + "configuration": { + "fw10_advertising_interval": null + }, + "last_tile_state": { + "uuid": "19264d2dffdbca32", + "connectionStateCode": 0, + "ringStateCode": 0, "tile_uuid": "19264d2dffdbca32", - "firmware_version": "01.12.14.0", - "owner_user_uuid": "2ea56f4d-6576-4b4e-af11-3410cc65e373", - "name": "Wallet", - "category": null, - "image_url": "https://local-tile-pub.s3.amazonaws.com/...", - "visible": true, - "is_dead": false, - "hw_version": "02.09", - "product": "DUTCH1", - "archetype": "WALLET", - "configuration": { - "fw10_advertising_interval": null - }, - "last_tile_state": { - "ringStateCode": 0, - "connectionStateCode": 0, - "uuid": "19264d2dffdbca32", - "tile_uuid": "19264d2dffdbca32", - "client_uuid": "a01bf97a-c89a-40e2-9534-29976010fb03", - "timestamp": 1512615215149, - "advertised_rssi": 1.4e-45, - "client_rssi": 1.4e-45, - "battery_level": 1.4e-45, - "latitude": 39.797571, - "longitude": -104.887826, - "altitude": 1588.002773, - "h_accuracy": 5, - "v_accuracy": 3, - "speed": 1.4e-45, - "course": 1.4e-45, - "authentication": null, - "owned": true, - "has_authentication": null, - "lost_timestamp": -1, - "connection_client_uuid": "2cc56adc-b96a-4293-9b94-eda716e0aa17", - "connection_event_timestamp": 1512615234268, - "last_owner_update": 1512615215149, - "connection_state": "DISCONNECTED", - "ring_state": "STOPPED", - "is_lost": false, - "voip_state": "OFFLINE" - }, - "firmware": { - "expected_firmware_version": "", - "expected_firmware_imagename": "", - "expected_firmware_urlprefix": "", - "expected_firmware_publish_date": 0, - "expected_ppm": null, - "expected_advertising_interval": null, - "security_level": 1, - "expiry_timestamp": 1529471079097, - "expected_tdt_cmd_config": null - }, - "auth_key": "aliuUAS7da980asdHJASDQ==", - "renewal_status": "LEVEL1", - "metadata": {}, - "auto_retile": false, - "status": "ACTIVATED", - "tile_type": "TILE", - "registration_timestamp": 1482711833983, + "client_uuid": "2cc56adc-b96a-4293-9b94-eda716e0aa17", + "timestamp": 1597254926000, + "advertised_rssi": -89, + "client_rssi": 0, + "battery_level": 0, + "latitude": 51.528308, + "longitude": -0.3817765, + "altitude": 0.4076319168123, + "raw_h_accuracy": 13.496111, + "v_accuracy": 9.395408, + "speed": 0.167378, + "course": 147.42035, + "authentication": null, + "owned": false, + "has_authentication": null, + "lost_timestamp": -1, + "connection_client_uuid": null, + "connection_event_timestamp": 0, + "last_owner_update": 0, + "partner_id": null, + "partner_client_id": null, + "speed_accuracy": null, + "course_accuracy": null, + "discovery_timestamp": 1597254933661, + "connection_state": "DISCONNECTED", + "ring_state": "STOPPED", "is_lost": false, - "auth_timestamp": 1512287015405, - "activation_timestamp": 1482711835011, - "last_modified_timestamp": 1514353410254 - } + "h_accuracy": 13.496111, + "voip_state": "OFFLINE" + }, + "firmware": { + "expected_firmware_version": "01.19.01.0", + "expected_firmware_imagename": "Tile_FW_Image_01.19.01.0.bin", + "expected_firmware_urlprefix": "https://s3.amazonaws.com/tile-tofu-fw/prod/", + "expected_firmware_publish_date": 1574380800000, + "expected_ppm": null, + "expected_advertising_interval": null, + "security_level": 1, + "expiry_timestamp": 1597290412960, + "expected_tdt_cmd_config": "xxxxxxxx" + }, + "auth_key": "xxxxxxxxxxxxxxxxxxxxxxxx", + "renewal_status": "NONE", + "metadata": { + "battery_state": "10" + }, + "battery_status": "NONE", + "serial_number": null, + "auto_retile": false, + "all_user_node_relationships": null, + "tile_type": "TILE", + "registration_timestamp": 1569634958090, + "is_lost": false, + "auth_timestamp": 1569634958090, + "status": "ACTIVATED", + "activation_timestamp": 1569634959186, + "thumbnail_image": "https://local-tile-pub.s3.amazonaws.com/images/thumb.jpeg", + "last_modified_timestamp": 1597268811531 } } diff --git a/tests/fixtures/tile_list_response.json b/tests/fixtures/tile_list_response.json deleted file mode 100644 index ef953ab..0000000 --- a/tests/fixtures/tile_list_response.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "version": 1, - "revision": 1, - "timestamp": "2018-06-19T23:04:32.442Z", - "timestamp_ms": 1529449472442, - "result_code": 0, - "result": [ - { - "tileType": "TILE", - "user_uuid": "fd0c10a5-d0f7-4619-9bce-5b2cb7a6754b", - "tile_uuid": "86368462-86fa-4a74-b19e-77f176f0095d", - "other_user_uuid": "", - "other_user_email": "user@email.com", - "mode": "OWNER", - "last_modified_timestamp": 1482711833985 - } - ] -} diff --git a/tests/fixtures/tile_states_response.json b/tests/fixtures/tile_states_response.json new file mode 100644 index 0000000..833067f --- /dev/null +++ b/tests/fixtures/tile_states_response.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "revision": 1, + "timestamp": "2020-08-12T21:46:38.993Z", + "timestamp_ms": 1597268798993, + "result_code": 0, + "result": [ + { + "location": { + "latitude": 51.528308, + "longitude": -0.3817765, + "location_timestamp": 1512615215149, + "horizontal_accuracy": 5, + "client_name": null + }, + "tile_id": "19264d2dffdbca32", + "mark_as_lost": { + "timestamp": -1, + "is_lost": false + } + } + ] +} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..0a043ad --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,197 @@ +"""Define tests for the client object.""" +import json +import re +from time import time + +import aiohttp +import pytest + +from pytile import async_login +from pytile.errors import RequestError + +from .common import ( + TILE_CLIENT_UUID, + TILE_EMAIL, + TILE_PASSWORD, + TILE_TILE_UUID, + TILE_USER_UUID, + load_fixture, +) + + +@pytest.mark.asyncio +async def test_bad_endpoint(aresponses, create_session_response): + """Test that an exception is raised on a bad endpoint.""" + aresponses.add( + "production.tile-api.com", + f"/api/v1/clients/{TILE_CLIENT_UUID}", + "put", + aresponses.Response( + text=load_fixture("create_client_response.json"), status=200 + ), + ) + aresponses.add( + "production.tile-api.com", + f"/api/v1/clients/{TILE_CLIENT_UUID}/sessions", + "post", + aresponses.Response( + text=json.dumps(create_session_response), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + aresponses.add( + "production.tile-api.com", + "/api/v1/bad_endpoint", + "get", + aresponses.Response(text="", status=404), + ) + + with pytest.raises(RequestError): + async with aiohttp.ClientSession() as session: + api = await async_login( + TILE_EMAIL, TILE_PASSWORD, session, client_uuid=TILE_CLIENT_UUID + ) + await api.request("get", "bad_endpoint") + + +@pytest.mark.asyncio +async def test_expired_session( + aresponses, create_session_response, expired_session_response +): + """Test that an expired session is recreated automatically.""" + aresponses.add( + "production.tile-api.com", + f"/api/v1/clients/{TILE_CLIENT_UUID}", + "put", + aresponses.Response( + text=load_fixture("create_client_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + aresponses.add( + "production.tile-api.com", + f"/api/v1/clients/{TILE_CLIENT_UUID}/sessions", + "post", + aresponses.Response( + text=json.dumps(create_session_response), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + aresponses.add( + "production.tile-api.com", + f"/api/v1/clients/{TILE_CLIENT_UUID}", + "put", + aresponses.Response( + text=load_fixture("create_client_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + aresponses.add( + "production.tile-api.com", + f"/api/v1/clients/{TILE_CLIENT_UUID}/sessions", + "post", + aresponses.Response( + text=json.dumps(create_session_response), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + aresponses.add( + "production.tile-api.com", + "/api/v1/tiles/tile_states", + "get", + aresponses.Response( + text=load_fixture("tile_states_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + aresponses.add( + "production.tile-api.com", + f"/api/v1/tiles/{TILE_TILE_UUID}", + "get", + aresponses.Response( + text=load_fixture("tile_details_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + + async with aiohttp.ClientSession() as session: + api = await async_login( + TILE_EMAIL, TILE_PASSWORD, session, client_uuid=TILE_CLIENT_UUID + ) + + # Simulate an expired session: + api._session_expiry = int(time() * 1000) - 1000000 + + tiles = await api.async_get_tiles() + + +@pytest.mark.asyncio +async def test_login(aresponses, create_session_response): + """Test initializing a client with a Tile session.""" + client_pattern = re.compile(r"/api/v1/clients/.+") + session_pattern = re.compile(r"/api/v1/clients/.+/sessions") + + aresponses.add( + "production.tile-api.com", + client_pattern, + "put", + aresponses.Response( + text=load_fixture("create_client_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + aresponses.add( + "production.tile-api.com", + session_pattern, + "post", + aresponses.Response( + text=json.dumps(create_session_response), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + + async with aiohttp.ClientSession() as session: + api = await async_login(TILE_EMAIL, TILE_PASSWORD, session) + assert isinstance(api.client_uuid, str) + assert api.client_uuid != TILE_CLIENT_UUID + assert api.user_uuid == TILE_USER_UUID + + +@pytest.mark.asyncio +async def test_login_existing(aresponses, create_session_response): + """Test the creation of a client with an existing client UUID.""" + aresponses.add( + "production.tile-api.com", + f"/api/v1/clients/{TILE_CLIENT_UUID}", + "put", + aresponses.Response( + text=load_fixture("create_client_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + aresponses.add( + "production.tile-api.com", + f"/api/v1/clients/{TILE_CLIENT_UUID}/sessions", + "post", + aresponses.Response( + text=json.dumps(create_session_response), + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + + async with aiohttp.ClientSession() as session: + api = await async_login( + TILE_EMAIL, TILE_PASSWORD, session, client_uuid=TILE_CLIENT_UUID + ) + assert api.client_uuid == TILE_CLIENT_UUID diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index ea79f0e..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Define tests for the client object.""" -import json -import re - -import aiohttp -import pytest - -from pytile import async_login -from pytile.errors import RequestError - -from .common import ( - TILE_CLIENT_UUID, - TILE_EMAIL, - TILE_PASSWORD, - TILE_USER_UUID, - load_fixture, -) - - -@pytest.mark.asyncio -async def test_bad_endpoint(aresponses, fixture_create_session): - """Test that an exception is raised on a bad endpoint.""" - aresponses.add( - "production.tile-api.com", - f"/api/v1/clients/{TILE_CLIENT_UUID}", - "put", - aresponses.Response( - text=load_fixture("create_client_response.json"), status=200 - ), - ) - aresponses.add( - "production.tile-api.com", - f"/api/v1/clients/{TILE_CLIENT_UUID}/sessions", - "post", - aresponses.Response(text=json.dumps(fixture_create_session), status=200), - ) - aresponses.add( - "production.tile-api.com", - "/api/v1/bad_endpoint", - "get", - aresponses.Response(text="", status=404), - ) - - with pytest.raises(RequestError): - async with aiohttp.ClientSession() as session: - client = await async_login( - TILE_EMAIL, TILE_PASSWORD, client_uuid=TILE_CLIENT_UUID, session=session - ) - await client._request("get", "bad_endpoint") - - -@pytest.mark.asyncio -async def test_login(aresponses, fixture_create_session): - """Test initializing a client with a Tile session.""" - client_pattern = re.compile(r"/api/v1/clients/.+") - session_pattern = re.compile(r"/api/v1/clients/.+/sessions") - - aresponses.add( - "production.tile-api.com", - client_pattern, - "put", - aresponses.Response( - text=load_fixture("create_client_response.json"), status=200 - ), - ) - aresponses.add( - "production.tile-api.com", - session_pattern, - "post", - aresponses.Response(text=json.dumps(fixture_create_session), status=200), - ) - - async with aiohttp.ClientSession() as session: - client = await async_login(TILE_EMAIL, TILE_PASSWORD, session=session) - assert isinstance(client.client_uuid, str) - assert client.client_uuid != TILE_CLIENT_UUID - assert client.user_uuid == TILE_USER_UUID - - -@pytest.mark.asyncio -async def test_login_existing(aresponses, fixture_create_session): - """Test the creation of a client with an existing client UUID.""" - aresponses.add( - "production.tile-api.com", - f"/api/v1/clients/{TILE_CLIENT_UUID}", - "put", - aresponses.Response( - text=load_fixture("create_client_response.json"), status=200 - ), - ) - aresponses.add( - "production.tile-api.com", - f"/api/v1/clients/{TILE_CLIENT_UUID}/sessions", - "post", - aresponses.Response(text=json.dumps(fixture_create_session), status=200), - ) - - async with aiohttp.ClientSession() as session: - client = await async_login( - TILE_EMAIL, TILE_PASSWORD, client_uuid=TILE_CLIENT_UUID, session=session - ) - assert client.client_uuid == TILE_CLIENT_UUID diff --git a/tests/test_tile.py b/tests/test_tile.py index 449ade1..a12e942 100644 --- a/tests/test_tile.py +++ b/tests/test_tile.py @@ -1,11 +1,12 @@ """Define tests for the client object.""" +from datetime import datetime import json import aiohttp import pytest from pytile import async_login -from pytile.errors import SessionExpiredError +from pytile.tile import Tile from .common import ( TILE_CLIENT_UUID, @@ -13,115 +14,145 @@ TILE_PASSWORD, TILE_TILE_NAME, TILE_TILE_UUID, - TILE_USER_UUID, load_fixture, ) -@pytest.mark.asyncio # noqa -async def test_expired_session(aresponses, fixture_expired_session): - """Test raising an exception on an expired session.""" +@pytest.mark.asyncio +async def test_get_tiles(aresponses, create_session_response): + """Test getting all Tiles associated with an account.""" aresponses.add( "production.tile-api.com", f"/api/v1/clients/{TILE_CLIENT_UUID}", "put", aresponses.Response( - text=load_fixture("create_client_response.json"), status=200 + text=load_fixture("create_client_response.json"), + status=200, + headers={"Content-Type": "application/json"}, ), ) aresponses.add( "production.tile-api.com", f"/api/v1/clients/{TILE_CLIENT_UUID}/sessions", "post", - aresponses.Response(text=json.dumps(fixture_expired_session), status=200), - ) - - with pytest.raises(SessionExpiredError): - async with aiohttp.ClientSession() as session: - client = await async_login( - TILE_EMAIL, TILE_PASSWORD, client_uuid=TILE_CLIENT_UUID, session=session - ) - await client.tiles.all() - - -@pytest.mark.asyncio -async def test_get_all( - aresponses, fixture_create_session, -): - """Test getting details on all of a user's tiles.""" - aresponses.add( - "production.tile-api.com", - f"/api/v1/clients/{TILE_CLIENT_UUID}", - "put", aresponses.Response( - text=load_fixture("create_client_response.json"), status=200 + text=json.dumps(create_session_response), + status=200, + headers={"Content-Type": "application/json"}, ), ) aresponses.add( "production.tile-api.com", - f"/api/v1/clients/{TILE_CLIENT_UUID}/sessions", - "post", - aresponses.Response(text=json.dumps(fixture_create_session), status=200), - ) - aresponses.add( - "production.tile-api.com", - f"/api/v1/users/{TILE_USER_UUID}/user_tiles", + "/api/v1/tiles/tile_states", "get", - aresponses.Response(text=load_fixture("tile_list_response.json"), status=200), + aresponses.Response( + text=load_fixture("tile_states_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ), ) aresponses.add( "production.tile-api.com", - "/api/v1/tiles", + f"/api/v1/tiles/{TILE_TILE_UUID}", "get", aresponses.Response( - text=load_fixture("tile_details_response.json"), status=200 + text=load_fixture("tile_details_response.json"), + status=200, + headers={"Content-Type": "application/json"}, ), ) async with aiohttp.ClientSession() as session: - client = await async_login( - TILE_EMAIL, TILE_PASSWORD, client_uuid=TILE_CLIENT_UUID, session=session + api = await async_login( + TILE_EMAIL, TILE_PASSWORD, session, client_uuid=TILE_CLIENT_UUID ) - tiles = await client.tiles.all() + tiles = await api.async_get_tiles() assert len(tiles) == 1 - assert tiles[TILE_TILE_UUID]["name"] == TILE_TILE_NAME + tile = tiles[TILE_TILE_UUID] + assert isinstance(tile, Tile) + assert str(tile) == f"" + assert tile.accuracy == 13.496111 + assert tile.altitude == 0.4076319168123 + assert tile.archetype == "WALLET" + assert not tile.dead + assert tile.firmware_version == "01.12.14.0" + assert tile.hardware_version == "02.09" + assert tile.kind == "TILE" + assert tile.last_timestamp == datetime(2020, 8, 12, 17, 55, 26) + assert tile.latitude == 51.528308 + assert tile.longitude == -0.3817765 + assert not tile.lost + assert tile.lost_timestamp == datetime(1969, 12, 31, 23, 59, 59, 999000) + assert tile.name == TILE_TILE_NAME + assert tile.uuid == TILE_TILE_UUID + assert tile.visible @pytest.mark.asyncio -async def test_get_all_no_explicit_session( - aresponses, fixture_create_session, +async def test_tile_update( + aresponses, create_session_response, tile_details_update_response ): - """Test getting details on all of a user's tiles with no explicit ClientSession.""" + """Test updating a Tile's status.""" aresponses.add( "production.tile-api.com", f"/api/v1/clients/{TILE_CLIENT_UUID}", "put", aresponses.Response( - text=load_fixture("create_client_response.json"), status=200 + text=load_fixture("create_client_response.json"), + status=200, + headers={"Content-Type": "application/json"}, ), ) aresponses.add( "production.tile-api.com", f"/api/v1/clients/{TILE_CLIENT_UUID}/sessions", "post", - aresponses.Response(text=json.dumps(fixture_create_session), status=200), + aresponses.Response( + text=json.dumps(create_session_response), + status=200, + headers={"Content-Type": "application/json"}, + ), ) aresponses.add( "production.tile-api.com", - f"/api/v1/users/{TILE_USER_UUID}/user_tiles", + "/api/v1/tiles/tile_states", "get", - aresponses.Response(text=load_fixture("tile_list_response.json"), status=200), + aresponses.Response( + text=load_fixture("tile_states_response.json"), + status=200, + headers={"Content-Type": "application/json"}, + ), ) aresponses.add( "production.tile-api.com", - "/api/v1/tiles", + f"/api/v1/tiles/{TILE_TILE_UUID}", "get", aresponses.Response( - text=load_fixture("tile_details_response.json"), status=200 + text=load_fixture("tile_details_response.json"), + status=200, + headers={"Content-Type": "application/json"}, ), ) + aresponses.add( + "production.tile-api.com", + f"/api/v1/tiles/{TILE_TILE_UUID}", + "get", + aresponses.Response( + text=tile_details_update_response, + status=200, + headers={"Content-Type": "application/json"}, + ), + ) + + async with aiohttp.ClientSession() as session: + api = await async_login( + TILE_EMAIL, TILE_PASSWORD, session, client_uuid=TILE_CLIENT_UUID + ) + tiles = await api.async_get_tiles() + tile = tiles[TILE_TILE_UUID] + assert tile.latitude == 51.528308 + assert tile.longitude == -0.3817765 - client = await async_login(TILE_EMAIL, TILE_PASSWORD, client_uuid=TILE_CLIENT_UUID) - tiles = await client.tiles.all() - assert len(tiles) == 1 - assert tiles[TILE_TILE_UUID]["name"] == TILE_TILE_NAME + await tile.async_update() + assert tile.latitude == 51.8943631 + assert tile.longitude == -0.4930538