Skip to content

Commit 0e2b434

Browse files
peeblesbachya
andauthored
Add support for retrieving media from outdoor cameras (#708)
* Add Outdoor Camera media url support. The WebsocketEvent now includes image, mp4 and hls media file urls extracted from outdoor camera motion events. An api is added to fetch media urls, using the access token and retry logic already in place for api access. * added tests for media urls and changes for pre-commit * Remove an unused method * Refactor api code to make it more reusable * Refactor retry codes specification, and more strongly type request_func Callable * Clean up some code and re-lint. * Fix spelling in a comment * Refactor is_fatal_error and how it is called * Small cleanup * Cleanup * Fix websocket test after changing some attributes to snake_case * Remove unreachable code and provide tests for coverage. * Remove unreachable code and provide tests for coverage. * Small docs cleanup * Small docs update * Include media fetch methods in disable/enable_request_retries() * Update docs/websocket.md * Update docs/websocket.md * Update docs/websocket.md --------- Co-authored-by: Aaron Bach <bachya1208@gmail.com>
1 parent 27aebde commit 0e2b434

7 files changed

Lines changed: 429 additions & 26 deletions

File tree

docs/websocket.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ The `event` argument provided to event callbacks is a
105105
- `sensor_type`: the type of the entity that triggered the event
106106
- `system_id`: the SimpliSafe™ system ID
107107
- `timestamp`: the UTC timestamp that the event occurred
108+
- `media_urls`: a dict containing media URLs if the `event_type` is "camera_motion_detected" (see below)
108109

109110
The `event_type` property will be one of the following values:
110111

@@ -137,6 +138,26 @@ The `event_type` property will be one of the following values:
137138
- `sensor_restored`
138139
- `user_initiated_test`
139140

141+
If the `event_type` is `camera_motion_detected`, then the `event` attribute `media_urls`
142+
will be a dictionary that looks like this:
143+
144+
```python
145+
{
146+
"image_url": "https://xxx.us-east-1.prd.cam.simplisafe.com/xxx",
147+
"clip_url": "https://xxx.us-east-1.prd.cam.simplisafe.com/xxx",
148+
}
149+
```
150+
151+
The `image_url` is an absolute URL to a JPEG file. The `clip_url` is an absolute URL to
152+
a short MPEG4 video clip. Both refer to the motion detected by the camera. You can
153+
retrieve the raw bytes of the media files at these URLs with the following method:
154+
155+
```python
156+
bytes = await api.async_media(url)
157+
```
158+
159+
If the `event_type` is not `camera_motion_detected`, then `media_urls` will be set to None.
160+
140161
If you should come across an event type that the library does not know about (and see
141162
a log message about it), please open an issue at
142163
<https://github.com/bachya/simplisafe-python/issues>.

simplipy/api.py

Lines changed: 112 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
API_URL_BASE = f"https://{API_URL_HOSTNAME}/v1"
3636

3737
DEFAULT_REQUEST_RETRIES = 4
38+
DEFAULT_MEDIA_RETRIES = 4
3839
DEFAULT_TIMEOUT = 10
3940
DEFAULT_TOKEN_EXPIRATION_WINDOW = 5
4041

@@ -49,12 +50,15 @@ class API: # pylint: disable=too-many-instance-attributes
4950
Args:
5051
session: session: An optional ``aiohttp`` ``ClientSession``.
5152
request_retries: The default number of request retries to use.
53+
media_retries: The default number of request retries to use to
54+
fetch media files.
5255
"""
5356

5457
def __init__(
5558
self,
5659
*,
5760
request_retries: int = DEFAULT_REQUEST_RETRIES,
61+
media_retries: int = DEFAULT_MEDIA_RETRIES,
5862
session: ClientSession,
5963
) -> None:
6064
"""Initialize.
@@ -67,6 +71,7 @@ def __init__(
6771
Callable[[str], Awaitable[None] | None]
6872
] = []
6973
self._request_retries = request_retries
74+
self._media_retries = media_retries
7075
self.session: ClientSession = session
7176

7277
# These will get filled in after initial authentication:
@@ -78,7 +83,16 @@ def __init__(
7883
self.user_id: int | None = None
7984
self.websocket: WebsocketClient | None = None
8085

81-
self.async_request = self._wrap_request_method(self._request_retries)
86+
self.async_request = self._wrap_request_method(
87+
request_retries=self._request_retries,
88+
retry_codes=[401, 409],
89+
request_func=self._async_api_request,
90+
)
91+
self._async_media_data = self._wrap_request_method(
92+
request_retries=self._media_retries,
93+
retry_codes=[401, 404, 409],
94+
request_func=self._async_media_request,
95+
)
8296

8397
@classmethod
8498
async def async_from_auth(
@@ -237,6 +251,38 @@ async def _async_api_request(
237251

238252
return data
239253

254+
async def async_media(self, url: str) -> bytes | None:
255+
"""Fetch a media file and return raw bytes to caller.
256+
257+
Args:
258+
url: An absolute url for the media file.
259+
260+
Returns:
261+
The raw bytes of the media file.
262+
"""
263+
data = await self._async_media_data(url)
264+
return cast(bytes, data["bytes"])
265+
266+
async def _async_media_request(self, url: str) -> dict[str, Any]:
267+
"""Fetch a media file.
268+
269+
Args:
270+
url: An absolute url for the media file.
271+
272+
Returns:
273+
A dict that looks like { "bytes": <raw-bytes> }.
274+
"""
275+
async with self.session.request(
276+
"get",
277+
url,
278+
headers={
279+
"User-Agent": DEFAULT_USER_AGENT,
280+
"Authorization": f"Bearer {self.access_token}",
281+
},
282+
) as resp:
283+
resp.raise_for_status()
284+
return {"bytes": await resp.read()}
285+
240286
@staticmethod
241287
def _handle_on_giveup(_: dict[str, Any]) -> None:
242288
"""Handle a give up after retries are exhausted.
@@ -260,60 +306,100 @@ def _save_token_data_from_response(self, token_data: dict[str, Any]) -> None:
260306
self.refresh_token = refresh_token
261307

262308
@staticmethod
263-
def is_fatal_error(err: ClientResponseError) -> bool:
309+
def is_fatal_error(
310+
retriable_error_codes: list[int],
311+
) -> Callable[[ClientResponseError], bool]:
264312
"""Determine whether a ClientResponseError is fatal and shouldn't be retried.
265313
266-
In general, we retry anything outside of HTTP 4xx codes (client errors) with a
267-
few exceptions:
314+
When sending general API requests:
268315
269316
1. 401: We catch this, refresh the access token, and retry the original request.
270317
2. 409: SimpliSafe base stations regular synchronize themselves with the API,
271318
which is where this error can occur; we can't control when/how that
272319
happens (e.g., we might query the API in the middle of a base station
273320
update), so it should be viewed as retryable.
274321
322+
But when fetching media files:
323+
324+
3. 404: When fetching media files, you may get a 404 if the media file is not
325+
yet available to read. Keep trying however, and it will eventually
326+
return a 200.
327+
275328
Args:
276-
err: An ``aiohttp`` ``ClientResponseError``
329+
retriable_error_codes: A list of retriable error status codes.
277330
278331
Returns:
279-
Whether the error is a fatal one.
332+
A callable function used by backoff to check for errors.
280333
"""
281-
if err.status in (401, 409):
282-
return False
283-
return 400 <= err.status < 500
334+
335+
def check(err: ClientResponseError) -> bool:
336+
"""Perform the check.
337+
338+
Args:
339+
err: An ``aiohttp`` ``ClientResponseError``
340+
341+
Returns:
342+
Whether the error is a fatal one.
343+
"""
344+
if err.status in retriable_error_codes:
345+
return False
346+
return 400 <= err.status < 500
347+
348+
return check
284349

285350
def _wrap_request_method(
286-
self, request_retries: int
351+
self,
352+
request_retries: int,
353+
retry_codes: list[int],
354+
request_func: Callable[..., Awaitable[dict[str, Any]]],
287355
) -> Callable[..., Awaitable[dict[str, Any]]]:
288-
"""Wrap the request method in backoff/retry logic.
356+
"""Wrap a request method in backoff/retry logic.
289357
290358
Args:
291359
request_retries: The number of retries to give a failed request.
360+
retry_codes: A list of HTTP status codes that cause the retry
361+
loop to continue.
362+
request_func: A function that performs the request.
292363
293364
Returns:
294365
A version of the request callable that can do retries.
295366
"""
296-
return cast(
297-
Callable,
298-
backoff.on_exception(
299-
backoff.expo,
300-
ClientResponseError,
301-
giveup=self.is_fatal_error, # type: ignore[arg-type]
302-
jitter=backoff.random_jitter,
303-
logger=LOGGER,
304-
max_tries=request_retries,
305-
on_backoff=self._async_handle_on_backoff, # type: ignore[arg-type]
306-
on_giveup=self._handle_on_giveup, # type: ignore[arg-type]
307-
)(self._async_api_request),
308-
)
367+
return backoff.on_exception(
368+
backoff.expo,
369+
ClientResponseError,
370+
giveup=self.is_fatal_error(retry_codes), # type: ignore[arg-type]
371+
jitter=backoff.random_jitter,
372+
logger=LOGGER,
373+
max_tries=request_retries,
374+
on_backoff=self._async_handle_on_backoff, # type: ignore[arg-type]
375+
on_giveup=self._handle_on_giveup, # type: ignore[arg-type]
376+
)(request_func)
309377

310378
def disable_request_retries(self) -> None:
311379
"""Disable the request retry mechanism."""
312-
self.async_request = self._wrap_request_method(1)
380+
self.async_request = self._wrap_request_method(
381+
request_retries=1,
382+
retry_codes=[401, 409],
383+
request_func=self._async_api_request,
384+
)
385+
self._async_media_data = self._wrap_request_method(
386+
request_retries=1,
387+
retry_codes=[401, 404, 409],
388+
request_func=self._async_media_request,
389+
)
313390

314391
def enable_request_retries(self) -> None:
315392
"""Enable the request retry mechanism."""
316-
self.async_request = self._wrap_request_method(self._request_retries)
393+
self.async_request = self._wrap_request_method(
394+
request_retries=self._request_retries,
395+
retry_codes=[401, 409],
396+
request_func=self._async_api_request,
397+
)
398+
self._async_media_data = self._wrap_request_method(
399+
request_retries=self._media_retries,
400+
retry_codes=[401, 404, 409],
401+
request_func=self._async_media_request,
402+
)
317403

318404
def add_refresh_token_callback(
319405
self, callback: Callable[[str], Awaitable[None] | None]

simplipy/websocket.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,12 @@ class WebsocketEvent:
150150
info: str
151151
system_id: int
152152
_raw_timestamp: float
153+
_video: dict | None
154+
_vid: str | None
153155

154156
event_type: str | None = field(init=False)
155157
timestamp: datetime = field(init=False)
158+
media_urls: dict[str, str] | None = field(init=False)
156159

157160
changed_by: str | None = None
158161
sensor_name: str | None = None
@@ -190,6 +193,25 @@ def __post_init__(self, event_cid: int) -> None:
190193
)
191194
object.__setattr__(self, "sensor_type", None)
192195

196+
if self._vid is not None and self._video is not None:
197+
object.__setattr__(
198+
self,
199+
"media_urls",
200+
{
201+
"image_url": self._video[self._vid]["_links"]["snapshot/jpg"][
202+
"href"
203+
],
204+
"clip_url": self._video[self._vid]["_links"]["download/mp4"][
205+
"href"
206+
],
207+
"hls_url": self._video[self._vid]["_links"]["playback/hls"]["href"],
208+
},
209+
)
210+
object.__setattr__(self, "_vid", None)
211+
object.__setattr__(self, "_video", None)
212+
else:
213+
object.__setattr__(self, "media_urls", None)
214+
193215

194216
def websocket_event_from_payload(payload: dict[str, Any]) -> WebsocketEvent:
195217
"""Create a Message object from a websocket event payload.
@@ -205,6 +227,8 @@ def websocket_event_from_payload(payload: dict[str, Any]) -> WebsocketEvent:
205227
payload["data"]["info"],
206228
payload["data"]["sid"],
207229
payload["data"]["eventTimestamp"],
230+
payload["data"].get("video"),
231+
payload["data"].get("videoStartedBy"),
208232
changed_by=payload["data"]["pinName"],
209233
sensor_name=payload["data"]["sensorName"],
210234
sensor_serial=payload["data"]["sensorSerial"],

tests/conftest.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,37 @@ def ws_message_event_data_fixture() -> dict[str, Any]:
388388
return cast(dict[str, Any], json.loads(load_fixture("ws_message_event_data.json")))
389389

390390

391+
@pytest.fixture(name="ws_motion_event")
392+
def ws_motion_event_fixture(ws_motion_event_data: dict[str, Any]) -> dict[str, Any]:
393+
"""Define a fixture to represent an event response.
394+
395+
Args:
396+
ws_motion_event_data: A mocked websocket response payload.
397+
398+
Returns:
399+
A websocket response payload.
400+
"""
401+
return {
402+
"data": ws_motion_event_data,
403+
"datacontenttype": "application/json",
404+
"id": "id:16803409109",
405+
"source": "messagequeue",
406+
"specversion": "1.0",
407+
"time": "2021-09-29T23:14:46.000Z",
408+
"type": "com.simplisafe.event.standard",
409+
}
410+
411+
412+
@pytest.fixture(name="ws_motion_event_data", scope="session")
413+
def ws_motion_event_data_fixture() -> dict[str, Any]:
414+
"""Define a fixture that returns the data payload from a data event.
415+
416+
Returns:
417+
A API response payload.
418+
"""
419+
return cast(dict[str, Any], json.loads(load_fixture("ws_motion_event_data.json")))
420+
421+
391422
@pytest.fixture(name="ws_message_hello")
392423
def ws_message_hello_fixture(ws_message_hello_data: dict[str, Any]) -> dict[str, Any]:
393424
"""Define a fixture to represent the "hello" response.

0 commit comments

Comments
 (0)