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/api.py
ADDED
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
"""High-level Casa API.
|
|
2
|
+
|
|
3
|
+
Confidence per method:
|
|
4
|
+
|
|
5
|
+
- VERIFIED: path and method confirmed against real traffic or a live call.
|
|
6
|
+
Each docstring states how much of the body is confirmed: the envelope (top
|
|
7
|
+
level keys) and, when the account returned data, the item shape inside lists.
|
|
8
|
+
- UNVERIFIED: provisional path and response schema from the app binary. HTTP
|
|
9
|
+
method confidence can be LIKELY from static accessor evidence. Query inputs
|
|
10
|
+
and decoder linkage can remain unknown. Server acceptance isn't established.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import uuid
|
|
18
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Sequence
|
|
19
|
+
from contextlib import aclosing, asynccontextmanager
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Literal, TypeVar
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ValidationError
|
|
25
|
+
|
|
26
|
+
from casahome.client import CasaClient
|
|
27
|
+
from casahome.const import (
|
|
28
|
+
APP_MARKETING_VERSION,
|
|
29
|
+
CASA_APP_BUILD,
|
|
30
|
+
CASA_APP_VERSION,
|
|
31
|
+
CASA_DEVICE_APP,
|
|
32
|
+
MAX_APP_BUILD,
|
|
33
|
+
)
|
|
34
|
+
from casahome.exceptions import CasaResponseError
|
|
35
|
+
from casahome.models import (
|
|
36
|
+
BasicMessageResponse,
|
|
37
|
+
Bootstrap,
|
|
38
|
+
CreateTodoResponse,
|
|
39
|
+
HomeBadgeCounts,
|
|
40
|
+
HomeDetailsResponse,
|
|
41
|
+
MailboxMailItemsResponse,
|
|
42
|
+
MailboxOverviewResponse,
|
|
43
|
+
MemberCredits,
|
|
44
|
+
PackagesResponse,
|
|
45
|
+
RequestDetails,
|
|
46
|
+
RequestsResponse,
|
|
47
|
+
SendRequestMessageRequest,
|
|
48
|
+
SendRequestMessageResponse,
|
|
49
|
+
TodoDetail,
|
|
50
|
+
UpdateEntityPropertiesRequest,
|
|
51
|
+
UserDeviceRequest,
|
|
52
|
+
VisitsCalendarResponse,
|
|
53
|
+
WeatherResponse,
|
|
54
|
+
)
|
|
55
|
+
from casahome.models.ai import CasaAIBootstrapResponse, CasaAIMessagesResponse
|
|
56
|
+
from casahome.models.assets import (
|
|
57
|
+
HomeMembersResponse,
|
|
58
|
+
ReleaseNotesResponse,
|
|
59
|
+
UtilitiesInsightsResponse,
|
|
60
|
+
VehiclesResponse,
|
|
61
|
+
)
|
|
62
|
+
from casahome.models.availability import MemberAvailabilityDetail
|
|
63
|
+
from casahome.models.catalogs import (
|
|
64
|
+
InventoryEnumsResponse,
|
|
65
|
+
InventoryMakesResponse,
|
|
66
|
+
RecurringVendor,
|
|
67
|
+
RecurringVendorCategoriesResponse,
|
|
68
|
+
RecurringVendorCategory,
|
|
69
|
+
RecurringVendorsResponse,
|
|
70
|
+
RoomType,
|
|
71
|
+
RoomTypesResponse,
|
|
72
|
+
)
|
|
73
|
+
from casahome.models.documents import (
|
|
74
|
+
DocumentDetailsResponse,
|
|
75
|
+
DocumentsResponse,
|
|
76
|
+
DocumentStructureSuggestionsResponse,
|
|
77
|
+
DocumentSuggestionsResponse,
|
|
78
|
+
DocumentTypesResponse,
|
|
79
|
+
WorkDocumentSourceResponse,
|
|
80
|
+
)
|
|
81
|
+
from casahome.models.feed import HomeFeedResponse
|
|
82
|
+
from casahome.models.help_center import HelpCenterBundleResponse
|
|
83
|
+
from casahome.models.home import SimpleEntity
|
|
84
|
+
from casahome.models.images import (
|
|
85
|
+
MatterportImage,
|
|
86
|
+
MatterportImagesResponse,
|
|
87
|
+
RoomImageOptionsResponse,
|
|
88
|
+
)
|
|
89
|
+
from casahome.models.maintenance import (
|
|
90
|
+
AutopilotCategoriesResponse,
|
|
91
|
+
AutopilotCategory,
|
|
92
|
+
CalendarBusyResponse,
|
|
93
|
+
CompletedRequestsResponse,
|
|
94
|
+
InventoryServiceHistoryResponse,
|
|
95
|
+
)
|
|
96
|
+
from casahome.models.membership import MembershipDetails, MembershipSavings
|
|
97
|
+
from casahome.models.paint import (
|
|
98
|
+
InventoryPaintMatchesResponse,
|
|
99
|
+
PaintBrandsResponse,
|
|
100
|
+
PaintCollectionsResponse,
|
|
101
|
+
PaintColorsResponse,
|
|
102
|
+
PaintSearchResponse,
|
|
103
|
+
)
|
|
104
|
+
from casahome.models.preferences import (
|
|
105
|
+
AccessPreferencesResponse,
|
|
106
|
+
CalendarSyncResponse,
|
|
107
|
+
NotificationSettingsResponse,
|
|
108
|
+
OnboardingAvailabilityResponse,
|
|
109
|
+
OnboardingDetailsResponse,
|
|
110
|
+
SchedulePreferencesResponse,
|
|
111
|
+
)
|
|
112
|
+
from casahome.models.purchases import PurchasesResponse, ScopedPurchasesResponse
|
|
113
|
+
from casahome.models.read_todos import SuggestedTodosResponse, TodoDetailsResponse
|
|
114
|
+
from casahome.models.referral import ReferralCodeResponse
|
|
115
|
+
from casahome.models.tasks import BookableHandymanTasksResponse, GroupedTaskDetails, TaskDetails
|
|
116
|
+
from casahome.models.taxes import PropertyTaxesResponse
|
|
117
|
+
from casahome.models.todos import CreateTodoBody
|
|
118
|
+
from casahome.models.visits import VendorVisitDetails, VisitCallsResponse, VisitConversation
|
|
119
|
+
from casahome.models.write_contracts import CreateTodoBody as StrictCreateTodoBody
|
|
120
|
+
from casahome.models.write_contracts import _write_payload
|
|
121
|
+
from casahome.streaming import CasaAIEvent, _stream_events, _validate_cursor, unverified_stream
|
|
122
|
+
|
|
123
|
+
F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
|
|
124
|
+
M = TypeVar("M", bound=BaseModel)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse(model: type[M], data: Any) -> M:
|
|
128
|
+
"""Validate a response, turning schema drift into a library error."""
|
|
129
|
+
try:
|
|
130
|
+
return model.model_validate(data)
|
|
131
|
+
except ValidationError as err:
|
|
132
|
+
message = f"Unexpected {model.__name__} response: {err.error_count()} validation error(s)"
|
|
133
|
+
# Raise outside the handler. Pydantic's exception can contain private field
|
|
134
|
+
# values, and even an implicit context would leak them in tracebacks.
|
|
135
|
+
raise CasaResponseError(message)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_UNVERIFIED_NOTE = (
|
|
139
|
+
"UNVERIFIED: path and HTTP method inferred statically from the iOS app (LIKELY). "
|
|
140
|
+
"Not yet observed live."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def unverified(func: F) -> F:
|
|
145
|
+
"""Mark a method whose path or HTTP method hasn't been seen in real traffic."""
|
|
146
|
+
func.__casa_unverified__ = True # type: ignore[attr-defined]
|
|
147
|
+
func.__doc__ = f"{_UNVERIFIED_NOTE}\n\n{func.__doc__ or ''}".rstrip()
|
|
148
|
+
return func
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def is_unverified(method: Callable[..., Any]) -> bool:
|
|
152
|
+
return bool(getattr(method, "__casa_unverified__", False))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _local_iana_timezone() -> str:
|
|
156
|
+
"""Best-effort IANA zone name (Casa rejects abbreviations like ``PDT``)."""
|
|
157
|
+
env = os.environ.get("TZ", "").lstrip(":")
|
|
158
|
+
if "/" in env:
|
|
159
|
+
return env
|
|
160
|
+
try:
|
|
161
|
+
target = str(Path("/etc/localtime").resolve())
|
|
162
|
+
except OSError:
|
|
163
|
+
return "UTC"
|
|
164
|
+
marker = "zoneinfo/"
|
|
165
|
+
return target.split(marker, 1)[1] if marker in target else "UTC"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _epoch(value: datetime | int) -> int:
|
|
169
|
+
if isinstance(value, datetime):
|
|
170
|
+
if value.tzinfo is None:
|
|
171
|
+
raise ValueError("calendar bounds must be timezone-aware datetimes")
|
|
172
|
+
return int(value.timestamp())
|
|
173
|
+
return int(value)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _home(home_id: str) -> str:
|
|
177
|
+
"""Base for most per-home endpoints."""
|
|
178
|
+
return f"/protected/home/{home_id}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _member_home(home_id: str) -> str:
|
|
182
|
+
"""Base for inventory, entity, room, and story endpoints only."""
|
|
183
|
+
return f"/protected/member/home/{home_id}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _safe_id(value: str | int) -> str:
|
|
187
|
+
"""Validate new identifier inputs without assuming UUID or normalizing IDs."""
|
|
188
|
+
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
|
189
|
+
raise ValueError("identifier must be a string or integer")
|
|
190
|
+
text = str(value)
|
|
191
|
+
if len(text) > 2048:
|
|
192
|
+
raise ValueError("identifier exceeds SDK limit of 2048 characters")
|
|
193
|
+
if text in {".", ".."} or not re.fullmatch(r"[A-Za-z0-9._~-]+", text):
|
|
194
|
+
raise ValueError("identifier must be a nonempty safe path segment")
|
|
195
|
+
return text
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _safe_home(home_id: str, *, member: bool = False) -> str:
|
|
199
|
+
home_id = _safe_id(home_id)
|
|
200
|
+
return _member_home(home_id) if member else _home(home_id)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _positive_int(value: int, name: str) -> str:
|
|
204
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
205
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
206
|
+
return str(value)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _app_build(value: int, name: str) -> str:
|
|
210
|
+
"""Validate a build number the app would send as a positive Int32."""
|
|
211
|
+
text = _positive_int(value, name)
|
|
212
|
+
if value > MAX_APP_BUILD:
|
|
213
|
+
raise ValueError(f"{name} must fit in a 32-bit signed integer")
|
|
214
|
+
return text
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _busy_bounds(start: datetime | int, end: datetime | int) -> dict[str, str]:
|
|
218
|
+
for value in (start, end):
|
|
219
|
+
if isinstance(value, datetime):
|
|
220
|
+
if value.utcoffset() is None:
|
|
221
|
+
raise ValueError("calendar bounds must be timezone-aware datetimes")
|
|
222
|
+
elif isinstance(value, bool) or not isinstance(value, int):
|
|
223
|
+
raise ValueError("calendar bounds must be aware datetimes or integer epoch seconds")
|
|
224
|
+
start_epoch, end_epoch = _epoch(start), _epoch(end)
|
|
225
|
+
if end_epoch <= start_epoch:
|
|
226
|
+
raise ValueError("end must be after start")
|
|
227
|
+
return {"start_time": str(start_epoch), "end_time": str(end_epoch)}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class CasaApi:
|
|
231
|
+
"""Typed wrappers around Casa member endpoints.
|
|
232
|
+
|
|
233
|
+
UNVERIFIED writes execute only when callers explicitly await them. They add
|
|
234
|
+
no upload chain, support confirmation or workflow retry. Writes make exactly
|
|
235
|
+
one attempt: the client force-refreshes the ID token first and raises
|
|
236
|
+
``CasaAuthError`` on HTTP 401 instead of retrying.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def __init__(
|
|
240
|
+
self,
|
|
241
|
+
client: CasaClient,
|
|
242
|
+
*,
|
|
243
|
+
device_id: str | None = None,
|
|
244
|
+
timezone: str | None = None,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""``device_id`` should be persisted and reused by long-lived callers.
|
|
247
|
+
|
|
248
|
+
``bootstrap`` registers the device with Casa, so a fresh random id on
|
|
249
|
+
every start piles up phantom devices on the account. ``timezone`` must
|
|
250
|
+
be an IANA name; it's detected from the OS when omitted.
|
|
251
|
+
"""
|
|
252
|
+
self.client = client
|
|
253
|
+
self.device_id = device_id or str(uuid.uuid4()).upper()
|
|
254
|
+
self._timezone = timezone
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
@asynccontextmanager
|
|
258
|
+
async def create(
|
|
259
|
+
cls,
|
|
260
|
+
refresh_token: str,
|
|
261
|
+
*,
|
|
262
|
+
device_id: str | None = None,
|
|
263
|
+
timezone: str | None = None,
|
|
264
|
+
) -> AsyncIterator[CasaApi]:
|
|
265
|
+
"""One-liner for scripts: owns its session, closes it on exit."""
|
|
266
|
+
async with CasaClient.create(refresh_token) as client:
|
|
267
|
+
yield cls(client, device_id=device_id, timezone=timezone)
|
|
268
|
+
|
|
269
|
+
def default_device(self) -> UserDeviceRequest:
|
|
270
|
+
"""Device descriptor sent with ``bootstrap`` when none is supplied."""
|
|
271
|
+
return UserDeviceRequest(
|
|
272
|
+
device_id=self.device_id,
|
|
273
|
+
device_name="iPhone",
|
|
274
|
+
system_name="iOS",
|
|
275
|
+
system_version="27.0",
|
|
276
|
+
model="iPhone",
|
|
277
|
+
language="en-US",
|
|
278
|
+
timezone=self._timezone or _local_iana_timezone(),
|
|
279
|
+
app=CASA_DEVICE_APP,
|
|
280
|
+
app_version=APP_MARKETING_VERSION,
|
|
281
|
+
app_build=CASA_APP_VERSION,
|
|
282
|
+
fcm_token="",
|
|
283
|
+
voip_token="",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# VERIFIED ---------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
async def bootstrap(self, device: UserDeviceRequest | None = None) -> Bootstrap:
|
|
289
|
+
"""VERIFIED. User profile, homes, badge counts, app update info.
|
|
290
|
+
|
|
291
|
+
Bootstrap is a POST, but it keeps the one-shot 401 retry on purpose.
|
|
292
|
+
It registers this ``device_id`` with Casa, and repeating it with the
|
|
293
|
+
same descriptor re-registers the same device rather than creating new
|
|
294
|
+
state. It's the session-start call, so a stale cached token is likely.
|
|
295
|
+
Real writes never retry on 401.
|
|
296
|
+
"""
|
|
297
|
+
device = device or self.default_device()
|
|
298
|
+
data = await self.client.post(
|
|
299
|
+
"/protected/member/bootstrap",
|
|
300
|
+
json={"user_device": device.model_dump(mode="json")},
|
|
301
|
+
retry_on_401=True,
|
|
302
|
+
)
|
|
303
|
+
return _parse(Bootstrap, data)
|
|
304
|
+
|
|
305
|
+
async def onboarding_details(self, home_id: str) -> dict[str, Any] | None:
|
|
306
|
+
"""VERIFIED GET and populated calendar_events envelope.
|
|
307
|
+
|
|
308
|
+
Returns raw data for backward compatibility, or None on a bodyless 304.
|
|
309
|
+
Use onboarding_details_typed for validated event metadata.
|
|
310
|
+
"""
|
|
311
|
+
data = await self.client.get(f"/protected/home/{home_id}/onboarding/details")
|
|
312
|
+
return data if isinstance(data, dict) else None
|
|
313
|
+
|
|
314
|
+
# VERIFIED reads ---------------------------------------------------------
|
|
315
|
+
#
|
|
316
|
+
# Each method below returned HTTP 200 in live traffic and parsed with the
|
|
317
|
+
# model shown. "Envelope" means the top-level keys. "Item shape" means the
|
|
318
|
+
# elements inside lists, which are only confirmed when a response had some.
|
|
319
|
+
|
|
320
|
+
async def home_details(self, home_id: str) -> HomeDetailsResponse:
|
|
321
|
+
"""VERIFIED (live GET, 2026-10). Stories, rooms, and inventory entities.
|
|
322
|
+
|
|
323
|
+
Path, method, envelope, and entity item shape verified against a populated
|
|
324
|
+
response.
|
|
325
|
+
"""
|
|
326
|
+
data = await self.client.get(f"{_member_home(home_id)}/inventory/v3")
|
|
327
|
+
return _parse(HomeDetailsResponse, data)
|
|
328
|
+
|
|
329
|
+
async def badge_counts(self, home_id: str) -> HomeBadgeCounts:
|
|
330
|
+
"""VERIFIED (live GET, 2026-10). Cheap poll for unread counts per feature.
|
|
331
|
+
|
|
332
|
+
Path, method, and full shape verified: ``computed_at``, ``total``, and
|
|
333
|
+
``features`` keyed by feature name.
|
|
334
|
+
"""
|
|
335
|
+
data = await self.client.get(f"{_home(home_id)}/badge_counts")
|
|
336
|
+
return _parse(HomeBadgeCounts, data)
|
|
337
|
+
|
|
338
|
+
async def weather(self, home_id: str) -> WeatherResponse:
|
|
339
|
+
"""VERIFIED (live GET, 2026-10). Current conditions at the home.
|
|
340
|
+
|
|
341
|
+
Path, method, and full shape verified. ``temperature_units`` observed as ``"F"``.
|
|
342
|
+
"""
|
|
343
|
+
data = await self.client.get(f"{_home(home_id)}/weather")
|
|
344
|
+
return _parse(WeatherResponse, data)
|
|
345
|
+
|
|
346
|
+
async def requests_tab(self, home_id: str) -> RequestsResponse:
|
|
347
|
+
"""VERIFIED path, method, and envelope (live GET, 2026-10).
|
|
348
|
+
|
|
349
|
+
Requests, tasks, and to-dos lanes in one call. Every observed lane was
|
|
350
|
+
empty, so the card item shape is still UNVERIFIED.
|
|
351
|
+
"""
|
|
352
|
+
data = await self.client.get(f"{_home(home_id)}/requests_tab")
|
|
353
|
+
return _parse(RequestsResponse, data)
|
|
354
|
+
|
|
355
|
+
async def calendar(
|
|
356
|
+
self, home_id: str, start: datetime | int, end: datetime | int
|
|
357
|
+
) -> VisitsCalendarResponse:
|
|
358
|
+
"""VERIFIED path, method, query keys, and envelope (live GET, 2026-10).
|
|
359
|
+
|
|
360
|
+
The server accepted ``start_time`` and ``end_time`` as stringified epoch
|
|
361
|
+
seconds and returned ``{"events": []}``. With no events returned, the
|
|
362
|
+
event item shape and the server's interpretation of the bounds (units,
|
|
363
|
+
inclusivity) remain UNVERIFIED.
|
|
364
|
+
|
|
365
|
+
Accepts aware datetimes or epoch seconds. Naive datetimes are rejected,
|
|
366
|
+
since guessing a zone silently shifts the window.
|
|
367
|
+
"""
|
|
368
|
+
data = await self.client.get(
|
|
369
|
+
f"{_home(home_id)}/calendar",
|
|
370
|
+
params={"start_time": str(_epoch(start)), "end_time": str(_epoch(end))},
|
|
371
|
+
)
|
|
372
|
+
return _parse(VisitsCalendarResponse, data)
|
|
373
|
+
|
|
374
|
+
async def credits_summary(self, home_id: str) -> MemberCredits:
|
|
375
|
+
"""VERIFIED (live GET, 2026-10). Credit balance and handyman rate.
|
|
376
|
+
|
|
377
|
+
Path, method, and full shape verified: ``home_id``, ``total_available``,
|
|
378
|
+
``handyman_hourly_rate_cents``.
|
|
379
|
+
"""
|
|
380
|
+
data = await self.client.get(f"{_home(home_id)}/credits/summary")
|
|
381
|
+
return _parse(MemberCredits, data)
|
|
382
|
+
|
|
383
|
+
async def mailbox(self, home_id: str) -> MailboxOverviewResponse:
|
|
384
|
+
"""VERIFIED path, method, and envelope (live GET, 2026-10).
|
|
385
|
+
|
|
386
|
+
Observed ``{"mailbox": null}``, so the populated mailbox shape is UNVERIFIED.
|
|
387
|
+
"""
|
|
388
|
+
data = await self.client.get(f"{_home(home_id)}/mailbox")
|
|
389
|
+
return _parse(MailboxOverviewResponse, data)
|
|
390
|
+
|
|
391
|
+
async def mail_items(self, home_id: str) -> MailboxMailItemsResponse:
|
|
392
|
+
"""VERIFIED path, method, and envelope (live GET, 2026-10).
|
|
393
|
+
|
|
394
|
+
The list was empty, so the mail item shape is UNVERIFIED.
|
|
395
|
+
"""
|
|
396
|
+
data = await self.client.get(f"{_home(home_id)}/mailbox/mail_items")
|
|
397
|
+
return _parse(MailboxMailItemsResponse, data)
|
|
398
|
+
|
|
399
|
+
async def packages(self, home_id: str) -> PackagesResponse:
|
|
400
|
+
"""VERIFIED path, method, and envelope (live GET, 2026-10).
|
|
401
|
+
|
|
402
|
+
No packages were returned, so the package item shape is UNVERIFIED.
|
|
403
|
+
"""
|
|
404
|
+
data = await self.client.get(f"{_home(home_id)}/packages/v3")
|
|
405
|
+
return _parse(PackagesResponse, data)
|
|
406
|
+
|
|
407
|
+
# Further VERIFIED reads (live GET, 2026-10).
|
|
408
|
+
|
|
409
|
+
async def completed_requests(
|
|
410
|
+
self, home_id: str, page: int, page_size: int = 100, search: str | None = None
|
|
411
|
+
) -> CompletedRequestsResponse:
|
|
412
|
+
"""VERIFIED (live GET, 2026-10).
|
|
413
|
+
|
|
414
|
+
kind=requests, page=1, page_size=100 and the pagination envelope observed.
|
|
415
|
+
RequestCard items were empty. Search and other pages remain UNVERIFIED.
|
|
416
|
+
Blank search is omitted. Nonempty search is trimmed.
|
|
417
|
+
"""
|
|
418
|
+
path = f"{_safe_home(home_id)}/requests_tab/completed"
|
|
419
|
+
params = {
|
|
420
|
+
"kind": "requests",
|
|
421
|
+
"page": _positive_int(page, "page"),
|
|
422
|
+
"page_size": _positive_int(page_size, "page_size"),
|
|
423
|
+
}
|
|
424
|
+
if search is not None and (trimmed := search.strip()):
|
|
425
|
+
params["search"] = trimmed
|
|
426
|
+
return _parse(CompletedRequestsResponse, await self.client.get(path, params=params))
|
|
427
|
+
|
|
428
|
+
async def autopilot_plans(
|
|
429
|
+
self,
|
|
430
|
+
home_id: str,
|
|
431
|
+
requirement_kinds: Sequence[Literal["entity_property", "room_property"]] = (
|
|
432
|
+
"entity_property",
|
|
433
|
+
"room_property",
|
|
434
|
+
),
|
|
435
|
+
) -> list[AutopilotCategory]:
|
|
436
|
+
"""VERIFIED (live GET, 2026-10).
|
|
437
|
+
|
|
438
|
+
Bare category list with populated plans, requirements, associations,
|
|
439
|
+
pricing policies and ineligible plans. Both default requirement kinds
|
|
440
|
+
observed together. Subset filtering semantics remain UNVERIFIED.
|
|
441
|
+
"""
|
|
442
|
+
path = f"{_safe_home(home_id)}/autopilot/list/v2"
|
|
443
|
+
if (
|
|
444
|
+
isinstance(requirement_kinds, str)
|
|
445
|
+
or not requirement_kinds
|
|
446
|
+
or any(kind not in {"entity_property", "room_property"} for kind in requirement_kinds)
|
|
447
|
+
):
|
|
448
|
+
raise ValueError("requirement_kinds must contain entity_property or room_property")
|
|
449
|
+
data = await self.client.get(
|
|
450
|
+
path, params={"requirement_kinds": ",".join(requirement_kinds)}
|
|
451
|
+
)
|
|
452
|
+
return _parse(AutopilotCategoriesResponse, data).root
|
|
453
|
+
|
|
454
|
+
async def inventory_service_history(
|
|
455
|
+
self, home_id: str, target_type: Literal["entity", "room", "home"], target_id: str
|
|
456
|
+
) -> InventoryServiceHistoryResponse:
|
|
457
|
+
"""VERIFIED (live GET, 2026-10) for room and home targets.
|
|
458
|
+
|
|
459
|
+
Empty plans/history_items envelopes observed. Entity target and populated
|
|
460
|
+
item schemas remain UNVERIFIED.
|
|
461
|
+
"""
|
|
462
|
+
path = f"{_safe_home(home_id)}/inventory/service_history"
|
|
463
|
+
if target_type not in {"entity", "room", "home"}:
|
|
464
|
+
raise ValueError("target_type must be entity, room, or home")
|
|
465
|
+
data = await self.client.get(
|
|
466
|
+
path,
|
|
467
|
+
params={
|
|
468
|
+
"inventory_target_type": target_type,
|
|
469
|
+
"inventory_target_id": _safe_id(target_id),
|
|
470
|
+
},
|
|
471
|
+
)
|
|
472
|
+
return _parse(InventoryServiceHistoryResponse, data)
|
|
473
|
+
|
|
474
|
+
async def calendar_busy(
|
|
475
|
+
self,
|
|
476
|
+
home_id: str,
|
|
477
|
+
start: datetime | int,
|
|
478
|
+
end: datetime | int,
|
|
479
|
+
*,
|
|
480
|
+
google_calendar_ids: Sequence[str] | None = None,
|
|
481
|
+
) -> CalendarBusyResponse:
|
|
482
|
+
"""VERIFIED (live GET, 2026-10).
|
|
483
|
+
|
|
484
|
+
Epoch query keys, blocks=[] and google_status observed. Block items,
|
|
485
|
+
bounds interpretation and google_calendar_ids filtering UNVERIFIED.
|
|
486
|
+
Optional Google IDs are comma-joined query values, not path segments.
|
|
487
|
+
"""
|
|
488
|
+
path = f"{_safe_home(home_id)}/calendar_busy"
|
|
489
|
+
params = _busy_bounds(start, end)
|
|
490
|
+
if google_calendar_ids is not None:
|
|
491
|
+
if (
|
|
492
|
+
isinstance(google_calendar_ids, str)
|
|
493
|
+
or not google_calendar_ids
|
|
494
|
+
or any(
|
|
495
|
+
not isinstance(value, str)
|
|
496
|
+
or not value.strip()
|
|
497
|
+
or "," in value
|
|
498
|
+
or any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in value)
|
|
499
|
+
for value in google_calendar_ids
|
|
500
|
+
)
|
|
501
|
+
):
|
|
502
|
+
raise ValueError("google_calendar_ids must contain nonempty IDs without commas")
|
|
503
|
+
params["google_calendar_ids"] = ",".join(google_calendar_ids)
|
|
504
|
+
return _parse(CalendarBusyResponse, await self.client.get(path, params=params))
|
|
505
|
+
|
|
506
|
+
async def room_image_options(self, home_id: str, room_id: str) -> RoomImageOptionsResponse:
|
|
507
|
+
"""VERIFIED (live GET, 2026-10).
|
|
508
|
+
|
|
509
|
+
Populated matterport_images. member_image was null, its shape UNVERIFIED.
|
|
510
|
+
Returns metadata only, never follows the image URLs.
|
|
511
|
+
"""
|
|
512
|
+
path = f"{_safe_home(home_id, member=True)}/room/{_safe_id(room_id)}/image/options"
|
|
513
|
+
return _parse(RoomImageOptionsResponse, await self.client.get(path))
|
|
514
|
+
|
|
515
|
+
async def room_image_candidates(self, home_id: str) -> RoomImageOptionsResponse:
|
|
516
|
+
"""VERIFIED (live GET, 2026-10).
|
|
517
|
+
|
|
518
|
+
Populated matterport_images, null member_image. Home-wide route, no room ID.
|
|
519
|
+
Member image shape remains UNVERIFIED. Metadata only.
|
|
520
|
+
"""
|
|
521
|
+
path = f"{_safe_home(home_id, member=True)}/room_image_candidates"
|
|
522
|
+
return _parse(RoomImageOptionsResponse, await self.client.get(path))
|
|
523
|
+
|
|
524
|
+
async def paint_search(self, home_id: str, q: str) -> PaintSearchResponse:
|
|
525
|
+
"""VERIFIED (live GET, 2026-10). Populated results for a query."""
|
|
526
|
+
path = f"{_safe_home(home_id)}/paint/library/search"
|
|
527
|
+
return _parse(PaintSearchResponse, await self.client.get(path, params={"q": q}))
|
|
528
|
+
|
|
529
|
+
async def paint_brands(self, home_id: str) -> PaintBrandsResponse:
|
|
530
|
+
"""VERIFIED (live GET, 2026-10). Populated brands."""
|
|
531
|
+
return _parse(
|
|
532
|
+
PaintBrandsResponse,
|
|
533
|
+
await self.client.get(f"{_safe_home(home_id)}/paint/library/brands"),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
async def paint_collections(
|
|
537
|
+
self, home_id: str, brand_id: int | str
|
|
538
|
+
) -> PaintCollectionsResponse:
|
|
539
|
+
"""VERIFIED (live GET, 2026-10). Populated collections.
|
|
540
|
+
|
|
541
|
+
Accepts integer or string catalog IDs unchanged. No UUID coercion.
|
|
542
|
+
"""
|
|
543
|
+
path = f"{_safe_home(home_id)}/paint/library/brands/{_safe_id(brand_id)}/collections"
|
|
544
|
+
return _parse(PaintCollectionsResponse, await self.client.get(path))
|
|
545
|
+
|
|
546
|
+
async def paint_colors(self, home_id: str, collection_id: int | str) -> PaintColorsResponse:
|
|
547
|
+
"""VERIFIED (live GET, 2026-10). Populated color rows."""
|
|
548
|
+
path = f"{_safe_home(home_id)}/paint/library/collections/{_safe_id(collection_id)}/colors"
|
|
549
|
+
return _parse(PaintColorsResponse, await self.client.get(path))
|
|
550
|
+
|
|
551
|
+
async def todo_details(self, home_id: str, todo_id: str) -> TodoDetailsResponse:
|
|
552
|
+
"""VERIFIED (live GET, 2026-10). Populated todo and attachments.
|
|
553
|
+
|
|
554
|
+
Question rounds were empty and destination null, so those nested
|
|
555
|
+
schemas remain opaque and UNVERIFIED.
|
|
556
|
+
"""
|
|
557
|
+
path = f"{_safe_home(home_id)}/todos/{_safe_id(todo_id)}"
|
|
558
|
+
return _parse(TodoDetailsResponse, await self.client.get(path))
|
|
559
|
+
|
|
560
|
+
async def suggested_todos(self, home_id: str) -> SuggestedTodosResponse:
|
|
561
|
+
"""VERIFIED (live GET, 2026-10). Populated suggested_todos.
|
|
562
|
+
|
|
563
|
+
Core fields and attachments observed, not question rounds or destination.
|
|
564
|
+
This call doesn't create or accept suggestions.
|
|
565
|
+
"""
|
|
566
|
+
return _parse(
|
|
567
|
+
SuggestedTodosResponse, await self.client.get(f"{_safe_home(home_id)}/suggested_todos")
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
async def work_document_source(
|
|
571
|
+
self,
|
|
572
|
+
home_id: str,
|
|
573
|
+
*,
|
|
574
|
+
todo_id: str | None = None,
|
|
575
|
+
task_id: str | None = None,
|
|
576
|
+
request_id: str | None = None,
|
|
577
|
+
) -> WorkDocumentSourceResponse:
|
|
578
|
+
"""VERIFIED (live GET, 2026-10) for todo_id with source=null.
|
|
579
|
+
|
|
580
|
+
Exactly one selector required.
|
|
581
|
+
task_id/request_id and populated source schemas remain UNVERIFIED.
|
|
582
|
+
"""
|
|
583
|
+
path = f"{_safe_home(home_id)}/work_document_source"
|
|
584
|
+
selectors = {
|
|
585
|
+
key: _safe_id(value)
|
|
586
|
+
for key, value in {
|
|
587
|
+
"todo_id": todo_id,
|
|
588
|
+
"task_id": task_id,
|
|
589
|
+
"request_id": request_id,
|
|
590
|
+
}.items()
|
|
591
|
+
if value is not None
|
|
592
|
+
}
|
|
593
|
+
if len(selectors) != 1:
|
|
594
|
+
raise ValueError("provide exactly one of todo_id, task_id, request_id")
|
|
595
|
+
return _parse(WorkDocumentSourceResponse, await self.client.get(path, params=selectors))
|
|
596
|
+
|
|
597
|
+
async def inventory_item(self, home_id: str, entity_id: str) -> SimpleEntity:
|
|
598
|
+
"""VERIFIED (live GET, 2026-10). Populated bare SimpleEntity.
|
|
599
|
+
|
|
600
|
+
Reuses the inventory/v3 entity model and preserves extras.
|
|
601
|
+
"""
|
|
602
|
+
path = f"{_safe_home(home_id, member=True)}/entity/{_safe_id(entity_id)}/v2"
|
|
603
|
+
return _parse(SimpleEntity, await self.client.get(path))
|
|
604
|
+
|
|
605
|
+
async def documents(
|
|
606
|
+
self,
|
|
607
|
+
home_id: str,
|
|
608
|
+
*,
|
|
609
|
+
cursor: str | None = None,
|
|
610
|
+
limit: int = 30,
|
|
611
|
+
q: str | None = None,
|
|
612
|
+
document_type: str | None = None,
|
|
613
|
+
sort: str | None = None,
|
|
614
|
+
) -> DocumentsResponse:
|
|
615
|
+
"""VERIFIED (live GET, 2026-10). Parameter-free, empty documents envelope.
|
|
616
|
+
|
|
617
|
+
Populated items and query
|
|
618
|
+
behavior remain UNVERIFIED. No default sort or invented sort vocabulary.
|
|
619
|
+
Only non-None filters are sent. Cursors remain opaque query strings.
|
|
620
|
+
"""
|
|
621
|
+
path = f"{_safe_home(home_id)}/documents"
|
|
622
|
+
params = {"limit": _positive_int(limit, "limit")}
|
|
623
|
+
params.update(
|
|
624
|
+
{
|
|
625
|
+
key: value
|
|
626
|
+
for key, value in {
|
|
627
|
+
"cursor": cursor,
|
|
628
|
+
"q": q,
|
|
629
|
+
"document_type": document_type,
|
|
630
|
+
"sort": sort,
|
|
631
|
+
}.items()
|
|
632
|
+
if value is not None
|
|
633
|
+
}
|
|
634
|
+
)
|
|
635
|
+
return _parse(DocumentsResponse, await self.client.get(path, params=params))
|
|
636
|
+
|
|
637
|
+
async def document_types(self, home_id: str) -> DocumentTypesResponse:
|
|
638
|
+
"""VERIFIED (live GET, 2026-10). Populated types and limits."""
|
|
639
|
+
return _parse(
|
|
640
|
+
DocumentTypesResponse, await self.client.get(f"{_safe_home(home_id)}/documents/types")
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
async def casa_ai_bootstrap(self, home_id: str) -> CasaAIBootstrapResponse:
|
|
644
|
+
"""VERIFIED (live GET, 2026-10). Conversation and starter suggestions.
|
|
645
|
+
|
|
646
|
+
Messages empty, active_response
|
|
647
|
+
null. Populated message/active-response schemas UNVERIFIED. No send or SSE.
|
|
648
|
+
"""
|
|
649
|
+
return _parse(
|
|
650
|
+
CasaAIBootstrapResponse, await self.client.get(f"{_safe_home(home_id)}/casa_ai")
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
async def casa_ai_messages(
|
|
654
|
+
self,
|
|
655
|
+
home_id: str,
|
|
656
|
+
*,
|
|
657
|
+
limit: int | None = None,
|
|
658
|
+
before: str | None = None,
|
|
659
|
+
) -> CasaAIMessagesResponse:
|
|
660
|
+
"""VERIFIED (live GET, 2026-10). Parameter-free, empty items envelope.
|
|
661
|
+
|
|
662
|
+
Optional limit/before behavior
|
|
663
|
+
and populated message schemas UNVERIFIED. Default sends no query keys.
|
|
664
|
+
"""
|
|
665
|
+
path = f"{_safe_home(home_id)}/casa_ai/messages"
|
|
666
|
+
params: dict[str, str] = {}
|
|
667
|
+
if limit is not None:
|
|
668
|
+
params["limit"] = _positive_int(limit, "limit")
|
|
669
|
+
if before is not None:
|
|
670
|
+
params["before"] = before
|
|
671
|
+
return _parse(CasaAIMessagesResponse, await self.client.get(path, params=params))
|
|
672
|
+
|
|
673
|
+
async def schedule_preferences(self, home_id: str) -> SchedulePreferencesResponse:
|
|
674
|
+
"""VERIFIED (live GET, 2026-10). Populated days and windows."""
|
|
675
|
+
return _parse(
|
|
676
|
+
SchedulePreferencesResponse,
|
|
677
|
+
await self.client.get(f"{_safe_home(home_id)}/schedule_preferences"),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
async def notification_settings(self, home_id: str) -> NotificationSettingsResponse:
|
|
681
|
+
"""VERIFIED (live GET, 2026-10). Populated groups, types and channels."""
|
|
682
|
+
return _parse(
|
|
683
|
+
NotificationSettingsResponse,
|
|
684
|
+
await self.client.get(f"{_safe_home(home_id)}/notification_settings"),
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
async def onboarding_availability(self, home_id: str) -> OnboardingAvailabilityResponse:
|
|
688
|
+
"""VERIFIED (live GET, 2026-10). Populated slots and timezone."""
|
|
689
|
+
return _parse(
|
|
690
|
+
OnboardingAvailabilityResponse,
|
|
691
|
+
await self.client.get(f"{_safe_home(home_id)}/onboarding/availability"),
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
async def onboarding_details_typed(self, home_id: str) -> OnboardingDetailsResponse | None:
|
|
695
|
+
"""VERIFIED (live GET, 2026-10). Populated calendar_events.
|
|
696
|
+
|
|
697
|
+
Typed companion to the backward-compatible raw onboarding_details.
|
|
698
|
+
A bodyless 304 still returns None. Invalid nonempty bodies raise CasaResponseError.
|
|
699
|
+
"""
|
|
700
|
+
data = await self.client.get(f"{_safe_home(home_id)}/onboarding/details")
|
|
701
|
+
return None if data is None else _parse(OnboardingDetailsResponse, data)
|
|
702
|
+
|
|
703
|
+
async def vehicles(self, home_id: str) -> VehiclesResponse:
|
|
704
|
+
"""VERIFIED (live GET, 2026-10). Envelope only, vehicles=[].
|
|
705
|
+
|
|
706
|
+
Populated vehicles UNVERIFIED. Static optional fields, opaque readings.
|
|
707
|
+
"""
|
|
708
|
+
return _parse(VehiclesResponse, await self.client.get(f"{_safe_home(home_id)}/vehicles"))
|
|
709
|
+
|
|
710
|
+
async def utilities_insights(self, home_id: str) -> UtilitiesInsightsResponse:
|
|
711
|
+
"""VERIFIED (live GET, 2026-10). Envelope only, services=[].
|
|
712
|
+
|
|
713
|
+
Populated service, statement, graph and account schemas remain UNVERIFIED.
|
|
714
|
+
"""
|
|
715
|
+
return _parse(
|
|
716
|
+
UtilitiesInsightsResponse,
|
|
717
|
+
await self.client.get(f"{_safe_home(home_id)}/utilities/insights"),
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
async def room_types(self) -> list[RoomType]:
|
|
721
|
+
"""VERIFIED (live GET, 2026-10). Populated bare list."""
|
|
722
|
+
return _parse(
|
|
723
|
+
RoomTypesResponse, await self.client.get("/protected/inventory/room_types/list")
|
|
724
|
+
).root
|
|
725
|
+
|
|
726
|
+
async def home_members(self, home_id: str) -> HomeMembersResponse:
|
|
727
|
+
"""VERIFIED (live GET, 2026-10). Populated members envelope."""
|
|
728
|
+
return _parse(
|
|
729
|
+
HomeMembersResponse, await self.client.get(f"{_safe_home(home_id)}/home_members/list")
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
async def purchases(self, home_id: str) -> PurchasesResponse:
|
|
733
|
+
"""VERIFIED (live GET, 2026-10). Empty item lists plus context metadata.
|
|
734
|
+
|
|
735
|
+
Item schemas UNVERIFIED. No
|
|
736
|
+
approval, payment or return mutation. Non-purchase invoices weren't populated.
|
|
737
|
+
"""
|
|
738
|
+
return _parse(PurchasesResponse, await self.client.get(f"{_safe_home(home_id)}/purchases"))
|
|
739
|
+
|
|
740
|
+
async def property_taxes(self, home_id: str) -> PropertyTaxesResponse:
|
|
741
|
+
"""VERIFIED (live GET, 2026-10). Refresh metadata and reports=[].
|
|
742
|
+
|
|
743
|
+
Report/assessment/installment item schemas remain UNVERIFIED.
|
|
744
|
+
"""
|
|
745
|
+
return _parse(
|
|
746
|
+
PropertyTaxesResponse, await self.client.get(f"{_safe_home(home_id)}/property_taxes")
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
async def matterport_images(self, home_id: str) -> list[MatterportImage]:
|
|
750
|
+
"""VERIFIED (live GET, 2026-10). Populated bare image list.
|
|
751
|
+
|
|
752
|
+
Photo metadata only. No downstream URL fetch.
|
|
753
|
+
"""
|
|
754
|
+
return _parse(
|
|
755
|
+
MatterportImagesResponse,
|
|
756
|
+
await self.client.get(f"{_safe_home(home_id)}/matterport/images"),
|
|
757
|
+
).root
|
|
758
|
+
|
|
759
|
+
async def release_notes(self) -> ReleaseNotesResponse:
|
|
760
|
+
"""VERIFIED (live GET, 2026-10). Populated data and sections for member_ios/all."""
|
|
761
|
+
return _parse(
|
|
762
|
+
ReleaseNotesResponse, await self.client.get("/protected/release_notes/member_ios/all")
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
async def access_preferences(self, home_id: str) -> AccessPreferencesResponse:
|
|
766
|
+
"""VERIFIED (live GET, 2026-10). home_id and nullable metadata."""
|
|
767
|
+
return _parse(
|
|
768
|
+
AccessPreferencesResponse,
|
|
769
|
+
await self.client.get(f"{_safe_home(home_id)}/access_preferences"),
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
async def calendar_sync(self, home_id: str) -> CalendarSyncResponse:
|
|
773
|
+
"""VERIFIED (live GET, 2026-10). Metadata with empty calendars.
|
|
774
|
+
|
|
775
|
+
Calendar items and sync_progress remain UNVERIFIED. Reads only, no OAuth action.
|
|
776
|
+
"""
|
|
777
|
+
return _parse(
|
|
778
|
+
CalendarSyncResponse, await self.client.get(f"{_safe_home(home_id)}/calendar_sync")
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
async def inventory_makes(self, entity_type_id: str) -> InventoryMakesResponse:
|
|
782
|
+
"""VERIFIED (live GET, 2026-10). Populated makes catalog."""
|
|
783
|
+
path = f"/protected/member/inventory/makes/entity_type/{_safe_id(entity_type_id)}"
|
|
784
|
+
return _parse(InventoryMakesResponse, await self.client.get(path))
|
|
785
|
+
|
|
786
|
+
async def recurring_vendor_categories(self, home_id: str) -> list[RecurringVendorCategory]:
|
|
787
|
+
"""VERIFIED (live GET, 2026-10). Populated bare category list."""
|
|
788
|
+
return _parse(
|
|
789
|
+
RecurringVendorCategoriesResponse,
|
|
790
|
+
await self.client.get(f"{_safe_home(home_id)}/dtd/categories"),
|
|
791
|
+
).root
|
|
792
|
+
|
|
793
|
+
async def recurring_vendors(self, home_id: str) -> list[RecurringVendor]:
|
|
794
|
+
"""VERIFIED (live GET, 2026-10). Empty bare list.
|
|
795
|
+
|
|
796
|
+
Populated recurring vendor fields UNVERIFIED, nested events/invoices opaque.
|
|
797
|
+
"""
|
|
798
|
+
return _parse(
|
|
799
|
+
RecurringVendorsResponse, await self.client.get(f"{_safe_home(home_id)}/dtd/list")
|
|
800
|
+
).root
|
|
801
|
+
|
|
802
|
+
async def bookable_handyman_tasks(
|
|
803
|
+
self, home_id: str, *, include_current_vendor_visit_id: str | None = None
|
|
804
|
+
) -> BookableHandymanTasksResponse:
|
|
805
|
+
"""VERIFIED (live GET, 2026-10). Parameter-free, items/availability envelope.
|
|
806
|
+
|
|
807
|
+
Items were empty. Populated items remain JsonValue because their wire
|
|
808
|
+
tags aren't known. The optional query key is inferred from the app only.
|
|
809
|
+
Its value encoding and behavior remain UNVERIFIED. No booking/options call.
|
|
810
|
+
"""
|
|
811
|
+
path = f"{_safe_home(home_id)}/vendor_visits/handyman/bookable_tasks"
|
|
812
|
+
params = (
|
|
813
|
+
{"include_current_vendor_visit_id": _safe_id(include_current_vendor_visit_id)}
|
|
814
|
+
if include_current_vendor_visit_id is not None
|
|
815
|
+
else {}
|
|
816
|
+
)
|
|
817
|
+
return _parse(BookableHandymanTasksResponse, await self.client.get(path, params=params))
|
|
818
|
+
|
|
819
|
+
async def inventory_enums(self, enum_type: str) -> InventoryEnumsResponse:
|
|
820
|
+
"""VERIFIED (live GET, 2026-10). Populated enums.
|
|
821
|
+
|
|
822
|
+
``enum_type`` is a property's string ``enum_type`` key, such as
|
|
823
|
+
``light_fixture_type`` or ``home_type``. It isn't a UUID, an entity-type
|
|
824
|
+
ID or a make ID. Observed options parsed as ``InventoryEnumOption``.
|
|
825
|
+
Other keys and empty catalogs are UNVERIFIED.
|
|
826
|
+
"""
|
|
827
|
+
path = f"/protected/member/inventory/enums/enum_type/{_safe_id(enum_type)}"
|
|
828
|
+
return _parse(InventoryEnumsResponse, await self.client.get(path))
|
|
829
|
+
|
|
830
|
+
async def inventory_paint_matches(
|
|
831
|
+
self, home_id: str, entity_id: str
|
|
832
|
+
) -> InventoryPaintMatchesResponse:
|
|
833
|
+
"""VERIFIED (live GET, 2026-10). Populated matches.
|
|
834
|
+
|
|
835
|
+
Uses the non-member home base with a paint entity ID. The envelope was
|
|
836
|
+
``matches`` (each parsed as ``InventoryPaintMatch``) plus a
|
|
837
|
+
Boolean ``is_manually_added``. Integer catalog IDs. Reads matches only,
|
|
838
|
+
never applies one. Non-paint entities and empty results are UNVERIFIED.
|
|
839
|
+
"""
|
|
840
|
+
path = f"{_safe_home(home_id)}/entity/{_safe_id(entity_id)}/paint/library/match"
|
|
841
|
+
return _parse(InventoryPaintMatchesResponse, await self.client.get(path))
|
|
842
|
+
|
|
843
|
+
async def help_center_bundle(self, app_id: str) -> HelpCenterBundleResponse:
|
|
844
|
+
"""VERIFIED (live GET, 2026-10) for ``app_id="member_ios"``.
|
|
845
|
+
|
|
846
|
+
Envelope ``data`` with ``app``, ``topics`` and ``articles``, all parsed
|
|
847
|
+
as the existing models. Those models accept both camelCase aliases and
|
|
848
|
+
snake_case names, so the wire key spelling isn't pinned.
|
|
849
|
+
Other app IDs are UNVERIFIED. No article-view tracking or feedback POST.
|
|
850
|
+
"""
|
|
851
|
+
path = f"/protected/help_center/{_safe_id(app_id)}/bundle"
|
|
852
|
+
return _parse(HelpCenterBundleResponse, await self.client.get(path))
|
|
853
|
+
|
|
854
|
+
async def referral_code(self) -> ReferralCodeResponse:
|
|
855
|
+
"""VERIFIED (live GET, 2026-10). Envelope only.
|
|
856
|
+
|
|
857
|
+
``referral_code`` held ``code``, ``referral_url``, a ``display`` object
|
|
858
|
+
and a ``referred_homes`` list. Display keys and referred-home item
|
|
859
|
+
shapes are UNVERIFIED and stay ``JsonValue``. The SDK never fetches the URL.
|
|
860
|
+
"""
|
|
861
|
+
data = await self.client.get("/protected/member/referral_code/v2")
|
|
862
|
+
return _parse(ReferralCodeResponse, data)
|
|
863
|
+
|
|
864
|
+
async def home_feed(self, home_id: str, *, app_build: int = CASA_APP_BUILD) -> HomeFeedResponse:
|
|
865
|
+
"""VERIFIED (live GET, 2026-10) with ``app_build=698``.
|
|
866
|
+
|
|
867
|
+
Envelope ``cards`` with populated cards, each with ``id``,
|
|
868
|
+
``template_id``, ``name``, a template-specific ``props`` dict, integer
|
|
869
|
+
show-window offsets and an empty ``children`` list. Omitting
|
|
870
|
+
``app_build`` got HTTP 400 ``INVALID_INPUT_PARAMETERS``, so it's always
|
|
871
|
+
sent. The app sends its CFBundleVersion as an Int32. Props stay
|
|
872
|
+
``JsonValue``, and nested children shapes are UNVERIFIED. The SDK never
|
|
873
|
+
follows image or action URLs found in props.
|
|
874
|
+
"""
|
|
875
|
+
path = f"{_safe_home(home_id)}/feed"
|
|
876
|
+
params = {"app_build": _app_build(app_build, "app_build")}
|
|
877
|
+
return _parse(HomeFeedResponse, await self.client.get(path, params=params))
|
|
878
|
+
|
|
879
|
+
async def release_notes_diff(
|
|
880
|
+
self, *, current_build: int = CASA_APP_BUILD
|
|
881
|
+
) -> ReleaseNotesResponse:
|
|
882
|
+
"""VERIFIED (live GET, 2026-10) with ``currentBuild=698``.
|
|
883
|
+
|
|
884
|
+
Notes newer than ``current_build``. The camelCase ``currentBuild`` key
|
|
885
|
+
comes from the app, and the bare route got HTTP 400, so it's always
|
|
886
|
+
sent. Same ``data`` envelope as ``release_notes``: populated notes with
|
|
887
|
+
integer ``date`` and ``min_build_number``, nullable ``max_build_number``
|
|
888
|
+
and sections. Doesn't record a highest-seen build.
|
|
889
|
+
"""
|
|
890
|
+
params = {"currentBuild": _app_build(current_build, "current_build")}
|
|
891
|
+
data = await self.client.get("/protected/release_notes/member_ios/diff", params=params)
|
|
892
|
+
return _parse(ReleaseNotesResponse, data)
|
|
893
|
+
|
|
894
|
+
# UNVERIFIED reads -------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
@unverified
|
|
897
|
+
async def membership_info(self, home_id: str) -> MembershipDetails:
|
|
898
|
+
"""Membership name, start date and benefits.
|
|
899
|
+
|
|
900
|
+
A live GET (2026-10) returned 404 with a generic body. Accounts without
|
|
901
|
+
membership data may legitimately 404, so that doesn't disprove the route.
|
|
902
|
+
Path and schema stay UNVERIFIED.
|
|
903
|
+
|
|
904
|
+
Provisional bare object, not a guessed membership envelope. No diagnosis,
|
|
905
|
+
billing-detail, lifecycle or payment calls. Amount and interval stay raw.
|
|
906
|
+
"""
|
|
907
|
+
path = f"{_safe_home(home_id)}/membership/info"
|
|
908
|
+
return _parse(MembershipDetails, await self.client.get(path))
|
|
909
|
+
|
|
910
|
+
@unverified
|
|
911
|
+
async def membership_savings(self, home_id: str) -> MembershipSavings:
|
|
912
|
+
"""Membership savings totals and breakdown.
|
|
913
|
+
|
|
914
|
+
A live GET (2026-10) returned 404 ``MEMBERSHIP_SAVINGS_NOT_FOUND``. The
|
|
915
|
+
domain-specific code suggests a routed handler with no data, not proof
|
|
916
|
+
of the schema.
|
|
917
|
+
|
|
918
|
+
Provisional bare object. Explicit seconds/cents retained. No inferred
|
|
919
|
+
reporting period, date parsing, currency conversion or financial actions.
|
|
920
|
+
"""
|
|
921
|
+
path = f"{_safe_home(home_id)}/membership_savings"
|
|
922
|
+
return _parse(MembershipSavings, await self.client.get(path))
|
|
923
|
+
|
|
924
|
+
@unverified
|
|
925
|
+
async def availability_request_details(
|
|
926
|
+
self, home_id: str, availability_request_id: str
|
|
927
|
+
) -> MemberAvailabilityDetail:
|
|
928
|
+
"""One availability request with its slots and constraints.
|
|
929
|
+
|
|
930
|
+
Availability metadata and slots only. No selection or submission.
|
|
931
|
+
"""
|
|
932
|
+
path = f"{_safe_home(home_id)}/availability_requests/{_safe_id(availability_request_id)}"
|
|
933
|
+
return _parse(MemberAvailabilityDetail, await self.client.get(path))
|
|
934
|
+
|
|
935
|
+
@unverified
|
|
936
|
+
async def document_details(self, home_id: str, document_id: str) -> DocumentDetailsResponse:
|
|
937
|
+
"""One document's metadata and organization status.
|
|
938
|
+
|
|
939
|
+
Metadata only. No file/thumbnail route, download, analysis or organization.
|
|
940
|
+
Envelope and populated document fields remain UNVERIFIED.
|
|
941
|
+
"""
|
|
942
|
+
path = f"{_safe_home(home_id)}/documents/{_safe_id(document_id)}"
|
|
943
|
+
return _parse(DocumentDetailsResponse, await self.client.get(path))
|
|
944
|
+
|
|
945
|
+
@unverified
|
|
946
|
+
async def document_suggestions(
|
|
947
|
+
self, home_id: str, document_id: str
|
|
948
|
+
) -> DocumentSuggestionsResponse:
|
|
949
|
+
"""Existing suggestions for one document.
|
|
950
|
+
|
|
951
|
+
Reads existing suggestions, including sensitive text/citations. Doesn't
|
|
952
|
+
analyze the document, create work, or accept/dismiss suggestions.
|
|
953
|
+
"""
|
|
954
|
+
path = f"{_safe_home(home_id)}/documents/{_safe_id(document_id)}/suggestions"
|
|
955
|
+
return _parse(DocumentSuggestionsResponse, await self.client.get(path))
|
|
956
|
+
|
|
957
|
+
@unverified
|
|
958
|
+
async def document_structure_suggestions(
|
|
959
|
+
self, home_id: str, document_id: str
|
|
960
|
+
) -> DocumentStructureSuggestionsResponse:
|
|
961
|
+
"""Existing structure suggestions for one document.
|
|
962
|
+
|
|
963
|
+
Reads metadata and citations only, never responds or edits inventory.
|
|
964
|
+
"""
|
|
965
|
+
path = f"{_safe_home(home_id)}/documents/{_safe_id(document_id)}/structure_suggestions"
|
|
966
|
+
return _parse(DocumentStructureSuggestionsResponse, await self.client.get(path))
|
|
967
|
+
|
|
968
|
+
@unverified
|
|
969
|
+
async def request_purchases(self, home_id: str, request_id: str) -> ScopedPurchasesResponse:
|
|
970
|
+
"""Purchases scoped to one request. Provisional response model.
|
|
971
|
+
|
|
972
|
+
Scoped envelope and items UNVERIFIED. Doesn't require the home-only
|
|
973
|
+
non_purchase_invoices field, which the app model omits.
|
|
974
|
+
No approval, returns, invoice-detail or payment calls.
|
|
975
|
+
"""
|
|
976
|
+
path = f"{_safe_home(home_id)}/requests/{_safe_id(request_id)}/purchases"
|
|
977
|
+
return _parse(ScopedPurchasesResponse, await self.client.get(path))
|
|
978
|
+
|
|
979
|
+
@unverified
|
|
980
|
+
async def task_details(self, home_id: str, task_id: str) -> TaskDetails:
|
|
981
|
+
"""One task with its updates and attachments.
|
|
982
|
+
|
|
983
|
+
Provisional bare object. Reads updates/attachments without fetching URLs.
|
|
984
|
+
No scheduling, edit, delete or concierge-transfer action.
|
|
985
|
+
"""
|
|
986
|
+
path = f"{_safe_home(home_id)}/tasks/{_safe_id(task_id)}"
|
|
987
|
+
return _parse(TaskDetails, await self.client.get(path))
|
|
988
|
+
|
|
989
|
+
@unverified
|
|
990
|
+
async def vendor_visit_details(self, home_id: str, visit_id: str) -> VendorVisitDetails:
|
|
991
|
+
"""One vendor visit with request, vendors and previews.
|
|
992
|
+
|
|
993
|
+
Provisional bare object. Task-item tags and feedback stay opaque JSON.
|
|
994
|
+
The legacy VendorVisit model isn't changed. No visit mutation.
|
|
995
|
+
"""
|
|
996
|
+
path = f"{_safe_home(home_id)}/vendor_visits/{_safe_id(visit_id)}"
|
|
997
|
+
return _parse(VendorVisitDetails, await self.client.get(path))
|
|
998
|
+
|
|
999
|
+
@unverified
|
|
1000
|
+
async def vendor_visit_autopilot_plan(
|
|
1001
|
+
self, home_id: str, visit_id: str, plan_id: str
|
|
1002
|
+
) -> GroupedTaskDetails:
|
|
1003
|
+
"""Grouped Autopilot tasks for one visit and plan.
|
|
1004
|
+
|
|
1005
|
+
Provisional bare grouped tasks, not the home-wide Autopilot catalog model.
|
|
1006
|
+
"""
|
|
1007
|
+
path = (
|
|
1008
|
+
f"{_safe_home(home_id)}/vendor_visits/{_safe_id(visit_id)}"
|
|
1009
|
+
f"/autopilot_plans/{_safe_id(plan_id)}"
|
|
1010
|
+
)
|
|
1011
|
+
return _parse(GroupedTaskDetails, await self.client.get(path))
|
|
1012
|
+
|
|
1013
|
+
@unverified
|
|
1014
|
+
async def vendor_visit_calls(self, home_id: str, visit_id: str) -> VisitCallsResponse:
|
|
1015
|
+
"""Call history metadata for one vendor visit.
|
|
1016
|
+
|
|
1017
|
+
Call history metadata only, not VisitCallGrant. Never starts, answers,
|
|
1018
|
+
declines, ends or joins a call. Personal participants/times are returned.
|
|
1019
|
+
"""
|
|
1020
|
+
path = f"{_safe_home(home_id)}/vendor_visits/{_safe_id(visit_id)}/calls"
|
|
1021
|
+
return _parse(VisitCallsResponse, await self.client.get(path))
|
|
1022
|
+
|
|
1023
|
+
@unverified
|
|
1024
|
+
async def vendor_visit_conversation(self, home_id: str, visit_id: str) -> VisitConversation:
|
|
1025
|
+
"""Conversation metadata and messages for one vendor visit.
|
|
1026
|
+
|
|
1027
|
+
Returns sensitive messages and participant metadata. Part tags remain
|
|
1028
|
+
opaque JSON. No send, read-marking, call-control or vendor stream request.
|
|
1029
|
+
"""
|
|
1030
|
+
path = f"{_safe_home(home_id)}/vendor_visits/{_safe_id(visit_id)}/conversation"
|
|
1031
|
+
return _parse(VisitConversation, await self.client.get(path))
|
|
1032
|
+
|
|
1033
|
+
@unverified
|
|
1034
|
+
async def vendor_visit_purchases(self, home_id: str, visit_id: str) -> ScopedPurchasesResponse:
|
|
1035
|
+
"""Purchases scoped to one vendor visit. Provisional response model.
|
|
1036
|
+
|
|
1037
|
+
Same scoped envelope as request_purchases. No financial actions.
|
|
1038
|
+
"""
|
|
1039
|
+
path = f"{_safe_home(home_id)}/vendor_visits/{_safe_id(visit_id)}/purchases"
|
|
1040
|
+
return _parse(ScopedPurchasesResponse, await self.client.get(path))
|
|
1041
|
+
|
|
1042
|
+
@unverified_stream
|
|
1043
|
+
async def casa_ai_events(
|
|
1044
|
+
self, home_id: str, after: str | None = None
|
|
1045
|
+
) -> AsyncGenerator[CasaAIEvent, None]:
|
|
1046
|
+
"""Opt-in GET SSE stream. No send, bootstrap, or automatic reconnection.
|
|
1047
|
+
|
|
1048
|
+
Calling this method opts into the stream contract inferred from the app.
|
|
1049
|
+
A live connection returned HTTP 200, text/event-stream, and stream.hello.
|
|
1050
|
+
Other event contracts are inferred and covered by synthetic tests only.
|
|
1051
|
+
Resume forwards the same opaque cursor as after and Last-Event-ID.
|
|
1052
|
+
thread.update payloads are JsonValue, with no guessed change variants.
|
|
1053
|
+
resync_required closes the response, yields a typed signal, and finishes.
|
|
1054
|
+
Unknown events retain bounded text. Malformed update JSON raises a
|
|
1055
|
+
sanitized CasaResponseError. EOF discards incomplete framing.
|
|
1056
|
+
|
|
1057
|
+
Use contextlib.aclosing when breaking iteration early. Cancellation or
|
|
1058
|
+
aclose closes this response, never the caller's shared client session.
|
|
1059
|
+
"""
|
|
1060
|
+
path = f"{_safe_home(home_id)}/casa_ai/events"
|
|
1061
|
+
_validate_cursor(after)
|
|
1062
|
+
async with aclosing(_stream_events(self.client, path, after)) as events:
|
|
1063
|
+
async for event in events:
|
|
1064
|
+
yield event
|
|
1065
|
+
|
|
1066
|
+
@unverified
|
|
1067
|
+
async def request_details(self, home_id: str, request_id: str) -> RequestDetails:
|
|
1068
|
+
"""A single request thread: messages, visits, invoices, purchases."""
|
|
1069
|
+
data = await self.client.get(f"{_home(home_id)}/requests/{request_id}")
|
|
1070
|
+
return _parse(RequestDetails, data)
|
|
1071
|
+
|
|
1072
|
+
# UNVERIFIED writes ------------------------------------------------------
|
|
1073
|
+
|
|
1074
|
+
@unverified
|
|
1075
|
+
async def create_todo(self, home_id: str, body: CreateTodoBody) -> TodoDetail:
|
|
1076
|
+
"""Legacy convenience return, accepting bare TodoDetail or a todo envelope.
|
|
1077
|
+
|
|
1078
|
+
This intentionally discards needs_support_confirmation if supplied and
|
|
1079
|
+
doesn't require it. Use create_todo_result to retain and strictly validate
|
|
1080
|
+
the full envelope. Delegating to that method would
|
|
1081
|
+
break legacy responses without the flag. No automatic confirmation.
|
|
1082
|
+
"""
|
|
1083
|
+
path = f"{_safe_home(home_id)}/todos"
|
|
1084
|
+
data = await self.client.post(path, json=_write_payload(body, StrictCreateTodoBody))
|
|
1085
|
+
if isinstance(data, dict) and isinstance(data.get("todo"), dict):
|
|
1086
|
+
data = data["todo"]
|
|
1087
|
+
return _parse(TodoDetail, data)
|
|
1088
|
+
|
|
1089
|
+
@unverified
|
|
1090
|
+
async def create_todo_result(self, home_id: str, body: CreateTodoBody) -> CreateTodoResponse:
|
|
1091
|
+
"""Create a to-do and retain the required support-confirmation flag.
|
|
1092
|
+
|
|
1093
|
+
Wire format inferred statically from the app (LIKELY). Backend behavior UNVERIFIED.
|
|
1094
|
+
Requires todo and a strict Boolean needs_support_confirmation, with no
|
|
1095
|
+
missing/null default or bare-response fallback. Reuses TodoDetail.
|
|
1096
|
+
Omit absent attachments, preserve empty content and upload-ID arrays.
|
|
1097
|
+
Caller chooses execution. No upload, support confirmation or workflow retry.
|
|
1098
|
+
"""
|
|
1099
|
+
path = f"{_safe_home(home_id)}/todos"
|
|
1100
|
+
data = await self.client.post(path, json=_write_payload(body, StrictCreateTodoBody))
|
|
1101
|
+
return _parse(CreateTodoResponse, data)
|
|
1102
|
+
|
|
1103
|
+
@unverified
|
|
1104
|
+
async def send_request_message(
|
|
1105
|
+
self, home_id: str, request_id: str, body: SendRequestMessageRequest
|
|
1106
|
+
) -> SendRequestMessageResponse:
|
|
1107
|
+
"""Send one request-thread message only when explicitly called.
|
|
1108
|
+
|
|
1109
|
+
Wire format inferred statically from the app (LIKELY). Backend behavior UNVERIFIED.
|
|
1110
|
+
Optional content, attachments.upload_ids and member role omit None.
|
|
1111
|
+
Explicit empty strings and lists are preserved. Privileged roles rejected.
|
|
1112
|
+
Required message envelope reuses RequestMessage, not CasaAIMessage.
|
|
1113
|
+
No upload chain, CasaAI send, MCP/HA action or workflow retry is added.
|
|
1114
|
+
"""
|
|
1115
|
+
path = f"{_safe_home(home_id)}/requests/{_safe_id(request_id)}/messages"
|
|
1116
|
+
data = await self.client.post(path, json=_write_payload(body, SendRequestMessageRequest))
|
|
1117
|
+
return _parse(SendRequestMessageResponse, data)
|
|
1118
|
+
|
|
1119
|
+
@unverified
|
|
1120
|
+
async def update_entity_properties(
|
|
1121
|
+
self, home_id: str, entity_id: str, body: UpdateEntityPropertiesRequest
|
|
1122
|
+
) -> BasicMessageResponse:
|
|
1123
|
+
"""PATCH inventory properties with String-valued property fields.
|
|
1124
|
+
|
|
1125
|
+
Wire format inferred statically from the app (LIKELY). Backend behavior UNVERIFIED.
|
|
1126
|
+
Props is required, even when empty. Optional fields omit None recursively.
|
|
1127
|
+
Empty strings/lists and false selection flags are preserved, not interpreted
|
|
1128
|
+
as clears or replacements. Merge semantics and retry safety are unknown.
|
|
1129
|
+
Returns a status string envelope, not an entity. No automatic refresh.
|
|
1130
|
+
"""
|
|
1131
|
+
path = f"{_safe_home(home_id, member=True)}/entity/{_safe_id(entity_id)}/props"
|
|
1132
|
+
data = await self.client.patch(
|
|
1133
|
+
path, json=_write_payload(body, UpdateEntityPropertiesRequest)
|
|
1134
|
+
)
|
|
1135
|
+
return _parse(BasicMessageResponse, data)
|