python-casahome 0.2.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. python_casahome-0.3.1/PKG-INFO +272 -0
  2. python_casahome-0.3.1/README.md +252 -0
  3. {python_casahome-0.2.0 → python_casahome-0.3.1}/pyproject.toml +7 -1
  4. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/__init__.py +3 -1
  5. python_casahome-0.3.1/src/casahome/_common.py +96 -0
  6. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/api.py +134 -125
  7. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/client.py +41 -9
  8. python_casahome-0.3.1/src/casahome/mail_workflows.py +270 -0
  9. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/__init__.py +209 -1
  10. python_casahome-0.3.1/src/casahome/models/ai.py +139 -0
  11. python_casahome-0.3.1/src/casahome/models/documents.py +208 -0
  12. python_casahome-0.3.1/src/casahome/models/entity_publish.py +125 -0
  13. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/home.py +2 -2
  14. python_casahome-0.3.1/src/casahome/models/mail_workflows.py +206 -0
  15. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/mailbox.py +6 -0
  16. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/membership.py +16 -3
  17. python_casahome-0.3.1/src/casahome/models/read_todos.py +48 -0
  18. python_casahome-0.3.1/src/casahome/models/recovered_contracts.py +131 -0
  19. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/requests.py +17 -2
  20. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/todos.py +5 -2
  21. python_casahome-0.3.1/src/casahome/models/verified_writes.py +163 -0
  22. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/visits.py +10 -2
  23. python_casahome-0.3.1/src/casahome/models/workflows.py +127 -0
  24. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/write_contracts.py +9 -6
  25. python_casahome-0.3.1/src/casahome/recovered.py +133 -0
  26. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/streaming.py +21 -5
  27. python_casahome-0.3.1/src/casahome/uploads.py +98 -0
  28. python_casahome-0.3.1/src/casahome/workflows.py +246 -0
  29. python_casahome-0.3.1/src/casahome/writes.py +715 -0
  30. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/test_api.py +37 -9
  31. python_casahome-0.3.1/tests/test_catalog_and_room_writes.py +314 -0
  32. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/test_client.py +15 -2
  33. python_casahome-0.3.1/tests/test_document_structure_response.py +230 -0
  34. python_casahome-0.3.1/tests/test_mail_workflows.py +1075 -0
  35. python_casahome-0.3.1/tests/test_observed_contracts.py +456 -0
  36. python_casahome-0.3.1/tests/test_recovered_contracts.py +352 -0
  37. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/test_static_reads.py +73 -11
  38. python_casahome-0.3.1/tests/test_upload_security.py +429 -0
  39. python_casahome-0.3.1/tests/test_verified_writes.py +1166 -0
  40. python_casahome-0.3.1/tests/test_workflows.py +952 -0
  41. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/test_write_contracts.py +5 -4
  42. {python_casahome-0.2.0 → python_casahome-0.3.1}/uv.lock +1 -1
  43. python_casahome-0.2.0/PKG-INFO +0 -272
  44. python_casahome-0.2.0/README.md +0 -256
  45. python_casahome-0.2.0/src/casahome/models/ai.py +0 -58
  46. python_casahome-0.2.0/src/casahome/models/documents.py +0 -127
  47. python_casahome-0.2.0/src/casahome/models/read_todos.py +0 -22
  48. {python_casahome-0.2.0 → python_casahome-0.3.1}/.gitignore +0 -0
  49. {python_casahome-0.2.0 → python_casahome-0.3.1}/.python-version +0 -0
  50. {python_casahome-0.2.0 → python_casahome-0.3.1}/LICENSE +0 -0
  51. {python_casahome-0.2.0 → python_casahome-0.3.1}/examples/bootstrap.py +0 -0
  52. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/_base_http.py +0 -0
  53. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/auth.py +0 -0
  54. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/const.py +0 -0
  55. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/exceptions.py +0 -0
  56. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/_base.py +0 -0
  57. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/assets.py +0 -0
  58. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/availability.py +0 -0
  59. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/bootstrap.py +0 -0
  60. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/calendar.py +0 -0
  61. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/catalogs.py +0 -0
  62. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/credits.py +0 -0
  63. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/enums.py +0 -0
  64. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/feed.py +0 -0
  65. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/help_center.py +0 -0
  66. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/images.py +0 -0
  67. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/maintenance.py +0 -0
  68. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/paint.py +0 -0
  69. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/preferences.py +0 -0
  70. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/purchases.py +0 -0
  71. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/referral.py +0 -0
  72. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/tasks.py +0 -0
  73. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/models/taxes.py +0 -0
  74. {python_casahome-0.2.0 → python_casahome-0.3.1}/src/casahome/py.typed +0 -0
  75. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/__init__.py +0 -0
  76. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/conftest.py +0 -0
  77. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/fixtures/bootstrap.json +0 -0
  78. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/fixtures/error.json +0 -0
  79. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/fixtures/token_refresh.json +0 -0
  80. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/test_auth.py +0 -0
  81. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/test_inventory_models.py +0 -0
  82. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/test_models.py +0 -0
  83. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/test_read_coverage.py +0 -0
  84. {python_casahome-0.2.0 → python_casahome-0.3.1}/tests/test_streaming.py +0 -0
@@ -0,0 +1,272 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-casahome
3
+ Version: 0.3.1
4
+ Summary: Unofficial async client for the Casa (getcasa.com) member API
5
+ Project-URL: Repository, https://github.com/borski/python-casahome
6
+ Project-URL: Homepage, https://github.com/borski/python-casahome
7
+ Project-URL: Issues, https://github.com/borski/python-casahome/issues
8
+ Author: Borski
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: aiohttp,api,async,casa,home
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: aiohttp>=3.9
18
+ Requires-Dist: pydantic>=2.5
19
+ Description-Content-Type: text/markdown
20
+
21
+ # python-casahome
22
+
23
+ An async Python client for the [Casa](https://getcasa.com) member API. This is
24
+ an unofficial library, unaffiliated with Casa. It uses a private API that Casa
25
+ can change without notice.
26
+
27
+ The current source version is 0.3.1. The package is alpha software. Some calls
28
+ were observed in app traffic, while others were reconstructed from the app and
29
+ have never been checked against Casa's backend. The SDK doesn't cover the full
30
+ app or backend API. See the method docstrings for the evidence and limits of a
31
+ particular call.
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ pip install python-casahome
37
+ ```
38
+
39
+ Python 3.11 or newer is required. To work from a source checkout, use
40
+ `uv sync --locked`.
41
+
42
+ ## Authentication
43
+
44
+ Casa uses Firebase phone authentication. This library starts with a Firebase
45
+ refresh token and exchanges it for short lived ID tokens. It doesn't implement
46
+ phone or SMS sign in. There is no official Casa token export. If you inspect
47
+ your own app session to obtain a token, keep it out of logs and source control.
48
+ Treat it like a password.
49
+
50
+ Firebase can rotate refresh tokens. For a long lived client, construct
51
+ `FirebaseAuth` with `on_refresh_token_rotated` and store the new token when the
52
+ callback runs. The callback can be synchronous or asynchronous. Callback
53
+ failures are logged and don't fail the request, so verify that storage
54
+ succeeded.
55
+
56
+ ## Quick start
57
+
58
+ ```python
59
+ import asyncio
60
+
61
+ import aiohttp
62
+
63
+ from casahome import CasaApi, CasaClient, FirebaseAuth
64
+
65
+
66
+ async def main() -> None:
67
+ async with aiohttp.ClientSession() as session:
68
+ auth = FirebaseAuth(
69
+ session,
70
+ "YOUR_REFRESH_TOKEN",
71
+ on_refresh_token_rotated=save_token,
72
+ )
73
+ api = CasaApi(
74
+ CasaClient(session, auth),
75
+ device_id="YOUR_PERSISTED_DEVICE_ID",
76
+ )
77
+ me = await api.bootstrap()
78
+ for home in me.homes:
79
+ print(home.status)
80
+ print(await api.weather(home.id))
81
+
82
+
83
+ asyncio.run(main())
84
+ ```
85
+
86
+ Define `save_token` to write to your secret store and fail if the write doesn't
87
+ succeed. Replace `YOUR_PERSISTED_DEVICE_ID` with a stable value that you retain
88
+ between runs.
89
+
90
+ `bootstrap` registers a device. Persist `api.device_id` and pass it as
91
+ `device_id` on later starts. Otherwise each start registers a new device.
92
+ `CasaApi.create` is a convenience for short scripts, but it doesn't install a
93
+ token rotation callback. Use the explicit setup above when the token must
94
+ survive restarts.
95
+
96
+ If you own an `aiohttp.ClientSession`, you can reuse it:
97
+
98
+ ```python
99
+ from casahome import CasaApi, CasaClient, FirebaseAuth
100
+
101
+ auth = FirebaseAuth(session, refresh_token, on_refresh_token_rotated=save_token)
102
+ api = CasaApi(CasaClient(session, auth), device_id=stored_device_id)
103
+ ```
104
+
105
+ The caller owns that session and must close it. `CasaApi.create` owns and closes
106
+ its session instead. A runnable bootstrap example is in
107
+ `examples/bootstrap.py`. It doesn't persist a rotated token. Its `--show-token`
108
+ option prints the token to the terminal, so don't use it in captured logs. Set
109
+ `CASA_REFRESH_TOKEN` in your shell to run it:
110
+
111
+ ```sh
112
+ uv run examples/bootstrap.py
113
+ ```
114
+
115
+ ## API coverage and evidence
116
+
117
+ Read methods include `home_details`, `inventory_item`, `weather`,
118
+ `credits_summary`, `requests_tab`, `request_details`, `todo_details`,
119
+ `documents`, `document_suggestions`, `autopilot_plans`, `mail_items`,
120
+ `packages` and `calendar`. Some routes were observed with populated items.
121
+ Others were observed only with empty lists, or have untested query variants.
122
+ For example, `document_suggestions` was observed with `view="all"` but other
123
+ filter values aren't confirmed. Several visit and task detail reads remain
124
+ unverified against the backend.
125
+
126
+ Observed write paths include `move_entity_to_room`, `rename_room`,
127
+ `send_ai_message`, `send_request_message`, `create_todo_result`, `delete_todo`,
128
+ `answer_todo_question`, `update_autopilot_plan`,
129
+ `update_notification_settings`, `update_access_preferences` and
130
+ `upload_document`. An observed path doesn't verify every optional input. For
131
+ example, `save_and_publish_entity` was observed with `model.name` and empty
132
+ `props`. The shared compiled codec supports ID and name selections, but
133
+ model ID traffic hasn't been verified. The SDK doesn't expose clearing make or
134
+ model. The app's compiled clear case encodes JSON null, but backend clearing
135
+ behavior is unknown.
136
+
137
+ Methods marked `UNVERIFIED` are based on static app reconstruction and local
138
+ synthetic tests, not a successful backend call. They include
139
+ `update_document`, `update_schedule_preferences`, `update_availability_slots`,
140
+ `cancel_vendor_visit`, `update_entity_properties` and the newer
141
+ `update_request_detail`, `dismiss_document_structure_suggestion`,
142
+ `update_task`, `delete_task`, `transfer_task_to_concierge`, `delete_document`,
143
+ `update_room_properties`, `update_story`, `retailer_discounts`,
144
+ `create_support_request` and `mark_vendor_visit_conversation_read`.
145
+ `update_document` is a PATCH method, with unverified date and clearing effects.
146
+ `create_support_request` may contact a person. A message, `ok` or `deleted`
147
+ field in a response must be read as returned. HTTP success alone doesn't
148
+ establish the action's business effect.
149
+
150
+ Use `casahome.is_unverified(api.update_document)` to check a method's marker.
151
+ Read its docstring for finer distinctions. An unmarked method can still have
152
+ untested variants. Response models live in `casahome.models`. They keep unknown
153
+ fields in `model_extra` and distinguish response envelopes from their nested
154
+ items. A schema mismatch raises `CasaResponseError`.
155
+
156
+ This SDK is separate from any MCP or Home Assistant adapter. A library method
157
+ doesn't imply that an adapter exposes it. Adapters choose their own limited
158
+ tools and action gates.
159
+
160
+ ## Write behavior
161
+
162
+ Each ordinary Casa mutation is sent once. The client force refreshes the ID
163
+ token before sending it and doesn't replay the mutation after a 401. Normal GET
164
+ requests retry once after a 401. `bootstrap` is a deliberate exception: it is a
165
+ POST that registers a device and keeps the one time 401 retry. `upload_document`
166
+ is a multi step workflow with a policy GET and three write steps. Each step is
167
+ attempted once, and its Cloud Storage PUT uses no Casa token. A response parsing
168
+ failure, timeout or connection error after a mutation can leave its outcome
169
+ unknown. Check the resulting state before deciding whether to send another
170
+ request. The `Idempotency-Key` on `send_ai_message` helps trace a send, but
171
+ server deduplication hasn't been verified.
172
+
173
+ Write bodies use strict validation. Invalid or unknown input fields raise
174
+ `CasaRequestError` before the write. Optional `None` values generally omit a
175
+ field, while empty strings and lists are sent as supplied. Some contracts send
176
+ explicit null, including the access preference fields. In
177
+ `UpdateDocumentBody`, omitting `document_date` leaves the key out, and
178
+ explicit `document_date=None` sends null. Don't treat those forms as
179
+ interchangeable. The backend effect of document date clearing is unverified.
180
+
181
+ Path identifiers must be nonempty safe segments. They aren't normalized to
182
+ UUIDs. Responses can contain home addresses, coordinates, phone numbers, email
183
+ addresses, account identifiers, payment and membership metadata, private
184
+ messages, documents, purchase details and short lived signed URLs. Avoid
185
+ logging complete models, request paths, event payloads or read exceptions.
186
+ Response URLs aren't followed unless a documented workflow requires it.
187
+ `upload_document` follows only its validated Cloud Storage session URL.
188
+
189
+ ## Document upload
190
+
191
+ ```python
192
+ from pathlib import Path
193
+
194
+ result = await api.upload_document(home_id, Path("inspection.pdf"), "inspection.pdf")
195
+ print(result.document.id, result.already_exists)
196
+ ```
197
+
198
+ `upload_document` first reads `document_types` to check MIME type and file
199
+ size. It reserves a session, uploads the bytes to the signed Cloud Storage URL
200
+ Casa returns, then registers the document. `find_todos=True` asks Casa to look
201
+ for suggested tasks. Each step is attempted once. An error in a later step
202
+ doesn't undo an earlier one.
203
+
204
+ `source` can be bytes or a local path. The MIME type is guessed from `filename`
205
+ when omitted and must appear in Casa's allowed MIME types. Local paths must
206
+ open as nonempty regular files. Symlinks to regular files are allowed. The read
207
+ is bounded by Casa's reported file size limit, plus one byte for detecting an
208
+ oversized file. The entire file is held in memory for one storage request. The
209
+ client timeout bounds the file read and storage request, but cancellation can't
210
+ stop a filesystem call already running in a worker thread.
211
+
212
+ The filename must contain 1 to 1,024 characters, include a nonwhitespace
213
+ character and contain no controls. The signed URL can contain at most 8,192
214
+ characters, must contain no embedded credentials and must use HTTPS on
215
+ `storage.googleapis.com` at port 443. A separate storage session sends no Casa
216
+ credentials, caller headers, cookies, environment proxy settings or netrc
217
+ configuration. It doesn't follow redirects and accepts only HTTP 200 or 201.
218
+ Keep signed session URLs private. See the `upload_document` and
219
+ `upload_document_content` docstrings for the complete contract.
220
+
221
+ ## Casa AI events
222
+
223
+ `casa_ai_events` is an opt in server sent event stream. A live connection and
224
+ some update traffic were observed, but the full event and reconnect behavior
225
+ isn't verified. The generator doesn't reconnect automatically. Close it when
226
+ you stop reading early:
227
+
228
+ ```python
229
+ from contextlib import aclosing
230
+
231
+ from casahome.models import CasaAIMessageUpsertChange
232
+ from casahome.streaming import CasaAIResyncRequiredEvent, CasaAIThreadUpdateEvent
233
+
234
+ async with aclosing(api.casa_ai_events(home_id, after=saved_cursor)) as events:
235
+ async for event in events:
236
+ if event.id is not None:
237
+ saved_cursor = event.id
238
+ if isinstance(event, CasaAIResyncRequiredEvent):
239
+ break # Reload state before subscribing again.
240
+ if isinstance(event, CasaAIThreadUpdateEvent):
241
+ for change in event.update().changes:
242
+ if isinstance(change, CasaAIMessageUpsertChange):
243
+ print(change.message.sender_role, change.message.content)
244
+ ```
245
+
246
+ An `after` cursor is opaque. Store it unchanged. `event.update()` validates the
247
+ decoded thread update. `event.payload` retains the decoded JSON value, while
248
+ `event.data` retains the original event text. Other change types remain
249
+ available as `CasaAIUnknownChange`. The stream limits lines to 64 KiB, events to
250
+ 256 KiB and cursors to 2,048 characters without controls. The cursor is sent as
251
+ both `after` and `Last-Event-ID`. Redirects are disabled. A 60 second socket
252
+ read gap raises a connection error. HTTP 204 ends the generator, and incomplete
253
+ event framing at EOF is discarded. `resync_required` is yielded once before
254
+ the response closes. The GET can refresh an expired token and retry once on
255
+ 401. That retry behavior doesn't apply to ordinary mutations.
256
+
257
+ ## Development
258
+
259
+ ```sh
260
+ uv sync
261
+ uv run --locked pytest -q
262
+ uv run --locked ruff check .
263
+ uv run --locked ruff format --check .
264
+ uv run --locked mypy src tests
265
+ ```
266
+
267
+ Tests use synthetic data and a loopback server. They don't call Casa or need
268
+ credentials.
269
+
270
+ ## License
271
+
272
+ MIT. See `LICENSE`.
@@ -0,0 +1,252 @@
1
+ # python-casahome
2
+
3
+ An async Python client for the [Casa](https://getcasa.com) member API. This is
4
+ an unofficial library, unaffiliated with Casa. It uses a private API that Casa
5
+ can change without notice.
6
+
7
+ The current source version is 0.3.1. The package is alpha software. Some calls
8
+ were observed in app traffic, while others were reconstructed from the app and
9
+ have never been checked against Casa's backend. The SDK doesn't cover the full
10
+ app or backend API. See the method docstrings for the evidence and limits of a
11
+ particular call.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pip install python-casahome
17
+ ```
18
+
19
+ Python 3.11 or newer is required. To work from a source checkout, use
20
+ `uv sync --locked`.
21
+
22
+ ## Authentication
23
+
24
+ Casa uses Firebase phone authentication. This library starts with a Firebase
25
+ refresh token and exchanges it for short lived ID tokens. It doesn't implement
26
+ phone or SMS sign in. There is no official Casa token export. If you inspect
27
+ your own app session to obtain a token, keep it out of logs and source control.
28
+ Treat it like a password.
29
+
30
+ Firebase can rotate refresh tokens. For a long lived client, construct
31
+ `FirebaseAuth` with `on_refresh_token_rotated` and store the new token when the
32
+ callback runs. The callback can be synchronous or asynchronous. Callback
33
+ failures are logged and don't fail the request, so verify that storage
34
+ succeeded.
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ import asyncio
40
+
41
+ import aiohttp
42
+
43
+ from casahome import CasaApi, CasaClient, FirebaseAuth
44
+
45
+
46
+ async def main() -> None:
47
+ async with aiohttp.ClientSession() as session:
48
+ auth = FirebaseAuth(
49
+ session,
50
+ "YOUR_REFRESH_TOKEN",
51
+ on_refresh_token_rotated=save_token,
52
+ )
53
+ api = CasaApi(
54
+ CasaClient(session, auth),
55
+ device_id="YOUR_PERSISTED_DEVICE_ID",
56
+ )
57
+ me = await api.bootstrap()
58
+ for home in me.homes:
59
+ print(home.status)
60
+ print(await api.weather(home.id))
61
+
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ Define `save_token` to write to your secret store and fail if the write doesn't
67
+ succeed. Replace `YOUR_PERSISTED_DEVICE_ID` with a stable value that you retain
68
+ between runs.
69
+
70
+ `bootstrap` registers a device. Persist `api.device_id` and pass it as
71
+ `device_id` on later starts. Otherwise each start registers a new device.
72
+ `CasaApi.create` is a convenience for short scripts, but it doesn't install a
73
+ token rotation callback. Use the explicit setup above when the token must
74
+ survive restarts.
75
+
76
+ If you own an `aiohttp.ClientSession`, you can reuse it:
77
+
78
+ ```python
79
+ from casahome import CasaApi, CasaClient, FirebaseAuth
80
+
81
+ auth = FirebaseAuth(session, refresh_token, on_refresh_token_rotated=save_token)
82
+ api = CasaApi(CasaClient(session, auth), device_id=stored_device_id)
83
+ ```
84
+
85
+ The caller owns that session and must close it. `CasaApi.create` owns and closes
86
+ its session instead. A runnable bootstrap example is in
87
+ `examples/bootstrap.py`. It doesn't persist a rotated token. Its `--show-token`
88
+ option prints the token to the terminal, so don't use it in captured logs. Set
89
+ `CASA_REFRESH_TOKEN` in your shell to run it:
90
+
91
+ ```sh
92
+ uv run examples/bootstrap.py
93
+ ```
94
+
95
+ ## API coverage and evidence
96
+
97
+ Read methods include `home_details`, `inventory_item`, `weather`,
98
+ `credits_summary`, `requests_tab`, `request_details`, `todo_details`,
99
+ `documents`, `document_suggestions`, `autopilot_plans`, `mail_items`,
100
+ `packages` and `calendar`. Some routes were observed with populated items.
101
+ Others were observed only with empty lists, or have untested query variants.
102
+ For example, `document_suggestions` was observed with `view="all"` but other
103
+ filter values aren't confirmed. Several visit and task detail reads remain
104
+ unverified against the backend.
105
+
106
+ Observed write paths include `move_entity_to_room`, `rename_room`,
107
+ `send_ai_message`, `send_request_message`, `create_todo_result`, `delete_todo`,
108
+ `answer_todo_question`, `update_autopilot_plan`,
109
+ `update_notification_settings`, `update_access_preferences` and
110
+ `upload_document`. An observed path doesn't verify every optional input. For
111
+ example, `save_and_publish_entity` was observed with `model.name` and empty
112
+ `props`. The shared compiled codec supports ID and name selections, but
113
+ model ID traffic hasn't been verified. The SDK doesn't expose clearing make or
114
+ model. The app's compiled clear case encodes JSON null, but backend clearing
115
+ behavior is unknown.
116
+
117
+ Methods marked `UNVERIFIED` are based on static app reconstruction and local
118
+ synthetic tests, not a successful backend call. They include
119
+ `update_document`, `update_schedule_preferences`, `update_availability_slots`,
120
+ `cancel_vendor_visit`, `update_entity_properties` and the newer
121
+ `update_request_detail`, `dismiss_document_structure_suggestion`,
122
+ `update_task`, `delete_task`, `transfer_task_to_concierge`, `delete_document`,
123
+ `update_room_properties`, `update_story`, `retailer_discounts`,
124
+ `create_support_request` and `mark_vendor_visit_conversation_read`.
125
+ `update_document` is a PATCH method, with unverified date and clearing effects.
126
+ `create_support_request` may contact a person. A message, `ok` or `deleted`
127
+ field in a response must be read as returned. HTTP success alone doesn't
128
+ establish the action's business effect.
129
+
130
+ Use `casahome.is_unverified(api.update_document)` to check a method's marker.
131
+ Read its docstring for finer distinctions. An unmarked method can still have
132
+ untested variants. Response models live in `casahome.models`. They keep unknown
133
+ fields in `model_extra` and distinguish response envelopes from their nested
134
+ items. A schema mismatch raises `CasaResponseError`.
135
+
136
+ This SDK is separate from any MCP or Home Assistant adapter. A library method
137
+ doesn't imply that an adapter exposes it. Adapters choose their own limited
138
+ tools and action gates.
139
+
140
+ ## Write behavior
141
+
142
+ Each ordinary Casa mutation is sent once. The client force refreshes the ID
143
+ token before sending it and doesn't replay the mutation after a 401. Normal GET
144
+ requests retry once after a 401. `bootstrap` is a deliberate exception: it is a
145
+ POST that registers a device and keeps the one time 401 retry. `upload_document`
146
+ is a multi step workflow with a policy GET and three write steps. Each step is
147
+ attempted once, and its Cloud Storage PUT uses no Casa token. A response parsing
148
+ failure, timeout or connection error after a mutation can leave its outcome
149
+ unknown. Check the resulting state before deciding whether to send another
150
+ request. The `Idempotency-Key` on `send_ai_message` helps trace a send, but
151
+ server deduplication hasn't been verified.
152
+
153
+ Write bodies use strict validation. Invalid or unknown input fields raise
154
+ `CasaRequestError` before the write. Optional `None` values generally omit a
155
+ field, while empty strings and lists are sent as supplied. Some contracts send
156
+ explicit null, including the access preference fields. In
157
+ `UpdateDocumentBody`, omitting `document_date` leaves the key out, and
158
+ explicit `document_date=None` sends null. Don't treat those forms as
159
+ interchangeable. The backend effect of document date clearing is unverified.
160
+
161
+ Path identifiers must be nonempty safe segments. They aren't normalized to
162
+ UUIDs. Responses can contain home addresses, coordinates, phone numbers, email
163
+ addresses, account identifiers, payment and membership metadata, private
164
+ messages, documents, purchase details and short lived signed URLs. Avoid
165
+ logging complete models, request paths, event payloads or read exceptions.
166
+ Response URLs aren't followed unless a documented workflow requires it.
167
+ `upload_document` follows only its validated Cloud Storage session URL.
168
+
169
+ ## Document upload
170
+
171
+ ```python
172
+ from pathlib import Path
173
+
174
+ result = await api.upload_document(home_id, Path("inspection.pdf"), "inspection.pdf")
175
+ print(result.document.id, result.already_exists)
176
+ ```
177
+
178
+ `upload_document` first reads `document_types` to check MIME type and file
179
+ size. It reserves a session, uploads the bytes to the signed Cloud Storage URL
180
+ Casa returns, then registers the document. `find_todos=True` asks Casa to look
181
+ for suggested tasks. Each step is attempted once. An error in a later step
182
+ doesn't undo an earlier one.
183
+
184
+ `source` can be bytes or a local path. The MIME type is guessed from `filename`
185
+ when omitted and must appear in Casa's allowed MIME types. Local paths must
186
+ open as nonempty regular files. Symlinks to regular files are allowed. The read
187
+ is bounded by Casa's reported file size limit, plus one byte for detecting an
188
+ oversized file. The entire file is held in memory for one storage request. The
189
+ client timeout bounds the file read and storage request, but cancellation can't
190
+ stop a filesystem call already running in a worker thread.
191
+
192
+ The filename must contain 1 to 1,024 characters, include a nonwhitespace
193
+ character and contain no controls. The signed URL can contain at most 8,192
194
+ characters, must contain no embedded credentials and must use HTTPS on
195
+ `storage.googleapis.com` at port 443. A separate storage session sends no Casa
196
+ credentials, caller headers, cookies, environment proxy settings or netrc
197
+ configuration. It doesn't follow redirects and accepts only HTTP 200 or 201.
198
+ Keep signed session URLs private. See the `upload_document` and
199
+ `upload_document_content` docstrings for the complete contract.
200
+
201
+ ## Casa AI events
202
+
203
+ `casa_ai_events` is an opt in server sent event stream. A live connection and
204
+ some update traffic were observed, but the full event and reconnect behavior
205
+ isn't verified. The generator doesn't reconnect automatically. Close it when
206
+ you stop reading early:
207
+
208
+ ```python
209
+ from contextlib import aclosing
210
+
211
+ from casahome.models import CasaAIMessageUpsertChange
212
+ from casahome.streaming import CasaAIResyncRequiredEvent, CasaAIThreadUpdateEvent
213
+
214
+ async with aclosing(api.casa_ai_events(home_id, after=saved_cursor)) as events:
215
+ async for event in events:
216
+ if event.id is not None:
217
+ saved_cursor = event.id
218
+ if isinstance(event, CasaAIResyncRequiredEvent):
219
+ break # Reload state before subscribing again.
220
+ if isinstance(event, CasaAIThreadUpdateEvent):
221
+ for change in event.update().changes:
222
+ if isinstance(change, CasaAIMessageUpsertChange):
223
+ print(change.message.sender_role, change.message.content)
224
+ ```
225
+
226
+ An `after` cursor is opaque. Store it unchanged. `event.update()` validates the
227
+ decoded thread update. `event.payload` retains the decoded JSON value, while
228
+ `event.data` retains the original event text. Other change types remain
229
+ available as `CasaAIUnknownChange`. The stream limits lines to 64 KiB, events to
230
+ 256 KiB and cursors to 2,048 characters without controls. The cursor is sent as
231
+ both `after` and `Last-Event-ID`. Redirects are disabled. A 60 second socket
232
+ read gap raises a connection error. HTTP 204 ends the generator, and incomplete
233
+ event framing at EOF is discarded. `resync_required` is yielded once before
234
+ the response closes. The GET can refresh an expired token and retry once on
235
+ 401. That retry behavior doesn't apply to ordinary mutations.
236
+
237
+ ## Development
238
+
239
+ ```sh
240
+ uv sync
241
+ uv run --locked pytest -q
242
+ uv run --locked ruff check .
243
+ uv run --locked ruff format --check .
244
+ uv run --locked mypy src tests
245
+ ```
246
+
247
+ Tests use synthetic data and a loopback server. They don't call Casa or need
248
+ credentials.
249
+
250
+ ## License
251
+
252
+ MIT. See `LICENSE`.
@@ -1,11 +1,12 @@
1
1
  [project]
2
2
  name = "python-casahome"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "Unofficial async client for the Casa (getcasa.com) member API"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  authors = [{ name = "Borski" }]
8
8
  requires-python = ">=3.11"
9
+ keywords = ["casa", "home", "api", "async", "aiohttp"]
9
10
  dependencies = [
10
11
  "aiohttp>=3.9",
11
12
  "pydantic>=2.5",
@@ -17,6 +18,11 @@ classifiers = [
17
18
  "Typing :: Typed",
18
19
  ]
19
20
 
21
+ [project.urls]
22
+ Repository = "https://github.com/borski/python-casahome"
23
+ Homepage = "https://github.com/borski/python-casahome"
24
+ Issues = "https://github.com/borski/python-casahome/issues"
25
+
20
26
  [dependency-groups]
21
27
  dev = [
22
28
  "mypy>=1.10",
@@ -11,8 +11,9 @@ from .exceptions import (
11
11
  CasaRequestError,
12
12
  CasaResponseError,
13
13
  )
14
+ from .writes import SentCasaAIMessage
14
15
 
15
- __version__ = "0.2.0"
16
+ __version__ = "0.3.1"
16
17
 
17
18
  __all__ = [
18
19
  "CasaApi",
@@ -24,6 +25,7 @@ __all__ = [
24
25
  "CasaRequestError",
25
26
  "CasaResponseError",
26
27
  "FirebaseAuth",
28
+ "SentCasaAIMessage",
27
29
  "TokenResponse",
28
30
  "__version__",
29
31
  "is_unverified",
@@ -0,0 +1,96 @@
1
+ """Helpers shared by the read and write API modules.
2
+
3
+ Kept separate from ``api`` so the write mixin can use them without a circular
4
+ import. Nothing here does I/O.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Awaitable, Callable
11
+ from typing import Any, TypeVar
12
+
13
+ from pydantic import BaseModel, ValidationError
14
+
15
+ from casahome.exceptions import CasaRequestError, CasaResponseError
16
+
17
+ F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
18
+ M = TypeVar("M", bound=BaseModel)
19
+
20
+ _UNVERIFIED_NOTE = (
21
+ "UNVERIFIED: path and HTTP method inferred statically from the iOS app (LIKELY). "
22
+ "Not yet observed live."
23
+ )
24
+
25
+
26
+ def _parse(model: type[M], data: Any) -> M:
27
+ """Validate a response, turning schema drift into a library error."""
28
+ try:
29
+ return model.model_validate(data)
30
+ except ValidationError as err:
31
+ message = f"Unexpected {model.__name__} response: {err.error_count()} validation error(s)"
32
+ # Raise outside the handler. Pydantic's exception can contain private field
33
+ # values, and even an implicit context would leak them in tracebacks.
34
+ raise CasaResponseError(message)
35
+
36
+
37
+ def unverified(func: F) -> F:
38
+ """Mark a method whose path or HTTP method hasn't been seen in real traffic."""
39
+ func.__casa_unverified__ = True # type: ignore[attr-defined]
40
+ func.__doc__ = f"{_UNVERIFIED_NOTE}\n\n{func.__doc__ or ''}".rstrip()
41
+ return func
42
+
43
+
44
+ def is_unverified(method: Callable[..., Any]) -> bool:
45
+ return bool(getattr(method, "__casa_unverified__", False))
46
+
47
+
48
+ def _home(home_id: str) -> str:
49
+ """Base for most per-home endpoints."""
50
+ return f"/protected/home/{home_id}"
51
+
52
+
53
+ def _member_home(home_id: str) -> str:
54
+ """Base for inventory, entity, room, and story endpoints only."""
55
+ return f"/protected/member/home/{home_id}"
56
+
57
+
58
+ def _safe_id(value: str | int) -> str:
59
+ """Validate new identifier inputs without assuming UUID or normalizing IDs."""
60
+ if isinstance(value, bool) or not isinstance(value, (str, int)):
61
+ raise ValueError("identifier must be a string or integer")
62
+ text = str(value)
63
+ if len(text) > 2048:
64
+ raise ValueError("identifier exceeds SDK limit of 2048 characters")
65
+ if text in {".", ".."} or not re.fullmatch(r"[A-Za-z0-9._~-]+", text):
66
+ raise ValueError("identifier must be a nonempty safe path segment")
67
+ return text
68
+
69
+
70
+ def _safe_home(home_id: str, *, member: bool = False) -> str:
71
+ home_id = _safe_id(home_id)
72
+ return _member_home(home_id) if member else _home(home_id)
73
+
74
+
75
+ def _write_id(value: str | int) -> str:
76
+ """``_safe_id`` for writes, raising the scrubbed ``CasaRequestError``.
77
+
78
+ ``_safe_id`` messages never include the rejected value. The original
79
+ exception is dropped so no traceback context carries it either.
80
+ """
81
+ try:
82
+ return _safe_id(value)
83
+ except ValueError as err:
84
+ message = str(err)
85
+ raise CasaRequestError(message)
86
+
87
+
88
+ def _write_home(home_id: str, *, member: bool = False) -> str:
89
+ home_id = _write_id(home_id)
90
+ return _member_home(home_id) if member else _home(home_id)
91
+
92
+
93
+ def _positive_int(value: int, name: str) -> str:
94
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
95
+ raise ValueError(f"{name} must be a positive integer")
96
+ return str(value)