ringwire 0.1.0__py3-none-any.whl

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/__init__.py 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
+ ]
ringwire/client.py ADDED
@@ -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
ringwire/errors.py ADDED
@@ -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."""
ringwire/nonce.py ADDED
@@ -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")
ringwire/types.py ADDED
@@ -0,0 +1,267 @@
1
+ """Plain dataclasses for the parts of Ring's JSON:API responses an app actually reads.
2
+
3
+ The raw response is always kept on `.raw` so nothing is lost when Ring adds fields.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Literal
9
+
10
+ EPOCH_NEVER_REPORTED = "1970-01-01T00:00:00Z"
11
+
12
+ SensorKind = Literal["contact", "flood_freeze", "temperature_humidity", "air_quality"]
13
+
14
+
15
+ @dataclass
16
+ class TokenSet:
17
+ access_token: str
18
+ refresh_token: str
19
+ expires_in: int
20
+ scope: str | None = None
21
+ token_type: str = "Bearer"
22
+ raw: dict[str, Any] = field(default_factory=dict)
23
+
24
+ @classmethod
25
+ def from_json(cls, data: dict[str, Any]) -> "TokenSet":
26
+ return cls(
27
+ access_token=data["access_token"],
28
+ refresh_token=data["refresh_token"],
29
+ expires_in=int(data.get("expires_in", 14400)),
30
+ scope=data.get("scope"),
31
+ token_type=data.get("token_type", "Bearer"),
32
+ raw=data,
33
+ )
34
+
35
+
36
+ @dataclass
37
+ class User:
38
+ account_id: str
39
+ name: str | None = None
40
+ email: str | None = None
41
+ phone: str | None = None
42
+ raw: dict[str, Any] = field(default_factory=dict)
43
+
44
+ @classmethod
45
+ def from_json(cls, data: dict[str, Any]) -> "User":
46
+ d = data.get("data", data)
47
+ attrs = d.get("attributes", {}) or {}
48
+ return cls(
49
+ account_id=str(d.get("id")),
50
+ name=attrs.get("name") or attrs.get("first_name"),
51
+ email=attrs.get("email"),
52
+ phone=attrs.get("phone") or attrs.get("phone_number"),
53
+ raw=data,
54
+ )
55
+
56
+
57
+ @dataclass
58
+ class Device:
59
+ id: str
60
+ name: str
61
+ image_url: str | None = None
62
+ location_id: str | None = None
63
+ raw: dict[str, Any] = field(default_factory=dict)
64
+
65
+ @classmethod
66
+ def from_json(cls, d: dict[str, Any]) -> "Device":
67
+ attrs = d.get("attributes", {}) or {}
68
+ rel = d.get("relationships", {}) or {}
69
+ loc = ((rel.get("location") or {}).get("data") or {}).get("id")
70
+ return cls(id=str(d["id"]), name=attrs.get("name", ""), image_url=attrs.get("image_url"),
71
+ location_id=loc, raw=d)
72
+
73
+
74
+ @dataclass
75
+ class DeviceStatus:
76
+ """Attributes of `GET /v1/devices/{id}/status`, flattened.
77
+
78
+ Sensor detection attributes are booleans (`flood_faulted`, `freeze_faulted`,
79
+ `contact_faulted`, `tamper_detected`) and are None when the device does not
80
+ report them. `reported_at` is None when Ring returned the 1970 epoch, which
81
+ means the device has never checked in; do not compare it as a real time.
82
+ """
83
+
84
+ online: bool
85
+ reported_at: str | None
86
+ battery_percentage: int | None = None
87
+ signal_strength: str | None = None
88
+ flood_faulted: bool | None = None
89
+ freeze_faulted: bool | None = None
90
+ contact_faulted: bool | None = None
91
+ tamper_detected: bool | None = None
92
+ temperature: float | None = None
93
+ humidity: float | None = None
94
+ snooze_active: bool | None = None
95
+ raw: dict[str, Any] = field(default_factory=dict)
96
+
97
+ @classmethod
98
+ def from_json(cls, data: dict[str, Any]) -> "DeviceStatus":
99
+ d = data.get("data", data)
100
+ a = d.get("attributes", {}) or {}
101
+
102
+ def faulted(key: str, sub: str = "faulted") -> bool | None:
103
+ v = a.get(key)
104
+ if not isinstance(v, dict) or sub not in v:
105
+ return None
106
+ return bool(v[sub])
107
+
108
+ reported = a.get("reported_at")
109
+ if reported == EPOCH_NEVER_REPORTED:
110
+ reported = None
111
+ batt = (a.get("battery_status") or {}).get("percentage") if isinstance(a.get("battery_status"), dict) else None
112
+ sig = (a.get("signal_strength") or {}).get("value") if isinstance(a.get("signal_strength"), dict) else None
113
+ snooze = ((a.get("audio") or {}).get("snooze") or {}).get("active") if isinstance(a.get("audio"), dict) else None
114
+ return cls(
115
+ online=bool(a.get("online", False)),
116
+ reported_at=reported,
117
+ battery_percentage=batt,
118
+ signal_strength=sig,
119
+ flood_faulted=faulted("flood_detection"),
120
+ freeze_faulted=faulted("freeze_detection"),
121
+ contact_faulted=faulted("contact_detection"),
122
+ tamper_detected=faulted("tamper_detection", "detected"),
123
+ temperature=a.get("temperature"),
124
+ humidity=a.get("humidity"),
125
+ snooze_active=snooze,
126
+ raw=data,
127
+ )
128
+
129
+ @property
130
+ def has_reported(self) -> bool:
131
+ return self.reported_at is not None
132
+
133
+
134
+ @dataclass
135
+ class DeviceCapabilities:
136
+ video_codecs: list[str] | None
137
+ chime_play: bool
138
+ audio_slots: int | None
139
+ raw: dict[str, Any] = field(default_factory=dict)
140
+
141
+ @classmethod
142
+ def from_json(cls, data: dict[str, Any]) -> "DeviceCapabilities":
143
+ d = data.get("data", data)
144
+ a = d.get("attributes", {}) or {}
145
+ video = a.get("video") or {}
146
+ audio = a.get("audio") or {}
147
+ actions = audio.get("supported_actions") or []
148
+ return cls(
149
+ video_codecs=video.get("codecs"),
150
+ chime_play="chime.play" in actions,
151
+ audio_slots=audio.get("customizable_slots") if isinstance(audio.get("customizable_slots"), int) else None,
152
+ raw=data,
153
+ )
154
+
155
+ @property
156
+ def has_camera(self) -> bool:
157
+ return bool(self.video_codecs)
158
+
159
+ @property
160
+ def hevc_only(self) -> bool:
161
+ return self.video_codecs == ["HEVC"]
162
+
163
+
164
+ @dataclass
165
+ class ChimeSlot:
166
+ event: str
167
+ audio_ref: str
168
+ audio_name: str | None
169
+ audio_url: str | None
170
+ disabled: bool
171
+
172
+
173
+ @dataclass
174
+ class DeviceConfigurations:
175
+ motion_detection_enabled: bool | None
176
+ chime_slots: list[ChimeSlot]
177
+ chime_volume: int | None
178
+ temperature_threshold: dict[str, float] | None
179
+ humidity_threshold: dict[str, float] | None
180
+ raw: dict[str, Any] = field(default_factory=dict)
181
+
182
+ @classmethod
183
+ def from_json(cls, data: dict[str, Any]) -> "DeviceConfigurations":
184
+ d = data.get("data", data)
185
+ a = d.get("attributes", {}) or {}
186
+ audio = a.get("audio") or {}
187
+ slots_raw = audio.get("customizable_slots")
188
+ slots: list[ChimeSlot] = []
189
+ if isinstance(slots_raw, list):
190
+ for s in slots_raw:
191
+ slots.append(ChimeSlot(
192
+ event=s.get("event", ""), audio_ref=s.get("audio_ref", ""),
193
+ audio_name=s.get("audio_name"), audio_url=s.get("audio_url"),
194
+ disabled=bool(s.get("disabled", False)),
195
+ ))
196
+ # Ring does not return slots in event order.
197
+ slots.sort(key=lambda s: s.event)
198
+ md = a.get("motion_detection") or {}
199
+ return cls(
200
+ motion_detection_enabled=md.get("enabled") if isinstance(md, dict) else None,
201
+ chime_slots=slots,
202
+ chime_volume=audio.get("volume") if isinstance(audio, dict) else None,
203
+ temperature_threshold=a.get("temperature_threshold"),
204
+ humidity_threshold=a.get("humidity_threshold"),
205
+ raw=data,
206
+ )
207
+
208
+
209
+ @dataclass
210
+ class HistoryEvent:
211
+ id: str
212
+ event_type: str
213
+ start_ms: int
214
+ end_ms: int
215
+ device_id: str | None
216
+ third_party_reviewed: bool
217
+ raw: dict[str, Any] = field(default_factory=dict)
218
+
219
+ @classmethod
220
+ def from_json(cls, d: dict[str, Any]) -> "HistoryEvent":
221
+ a = d.get("attributes", {}) or {}
222
+ src = (((d.get("relationships") or {}).get("source") or {}).get("data") or {}).get("id")
223
+ return cls(id=str(d["id"]), event_type=a.get("event_type", ""), start_ms=int(a.get("start", 0)),
224
+ end_ms=int(a.get("end", 0)), device_id=src,
225
+ third_party_reviewed=bool(a.get("is_third_party_reviewed", False)), raw=d)
226
+
227
+
228
+ @dataclass
229
+ class HistoryPage:
230
+ events: list[HistoryEvent]
231
+ next_path: str | None
232
+ raw: dict[str, Any] = field(default_factory=dict)
233
+
234
+
235
+ @dataclass
236
+ class MediaImage:
237
+ data: bytes
238
+ content_type: str
239
+ media_timestamp_ms: int | None
240
+ media_origin: str | None # "recording" or "snapshot"
241
+ requested_timestamp_ms: int | None = None
242
+
243
+ @property
244
+ def drift_ms(self) -> int | None:
245
+ """How far the frame Ring returned is from the timestamp asked for."""
246
+ if self.media_timestamp_ms is None or self.requested_timestamp_ms is None:
247
+ return None
248
+ return self.media_timestamp_ms - self.requested_timestamp_ms
249
+
250
+
251
+ @dataclass
252
+ class MediaClip:
253
+ data: bytes
254
+ media_timestamp_ms: int | None
255
+ media_duration_ms: int | None
256
+ partial: bool
257
+
258
+
259
+ @dataclass
260
+ class WhepSession:
261
+ session_url: str
262
+ sdp_answer: str
263
+ device_id: str
264
+
265
+ @property
266
+ def session_id(self) -> str:
267
+ return self.session_url.rstrip("/").rsplit("/", 1)[-1]
ringwire/webhooks.py ADDED
@@ -0,0 +1,162 @@
1
+ """Webhook verification and parsing.
2
+
3
+ Ring signs every webhook with HMAC-SHA256 over the raw body bytes using the
4
+ partner's HMAC signing key, hex encoded, sent as `X-Signature: sha256=<hex>`.
5
+ Verify against the raw bytes exactly as received; re-serialising the JSON
6
+ changes whitespace and breaks the signature.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ from .errors import SignatureError
17
+
18
+ SIGNATURE_HEADER = "X-Signature"
19
+
20
+ CAMERA_EVENTS = frozenset({"motion_detected", "button_press"})
21
+ DEVICE_EVENTS = frozenset({"device_added", "device_removed", "device_online", "device_offline"})
22
+ ACCOUNT_EVENTS = frozenset({
23
+ "app_integration_added", "app_integration_removed",
24
+ "subscription_activated", "subscription_deactivated",
25
+ })
26
+ SENSOR_STATE_EVENTS = frozenset({
27
+ "contact_sensor_faulted", "contact_sensor_cleared",
28
+ "tamper_detected", "tamper_cleared",
29
+ "flood_detected", "flood_cleared",
30
+ "freeze_detected", "freeze_cleared",
31
+ })
32
+ SENSOR_THRESHOLD_EVENTS = frozenset({
33
+ "temperature_exceeded", "temperature_cleared",
34
+ "humidity_exceeded", "humidity_cleared",
35
+ "pm25_exceeded", "pm25_cleared",
36
+ "co_exceeded", "co_cleared",
37
+ })
38
+ SENSOR_EVENTS = SENSOR_STATE_EVENTS | SENSOR_THRESHOLD_EVENTS
39
+ ALL_EVENTS = CAMERA_EVENTS | DEVICE_EVENTS | ACCOUNT_EVENTS | SENSOR_EVENTS
40
+
41
+
42
+ def sign(raw_body: bytes, signing_key: str) -> str:
43
+ """Produce the `X-Signature` value Ring would send for this body. Used by tests
44
+ and by anyone replaying captured webhooks into a local server."""
45
+ digest = hmac.new(signing_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
46
+ return f"sha256={digest}"
47
+
48
+
49
+ def verify_signature(raw_body: bytes, received: str | None, signing_key: str) -> bool:
50
+ """Constant-time check of `X-Signature` against the raw body."""
51
+ if not received:
52
+ return False
53
+ expected = sign(raw_body, signing_key)
54
+ return hmac.compare_digest(expected, received.strip())
55
+
56
+
57
+ @dataclass
58
+ class WebhookEvent:
59
+ """One webhook, flattened. `raw` keeps the full envelope."""
60
+
61
+ type: str
62
+ request_id: str
63
+ account_id: str | None
64
+ sent_at: str | None
65
+ event_id: str | None
66
+ device_id: str | None
67
+ timestamp_ms: int | None
68
+ sub_type: str | None = None
69
+ reading: float | None = None # temperature / humidity / pm25 / co on threshold events
70
+ threshold_type: str | None = None # "min" or "max" on *_exceeded events
71
+ version: str | None = None
72
+ raw: dict[str, Any] = field(default_factory=dict)
73
+
74
+ @property
75
+ def is_sensor(self) -> bool:
76
+ return self.type in SENSOR_EVENTS
77
+
78
+ @property
79
+ def is_known(self) -> bool:
80
+ return self.type in ALL_EVENTS
81
+
82
+ @property
83
+ def device_path(self) -> str | None:
84
+ rel = ((self.raw.get("data") or {}).get("relationships") or {}).get("devices") or {}
85
+ return (rel.get("links") or {}).get("self")
86
+
87
+
88
+ def parse_event(raw_body: bytes | str | dict[str, Any]) -> WebhookEvent:
89
+ """Turn a v1.1 webhook envelope into a `WebhookEvent`.
90
+
91
+ Reads the device from `data.attributes.source`, never by parsing `data.id`;
92
+ Ring documents the id's structure as not a contract.
93
+ """
94
+ if isinstance(raw_body, (bytes, bytearray)):
95
+ payload = json.loads(raw_body.decode("utf-8"))
96
+ elif isinstance(raw_body, str):
97
+ payload = json.loads(raw_body)
98
+ else:
99
+ payload = raw_body
100
+ meta = payload.get("meta") or {}
101
+ data = payload.get("data") or {}
102
+ attrs = data.get("attributes") or {}
103
+ reading = None
104
+ for key in ("temperature", "humidity", "pm25", "co"):
105
+ if key in attrs and isinstance(attrs[key], (int, float)):
106
+ reading = float(attrs[key])
107
+ break
108
+ ts = attrs.get("timestamp")
109
+ return WebhookEvent(
110
+ type=str(data.get("type", "")),
111
+ request_id=str(meta.get("request_id", "")),
112
+ account_id=meta.get("account_id"),
113
+ sent_at=meta.get("time"),
114
+ event_id=data.get("id"),
115
+ device_id=attrs.get("source"),
116
+ timestamp_ms=int(ts) if isinstance(ts, (int, float)) else None,
117
+ sub_type=data.get("subType") or attrs.get("sub_type"),
118
+ reading=reading,
119
+ threshold_type=attrs.get("threshold_type"),
120
+ version=meta.get("version"),
121
+ raw=payload,
122
+ )
123
+
124
+
125
+ def verify_and_parse(raw_body: bytes, received_signature: str | None, signing_key: str) -> WebhookEvent:
126
+ """The one call a webhook handler needs. Raises `SignatureError` on a bad signature."""
127
+ if not verify_signature(raw_body, received_signature, signing_key):
128
+ raise SignatureError("X-Signature did not match the raw body")
129
+ return parse_event(raw_body)
130
+
131
+
132
+ def make_event(event_type: str, device_id: str, *, account_id: str = "acct_test",
133
+ timestamp_ms: int | None = None, request_id: str | None = None,
134
+ extra_attributes: dict[str, Any] | None = None,
135
+ sub_type: str | None = None) -> dict[str, Any]:
136
+ """Build a webhook envelope in Ring's v1.1 shape. For tests, the sandbox
137
+ replay tool, and local development without a public webhook URL."""
138
+ import time
139
+ import uuid
140
+
141
+ ts = timestamp_ms if timestamp_ms is not None else int(time.time() * 1000)
142
+ rid = request_id or uuid.uuid4().hex
143
+ attrs: dict[str, Any] = {"source": device_id, "source_type": "devices", "timestamp": ts}
144
+ if extra_attributes:
145
+ attrs.update(extra_attributes)
146
+ data: dict[str, Any] = {
147
+ "id": f"{device_id}_{event_type}_{ts}",
148
+ "type": event_type,
149
+ "attributes": attrs,
150
+ "relationships": {"devices": {"links": {"self": f"/v1/devices/{device_id}"}}},
151
+ }
152
+ if sub_type:
153
+ data["subType"] = sub_type
154
+ return {
155
+ "meta": {
156
+ "version": "1.1",
157
+ "time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts / 1000)),
158
+ "request_id": rid,
159
+ "account_id": account_id,
160
+ },
161
+ "data": data,
162
+ }
@@ -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.
@@ -0,0 +1,11 @@
1
+ ringwire/__init__.py,sha256=w00cHRMhVIQYX-8rflEqx4yzO1NwlwoCFxAhlnQWxRI,1685
2
+ ringwire/client.py,sha256=GXhtDMg3_mOaLXEkoaISPLxDi6MpENUpMG4Mc6v-Ljg,16851
3
+ ringwire/errors.py,sha256=_wNrlyU_XmLNwO9RDqR1_oi-82wy8eiWRb_cQRKorK0,1863
4
+ ringwire/nonce.py,sha256=zenJnplB7JXStS8v2nHv69EqZKm4Vy1dZ9mA-V3FML4,1874
5
+ ringwire/types.py,sha256=5WT8bsYWsg0IfiuuGqpC677LKClwPNjfDsFfEOeit8o,8744
6
+ ringwire/webhooks.py,sha256=_GeCV3my4PWC_f6rlkTYhkWYVdC6KikWpzrOUv39yKs,5994
7
+ ringwire-0.1.0.dist-info/licenses/LICENSE,sha256=jchmxv6LImPyoRSz6vD2Gp4kA2bZtayrd8vegR6W8J0,1072
8
+ ringwire-0.1.0.dist-info/METADATA,sha256=4ZYvbZ1xAnsptS5Sa3O4_SM2X6YRq5L-XN3yL1ZyHFA,3497
9
+ ringwire-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ ringwire-0.1.0.dist-info/top_level.txt,sha256=ij8lqlBNLOgSgPlv03n658l7izFLXua5hfrxA7DmTLI,9
11
+ ringwire-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ ringwire