python-casahome 0.2.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.
- casahome/__init__.py +30 -0
- casahome/_base_http.py +21 -0
- casahome/api.py +1135 -0
- casahome/auth.py +199 -0
- casahome/client.py +168 -0
- casahome/const.py +34 -0
- casahome/exceptions.py +52 -0
- casahome/models/__init__.py +477 -0
- casahome/models/_base.py +25 -0
- casahome/models/ai.py +58 -0
- casahome/models/assets.py +123 -0
- casahome/models/availability.py +39 -0
- casahome/models/bootstrap.py +134 -0
- casahome/models/calendar.py +33 -0
- casahome/models/catalogs.py +73 -0
- casahome/models/credits.py +29 -0
- casahome/models/documents.py +127 -0
- casahome/models/enums.py +183 -0
- casahome/models/feed.py +30 -0
- casahome/models/help_center.py +45 -0
- casahome/models/home.py +175 -0
- casahome/models/images.py +48 -0
- casahome/models/mailbox.py +160 -0
- casahome/models/maintenance.py +200 -0
- casahome/models/membership.py +65 -0
- casahome/models/paint.py +56 -0
- casahome/models/preferences.py +104 -0
- casahome/models/purchases.py +106 -0
- casahome/models/read_todos.py +22 -0
- casahome/models/referral.py +21 -0
- casahome/models/requests.py +149 -0
- casahome/models/tasks.py +77 -0
- casahome/models/taxes.py +47 -0
- casahome/models/todos.py +52 -0
- casahome/models/visits.py +158 -0
- casahome/models/write_contracts.py +147 -0
- casahome/py.typed +0 -0
- casahome/streaming.py +278 -0
- python_casahome-0.2.0.dist-info/METADATA +272 -0
- python_casahome-0.2.0.dist-info/RECORD +42 -0
- python_casahome-0.2.0.dist-info/WHEEL +4 -0
- python_casahome-0.2.0.dist-info/licenses/LICENSE +21 -0
casahome/auth.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Firebase refresh-token auth for the Casa API.
|
|
2
|
+
|
|
3
|
+
Casa signs members in with Firebase Phone Auth. SMS sign-in can't be reproduced
|
|
4
|
+
off-device (Firebase requires APNs app verification), so this library starts
|
|
5
|
+
from a refresh token taken from the member's own app session and exchanges it for short-lived
|
|
6
|
+
ID tokens via the standard Firebase secure token endpoint.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
|
|
17
|
+
import aiohttp
|
|
18
|
+
from pydantic import ValidationError
|
|
19
|
+
|
|
20
|
+
from ._base_http import wrap_connection_errors
|
|
21
|
+
from .const import (
|
|
22
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
23
|
+
FIREBASE_API_KEY,
|
|
24
|
+
FIREBASE_BUNDLE_ID,
|
|
25
|
+
FIREBASE_CLIENT_VERSION,
|
|
26
|
+
FIREBASE_GMP_ID,
|
|
27
|
+
SECURETOKEN_URL,
|
|
28
|
+
TOKEN_REFRESH_LEEWAY_SECONDS,
|
|
29
|
+
)
|
|
30
|
+
from .exceptions import CasaAuthError, CasaConnectionError, CasaError
|
|
31
|
+
from .models._base import CasaModel
|
|
32
|
+
|
|
33
|
+
_LOGGER = logging.getLogger("casahome")
|
|
34
|
+
|
|
35
|
+
_FATAL_REFRESH_ERRORS = frozenset(
|
|
36
|
+
{"TOKEN_EXPIRED", "USER_DISABLED", "USER_NOT_FOUND", "INVALID_REFRESH_TOKEN"}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TokenResponse(CasaModel):
|
|
41
|
+
"""Response from ``securetoken.googleapis.com/v1/token``."""
|
|
42
|
+
|
|
43
|
+
access_token: str
|
|
44
|
+
expires_in: str
|
|
45
|
+
token_type: str
|
|
46
|
+
refresh_token: str
|
|
47
|
+
id_token: str
|
|
48
|
+
user_id: str
|
|
49
|
+
project_id: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FirebaseAuth:
|
|
53
|
+
"""Exchanges a Firebase refresh token for ID tokens, caching until near expiry.
|
|
54
|
+
|
|
55
|
+
Firebase may rotate the refresh token on any refresh. Pass
|
|
56
|
+
``on_refresh_token_rotated`` (sync or async) to persist the new one, or read
|
|
57
|
+
:attr:`refresh_token` after use.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
session: aiohttp.ClientSession,
|
|
63
|
+
refresh_token: str,
|
|
64
|
+
*,
|
|
65
|
+
api_key: str = FIREBASE_API_KEY,
|
|
66
|
+
token_url: str = SECURETOKEN_URL,
|
|
67
|
+
on_refresh_token_rotated: Callable[[str], Awaitable[None] | None] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
if not refresh_token:
|
|
70
|
+
raise CasaAuthError("refresh_token is required")
|
|
71
|
+
self._session = session
|
|
72
|
+
self._refresh_token = refresh_token
|
|
73
|
+
self._api_key = api_key
|
|
74
|
+
self._token_url = token_url
|
|
75
|
+
self._id_token: str | None = None
|
|
76
|
+
self._expires_at: float = 0.0
|
|
77
|
+
self._user_id: str | None = None
|
|
78
|
+
self._lock = asyncio.Lock()
|
|
79
|
+
self._on_rotated = on_refresh_token_rotated
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def refresh_token(self) -> str:
|
|
83
|
+
"""The current refresh token, possibly rotated by Firebase."""
|
|
84
|
+
return self._refresh_token
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def user_id(self) -> str | None:
|
|
88
|
+
"""Firebase uid, known after the first refresh."""
|
|
89
|
+
return self._user_id
|
|
90
|
+
|
|
91
|
+
def _token_is_fresh(self) -> bool:
|
|
92
|
+
return (
|
|
93
|
+
self._id_token is not None
|
|
94
|
+
and self._expires_at - time.monotonic() > TOKEN_REFRESH_LEEWAY_SECONDS
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async def get_id_token(self) -> str:
|
|
98
|
+
"""Return a valid ID token, refreshing if it expires within the leeway."""
|
|
99
|
+
if self._token_is_fresh():
|
|
100
|
+
assert self._id_token is not None
|
|
101
|
+
return self._id_token
|
|
102
|
+
async with self._lock:
|
|
103
|
+
if not self._token_is_fresh():
|
|
104
|
+
await self._refresh_locked()
|
|
105
|
+
assert self._id_token is not None
|
|
106
|
+
return self._id_token
|
|
107
|
+
|
|
108
|
+
def invalidate(self, token: str | None = None) -> None:
|
|
109
|
+
"""Drop the cached ID token so the next call refreshes.
|
|
110
|
+
|
|
111
|
+
Pass the token that was rejected. If another request already replaced
|
|
112
|
+
it, this is a no-op, so a burst of concurrent 401s costs one refresh.
|
|
113
|
+
"""
|
|
114
|
+
if token is not None and token != self._id_token:
|
|
115
|
+
return
|
|
116
|
+
self._id_token = None
|
|
117
|
+
self._expires_at = 0.0
|
|
118
|
+
|
|
119
|
+
async def refresh(self) -> TokenResponse:
|
|
120
|
+
"""Force a refresh regardless of cached token state."""
|
|
121
|
+
async with self._lock:
|
|
122
|
+
return await self._refresh_locked()
|
|
123
|
+
|
|
124
|
+
async def _refresh_locked(self) -> TokenResponse:
|
|
125
|
+
_LOGGER.debug("Refreshing Firebase ID token")
|
|
126
|
+
headers = {
|
|
127
|
+
"X-Ios-Bundle-Identifier": FIREBASE_BUNDLE_ID,
|
|
128
|
+
"X-Firebase-GMPID": FIREBASE_GMP_ID,
|
|
129
|
+
"X-Client-Version": FIREBASE_CLIENT_VERSION,
|
|
130
|
+
}
|
|
131
|
+
data = {"grant_type": "refresh_token", "refresh_token": self._refresh_token}
|
|
132
|
+
async with (
|
|
133
|
+
wrap_connection_errors(),
|
|
134
|
+
self._session.post(
|
|
135
|
+
self._token_url,
|
|
136
|
+
params={"key": self._api_key},
|
|
137
|
+
data=data,
|
|
138
|
+
headers=headers,
|
|
139
|
+
timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT_SECONDS),
|
|
140
|
+
) as resp,
|
|
141
|
+
):
|
|
142
|
+
text = await resp.text()
|
|
143
|
+
if resp.status >= 400:
|
|
144
|
+
raise _auth_error(resp.status, text)
|
|
145
|
+
try:
|
|
146
|
+
token = TokenResponse.model_validate_json(text)
|
|
147
|
+
except ValidationError as err:
|
|
148
|
+
# A 200 that isn't a token response is a proxy or portal, not a bad credential.
|
|
149
|
+
raise CasaConnectionError(
|
|
150
|
+
f"Token endpoint returned an unexpected body: {text[:200]}"
|
|
151
|
+
) from err
|
|
152
|
+
|
|
153
|
+
self._id_token = token.id_token
|
|
154
|
+
previous = self._refresh_token
|
|
155
|
+
self._refresh_token = token.refresh_token or self._refresh_token
|
|
156
|
+
self._user_id = token.user_id
|
|
157
|
+
try:
|
|
158
|
+
lifetime = int(token.expires_in)
|
|
159
|
+
except ValueError:
|
|
160
|
+
lifetime = 3600
|
|
161
|
+
self._expires_at = time.monotonic() + lifetime
|
|
162
|
+
if self._refresh_token != previous and self._on_rotated is not None:
|
|
163
|
+
await self._notify_rotated(self._refresh_token)
|
|
164
|
+
return token
|
|
165
|
+
|
|
166
|
+
async def _notify_rotated(self, new_token: str) -> None:
|
|
167
|
+
"""Run the rotation callback. Its failures are logged, never raised.
|
|
168
|
+
|
|
169
|
+
Auth already succeeded at this point. A broken persistence hook must
|
|
170
|
+
not fail the request that triggered the refresh.
|
|
171
|
+
"""
|
|
172
|
+
assert self._on_rotated is not None
|
|
173
|
+
try:
|
|
174
|
+
result = self._on_rotated(new_token)
|
|
175
|
+
if result is not None:
|
|
176
|
+
await result
|
|
177
|
+
except Exception:
|
|
178
|
+
_LOGGER.exception("on_refresh_token_rotated callback failed; new token not persisted")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _auth_error(status: int, body: str) -> CasaError:
|
|
182
|
+
"""Map a token endpoint failure.
|
|
183
|
+
|
|
184
|
+
Only 4xx means the credential itself is bad. 5xx is Google having a bad
|
|
185
|
+
day and must not trigger reauth in Home Assistant.
|
|
186
|
+
"""
|
|
187
|
+
message = ""
|
|
188
|
+
try:
|
|
189
|
+
payload = json.loads(body)
|
|
190
|
+
error = payload.get("error", {})
|
|
191
|
+
message = error.get("message", "") if isinstance(error, dict) else str(error)
|
|
192
|
+
except (ValueError, AttributeError):
|
|
193
|
+
pass
|
|
194
|
+
if message in _FATAL_REFRESH_ERRORS:
|
|
195
|
+
return CasaAuthError(message)
|
|
196
|
+
excerpt = message or body[:200]
|
|
197
|
+
if status >= 500 or status == 429:
|
|
198
|
+
return CasaConnectionError(f"Token endpoint unavailable (HTTP {status}): {excerpt}")
|
|
199
|
+
return CasaAuthError(f"Token refresh failed (HTTP {status}): {excerpt}")
|
casahome/client.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Low-level authenticated HTTP client for the Casa foundation API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as jsonlib
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator, Mapping
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from ._base_http import wrap_connection_errors
|
|
14
|
+
from .auth import FirebaseAuth
|
|
15
|
+
from .const import (
|
|
16
|
+
CASA_APP,
|
|
17
|
+
CASA_APP_VERSION,
|
|
18
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
19
|
+
FOUNDATION_BASE_URL,
|
|
20
|
+
USER_AGENT,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import CasaApiError, CasaAuthError
|
|
23
|
+
|
|
24
|
+
_LOGGER = logging.getLogger("casahome")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CasaClient:
|
|
28
|
+
"""Sends authenticated requests and turns Casa error envelopes into exceptions.
|
|
29
|
+
|
|
30
|
+
The client never owns the aiohttp session. Callers create and close it, which
|
|
31
|
+
is what Home Assistant expects. For scripts, use :meth:`create`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
session: aiohttp.ClientSession,
|
|
37
|
+
auth: FirebaseAuth,
|
|
38
|
+
*,
|
|
39
|
+
base_url: str = FOUNDATION_BASE_URL,
|
|
40
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._session = session
|
|
43
|
+
self.auth = auth
|
|
44
|
+
self._base_url = base_url.rstrip("/")
|
|
45
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
@asynccontextmanager
|
|
49
|
+
async def create(
|
|
50
|
+
cls,
|
|
51
|
+
refresh_token: str,
|
|
52
|
+
*,
|
|
53
|
+
base_url: str = FOUNDATION_BASE_URL,
|
|
54
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
55
|
+
) -> AsyncIterator[CasaClient]:
|
|
56
|
+
"""Build a client with its own session. Convenience for scripts."""
|
|
57
|
+
async with aiohttp.ClientSession() as session:
|
|
58
|
+
auth = FirebaseAuth(session, refresh_token)
|
|
59
|
+
yield cls(session, auth, base_url=base_url, timeout=timeout)
|
|
60
|
+
|
|
61
|
+
async def request(
|
|
62
|
+
self,
|
|
63
|
+
method: str,
|
|
64
|
+
path: str,
|
|
65
|
+
*,
|
|
66
|
+
json: Any | None = None,
|
|
67
|
+
params: Mapping[str, str] | None = None,
|
|
68
|
+
retry_on_401: bool | None = None,
|
|
69
|
+
) -> Any:
|
|
70
|
+
"""Send a request and return decoded JSON (or None for empty bodies).
|
|
71
|
+
|
|
72
|
+
``retry_on_401`` defaults to True for GET only. Writes (POST, PATCH,
|
|
73
|
+
DELETE) default to a single attempt: a 401 after the server may have
|
|
74
|
+
acted would turn a retry into a duplicate write. Non-retrying requests
|
|
75
|
+
start with a force-refreshed ID token instead, so an expiring token
|
|
76
|
+
can't trigger the 401 in the first place. A 401 still invalidates the
|
|
77
|
+
rejected token and raises ``CasaAuthError``.
|
|
78
|
+
|
|
79
|
+
Non-GET error bodies never fall back to raw response text. Only the
|
|
80
|
+
structured error envelope fields are kept, since a write's error body
|
|
81
|
+
could echo the private content that was sent.
|
|
82
|
+
"""
|
|
83
|
+
retry = method == "GET" if retry_on_401 is None else retry_on_401
|
|
84
|
+
scrub = method != "GET"
|
|
85
|
+
_LOGGER.debug("%s %s", method, path)
|
|
86
|
+
if not retry:
|
|
87
|
+
await self.auth.refresh()
|
|
88
|
+
status, body, used = await self._send(method, path, json=json, params=params)
|
|
89
|
+
if status == 401:
|
|
90
|
+
self.auth.invalidate(used)
|
|
91
|
+
if not retry:
|
|
92
|
+
raise CasaAuthError(f"Casa rejected the ID token (HTTP 401) on {method}")
|
|
93
|
+
_LOGGER.debug("401 on %s %s, refreshing token and retrying once", method, path)
|
|
94
|
+
status, body, _ = await self._send(method, path, json=json, params=params)
|
|
95
|
+
if status == 401:
|
|
96
|
+
raise CasaAuthError("Casa rejected the refreshed token (HTTP 401)")
|
|
97
|
+
|
|
98
|
+
if status == 304 or (200 <= status < 300 and not body.strip()):
|
|
99
|
+
return None
|
|
100
|
+
if 200 <= status < 300:
|
|
101
|
+
try:
|
|
102
|
+
return jsonlib.loads(body)
|
|
103
|
+
except ValueError:
|
|
104
|
+
display = "" if scrub else body[:200]
|
|
105
|
+
# Raised outside the handler so the decode error's context is dropped.
|
|
106
|
+
raise CasaApiError(status, "invalid_json", display_message=display)
|
|
107
|
+
raise _api_error(status, body, raw_fallback=not scrub)
|
|
108
|
+
|
|
109
|
+
async def _send(
|
|
110
|
+
self,
|
|
111
|
+
method: str,
|
|
112
|
+
path: str,
|
|
113
|
+
*,
|
|
114
|
+
json: Any | None,
|
|
115
|
+
params: Mapping[str, str] | None,
|
|
116
|
+
) -> tuple[int, str, str]:
|
|
117
|
+
token = await self.auth.get_id_token()
|
|
118
|
+
headers = {
|
|
119
|
+
"Authorization": f"Bearer {token}",
|
|
120
|
+
"X-Casa-App": CASA_APP,
|
|
121
|
+
"X-Casa-App-Version": CASA_APP_VERSION,
|
|
122
|
+
"User-Agent": USER_AGENT,
|
|
123
|
+
"Accept": "application/json",
|
|
124
|
+
}
|
|
125
|
+
kwargs: dict[str, Any] = {"headers": headers, "timeout": self._timeout}
|
|
126
|
+
if json is not None:
|
|
127
|
+
headers["Content-Type"] = "application/json"
|
|
128
|
+
kwargs["data"] = jsonlib.dumps(json)
|
|
129
|
+
if params:
|
|
130
|
+
kwargs["params"] = dict(params)
|
|
131
|
+
url = self._base_url + path
|
|
132
|
+
async with (
|
|
133
|
+
wrap_connection_errors(),
|
|
134
|
+
self._session.request(method, url, **kwargs) as resp,
|
|
135
|
+
):
|
|
136
|
+
return resp.status, await resp.text(), token
|
|
137
|
+
|
|
138
|
+
async def get(self, path: str, *, params: Mapping[str, str] | None = None) -> Any:
|
|
139
|
+
return await self.request("GET", path, params=params)
|
|
140
|
+
|
|
141
|
+
async def post(self, path: str, *, json: Any | None = None, retry_on_401: bool = False) -> Any:
|
|
142
|
+
return await self.request("POST", path, json=json, retry_on_401=retry_on_401)
|
|
143
|
+
|
|
144
|
+
async def patch(self, path: str, *, json: Any | None = None) -> Any:
|
|
145
|
+
return await self.request("PATCH", path, json=json, retry_on_401=False)
|
|
146
|
+
|
|
147
|
+
async def delete(self, path: str) -> Any:
|
|
148
|
+
return await self.request("DELETE", path, retry_on_401=False)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _api_error(status: int, body: str, *, raw_fallback: bool = True) -> CasaApiError:
|
|
152
|
+
try:
|
|
153
|
+
payload = jsonlib.loads(body)
|
|
154
|
+
except ValueError:
|
|
155
|
+
payload = None
|
|
156
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
|
157
|
+
err = payload["error"]
|
|
158
|
+
return CasaApiError(
|
|
159
|
+
status,
|
|
160
|
+
str(err.get("code", "unknown")),
|
|
161
|
+
str(err.get("displayTitle", "")),
|
|
162
|
+
str(err.get("displayMessage", "")),
|
|
163
|
+
str(err.get("requestId", "")),
|
|
164
|
+
)
|
|
165
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), str):
|
|
166
|
+
# Second observed shape: {"error": "Invalid user device info"}
|
|
167
|
+
return CasaApiError(status, "unknown", display_message=payload["error"])
|
|
168
|
+
return CasaApiError(status, "unknown", display_message=body[:500] if raw_fallback else "")
|
casahome/const.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Constants observed in Casa iOS app traffic and bundle metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
FOUNDATION_BASE_URL = "https://foundation.platform.casa"
|
|
6
|
+
UTILITY_CONNECT_BASE_URL = "https://utility-connect.platform.casa" # not used yet
|
|
7
|
+
|
|
8
|
+
# Firebase project the app authenticates against. The API key is a public client
|
|
9
|
+
# identifier shipped inside the iOS bundle (GoogleService-Info.plist), not a secret.
|
|
10
|
+
FIREBASE_API_KEY = "AIzaSyA4XygCbi-oW1M8lP5z51NSX19O2AX2j14"
|
|
11
|
+
FIREBASE_PROJECT_ID = "casa-technologies-inc"
|
|
12
|
+
FIREBASE_GMP_ID = "1:312296672090:ios:5a8ecec7caa297a05b4163"
|
|
13
|
+
FIREBASE_BUNDLE_ID = "casa-technologies.member-ios"
|
|
14
|
+
FIREBASE_CLIENT_VERSION = "iOS/FirebaseSDK/12.18.0/FirebaseCore-iOS"
|
|
15
|
+
SECURETOKEN_URL = "https://securetoken.googleapis.com/v1/token"
|
|
16
|
+
|
|
17
|
+
# App identity headers the Casa backend expects.
|
|
18
|
+
CASA_APP = "member-ios"
|
|
19
|
+
# The bootstrap body spells it with an underscore. Header uses a dash. Both VERIFIED.
|
|
20
|
+
CASA_DEVICE_APP = "member_ios"
|
|
21
|
+
CASA_APP_VERSION = "698"
|
|
22
|
+
# Same build as an integer. The app sends it as the feed's ``app_build`` and
|
|
23
|
+
# release notes' ``currentBuild`` query values (CFBundleVersion parsed as Int32).
|
|
24
|
+
CASA_APP_BUILD = int(CASA_APP_VERSION)
|
|
25
|
+
# The app's Int32 ceiling, also its fallback when the bundle build is missing.
|
|
26
|
+
MAX_APP_BUILD = 2_147_483_647
|
|
27
|
+
APP_MARKETING_VERSION = "2026.38.2"
|
|
28
|
+
USER_AGENT = (
|
|
29
|
+
f"Casa/{APP_MARKETING_VERSION} ({FIREBASE_BUNDLE_ID}; build:{CASA_APP_VERSION}; "
|
|
30
|
+
"iOS 27.0.0) Alamofire/5.12.0"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
TOKEN_REFRESH_LEEWAY_SECONDS = 120
|
|
34
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
casahome/exceptions.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Exception hierarchy for python-casahome."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CasaError(Exception):
|
|
7
|
+
"""Base class for all library errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CasaAuthError(CasaError):
|
|
11
|
+
"""Authentication failed or the refresh token is no longer valid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CasaConnectionError(CasaError):
|
|
15
|
+
"""Network failure or timeout talking to Casa or Firebase."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CasaResponseError(CasaError):
|
|
19
|
+
"""Casa answered, but the body didn't match the expected schema.
|
|
20
|
+
|
|
21
|
+
Usually means Casa changed a response shape. ``__cause__`` carries the
|
|
22
|
+
underlying pydantic ValidationError.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CasaRequestError(CasaError, ValueError):
|
|
27
|
+
"""A write body failed validation before any I/O.
|
|
28
|
+
|
|
29
|
+
The message carries only the model name and error count. Pydantic's own
|
|
30
|
+
error would echo the rejected input, which can be private message text.
|
|
31
|
+
Subclasses ``ValueError`` so existing ``except ValueError`` callers still work.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CasaApiError(CasaError):
|
|
36
|
+
"""Casa returned a non-success response."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
status: int,
|
|
41
|
+
code: str,
|
|
42
|
+
display_title: str = "",
|
|
43
|
+
display_message: str = "",
|
|
44
|
+
request_id: str = "",
|
|
45
|
+
) -> None:
|
|
46
|
+
self.status = status
|
|
47
|
+
self.code = code
|
|
48
|
+
self.display_title = display_title
|
|
49
|
+
self.display_message = display_message
|
|
50
|
+
self.request_id = request_id
|
|
51
|
+
detail = display_message or display_title or code
|
|
52
|
+
super().__init__(f"HTTP {status} [{code}] {detail}".strip())
|