ringwire 0.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.
- ringwire-0.1.0/LICENSE +21 -0
- ringwire-0.1.0/PKG-INFO +81 -0
- ringwire-0.1.0/README.md +58 -0
- ringwire-0.1.0/pyproject.toml +34 -0
- ringwire-0.1.0/setup.cfg +4 -0
- ringwire-0.1.0/src/ringwire/__init__.py +33 -0
- ringwire-0.1.0/src/ringwire/client.py +332 -0
- ringwire-0.1.0/src/ringwire/errors.py +58 -0
- ringwire-0.1.0/src/ringwire/nonce.py +43 -0
- ringwire-0.1.0/src/ringwire/types.py +267 -0
- ringwire-0.1.0/src/ringwire/webhooks.py +162 -0
- ringwire-0.1.0/src/ringwire.egg-info/PKG-INFO +81 -0
- ringwire-0.1.0/src/ringwire.egg-info/SOURCES.txt +16 -0
- ringwire-0.1.0/src/ringwire.egg-info/dependency_links.txt +1 -0
- ringwire-0.1.0/src/ringwire.egg-info/requires.txt +5 -0
- ringwire-0.1.0/src/ringwire.egg-info/top_level.txt +1 -0
- ringwire-0.1.0/tests/test_client.py +213 -0
- ringwire-0.1.0/tests/test_webhooks.py +104 -0
ringwire-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonathan Andrei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ringwire-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ringwire
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client for the Ring Partner API (Amazon Vision API): devices, sensors, media, webhooks, account linking.
|
|
5
|
+
Author: Jonathan Andrei
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/JonathanSolvesProblems/ringwire
|
|
8
|
+
Project-URL: Documentation, https://developer.amazon.com/docs/ring/api-documentation.html
|
|
9
|
+
Keywords: ring,amazon,vision,api,webhooks,sensors,smart-home
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Home Automation
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
21
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# ringwire
|
|
25
|
+
|
|
26
|
+
A typed Python client for the **Ring Partner API** (the `api.amazonvision.com` API behind the
|
|
27
|
+
Ring Appstore), including the parts most samples skip: sensors, the two-step media
|
|
28
|
+
downloads, chime playback, webhook verification, and the account-linking nonce.
|
|
29
|
+
|
|
30
|
+
Built for [Watermark](../README.md) and published on its own so other Ring Appstore
|
|
31
|
+
developers do not have to re-read the docs I already read.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install ringwire
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## What it covers
|
|
38
|
+
|
|
39
|
+
| Area | Calls |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Auth | `RingClient.exchange_code`, `RingClient.refresh` (form-encoded, as Ring requires) |
|
|
42
|
+
| Users and linking | `me()`, `confirm_account_link(nonce)`, `update_integration(status="completed")` |
|
|
43
|
+
| Devices | `list_devices()`, `status()`, `capabilities()`, `configurations()`, `location()` |
|
|
44
|
+
| Sensors | `status()` exposes `flood_faulted`, `freeze_faulted`, `contact_faulted`, `tamper_detected`, `temperature`, `humidity`; `reported_at` is `None` for the 1970 "never reported" epoch |
|
|
45
|
+
| History | `event_history()`, `iter_event_history()` (follows `links.next`, stops on the empty page) |
|
|
46
|
+
| Media | `download_image()` and `download_clip()` do the 303 two-step and read `X-Media-Timestamp` / `X-Media-Origin` |
|
|
47
|
+
| Live | `create_whep_session()`, `close_whep_session()`, `rtsps_url()` |
|
|
48
|
+
| Chimes | `play_chime(device_id, audio_ref)`, with slots read from `configurations()` |
|
|
49
|
+
| Webhooks | `verify_signature`, `verify_and_parse`, `parse_event`, `make_event`, every documented event type including the Early Access sensor events |
|
|
50
|
+
| Nonce | `compute_nonce`, `match_nonce` (URL-safe base64, no padding, 600 s window, constant-time) |
|
|
51
|
+
|
|
52
|
+
## Things the docs say that this library enforces
|
|
53
|
+
|
|
54
|
+
- The image and clip endpoints **never trigger a capture**. A `416` raises `MediaNotFound`
|
|
55
|
+
so the caller can start a live session first.
|
|
56
|
+
- Sensor events have **no history endpoint**. Persist them on receipt.
|
|
57
|
+
- Webhook signatures are computed over the **raw body bytes**. Pass the bytes, not the
|
|
58
|
+
parsed JSON.
|
|
59
|
+
- Refresh tokens are not Bearer tokens.
|
|
60
|
+
|
|
61
|
+
## Example
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from ringwire import RingClient, verify_and_parse
|
|
65
|
+
|
|
66
|
+
client = RingClient(access_token)
|
|
67
|
+
sensors = [d for d in client.list_devices() if client.status(d.id).flood_faulted is not None]
|
|
68
|
+
|
|
69
|
+
# In your webhook handler (raw bytes, before any JSON parsing):
|
|
70
|
+
event = verify_and_parse(raw_body, headers["X-Signature"], signing_key)
|
|
71
|
+
if event.type == "flood_detected":
|
|
72
|
+
...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Tests
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install -e ".[dev]" && pytest
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
MIT. Not affiliated with Ring or Amazon.
|
ringwire-0.1.0/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# ringwire
|
|
2
|
+
|
|
3
|
+
A typed Python client for the **Ring Partner API** (the `api.amazonvision.com` API behind the
|
|
4
|
+
Ring Appstore), including the parts most samples skip: sensors, the two-step media
|
|
5
|
+
downloads, chime playback, webhook verification, and the account-linking nonce.
|
|
6
|
+
|
|
7
|
+
Built for [Watermark](../README.md) and published on its own so other Ring Appstore
|
|
8
|
+
developers do not have to re-read the docs I already read.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install ringwire
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## What it covers
|
|
15
|
+
|
|
16
|
+
| Area | Calls |
|
|
17
|
+
|---|---|
|
|
18
|
+
| Auth | `RingClient.exchange_code`, `RingClient.refresh` (form-encoded, as Ring requires) |
|
|
19
|
+
| Users and linking | `me()`, `confirm_account_link(nonce)`, `update_integration(status="completed")` |
|
|
20
|
+
| Devices | `list_devices()`, `status()`, `capabilities()`, `configurations()`, `location()` |
|
|
21
|
+
| Sensors | `status()` exposes `flood_faulted`, `freeze_faulted`, `contact_faulted`, `tamper_detected`, `temperature`, `humidity`; `reported_at` is `None` for the 1970 "never reported" epoch |
|
|
22
|
+
| History | `event_history()`, `iter_event_history()` (follows `links.next`, stops on the empty page) |
|
|
23
|
+
| Media | `download_image()` and `download_clip()` do the 303 two-step and read `X-Media-Timestamp` / `X-Media-Origin` |
|
|
24
|
+
| Live | `create_whep_session()`, `close_whep_session()`, `rtsps_url()` |
|
|
25
|
+
| Chimes | `play_chime(device_id, audio_ref)`, with slots read from `configurations()` |
|
|
26
|
+
| Webhooks | `verify_signature`, `verify_and_parse`, `parse_event`, `make_event`, every documented event type including the Early Access sensor events |
|
|
27
|
+
| Nonce | `compute_nonce`, `match_nonce` (URL-safe base64, no padding, 600 s window, constant-time) |
|
|
28
|
+
|
|
29
|
+
## Things the docs say that this library enforces
|
|
30
|
+
|
|
31
|
+
- The image and clip endpoints **never trigger a capture**. A `416` raises `MediaNotFound`
|
|
32
|
+
so the caller can start a live session first.
|
|
33
|
+
- Sensor events have **no history endpoint**. Persist them on receipt.
|
|
34
|
+
- Webhook signatures are computed over the **raw body bytes**. Pass the bytes, not the
|
|
35
|
+
parsed JSON.
|
|
36
|
+
- Refresh tokens are not Bearer tokens.
|
|
37
|
+
|
|
38
|
+
## Example
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from ringwire import RingClient, verify_and_parse
|
|
42
|
+
|
|
43
|
+
client = RingClient(access_token)
|
|
44
|
+
sensors = [d for d in client.list_devices() if client.status(d.id).flood_faulted is not None]
|
|
45
|
+
|
|
46
|
+
# In your webhook handler (raw bytes, before any JSON parsing):
|
|
47
|
+
event = verify_and_parse(raw_body, headers["X-Signature"], signing_key)
|
|
48
|
+
if event.type == "flood_detected":
|
|
49
|
+
...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Tests
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install -e ".[dev]" && pytest
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
MIT. Not affiliated with Ring or Amazon.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ringwire"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed Python client for the Ring Partner API (Amazon Vision API): devices, sensors, media, webhooks, account linking."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Jonathan Andrei" }]
|
|
13
|
+
keywords = ["ring", "amazon", "vision", "api", "webhooks", "sensors", "smart-home"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Home Automation",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["httpx>=0.27"]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=8", "respx>=0.21"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/JonathanSolvesProblems/ringwire"
|
|
28
|
+
Documentation = "https://developer.amazon.com/docs/ring/api-documentation.html"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
ringwire-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""ringwire: a typed Python client for the Ring Partner API.
|
|
2
|
+
|
|
3
|
+
from ringwire import RingClient, verify_and_parse
|
|
4
|
+
|
|
5
|
+
client = RingClient(access_token)
|
|
6
|
+
for d in client.list_devices():
|
|
7
|
+
s = client.status(d.id)
|
|
8
|
+
if s.flood_faulted:
|
|
9
|
+
...
|
|
10
|
+
|
|
11
|
+
event = verify_and_parse(raw_body, request.headers["X-Signature"], signing_key)
|
|
12
|
+
if event.type == "flood_detected": ...
|
|
13
|
+
"""
|
|
14
|
+
from .client import API_BASE, OAUTH_TOKEN_URL, RingClient
|
|
15
|
+
from .errors import (Forbidden, MediaNotFound, NonceError, NotFound, RateLimited, RingAPIError, RingError,
|
|
16
|
+
SignatureError, Unauthorized)
|
|
17
|
+
from .nonce import compute_nonce, is_fresh, match_nonce
|
|
18
|
+
from .types import (Device, DeviceCapabilities, DeviceConfigurations, DeviceStatus, HistoryEvent, HistoryPage,
|
|
19
|
+
MediaClip, MediaImage, TokenSet, User, WhepSession)
|
|
20
|
+
from .webhooks import (ALL_EVENTS, SENSOR_EVENTS, SENSOR_STATE_EVENTS, SENSOR_THRESHOLD_EVENTS, WebhookEvent,
|
|
21
|
+
make_event, parse_event, sign, verify_and_parse, verify_signature)
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"API_BASE", "OAUTH_TOKEN_URL", "RingClient",
|
|
26
|
+
"RingError", "RingAPIError", "Unauthorized", "Forbidden", "NotFound", "MediaNotFound", "RateLimited",
|
|
27
|
+
"SignatureError", "NonceError",
|
|
28
|
+
"compute_nonce", "is_fresh", "match_nonce",
|
|
29
|
+
"TokenSet", "User", "Device", "DeviceStatus", "DeviceCapabilities", "DeviceConfigurations",
|
|
30
|
+
"HistoryEvent", "HistoryPage", "MediaImage", "MediaClip", "WhepSession",
|
|
31
|
+
"WebhookEvent", "parse_event", "verify_signature", "verify_and_parse", "sign", "make_event",
|
|
32
|
+
"ALL_EVENTS", "SENSOR_EVENTS", "SENSOR_STATE_EVENTS", "SENSOR_THRESHOLD_EVENTS",
|
|
33
|
+
]
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Synchronous client for the Ring Partner API (api.amazonvision.com).
|
|
2
|
+
|
|
3
|
+
Covers everything a partner app calls: OAuth token exchange and refresh, users,
|
|
4
|
+
devices (cameras, doorbells, chimes, sensors), event history, image and clip
|
|
5
|
+
download (the two-step 303 flow), WHEP live sessions, chime playback, and the
|
|
6
|
+
app-integrations calls that finish account linking.
|
|
7
|
+
|
|
8
|
+
Retries 429 (honouring Retry-After) and 5xx with backoff. Never follows the 303
|
|
9
|
+
on media downloads automatically, because the redirect target must be fetched
|
|
10
|
+
without the Bearer header.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Iterator
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from .errors import Forbidden, MediaNotFound, NotFound, RateLimited, RingAPIError, Unauthorized
|
|
21
|
+
from .types import (Device, DeviceCapabilities, DeviceConfigurations, DeviceStatus, HistoryEvent,
|
|
22
|
+
HistoryPage, MediaClip, MediaImage, TokenSet, User, WhepSession)
|
|
23
|
+
|
|
24
|
+
API_BASE = "https://api.amazonvision.com"
|
|
25
|
+
OAUTH_TOKEN_URL = "https://oauth.ring.com/oauth/token"
|
|
26
|
+
RTSPS_HOST = "video.rtsp.amazonvision.com"
|
|
27
|
+
RTSPS_PORT = 322
|
|
28
|
+
|
|
29
|
+
TokenProvider = Callable[[], str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _error_from_response(r: httpx.Response) -> RingAPIError:
|
|
33
|
+
code = detail = None
|
|
34
|
+
try:
|
|
35
|
+
body = r.json()
|
|
36
|
+
errs = body.get("errors") or []
|
|
37
|
+
if errs:
|
|
38
|
+
code = errs[0].get("code")
|
|
39
|
+
detail = errs[0].get("detail") or errs[0].get("title")
|
|
40
|
+
elif "error" in body:
|
|
41
|
+
code = body.get("error")
|
|
42
|
+
detail = body.get("error_description") or body.get("message")
|
|
43
|
+
except Exception:
|
|
44
|
+
detail = r.text[:200] if r.text else None
|
|
45
|
+
rid = r.headers.get("X-Request-ID")
|
|
46
|
+
kw = dict(code=code, detail=detail, request_id=rid)
|
|
47
|
+
if r.status_code == 401:
|
|
48
|
+
return Unauthorized(401, **kw)
|
|
49
|
+
if r.status_code == 403:
|
|
50
|
+
return Forbidden(403, **kw)
|
|
51
|
+
if r.status_code == 404:
|
|
52
|
+
return NotFound(404, **kw)
|
|
53
|
+
if r.status_code == 416:
|
|
54
|
+
return MediaNotFound(416, **kw)
|
|
55
|
+
if r.status_code == 429:
|
|
56
|
+
ra = r.headers.get("Retry-After")
|
|
57
|
+
return RateLimited(retry_after=float(ra) if ra else None, **kw)
|
|
58
|
+
return RingAPIError(r.status_code, **kw)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RingClient:
|
|
62
|
+
"""One client per Ring user. Tokens are user-scoped.
|
|
63
|
+
|
|
64
|
+
Pass either a static `access_token` or a `token_provider` callable that
|
|
65
|
+
returns a currently valid access token (so the caller owns refresh).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, access_token: str | None = None, *, token_provider: TokenProvider | None = None,
|
|
69
|
+
base_url: str = API_BASE, timeout: float = 20.0, max_retries: int = 3,
|
|
70
|
+
transport: httpx.BaseTransport | None = None, user_agent: str = "ringwire-py/0.1"):
|
|
71
|
+
if not access_token and not token_provider:
|
|
72
|
+
raise ValueError("access_token or token_provider is required")
|
|
73
|
+
self._token = access_token
|
|
74
|
+
self._provider = token_provider
|
|
75
|
+
self.base_url = base_url.rstrip("/")
|
|
76
|
+
self.max_retries = max_retries
|
|
77
|
+
self._http = httpx.Client(timeout=timeout, transport=transport,
|
|
78
|
+
headers={"User-Agent": user_agent}, follow_redirects=False)
|
|
79
|
+
|
|
80
|
+
# ---------- auth (static, no token needed) ----------
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def exchange_code(code: str, client_id: str, client_secret: str, code_verifier: str | None = None,
|
|
84
|
+
*, token_url: str = OAUTH_TOKEN_URL, transport: httpx.BaseTransport | None = None) -> TokenSet:
|
|
85
|
+
"""Exchange the authorization code Ring POSTed to the Token Exchange URL.
|
|
86
|
+
One-way codes expire in 60 seconds; partner-initiated in 10 minutes."""
|
|
87
|
+
form = {"grant_type": "authorization_code", "code": code, "client_id": client_id,
|
|
88
|
+
"client_secret": client_secret}
|
|
89
|
+
if code_verifier:
|
|
90
|
+
form["code_verifier"] = code_verifier
|
|
91
|
+
with httpx.Client(transport=transport, timeout=20.0) as c:
|
|
92
|
+
r = c.post(token_url, data=form)
|
|
93
|
+
if r.status_code != 200:
|
|
94
|
+
raise _error_from_response(r)
|
|
95
|
+
return TokenSet.from_json(r.json())
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def refresh(refresh_token: str, client_id: str, client_secret: str, *, token_url: str = OAUTH_TOKEN_URL,
|
|
99
|
+
transport: httpx.BaseTransport | None = None) -> TokenSet:
|
|
100
|
+
"""Refresh tokens are valid ~30 days and rotate on every exchange; store the new pair."""
|
|
101
|
+
form = {"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": client_id,
|
|
102
|
+
"client_secret": client_secret}
|
|
103
|
+
with httpx.Client(transport=transport, timeout=20.0) as c:
|
|
104
|
+
r = c.post(token_url, data=form)
|
|
105
|
+
if r.status_code != 200:
|
|
106
|
+
raise _error_from_response(r)
|
|
107
|
+
return TokenSet.from_json(r.json())
|
|
108
|
+
|
|
109
|
+
# ---------- plumbing ----------
|
|
110
|
+
|
|
111
|
+
def _auth(self) -> dict[str, str]:
|
|
112
|
+
token = self._provider() if self._provider else self._token
|
|
113
|
+
return {"Authorization": f"Bearer {token}"}
|
|
114
|
+
|
|
115
|
+
def _request(self, method: str, path: str, *, json: Any = None, params: dict[str, Any] | None = None,
|
|
116
|
+
content: bytes | str | None = None, headers: dict[str, str] | None = None,
|
|
117
|
+
ok: tuple[int, ...] = (200, 201)) -> httpx.Response:
|
|
118
|
+
url = path if path.startswith("http") else f"{self.base_url}{path}"
|
|
119
|
+
attempt = 0
|
|
120
|
+
while True:
|
|
121
|
+
h = {**self._auth(), **(headers or {})}
|
|
122
|
+
r = self._http.request(method, url, json=json, params=params, content=content, headers=h)
|
|
123
|
+
if r.status_code in ok:
|
|
124
|
+
return r
|
|
125
|
+
err = _error_from_response(r)
|
|
126
|
+
retryable = isinstance(err, RateLimited) or 500 <= r.status_code < 600
|
|
127
|
+
if retryable and attempt < self.max_retries:
|
|
128
|
+
delay = err.retry_after if isinstance(err, RateLimited) and err.retry_after else (2 ** attempt) * 0.5
|
|
129
|
+
time.sleep(delay)
|
|
130
|
+
attempt += 1
|
|
131
|
+
continue
|
|
132
|
+
raise err
|
|
133
|
+
|
|
134
|
+
def _get_json(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
135
|
+
return self._request("GET", path, params=params).json()
|
|
136
|
+
|
|
137
|
+
def close(self) -> None:
|
|
138
|
+
self._http.close()
|
|
139
|
+
|
|
140
|
+
def __enter__(self) -> "RingClient":
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def __exit__(self, *exc: Any) -> None:
|
|
144
|
+
self.close()
|
|
145
|
+
|
|
146
|
+
# ---------- users / account ----------
|
|
147
|
+
|
|
148
|
+
def me(self) -> User:
|
|
149
|
+
return User.from_json(self._get_json("/v1/users/me"))
|
|
150
|
+
|
|
151
|
+
def confirm_account_link(self, nonce: str, account_identifier: str | None = None) -> dict[str, Any]:
|
|
152
|
+
"""One-way linking step 1: POST the nonce. Status becomes `awaiting`."""
|
|
153
|
+
body: dict[str, Any] = {"nonce": nonce}
|
|
154
|
+
if account_identifier:
|
|
155
|
+
body["account_identifier"] = account_identifier
|
|
156
|
+
return self._request("POST", "/v1/accounts/me/app-integrations", json=body).json()
|
|
157
|
+
|
|
158
|
+
def update_integration(self, status: str | None = None, account_identifier: str | None = None) -> dict[str, Any]:
|
|
159
|
+
"""Step 2, mandatory: PATCH `status: completed`. Without it the integration
|
|
160
|
+
stays `awaiting`, device consents stay inactive and no webhooks arrive."""
|
|
161
|
+
body: dict[str, Any] = {}
|
|
162
|
+
if status:
|
|
163
|
+
body["status"] = status
|
|
164
|
+
if account_identifier:
|
|
165
|
+
body["account_identifier"] = account_identifier
|
|
166
|
+
if not body:
|
|
167
|
+
raise ValueError("status or account_identifier is required")
|
|
168
|
+
return self._request("PATCH", "/v1/accounts/me/app-integrations", json=body).json()
|
|
169
|
+
|
|
170
|
+
def subscriptions(self) -> dict[str, Any]:
|
|
171
|
+
return self._get_json("/v1/accounts/me/subscriptions")
|
|
172
|
+
|
|
173
|
+
# ---------- devices ----------
|
|
174
|
+
|
|
175
|
+
def list_devices(self, include: list[str] | None = None) -> list[Device]:
|
|
176
|
+
params = {"include": ",".join(include)} if include else None
|
|
177
|
+
body = self._get_json("/v1/devices", params=params)
|
|
178
|
+
return [Device.from_json(d) for d in body.get("data", [])]
|
|
179
|
+
|
|
180
|
+
def list_devices_raw(self, include: list[str] | None = None) -> dict[str, Any]:
|
|
181
|
+
params = {"include": ",".join(include)} if include else None
|
|
182
|
+
return self._get_json("/v1/devices", params=params)
|
|
183
|
+
|
|
184
|
+
def get_device(self, device_id: str) -> Device:
|
|
185
|
+
body = self._get_json(f"/v1/devices/{device_id}")
|
|
186
|
+
return Device.from_json(body.get("data", body))
|
|
187
|
+
|
|
188
|
+
def status(self, device_id: str, component_id: int | None = None) -> DeviceStatus:
|
|
189
|
+
params = {"component_id": component_id} if component_id is not None else None
|
|
190
|
+
return DeviceStatus.from_json(self._get_json(f"/v1/devices/{device_id}/status", params=params))
|
|
191
|
+
|
|
192
|
+
def capabilities(self, device_id: str, component_id: int | None = None) -> DeviceCapabilities:
|
|
193
|
+
params = {"component_id": component_id} if component_id is not None else None
|
|
194
|
+
return DeviceCapabilities.from_json(self._get_json(f"/v1/devices/{device_id}/capabilities", params=params))
|
|
195
|
+
|
|
196
|
+
def configurations(self, device_id: str, component_id: int | None = None) -> DeviceConfigurations:
|
|
197
|
+
params = {"component_id": component_id} if component_id is not None else None
|
|
198
|
+
return DeviceConfigurations.from_json(self._get_json(f"/v1/devices/{device_id}/configurations", params=params))
|
|
199
|
+
|
|
200
|
+
def location(self, device_id: str) -> dict[str, Any]:
|
|
201
|
+
return self._get_json(f"/v1/devices/{device_id}/location")
|
|
202
|
+
|
|
203
|
+
# ---------- event history (cameras and doorbells only; sensors have none) ----------
|
|
204
|
+
|
|
205
|
+
def event_history(self, device_id: str, event_types: str | list[str] | None = None,
|
|
206
|
+
page_key: str | None = None) -> HistoryPage:
|
|
207
|
+
params: dict[str, Any] = {}
|
|
208
|
+
if event_types:
|
|
209
|
+
params["event_types"] = ",".join(event_types) if isinstance(event_types, list) else event_types
|
|
210
|
+
if page_key:
|
|
211
|
+
params["page[key]"] = page_key
|
|
212
|
+
body = self._get_json(f"/v1/history/devices/{device_id}/events", params=params or None)
|
|
213
|
+
events = [HistoryEvent.from_json(d) for d in body.get("data", [])]
|
|
214
|
+
return HistoryPage(events=events, next_path=(body.get("links") or {}).get("next"), raw=body)
|
|
215
|
+
|
|
216
|
+
def iter_event_history(self, device_id: str, event_types: str | list[str] | None = None,
|
|
217
|
+
max_pages: int = 50) -> Iterator[HistoryEvent]:
|
|
218
|
+
"""Follows `links.next` until a page comes back empty. Ring keeps sending a
|
|
219
|
+
`next` link even on the last page, so an empty page is the stop signal."""
|
|
220
|
+
page = self.event_history(device_id, event_types)
|
|
221
|
+
pages = 1
|
|
222
|
+
while True:
|
|
223
|
+
yield from page.events
|
|
224
|
+
if not page.events or not page.next_path or pages >= max_pages:
|
|
225
|
+
return
|
|
226
|
+
key = _page_key_from_path(page.next_path)
|
|
227
|
+
page = self.event_history(device_id, event_types, page_key=key)
|
|
228
|
+
pages += 1
|
|
229
|
+
|
|
230
|
+
# ---------- media ----------
|
|
231
|
+
|
|
232
|
+
def download_image(self, device_id: str, *, at_timestamp_ms: int | None = None,
|
|
233
|
+
start_ms: int | None = None, end_ms: int | None = None,
|
|
234
|
+
fmt: str = "jpeg", width: int = 1920, height: int = 1080,
|
|
235
|
+
component_id: int | None = None) -> MediaImage:
|
|
236
|
+
"""Two-step download. Pass `at_timestamp_ms` (±10 s tolerance) or a
|
|
237
|
+
`start_ms`/`end_ms` window of at most 24 h for the latest frame in it.
|
|
238
|
+
|
|
239
|
+
This never captures. If nothing was recorded near that time Ring returns
|
|
240
|
+
416 and this raises `MediaNotFound`.
|
|
241
|
+
"""
|
|
242
|
+
if at_timestamp_ms is not None:
|
|
243
|
+
payload: dict[str, Any] = {"type": "at_timestamp", "timestamp": int(at_timestamp_ms)}
|
|
244
|
+
elif start_ms is not None:
|
|
245
|
+
payload = {"type": "latest_in_range", "start_timestamp": int(start_ms)}
|
|
246
|
+
if end_ms is not None:
|
|
247
|
+
payload["end_timestamp"] = int(end_ms)
|
|
248
|
+
else:
|
|
249
|
+
raise ValueError("at_timestamp_ms or start_ms is required")
|
|
250
|
+
payload["image_options"] = {"format": fmt, "resolution": {"width": width, "height": height}}
|
|
251
|
+
params = {"component_id": component_id} if component_id is not None else None
|
|
252
|
+
r = self._request("POST", f"/v1/devices/{device_id}/media/image/download", json=payload,
|
|
253
|
+
params=params, ok=(303,))
|
|
254
|
+
presigned = r.headers.get("Location")
|
|
255
|
+
if not presigned:
|
|
256
|
+
raise RingAPIError(r.status_code, code="NO_LOCATION", detail="303 without Location header")
|
|
257
|
+
dl = self._http.get(presigned, follow_redirects=True)
|
|
258
|
+
if dl.status_code != 200:
|
|
259
|
+
raise _error_from_response(dl)
|
|
260
|
+
ts = dl.headers.get("X-Media-Timestamp")
|
|
261
|
+
return MediaImage(
|
|
262
|
+
data=dl.content,
|
|
263
|
+
content_type=dl.headers.get("Content-Type", "application/octet-stream"),
|
|
264
|
+
media_timestamp_ms=int(ts) if ts and ts.isdigit() else None,
|
|
265
|
+
media_origin=dl.headers.get("X-Media-Origin"),
|
|
266
|
+
requested_timestamp_ms=at_timestamp_ms,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def download_clip(self, device_id: str, *, timestamp_ms: int, duration_ms: int = 5000,
|
|
270
|
+
codec: str = "avc", frame_rate: int = 5, width: int = 1080, height: int = 720,
|
|
271
|
+
audio: bool = False, component_id: int | None = None) -> MediaClip:
|
|
272
|
+
"""MP4 of already-recorded footage (up to 15 minutes). 206 means Ring had
|
|
273
|
+
only part of the window; `partial` is set and the headers say what came back."""
|
|
274
|
+
payload = {
|
|
275
|
+
"timestamp": int(timestamp_ms), "duration": int(duration_ms),
|
|
276
|
+
"video_options": {"codec": codec, "frame_rate": frame_rate,
|
|
277
|
+
"resolution": {"width": width, "height": height}},
|
|
278
|
+
"audio_options": {"audio_enabled": bool(audio)},
|
|
279
|
+
}
|
|
280
|
+
params = {"component_id": component_id} if component_id is not None else None
|
|
281
|
+
r = self._request("POST", f"/v1/devices/{device_id}/media/video/download", json=payload,
|
|
282
|
+
params=params, ok=(303,))
|
|
283
|
+
presigned = r.headers.get("Location")
|
|
284
|
+
if not presigned:
|
|
285
|
+
raise RingAPIError(r.status_code, code="NO_LOCATION", detail="303 without Location header")
|
|
286
|
+
dl = self._http.get(presigned, follow_redirects=True)
|
|
287
|
+
if dl.status_code not in (200, 206):
|
|
288
|
+
raise _error_from_response(dl)
|
|
289
|
+
ts = dl.headers.get("X-Media-Timestamp")
|
|
290
|
+
dur = dl.headers.get("X-Media-Duration")
|
|
291
|
+
return MediaClip(data=dl.content, media_timestamp_ms=int(ts) if ts and ts.isdigit() else None,
|
|
292
|
+
media_duration_ms=int(dur) if dur and dur.isdigit() else None,
|
|
293
|
+
partial=dl.status_code == 206)
|
|
294
|
+
|
|
295
|
+
# ---------- live view ----------
|
|
296
|
+
|
|
297
|
+
def create_whep_session(self, device_id: str, sdp_offer: str, component_id: int | None = None) -> WhepSession:
|
|
298
|
+
"""POST an SDP offer, get the answer and the session URL. Battery devices
|
|
299
|
+
stream 30 s, line-powered 60 s. Starting a session forces a recording on
|
|
300
|
+
supported devices, which is what makes a later image download possible."""
|
|
301
|
+
params = {"component_id": component_id} if component_id is not None else None
|
|
302
|
+
r = self._request("POST", f"/v1/devices/{device_id}/media/streaming/whep/sessions",
|
|
303
|
+
content=sdp_offer, headers={"Content-Type": "application/sdp"},
|
|
304
|
+
params=params, ok=(201,))
|
|
305
|
+
return WhepSession(session_url=r.headers.get("Location", ""), sdp_answer=r.text, device_id=device_id)
|
|
306
|
+
|
|
307
|
+
def close_whep_session(self, session: WhepSession | str) -> None:
|
|
308
|
+
url = session.session_url if isinstance(session, WhepSession) else session
|
|
309
|
+
self._request("DELETE", url, ok=(200, 202, 204))
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def rtsps_url(device_id: str, access_token: str | None = None) -> str:
|
|
313
|
+
"""RTSPS URL for server-side clients (ffmpeg, VLC). With a token it uses the
|
|
314
|
+
Basic-auth-in-URL form Ring documents (empty user, token as password)."""
|
|
315
|
+
auth = f":{access_token}@" if access_token else ""
|
|
316
|
+
return f"rtsps://{auth}{RTSPS_HOST}:{RTSPS_PORT}/v1/devices/{device_id}/stream"
|
|
317
|
+
|
|
318
|
+
# ---------- chimes (the only write in the API) ----------
|
|
319
|
+
|
|
320
|
+
def play_chime(self, device_id: str, audio_ref: str) -> str:
|
|
321
|
+
"""Play the audio in one of the app's slots. Read `audio_ref` from
|
|
322
|
+
`configurations(device_id).chime_slots`; the request carries no slot name."""
|
|
323
|
+
body = {"data": {"type": "audio", "attributes": {"audio_ref": audio_ref}}}
|
|
324
|
+
r = self._request("POST", f"/v1/devices/{device_id}/media/audio/playback", json=body)
|
|
325
|
+
return ((r.json().get("data") or {}).get("attributes") or {}).get("status", "unknown")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _page_key_from_path(path: str) -> str | None:
|
|
329
|
+
from urllib.parse import parse_qs, urlparse
|
|
330
|
+
q = parse_qs(urlparse(path).query)
|
|
331
|
+
vals = q.get("page[key]") or q.get("page%5Bkey%5D")
|
|
332
|
+
return vals[0] if vals else None
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Exceptions raised by the client. Every HTTP failure maps to one of these."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RingError(Exception):
|
|
6
|
+
"""Base class for anything this package raises."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RingAPIError(RingError):
|
|
10
|
+
"""A non-2xx response from the Ring API.
|
|
11
|
+
|
|
12
|
+
`code` and `detail` come from Ring's JSON:API error body when present.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status: int, code: str | None = None, detail: str | None = None,
|
|
16
|
+
request_id: str | None = None):
|
|
17
|
+
self.status = status
|
|
18
|
+
self.code = code
|
|
19
|
+
self.detail = detail
|
|
20
|
+
self.request_id = request_id
|
|
21
|
+
super().__init__(f"HTTP {status}" + (f" {code}" if code else "") + (f": {detail}" if detail else ""))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Unauthorized(RingAPIError):
|
|
25
|
+
"""401. The access token is missing, expired, or is a refresh token used as a Bearer."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Forbidden(RingAPIError):
|
|
29
|
+
"""403. The token is valid but not authorized for this device or scope."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotFound(RingAPIError):
|
|
33
|
+
"""404. Device, integration, or resource does not exist for this token."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MediaNotFound(RingAPIError):
|
|
37
|
+
"""416 MEDIA_NOT_FOUND. No recording or snapshot exists at the requested time.
|
|
38
|
+
|
|
39
|
+
The image and clip download endpoints never trigger a capture. Start a live
|
|
40
|
+
session (which forces a recording on supported devices) or wait for a motion
|
|
41
|
+
event, then download.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RateLimited(RingAPIError):
|
|
46
|
+
"""429. `retry_after` is seconds, from the Retry-After header when Ring sends one."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, retry_after: float | None = None, **kw):
|
|
49
|
+
self.retry_after = retry_after
|
|
50
|
+
super().__init__(429, **kw)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SignatureError(RingError):
|
|
54
|
+
"""A webhook body failed HMAC verification."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NonceError(RingError):
|
|
58
|
+
"""An account-linking nonce was stale or did not match any unclaimed token."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""One-way (Ring-driven) account linking nonce helpers.
|
|
2
|
+
|
|
3
|
+
Ring redirects the user to the partner's Account Link URL with `nonce` and `time`.
|
|
4
|
+
The nonce is HMAC-SHA256(signing_key, "<time_ms>:<account_id>") encoded as URL-safe
|
|
5
|
+
Base64 without padding. Same key as webhooks, different encoding: webhooks are hex.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
|
|
15
|
+
from .errors import NonceError
|
|
16
|
+
|
|
17
|
+
DEFAULT_WINDOW_SECONDS = 600
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compute_nonce(signing_key: str, time_ms: int, account_id: str) -> str:
|
|
21
|
+
mac = hmac.new(signing_key.encode("utf-8"), f"{time_ms}:{account_id}".encode("utf-8"), hashlib.sha256).digest()
|
|
22
|
+
return base64.urlsafe_b64encode(mac).rstrip(b"=").decode("ascii")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_fresh(time_ms: int, now_ms: int | None = None, window_seconds: int = DEFAULT_WINDOW_SECONDS) -> bool:
|
|
26
|
+
now = now_ms if now_ms is not None else int(time.time() * 1000)
|
|
27
|
+
return (now - int(time_ms)) / 1000.0 <= window_seconds and int(time_ms) <= now + 5000
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def match_nonce(signing_key: str, nonce: str, time_ms: int, candidate_account_ids: Iterable[str],
|
|
31
|
+
now_ms: int | None = None, window_seconds: int = DEFAULT_WINDOW_SECONDS) -> str:
|
|
32
|
+
"""Return the account id whose recomputed nonce matches, constant-time per candidate.
|
|
33
|
+
|
|
34
|
+
`candidate_account_ids` is the set of unclaimed tokens' account ids (from
|
|
35
|
+
`GET /v1/users/me` on each token). Raises `NonceError` if stale or unmatched.
|
|
36
|
+
"""
|
|
37
|
+
if not is_fresh(time_ms, now_ms, window_seconds):
|
|
38
|
+
raise NonceError("nonce timestamp is outside the validation window")
|
|
39
|
+
for account_id in candidate_account_ids:
|
|
40
|
+
expected = compute_nonce(signing_key, time_ms, account_id)
|
|
41
|
+
if hmac.compare_digest(expected, nonce):
|
|
42
|
+
return account_id
|
|
43
|
+
raise NonceError("nonce did not match any unclaimed token")
|