petsafe-api 3.1.2__tar.gz → 3.2.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.1.2 → petsafe_api-3.2.0}/PKG-INFO +5 -2
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/README.md +4 -1
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/local.py +97 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe_api.egg-info/PKG-INFO +5 -2
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/setup.py +1 -1
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/tests/test_local.py +76 -1
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/LICENSE +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/__init__.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/__main__.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/client.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/const.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/devices.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/general.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/pets.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/provisioning.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe/setup.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe_api.egg-info/SOURCES.txt +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe_api.egg-info/dependency_links.txt +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe_api.egg-info/requires.txt +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/petsafe_api.egg-info/top_level.txt +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/setup.cfg +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/tests/test_provisioning.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/tests/test_setup.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/tests/test_smartdoor_modes.py +0 -0
- {petsafe_api-3.1.2 → petsafe_api-3.2.0}/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.
|
|
3
|
+
Version: 3.2.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
|
|
@@ -57,7 +57,7 @@ Thank you to both projects for building the API integrations for existing feeder
|
|
|
57
57
|
If installing from source code,
|
|
58
58
|
`python setup.py install`
|
|
59
59
|
|
|
60
|
-
## Local SmartDoor control and provisioning (3.
|
|
60
|
+
## Local SmartDoor control and provisioning (3.2.0)
|
|
61
61
|
|
|
62
62
|
`LocalPetSafeClient` connects to a compatible local server using its HTTPS origin
|
|
63
63
|
and API token. It does not use a PetSafe vendor login, and returns the same
|
|
@@ -77,6 +77,9 @@ async def main() -> None:
|
|
|
77
77
|
await door.unlock()
|
|
78
78
|
await door.smart_mode()
|
|
79
79
|
|
|
80
|
+
async for snapshot in client.iter_events():
|
|
81
|
+
print(snapshot["revision"], snapshot["door"])
|
|
82
|
+
|
|
80
83
|
|
|
81
84
|
asyncio.run(main())
|
|
82
85
|
```
|
|
@@ -24,7 +24,7 @@ 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
|
-
## Local SmartDoor control and provisioning (3.
|
|
27
|
+
## Local SmartDoor control and provisioning (3.2.0)
|
|
28
28
|
|
|
29
29
|
`LocalPetSafeClient` connects to a compatible local server using its HTTPS origin
|
|
30
30
|
and API token. It does not use a PetSafe vendor login, and returns the same
|
|
@@ -44,6 +44,9 @@ async def main() -> None:
|
|
|
44
44
|
await door.unlock()
|
|
45
45
|
await door.smart_mode()
|
|
46
46
|
|
|
47
|
+
async for snapshot in client.iter_events():
|
|
48
|
+
print(snapshot["revision"], snapshot["door"])
|
|
49
|
+
|
|
47
50
|
|
|
48
51
|
asyncio.run(main())
|
|
49
52
|
```
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
5
|
import json
|
|
6
|
+
import math
|
|
6
7
|
from typing import Any, Optional
|
|
7
8
|
from urllib.parse import unquote, urlsplit
|
|
8
9
|
|
|
@@ -12,6 +13,7 @@ from .devices import DeviceSmartDoor
|
|
|
12
13
|
from .setup import _TOKEN
|
|
13
14
|
|
|
14
15
|
MAX_RESPONSE_BYTES = 1024 * 1024
|
|
16
|
+
MAX_EVENT_BYTES = 4 * 1024 * 1024
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class LocalPetSafeClient:
|
|
@@ -97,6 +99,101 @@ class LocalPetSafeClient:
|
|
|
97
99
|
async def api_delete(self, path: str = "") -> httpx.Response:
|
|
98
100
|
return await self._request("DELETE", path)
|
|
99
101
|
|
|
102
|
+
async def iter_events(self):
|
|
103
|
+
"""Yield validated full snapshots from the local server's SSE stream."""
|
|
104
|
+
request = self._client.build_request(
|
|
105
|
+
"GET", self._request_url("events"),
|
|
106
|
+
headers={"Authorization": f"Bearer {self._api_token}",
|
|
107
|
+
"Accept": "text/event-stream"},
|
|
108
|
+
timeout=httpx.Timeout(60, read=45),
|
|
109
|
+
)
|
|
110
|
+
response = await self._client.send(request, stream=True, follow_redirects=False)
|
|
111
|
+
event_type, event_data, event_bytes = None, [], 0
|
|
112
|
+
try:
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
if response.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "text/event-stream":
|
|
115
|
+
raise ValueError("Unexpected local events content type")
|
|
116
|
+
buffer = bytearray()
|
|
117
|
+
async for chunk in response.aiter_bytes():
|
|
118
|
+
buffer.extend(chunk)
|
|
119
|
+
while b"\n" in buffer:
|
|
120
|
+
line, _, remainder = buffer.partition(b"\n")
|
|
121
|
+
buffer = bytearray(remainder)
|
|
122
|
+
event_bytes += len(line) + 1
|
|
123
|
+
if event_bytes > MAX_EVENT_BYTES:
|
|
124
|
+
raise ValueError("SSE event is too large")
|
|
125
|
+
line = line.removesuffix(b"\r")
|
|
126
|
+
if line:
|
|
127
|
+
if line.startswith(b":"):
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
field, separator, value = line.decode("UTF-8").partition(":")
|
|
131
|
+
except UnicodeDecodeError as error:
|
|
132
|
+
raise ValueError("Invalid UTF-8 in SSE event") from error
|
|
133
|
+
if separator and value.startswith(" "):
|
|
134
|
+
value = value[1:]
|
|
135
|
+
if field == "event":
|
|
136
|
+
event_type = value
|
|
137
|
+
elif field == "data":
|
|
138
|
+
event_data.append(value)
|
|
139
|
+
continue
|
|
140
|
+
event = self._snapshot(event_type, event_data)
|
|
141
|
+
event_type, event_data, event_bytes = None, [], 0
|
|
142
|
+
if event is not None:
|
|
143
|
+
yield event
|
|
144
|
+
if event_bytes + len(buffer) > MAX_EVENT_BYTES:
|
|
145
|
+
raise ValueError("SSE event is too large")
|
|
146
|
+
# A disconnected peer may leave a partial frame; reconnect policy belongs to the caller.
|
|
147
|
+
finally:
|
|
148
|
+
await response.aclose()
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def _snapshot(cls, event_type: Optional[str], data: list[str]):
|
|
152
|
+
if event_type != "snapshot":
|
|
153
|
+
return None
|
|
154
|
+
if not data:
|
|
155
|
+
raise ValueError("Snapshot event has no data")
|
|
156
|
+
try:
|
|
157
|
+
payload = json.loads("\n".join(data), parse_constant=lambda value: (_ for _ in ()).throw(
|
|
158
|
+
ValueError("Invalid JSON number")))
|
|
159
|
+
except (TypeError, ValueError, RecursionError) as error:
|
|
160
|
+
raise ValueError("Invalid snapshot JSON") from error
|
|
161
|
+
keys = {"version", "revision", "brokerConnected", "door", "pets",
|
|
162
|
+
"links", "activity", "override"}
|
|
163
|
+
try:
|
|
164
|
+
finite = cls._finite(payload)
|
|
165
|
+
except RecursionError as error:
|
|
166
|
+
raise ValueError("Invalid snapshot schema") from error
|
|
167
|
+
if (not isinstance(payload, dict) or set(payload) != keys
|
|
168
|
+
or type(payload["version"]) is not int or payload["version"] != 1
|
|
169
|
+
or type(payload["revision"]) is not int
|
|
170
|
+
or payload["revision"] < 0
|
|
171
|
+
or type(payload["brokerConnected"]) is not bool
|
|
172
|
+
or not isinstance(payload["door"], dict)
|
|
173
|
+
or not isinstance(payload["pets"], list)
|
|
174
|
+
or any(not isinstance(item, dict) for item in payload["pets"])
|
|
175
|
+
or not isinstance(payload["links"], dict)
|
|
176
|
+
or any(not isinstance(key, str) or not isinstance(items, list)
|
|
177
|
+
or any(not isinstance(item, dict) for item in items)
|
|
178
|
+
for key, items in payload["links"].items())
|
|
179
|
+
or not isinstance(payload["activity"], list)
|
|
180
|
+
or any(not isinstance(item, dict) for item in payload["activity"])
|
|
181
|
+
or payload["override"] is not None and not isinstance(payload["override"], dict)
|
|
182
|
+
or not finite):
|
|
183
|
+
raise ValueError("Invalid snapshot schema")
|
|
184
|
+
return payload
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _finite(value: Any) -> bool:
|
|
188
|
+
if isinstance(value, float):
|
|
189
|
+
return math.isfinite(value)
|
|
190
|
+
if isinstance(value, dict):
|
|
191
|
+
return all(isinstance(key, str) and LocalPetSafeClient._finite(item)
|
|
192
|
+
for key, item in value.items())
|
|
193
|
+
if isinstance(value, list):
|
|
194
|
+
return all(LocalPetSafeClient._finite(item) for item in value)
|
|
195
|
+
return True
|
|
196
|
+
|
|
100
197
|
async def get_smartdoors(self) -> list[DeviceSmartDoor]:
|
|
101
198
|
response = await self.api_get("smartdoor/product/product")
|
|
102
199
|
payload = json.loads(response.content.decode("UTF-8"))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: petsafe-api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.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
|
|
@@ -57,7 +57,7 @@ Thank you to both projects for building the API integrations for existing feeder
|
|
|
57
57
|
If installing from source code,
|
|
58
58
|
`python setup.py install`
|
|
59
59
|
|
|
60
|
-
## Local SmartDoor control and provisioning (3.
|
|
60
|
+
## Local SmartDoor control and provisioning (3.2.0)
|
|
61
61
|
|
|
62
62
|
`LocalPetSafeClient` connects to a compatible local server using its HTTPS origin
|
|
63
63
|
and API token. It does not use a PetSafe vendor login, and returns the same
|
|
@@ -77,6 +77,9 @@ async def main() -> None:
|
|
|
77
77
|
await door.unlock()
|
|
78
78
|
await door.smart_mode()
|
|
79
79
|
|
|
80
|
+
async for snapshot in client.iter_events():
|
|
81
|
+
print(snapshot["revision"], snapshot["door"])
|
|
82
|
+
|
|
80
83
|
|
|
81
84
|
asyncio.run(main())
|
|
82
85
|
```
|
|
@@ -8,7 +8,7 @@ import pytest
|
|
|
8
8
|
import petsafe
|
|
9
9
|
from petsafe import pets
|
|
10
10
|
from petsafe.devices import DeviceSmartDoor
|
|
11
|
-
from petsafe.local import MAX_RESPONSE_BYTES
|
|
11
|
+
from petsafe.local import MAX_EVENT_BYTES, MAX_RESPONSE_BYTES
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
BASE = "https://local.example.test"
|
|
@@ -166,3 +166,78 @@ def test_smartdoor_collection_shape_is_validated(payload):
|
|
|
166
166
|
await petsafe.LocalPetSafeClient(BASE, TOKEN, client=http).get_smartdoors()
|
|
167
167
|
|
|
168
168
|
run(check())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_iter_events_parses_snapshots_and_closes_stream():
|
|
172
|
+
snapshot = {"version": 1, "revision": 7, "brokerConnected": True,
|
|
173
|
+
"door": {"thingName": "door-test"}, "pets": [], "links": {},
|
|
174
|
+
"activity": [], "override": None}
|
|
175
|
+
|
|
176
|
+
class Stream(httpx.AsyncByteStream):
|
|
177
|
+
closed = False
|
|
178
|
+
|
|
179
|
+
async def __aiter__(self):
|
|
180
|
+
encoded = json.dumps(snapshot)
|
|
181
|
+
split = encoded.index('"door"')
|
|
182
|
+
yield (": heartbeat\n\nevent: snapshot\nid: 7\ndata: " + encoded[:split]
|
|
183
|
+
+ "\ndata: " + encoded[split:] + "\n\n").encode()
|
|
184
|
+
|
|
185
|
+
async def aclose(self):
|
|
186
|
+
self.closed = True
|
|
187
|
+
|
|
188
|
+
stream = Stream()
|
|
189
|
+
|
|
190
|
+
async def handler(request):
|
|
191
|
+
assert request.url == BASE + "/local/events"
|
|
192
|
+
assert request.headers["authorization"] == f"Bearer {TOKEN}"
|
|
193
|
+
assert request.headers["accept"] == "text/event-stream"
|
|
194
|
+
assert request.extensions["timeout"]["read"] == 45
|
|
195
|
+
return httpx.Response(200, headers={"content-type": "text/event-stream; charset=utf-8"},
|
|
196
|
+
stream=stream)
|
|
197
|
+
|
|
198
|
+
async def check():
|
|
199
|
+
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
|
200
|
+
events = petsafe.LocalPetSafeClient(BASE, TOKEN, client=http).iter_events()
|
|
201
|
+
assert await events.__anext__() == snapshot
|
|
202
|
+
await events.aclose()
|
|
203
|
+
assert stream.closed
|
|
204
|
+
|
|
205
|
+
run(check())
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@pytest.mark.parametrize("body, message", [
|
|
209
|
+
(b"event: snapshot\ndata: {}\n\n", "schema"),
|
|
210
|
+
(b"event: snapshot\ndata: {\"version\":NaN}\n\n", "JSON"),
|
|
211
|
+
(b":" + b"x" * MAX_EVENT_BYTES + b"\n", "too large"),
|
|
212
|
+
])
|
|
213
|
+
def test_iter_events_rejects_invalid_or_oversized_frames(body, message):
|
|
214
|
+
async def check():
|
|
215
|
+
async with httpx.AsyncClient(transport=httpx.MockTransport(
|
|
216
|
+
lambda request: httpx.Response(200, headers={"content-type": "text/event-stream"},
|
|
217
|
+
content=body))) as http:
|
|
218
|
+
with pytest.raises(ValueError, match=message):
|
|
219
|
+
await petsafe.LocalPetSafeClient(BASE, TOKEN, client=http).iter_events().__anext__()
|
|
220
|
+
|
|
221
|
+
run(check())
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_iter_events_preserves_http_status_errors():
|
|
225
|
+
async def check():
|
|
226
|
+
async with httpx.AsyncClient(transport=httpx.MockTransport(
|
|
227
|
+
lambda request: httpx.Response(401))) as http:
|
|
228
|
+
with pytest.raises(httpx.HTTPStatusError) as raised:
|
|
229
|
+
await petsafe.LocalPetSafeClient(BASE, TOKEN, client=http).iter_events().__anext__()
|
|
230
|
+
assert raised.value.response.status_code == 401
|
|
231
|
+
|
|
232
|
+
run(check())
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_iter_events_sanitizes_deep_schema_recursion():
|
|
236
|
+
nested = "[]"
|
|
237
|
+
for _ in range(500):
|
|
238
|
+
nested = "[" + nested + "]"
|
|
239
|
+
payload = ('{"version":1,"revision":0,"brokerConnected":true,'
|
|
240
|
+
'"door":{"nested":' + nested
|
|
241
|
+
+ '},"pets":[],"links":{},"activity":[],"override":null}')
|
|
242
|
+
with pytest.raises(ValueError, match="snapshot schema"):
|
|
243
|
+
petsafe.LocalPetSafeClient._snapshot("snapshot", [payload])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|