petsafe-api 3.0.3__tar.gz → 3.1.0__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.
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/PKG-INFO +58 -3
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/README.md +51 -1
- petsafe_api-3.1.0/petsafe/__init__.py +4 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/petsafe/devices.py +7 -4
- petsafe_api-3.1.0/petsafe/local.py +118 -0
- petsafe_api-3.1.0/petsafe/provisioning.py +549 -0
- petsafe_api-3.1.0/petsafe/setup.py +212 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/petsafe_api.egg-info/PKG-INFO +58 -3
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/petsafe_api.egg-info/SOURCES.txt +8 -1
- petsafe_api-3.1.0/petsafe_api.egg-info/requires.txt +7 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/setup.py +3 -2
- petsafe_api-3.1.0/tests/test_local.py +168 -0
- petsafe_api-3.1.0/tests/test_provisioning.py +419 -0
- petsafe_api-3.1.0/tests/test_setup.py +274 -0
- petsafe_api-3.1.0/tests/test_smartfeed_last_feeding.py +34 -0
- petsafe_api-3.0.3/petsafe/__init__.py +0 -2
- petsafe_api-3.0.3/petsafe_api.egg-info/requires.txt +0 -2
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/LICENSE +0 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/petsafe/__main__.py +0 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/petsafe/client.py +0 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/petsafe/const.py +0 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/petsafe/general.py +0 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/petsafe/pets.py +0 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/petsafe_api.egg-info/dependency_links.txt +0 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/petsafe_api.egg-info/top_level.txt +0 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/setup.cfg +0 -0
- {petsafe_api-3.0.3 → petsafe_api-3.1.0}/tests/test_smartdoor_modes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: petsafe-api
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.1.0
|
|
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.
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
|
|
@@ -83,10 +83,13 @@ class DeviceSmartFeed:
|
|
|
83
83
|
|
|
84
84
|
"""
|
|
85
85
|
messages = await self.get_messages_since()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
feed_done = [
|
|
87
|
+
message for message in messages
|
|
88
|
+
if message.get("message_type") == "FEED_DONE"
|
|
89
|
+
and isinstance(message.get("payload"), dict)
|
|
90
|
+
and isinstance(message["payload"].get("time"), int)
|
|
91
|
+
]
|
|
92
|
+
return max(feed_done, key=lambda message: message["payload"]["time"], default=None)
|
|
90
93
|
|
|
91
94
|
async def feed(
|
|
92
95
|
self, amount: int = 1, slow_feed: bool = None, update_data: bool = True
|
|
@@ -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=10, 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=10,
|
|
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 []
|