petsafe-api 3.0.4__tar.gz → 3.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/PKG-INFO +58 -3
  2. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/README.md +51 -1
  3. petsafe_api-3.1.1/petsafe/__init__.py +4 -0
  4. petsafe_api-3.1.1/petsafe/local.py +118 -0
  5. petsafe_api-3.1.1/petsafe/provisioning.py +549 -0
  6. petsafe_api-3.1.1/petsafe/setup.py +212 -0
  7. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/petsafe_api.egg-info/PKG-INFO +58 -3
  8. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/petsafe_api.egg-info/SOURCES.txt +6 -0
  9. petsafe_api-3.1.1/petsafe_api.egg-info/requires.txt +7 -0
  10. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/setup.py +3 -2
  11. petsafe_api-3.1.1/tests/test_local.py +168 -0
  12. petsafe_api-3.1.1/tests/test_provisioning.py +419 -0
  13. petsafe_api-3.1.1/tests/test_setup.py +274 -0
  14. petsafe_api-3.0.4/petsafe/__init__.py +0 -2
  15. petsafe_api-3.0.4/petsafe_api.egg-info/requires.txt +0 -2
  16. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/LICENSE +0 -0
  17. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/petsafe/__main__.py +0 -0
  18. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/petsafe/client.py +0 -0
  19. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/petsafe/const.py +0 -0
  20. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/petsafe/devices.py +0 -0
  21. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/petsafe/general.py +0 -0
  22. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/petsafe/pets.py +0 -0
  23. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/petsafe_api.egg-info/dependency_links.txt +0 -0
  24. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/petsafe_api.egg-info/top_level.txt +0 -0
  25. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/setup.cfg +0 -0
  26. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/tests/test_smartdoor_modes.py +0 -0
  27. {petsafe_api-3.0.4 → petsafe_api-3.1.1}/tests/test_smartfeed_last_feeding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: petsafe-api
3
- Version: 3.0.4
3
+ Version: 3.1.1
4
4
  Summary: Provides ability to connect and control a PetSafe Smart Feed, Scoopfree, and Smart Door devices using the PetSafe API.
5
5
  Home-page: https://github.com/ThomasHFWright/petsafe-api
6
6
  Author: Jordan Stremming & Dominick Meglio & Thomas Wright
@@ -9,11 +9,15 @@ License: MIT
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.6
12
+ Requires-Python: >=3.9
13
13
  Description-Content-Type: text/markdown
14
14
  License-File: LICENSE
15
15
  Requires-Dist: httpx
16
16
  Requires-Dist: botocore
17
+ Provides-Extra: ble
18
+ Requires-Dist: bleak; extra == "ble"
19
+ Requires-Dist: cbor2; extra == "ble"
20
+ Requires-Dist: cryptography; extra == "ble"
17
21
  Dynamic: author
18
22
  Dynamic: author-email
19
23
  Dynamic: classifier
@@ -22,6 +26,7 @@ Dynamic: description-content-type
22
26
  Dynamic: home-page
23
27
  Dynamic: license
24
28
  Dynamic: license-file
29
+ Dynamic: provides-extra
25
30
  Dynamic: requires-dist
26
31
  Dynamic: requires-python
27
32
  Dynamic: summary
@@ -52,7 +57,57 @@ Thank you to both projects for building the API integrations for existing feeder
52
57
  If installing from source code,
53
58
  `python setup.py install`
54
59
 
55
- ## Login tokens
60
+ ## Local SmartDoor control and provisioning (3.1.0)
61
+
62
+ `LocalPetSafeClient` connects to a compatible local server using its HTTPS origin
63
+ and API token. It does not use a PetSafe vendor login, and returns the same
64
+ `DeviceSmartDoor` objects as the cloud client, so existing methods are reused:
65
+
66
+ ```python
67
+ import asyncio
68
+ import petsafe
69
+
70
+
71
+ async def main() -> None:
72
+ async with petsafe.LocalPetSafeClient(
73
+ "https://petsafe.example", "LOCAL_API_TOKEN"
74
+ ) as client:
75
+ door = (await client.get_smartdoors())[0]
76
+ await door.lock()
77
+ await door.unlock()
78
+ await door.smart_mode()
79
+
80
+
81
+ asyncio.run(main())
82
+ ```
83
+
84
+ BLE provisioning dependencies are optional: `pip install petsafe-api[ble]`.
85
+ [`SetupClient`](petsafe/setup.py) creates a server-correlated setup session;
86
+ the BLE entry point is
87
+ [`provision_smartdoor`](petsafe/provisioning.py):
88
+
89
+ ```python
90
+ from petsafe import SetupClient
91
+ from petsafe.provisioning import WifiNetwork, provision_smartdoor
92
+
93
+
94
+ async def provision() -> None:
95
+ async with SetupClient(SERVER_ORIGIN, API_TOKEN) as setup:
96
+ session = await setup.create(DEVICE_ID, "provision")
97
+ result = await provision_smartdoor(
98
+ ble_device,
99
+ session.endpoint,
100
+ WifiNetwork(ssid=SSID, password=PASSWORD, bssid=BSSID_BYTES),
101
+ confirm=setup.confirmation(session),
102
+ )
103
+ ```
104
+
105
+ Local operation requires a compatible server and a hostname resolving to it. Provisioning
106
+ also requires supported BLE hardware or a nearby proxy and an HTTPS hostname and
107
+ certificate trusted by the SmartDoor; hardware testing used an Amazon-trusted
108
+ certificate. A raw IP address is not a supported or promised substitute.
109
+
110
+ ## Cloud login tokens
56
111
  You **must** use tokens to interact with the PetSafe Smart-Feed API.
57
112
  There are two methods to retrieve tokens:
58
113
 
@@ -24,7 +24,57 @@ Thank you to both projects for building the API integrations for existing feeder
24
24
  If installing from source code,
25
25
  `python setup.py install`
26
26
 
27
- ## Login tokens
27
+ ## Local SmartDoor control and provisioning (3.1.0)
28
+
29
+ `LocalPetSafeClient` connects to a compatible local server using its HTTPS origin
30
+ and API token. It does not use a PetSafe vendor login, and returns the same
31
+ `DeviceSmartDoor` objects as the cloud client, so existing methods are reused:
32
+
33
+ ```python
34
+ import asyncio
35
+ import petsafe
36
+
37
+
38
+ async def main() -> None:
39
+ async with petsafe.LocalPetSafeClient(
40
+ "https://petsafe.example", "LOCAL_API_TOKEN"
41
+ ) as client:
42
+ door = (await client.get_smartdoors())[0]
43
+ await door.lock()
44
+ await door.unlock()
45
+ await door.smart_mode()
46
+
47
+
48
+ asyncio.run(main())
49
+ ```
50
+
51
+ BLE provisioning dependencies are optional: `pip install petsafe-api[ble]`.
52
+ [`SetupClient`](petsafe/setup.py) creates a server-correlated setup session;
53
+ the BLE entry point is
54
+ [`provision_smartdoor`](petsafe/provisioning.py):
55
+
56
+ ```python
57
+ from petsafe import SetupClient
58
+ from petsafe.provisioning import WifiNetwork, provision_smartdoor
59
+
60
+
61
+ async def provision() -> None:
62
+ async with SetupClient(SERVER_ORIGIN, API_TOKEN) as setup:
63
+ session = await setup.create(DEVICE_ID, "provision")
64
+ result = await provision_smartdoor(
65
+ ble_device,
66
+ session.endpoint,
67
+ WifiNetwork(ssid=SSID, password=PASSWORD, bssid=BSSID_BYTES),
68
+ confirm=setup.confirmation(session),
69
+ )
70
+ ```
71
+
72
+ Local operation requires a compatible server and a hostname resolving to it. Provisioning
73
+ also requires supported BLE hardware or a nearby proxy and an HTTPS hostname and
74
+ certificate trusted by the SmartDoor; hardware testing used an Amazon-trusted
75
+ certificate. A raw IP address is not a supported or promised substitute.
76
+
77
+ ## Cloud login tokens
28
78
  You **must** use tokens to interact with the PetSafe Smart-Feed API.
29
79
  There are two methods to retrieve tokens:
30
80
 
@@ -0,0 +1,4 @@
1
+ from . import devices, general, pets
2
+ from .client import PetSafeClient
3
+ from .local import LocalPetSafeClient
4
+ from .setup import InvalidSetupResponse, SetupAPIError, SetupClient, SetupSession
@@ -0,0 +1,118 @@
1
+ """Client for a PetSafe local-control server."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ from typing import Any, Optional
7
+ from urllib.parse import unquote, urlsplit
8
+
9
+ import httpx
10
+
11
+ from .devices import DeviceSmartDoor
12
+ from .setup import _TOKEN
13
+
14
+ MAX_RESPONSE_BYTES = 1024 * 1024
15
+
16
+
17
+ class LocalPetSafeClient:
18
+ """Minimal async client exposing the cloud-compatible local API surface."""
19
+
20
+ def __init__(self, server_url: str, api_token: str, *,
21
+ client: Optional[httpx.AsyncClient] = None) -> None:
22
+ if (type(api_token) is not str or not api_token or len(api_token) > 4096
23
+ or not _TOKEN.fullmatch(api_token)):
24
+ raise ValueError("api_token must be a valid bearer token")
25
+ try:
26
+ url = httpx.URL(server_url)
27
+ except (TypeError, httpx.InvalidURL) as error:
28
+ raise ValueError("server_url must be an HTTPS origin") from error
29
+ if (type(server_url) is not str or len(server_url) > 2048 or url.scheme != "https"
30
+ or not url.host or url.userinfo or url.path != "/" or url.query or url.fragment):
31
+ raise ValueError("server_url must be an HTTPS origin")
32
+ self._origin = (url.scheme, url.host, url.port or 443)
33
+ self._base_url = str(url).rstrip("/") + "/local/"
34
+ self._api_token = api_token
35
+ self._owned = client is None
36
+ self._client = client or httpx.AsyncClient(timeout=30, follow_redirects=False)
37
+
38
+ def __repr__(self) -> str:
39
+ return f"{type(self).__name__}(server_url={self._base_url[:-7]!r})"
40
+
41
+ async def __aenter__(self) -> "LocalPetSafeClient":
42
+ return self
43
+
44
+ async def __aexit__(self, *_: Any) -> None:
45
+ await self.aclose()
46
+
47
+ async def aclose(self) -> None:
48
+ if self._owned:
49
+ await self._client.aclose()
50
+
51
+ def _request_url(self, path: str) -> str:
52
+ if type(path) is not str or len(path) > 2048 or "\\" in path:
53
+ raise ValueError("path must be a relative API path")
54
+ parsed = urlsplit(path)
55
+ decoded = unquote(unquote(parsed.path))
56
+ if (parsed.scheme or parsed.netloc or parsed.fragment or decoded.startswith("/")
57
+ or "\\" in decoded or any(part in (".", "..") for part in decoded.split("/"))):
58
+ raise ValueError("path must be a relative API path without traversal")
59
+ url = httpx.URL(self._base_url + path)
60
+ if (url.scheme, url.host, url.port or 443) != self._origin:
61
+ raise ValueError("path must remain on the configured server")
62
+ return str(url)
63
+
64
+ async def _request(self, method: str, path: str, data: Optional[dict] = None) -> httpx.Response:
65
+ request = self._client.build_request(
66
+ method, self._request_url(path),
67
+ headers={"Authorization": f"Bearer {self._api_token}", "Accept": "application/json"},
68
+ json=data, timeout=30,
69
+ )
70
+ response = await self._client.send(request, stream=True, follow_redirects=False)
71
+ try:
72
+ response.raise_for_status()
73
+ body = bytearray()
74
+ async for chunk in response.aiter_bytes():
75
+ body.extend(chunk)
76
+ if len(body) > MAX_RESPONSE_BYTES:
77
+ raise httpx.HTTPError("local API response is too large")
78
+ headers = response.headers.copy()
79
+ headers.pop("content-encoding", None)
80
+ headers.pop("content-length", None)
81
+ return httpx.Response(
82
+ response.status_code, headers=headers, content=bytes(body),
83
+ request=request, extensions=response.extensions,
84
+ )
85
+ finally:
86
+ await response.aclose()
87
+
88
+ async def api_get(self, path: str = "") -> httpx.Response:
89
+ return await self._request("GET", path)
90
+
91
+ async def api_patch(self, path: str = "", data: Optional[dict] = None) -> httpx.Response:
92
+ return await self._request("PATCH", path, data)
93
+
94
+ async def api_post(self, path: str = "", data: Optional[dict] = None) -> httpx.Response:
95
+ return await self._request("POST", path, data)
96
+
97
+ async def api_delete(self, path: str = "") -> httpx.Response:
98
+ return await self._request("DELETE", path)
99
+
100
+ async def get_smartdoors(self) -> list[DeviceSmartDoor]:
101
+ response = await self.api_get("smartdoor/product/product")
102
+ payload = json.loads(response.content.decode("UTF-8"))
103
+ devices = payload.get("data", payload) if isinstance(payload, dict) else payload
104
+ if not isinstance(devices, list) or any(not isinstance(data, dict) for data in devices):
105
+ raise ValueError("Unexpected response payload for SmartDoor devices")
106
+ doors = [DeviceSmartDoor(self, data) for data in devices]
107
+ preferences = await asyncio.gather(*(door.get_preferences() for door in doors))
108
+ for door, prefs in zip(doors, preferences):
109
+ for name in ("friendlyName", "tz"):
110
+ if name in prefs:
111
+ door.data[name] = prefs[name]
112
+ return doors
113
+
114
+ async def get_feeders(self) -> list:
115
+ return []
116
+
117
+ async def get_litterboxes(self) -> list:
118
+ return []