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.
@@ -0,0 +1,158 @@
1
+ """Provisional visit and conversation reads, with no call grants or controls.
2
+
3
+ Fields inferred from the app. Envelopes haven't been observed live.
4
+ Task-item and conversation-part unions stay opaque JSON, not guessed tags.
5
+ The older requests.VendorVisit stays unchanged for compatibility.
6
+ """
7
+
8
+ from pydantic import JsonValue
9
+
10
+ from casahome.models._base import CasaModel
11
+ from casahome.models.images import MediaAttachment
12
+ from casahome.models.purchases import RequestPurchase
13
+ from casahome.models.requests import VendorVisitCalendarEvent
14
+
15
+
16
+ class MemberInvoicePreview(CasaModel):
17
+ id: str
18
+ vendor_name: str
19
+ amount_display: str
20
+ user_collection_status: str
21
+ status: str
22
+
23
+
24
+ class VendorVisitMessagePreview(CasaModel):
25
+ sender_name: str
26
+ sent_at: int
27
+ message: str
28
+
29
+
30
+ class VendorVisitRequestSummary(CasaModel):
31
+ id: str
32
+ type: str
33
+ status: str
34
+ title: str
35
+ emoji: str
36
+ has_unread_messages: bool
37
+ message_preview: VendorVisitMessagePreview | None = None
38
+
39
+
40
+ class VisitDetailVendor(CasaModel):
41
+ id: str
42
+ display_name: str
43
+ compact_display_name: str
44
+ type: str
45
+ image_url: str | None = None
46
+ image_cache_key: str | None = None
47
+ active_work_started_at: int | None = None
48
+
49
+
50
+ class VisitConversationWindow(CasaModel):
51
+ state: str
52
+ opens_at: int | None = None
53
+ closes_at: int | None = None
54
+ closed_reason: str | None = None
55
+
56
+
57
+ class VisitConversationLiveCall(CasaModel):
58
+ call_id: str
59
+ started_at: int
60
+ caption: str
61
+
62
+
63
+ class VisitConversationCounterparty(CasaModel):
64
+ user_id: str
65
+ role: str
66
+ vendor_id: str | None = None
67
+ display_name: str
68
+ avatar_url: str | None = None
69
+ avatar_url_cache_key: str | None = None
70
+
71
+
72
+ class VisitConversationParticipant(VisitConversationCounterparty):
73
+ is_me: bool
74
+
75
+
76
+ class VisitConversationLatestMessage(CasaModel):
77
+ sender_display_name: str
78
+ sent_at: int
79
+ message: str
80
+
81
+
82
+ class VisitConversationSummary(CasaModel):
83
+ thread_id: str | None = None
84
+ window: VisitConversationWindow
85
+ counterparties: list[VisitConversationCounterparty]
86
+ live_call: VisitConversationLiveCall | None = None
87
+ calls_enabled: bool | None = None
88
+ unread_count: int
89
+ latest_message: VisitConversationLatestMessage | None = None
90
+
91
+
92
+ class VisitConversationMessage(CasaModel):
93
+ id: str
94
+ thread_id: str
95
+ created_at: int
96
+ sender_role: str
97
+ sender_user_id: str
98
+ sender_display_name: str
99
+ sender_avatar_url: str | None = None
100
+ sender_avatar_url_cache_key: str | None = None
101
+ is_mine: bool
102
+ content: str | None = None
103
+ attachments: list[MediaAttachment]
104
+ parts: list[JsonValue]
105
+
106
+
107
+ class VisitConversation(CasaModel):
108
+ visit_id: str
109
+ thread_id: str | None = None
110
+ window: VisitConversationWindow
111
+ calls_enabled: bool | None = None
112
+ live_call: VisitConversationLiveCall | None = None
113
+ unread_count: int
114
+ participants: list[VisitConversationParticipant]
115
+ messages: list[VisitConversationMessage]
116
+ event_cursor: str | None = None
117
+
118
+
119
+ class VisitCallParticipant(CasaModel):
120
+ user_id: str
121
+ role: str
122
+ rang_at: int | None = None
123
+ joined_at: int | None = None
124
+ left_at: int | None = None
125
+
126
+
127
+ class VisitCall(CasaModel):
128
+ id: str
129
+ vendor_visit_id: str
130
+ status: str
131
+ started_by_user_id: str
132
+ started_by_role: str
133
+ started_at: int
134
+ answered_at: int | None = None
135
+ ended_at: int | None = None
136
+ duration_seconds: int | None = None
137
+ recorded: bool
138
+ participants: list[VisitCallParticipant]
139
+
140
+
141
+ class VisitCallsResponse(CasaModel):
142
+ calls: list[VisitCall]
143
+
144
+
145
+ class VendorVisitDetails(CasaModel):
146
+ id: str
147
+ type: str
148
+ status: str
149
+ request: VendorVisitRequestSummary
150
+ calendar_event: VendorVisitCalendarEvent | None = None
151
+ vendors: list[VisitDetailVendor]
152
+ task_items: list[JsonValue]
153
+ invoices: list[MemberInvoicePreview]
154
+ purchases: list[RequestPurchase]
155
+ can_add_remove_items: bool
156
+ can_cancel: bool
157
+ feedback: JsonValue = None
158
+ conversation: VisitConversationSummary | None = None
@@ -0,0 +1,147 @@
1
+ """Static-only write codecs. Backend acceptance and business semantics are UNVERIFIED.
2
+
3
+ Request encoders, coding keys and response shapes were inferred from the Casa
4
+ iOS app. The reconstructed wire contracts are LIKELY, not confirmed live.
5
+ These models neither authorize nor execute operations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Annotated, Any, Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, ValidationError
13
+
14
+ from casahome.exceptions import CasaRequestError
15
+ from casahome.models._base import CasaModel
16
+ from casahome.models.requests import RequestMessage
17
+ from casahome.models.todos import AttachMediaPayload as LegacyAttachMediaPayload
18
+ from casahome.models.todos import CreateTodoBody as LegacyCreateTodoBody
19
+ from casahome.models.todos import TodoDetail
20
+
21
+ WRITE_TEXT_MAX_CHARS = 10_000
22
+ """Cap on free-text write fields (to-do content, message content, property values).
23
+
24
+ Not a server limit, which is UNKNOWN. It bounds what a caller, or an agent
25
+ driving one, can send in a single write. Real to-dos and messages are short.
26
+ """
27
+
28
+ _WriteText = Annotated[str, Field(max_length=WRITE_TEXT_MAX_CHARS)]
29
+
30
+ _WRITE_CONFIG = ConfigDict(
31
+ extra="forbid", strict=True, validate_assignment=True, revalidate_instances="always"
32
+ )
33
+
34
+
35
+ class _WriteModel(BaseModel):
36
+ """Reject coercion and unknown write fields, including nested fields."""
37
+
38
+ model_config = _WRITE_CONFIG
39
+
40
+
41
+ class AttachMediaPayload(LegacyAttachMediaPayload):
42
+ """Existing upload IDs only. No upload, URL fetch or lifecycle inference.
43
+
44
+ The recovered codec always encodes upload_ids, including an empty list.
45
+ This strict public companion requires callers to supply that list explicitly.
46
+ """
47
+
48
+ model_config = _WRITE_CONFIG
49
+ upload_ids: list[str] = Field(...)
50
+
51
+
52
+ class CreateTodoBody(LegacyCreateTodoBody):
53
+ """Strict public companion to the legacy todo body, with the same wire keys."""
54
+
55
+ model_config = _WRITE_CONFIG
56
+ content: _WriteText
57
+ attachments: AttachMediaPayload | None = None
58
+
59
+
60
+ class CreateTodoResponse(CasaModel):
61
+ """Required app envelope. A missing flag must never become false.
62
+
63
+ TodoDetail is the existing model. Its nested opaque fields aren't re-audited.
64
+ A true flag isn't permission to perform any support confirmation.
65
+ """
66
+
67
+ todo: TodoDetail
68
+ needs_support_confirmation: StrictBool
69
+
70
+
71
+ class SendRequestMessageRequest(_WriteModel):
72
+ """Optional fields use omission, not null. Only member role is public input.
73
+
74
+ The app codec also represents privileged roles. Their authorization is
75
+ UNVERIFIED, so they aren't accepted here. No content requirement is inferred.
76
+ """
77
+
78
+ content: _WriteText | None = None
79
+ attachments: AttachMediaPayload | None = None
80
+ sender_role: Literal["member"] | None = None
81
+
82
+
83
+ class SendRequestMessageResponse(CasaModel):
84
+ """Required message envelope, reusing RequestMessage without new media variants."""
85
+
86
+ message: RequestMessage
87
+
88
+
89
+ class UpdateMultiEnumProperty(_WriteModel):
90
+ """Both selection states are encoded. Server deselection semantics are unknown."""
91
+
92
+ id: str
93
+ is_selected: StrictBool
94
+
95
+
96
+ class UpdateEntityProperty(_WriteModel):
97
+ """String-valued write property, not an arbitrary read-model scalar.
98
+
99
+ The codec doesn't enforce mutual exclusion among the optional value fields.
100
+ Server acceptance of combinations, duplicates and empty values is UNVERIFIED.
101
+ """
102
+
103
+ definition_id: str
104
+ value: _WriteText | None = None
105
+ enum_id: str | None = None
106
+ multi_enums: list[UpdateMultiEnumProperty] | None = None
107
+
108
+
109
+ class UpdateEntityPropertiesRequest(_WriteModel):
110
+ """Required props list with optional make/model strings. No null clear operation."""
111
+
112
+ make_id: str | None = None
113
+ model_id: str | None = None
114
+ props: list[UpdateEntityProperty]
115
+
116
+
117
+ class BasicMessageResponse(CasaModel):
118
+ """Required status string. The PATCH doesn't return an updated entity."""
119
+
120
+ message: StrictStr
121
+
122
+
123
+ def _write_payload(body: BaseModel, schema: type[BaseModel]) -> dict[str, Any]:
124
+ """Revalidate at the I/O boundary, including copied or mutated nested models.
125
+
126
+ Accept valid legacy todo bodies without accepting their permissive extras.
127
+ Validate strict instances directly so model_copy's unknown fields can't hide
128
+ behind serialization. Python-mode dumping adapts valid legacy bodies without
129
+ coercing their values. Only the validated result is JSON-encoded, recursively
130
+ omitting None but preserving empty values.
131
+ """
132
+ if not isinstance(body, BaseModel):
133
+ raise TypeError("write body must be a Pydantic request model")
134
+ source = (
135
+ body
136
+ if isinstance(body, schema)
137
+ else body.model_dump(mode="python", round_trip=True, warnings=False)
138
+ )
139
+ try:
140
+ validated = schema.model_validate(source)
141
+ except ValidationError as err:
142
+ message = f"Invalid {schema.__name__} write body: {err.error_count()} validation error(s)"
143
+ else:
144
+ return validated.model_dump(mode="json", exclude_none=True)
145
+ # Raise outside the handler, like _parse. Pydantic's error echoes input
146
+ # values, and an implicit __context__ would leak them in tracebacks.
147
+ raise CasaRequestError(message)
casahome/py.typed ADDED
File without changes
casahome/streaming.py ADDED
@@ -0,0 +1,278 @@
1
+ """Opt-in CasaAI SSE support. Payload and replay behavior remain UNVERIFIED.
2
+
3
+ The app uses GET, after plus Last-Event-ID, thread.update JSON, and a
4
+ terminal resync_required event. Change discriminator values aren't known.
5
+ Nothing in an event is executed, fetched, or treated as an instruction.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterator
13
+ from contextlib import asynccontextmanager
14
+ from dataclasses import dataclass
15
+ from typing import Literal, TypeVar, cast
16
+
17
+ import aiohttp
18
+ from pydantic import JsonValue
19
+
20
+ from .client import CasaClient
21
+ from .const import CASA_APP, CASA_APP_VERSION, USER_AGENT
22
+ from .exceptions import CasaApiError, CasaAuthError, CasaConnectionError, CasaResponseError
23
+
24
+ MAX_EVENT_BYTES = 256 * 1024
25
+ MAX_LINE_BYTES = 64 * 1024
26
+ MAX_CURSOR_LENGTH = 2048
27
+ STREAM_READ_TIMEOUT = 60.0
28
+ _CHUNK_BYTES = 16 * 1024
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ServerSentEvent:
33
+ """SSE fields. An absent or empty event field becomes the standard message type.
34
+
35
+ id persists across dispatches. data is joined text, including unknown events.
36
+ These values can contain member content. Don't log them indiscriminately.
37
+ """
38
+
39
+ event: str
40
+ id: str | None
41
+ data: str
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class CasaAIThreadUpdateEvent(ServerSentEvent):
46
+ """thread.update with JSON preserved without guessing nested wire variants."""
47
+
48
+ event: Literal["thread.update"]
49
+ payload: JsonValue
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class CasaAIResyncRequiredEvent(ServerSentEvent):
54
+ """Terminal resync signal. The caller decides how to reload and resume."""
55
+
56
+ event: Literal["resync_required"]
57
+
58
+
59
+ CasaAIEvent = CasaAIThreadUpdateEvent | CasaAIResyncRequiredEvent | ServerSentEvent
60
+ _F = TypeVar("_F", bound=Callable[..., AsyncIterator[CasaAIEvent]])
61
+
62
+
63
+ def unverified_stream(func: _F) -> _F:
64
+ """Mark an async generator without changing its identity or coroutine markers."""
65
+ func.__casa_unverified__ = True # type: ignore[attr-defined]
66
+ func.__doc__ = (
67
+ "UNVERIFIED: payload decoding and replay behavior aren't established against live data. "
68
+ "Live traffic (2026-10) confirmed GET/SSE transport only.\n\n" + (func.__doc__ or "")
69
+ ).rstrip()
70
+ return func
71
+
72
+
73
+ def _validate_cursor(after: str | None) -> None:
74
+ """Keep cursors opaque, but safe for the matching query and HTTP header."""
75
+ if after is not None and (
76
+ not isinstance(after, str)
77
+ or len(after) > MAX_CURSOR_LENGTH
78
+ or any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in after)
79
+ or any(0xD800 <= ord(c) <= 0xDFFF for c in after)
80
+ ):
81
+ raise ValueError("after must be a cursor of at most 2048 characters without controls")
82
+
83
+
84
+ class SSEParser:
85
+ """Incremental bounded UTF-8 SSE parser. EOF discards an unfinished event.
86
+
87
+ Supports LF, CRLF, and CR, comments, multiline data, a leading BOM, persistent
88
+ IDs, and the standard empty ID reset. NUL IDs are ignored. Unknown fields,
89
+ including retry, are ignored. Bounds include comments and unknown fields in
90
+ each blank-line-delimited block. CRLF counts as one line-ending byte.
91
+ feed is lazy, so stopping at a terminal event doesn't parse later bytes.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ *,
97
+ last_event_id: str | None = None,
98
+ max_event_bytes: int = MAX_EVENT_BYTES,
99
+ max_line_bytes: int = MAX_LINE_BYTES,
100
+ ) -> None:
101
+ for value in (max_event_bytes, max_line_bytes):
102
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
103
+ raise ValueError("SSE byte limits must be positive integers")
104
+ self._max_event_bytes = max_event_bytes
105
+ self._max_line_bytes = max_line_bytes
106
+ self._line = bytearray()
107
+ self._block_bytes = 0
108
+ self._skip_lf = False
109
+ self._first_line = True
110
+ self._event = ""
111
+ self._id = last_event_id
112
+ self._data: list[str] = []
113
+
114
+ def feed(self, chunk: bytes) -> Iterator[ServerSentEvent]:
115
+ """Consume a bounded chunk and yield only blank-line-terminated events."""
116
+ for byte in chunk:
117
+ if self._skip_lf:
118
+ self._skip_lf = False
119
+ if byte == 10:
120
+ continue
121
+ self._block_bytes += 1
122
+ if self._block_bytes > self._max_event_bytes:
123
+ raise CasaResponseError("SSE event exceeds byte limit")
124
+ if byte in (10, 13):
125
+ self._skip_lf = byte == 13
126
+ event = self._consume_line()
127
+ self._line.clear()
128
+ if event is not None:
129
+ yield event
130
+ else:
131
+ if len(self._line) >= self._max_line_bytes:
132
+ raise CasaResponseError("SSE line exceeds byte limit")
133
+ self._line.append(byte)
134
+
135
+ def _consume_line(self) -> ServerSentEvent | None:
136
+ line = self._line.decode("utf-8", errors="replace")
137
+ if self._first_line:
138
+ line = line.removeprefix("\ufeff")
139
+ self._first_line = False
140
+ if not line:
141
+ event = (
142
+ ServerSentEvent(self._event or "message", self._id, "\n".join(self._data))
143
+ if self._data
144
+ else None
145
+ )
146
+ self._event = ""
147
+ self._data.clear()
148
+ self._block_bytes = 0
149
+ return event
150
+ if line.startswith(":"):
151
+ return None
152
+ field, separator, value = line.partition(":")
153
+ if separator:
154
+ value = value.removeprefix(" ")
155
+ if field == "event":
156
+ self._event = value
157
+ elif field == "id" and "\x00" not in value:
158
+ self._id = value
159
+ elif field == "data":
160
+ self._data.append(value)
161
+ return None
162
+
163
+
164
+ def _reject_constant(value: str) -> None:
165
+ raise ValueError("Non-JSON numeric constant")
166
+
167
+
168
+ def _finite_float(value: str) -> float:
169
+ number = float(value)
170
+ if not math.isfinite(number):
171
+ raise ValueError("Nonfinite JSON number")
172
+ return number
173
+
174
+
175
+ def _typed_event(event: ServerSentEvent) -> CasaAIEvent:
176
+ if event.event == "resync_required":
177
+ return CasaAIResyncRequiredEvent("resync_required", event.id, event.data)
178
+ if event.event == "thread.update":
179
+ # Don't retain a decoding exception as cause/context. It can hold raw data.
180
+ try:
181
+ payload = cast(
182
+ JsonValue,
183
+ json.loads(event.data, parse_constant=_reject_constant, parse_float=_finite_float),
184
+ )
185
+ valid = True
186
+ except (ValueError, RecursionError):
187
+ payload = None
188
+ valid = False
189
+ if not valid:
190
+ raise CasaResponseError("Malformed thread.update JSON")
191
+ return CasaAIThreadUpdateEvent("thread.update", event.id, event.data, payload)
192
+ return event
193
+
194
+
195
+ @asynccontextmanager
196
+ async def _safe_stream_errors() -> AsyncIterator[None]:
197
+ """Keep transport errors free of URLs, tokens, and remote body snippets."""
198
+ error: CasaConnectionError | CasaAuthError | None = None
199
+ try:
200
+ yield
201
+ except TimeoutError:
202
+ error = CasaConnectionError("SSE request timed out")
203
+ except (aiohttp.ClientError, CasaConnectionError):
204
+ error = CasaConnectionError("SSE connection failed")
205
+ except CasaAuthError:
206
+ error = CasaAuthError("SSE authentication failed")
207
+ if error is not None:
208
+ # athrow() resumes this context manager under the caller's exception.
209
+ # Clear that implicit context too, not only the displayed traceback.
210
+ try:
211
+ raise error from None
212
+ finally:
213
+ error.__context__ = None
214
+
215
+
216
+ async def _stream_events(
217
+ client: CasaClient, path: str, after: str | None
218
+ ) -> AsyncGenerator[CasaAIEvent, None]:
219
+ """Internal fixed-route transport. The API validates the path and cursor.
220
+
221
+ CasaClient has no public session/base/timeout accessors. Read those existing
222
+ fields here only, without changing ownership or bypassing FirebaseAuth's
223
+ cache and token-specific invalidation. JSON request() can't stream and its
224
+ error-body helper exposes raw snippets, so neither is used for SSE bodies.
225
+ """
226
+ timeout = aiohttp.ClientTimeout(
227
+ total=None,
228
+ connect=min(client._timeout.total or STREAM_READ_TIMEOUT, STREAM_READ_TIMEOUT),
229
+ sock_read=STREAM_READ_TIMEOUT,
230
+ )
231
+ async with _safe_stream_errors():
232
+ for attempt in range(2):
233
+ token = await client.auth.get_id_token()
234
+ headers = {
235
+ "Authorization": f"Bearer {token}",
236
+ "X-Casa-App": CASA_APP,
237
+ "X-Casa-App-Version": CASA_APP_VERSION,
238
+ "User-Agent": USER_AGENT,
239
+ "Accept": "text/event-stream",
240
+ }
241
+ params = None
242
+ if after is not None:
243
+ headers["Last-Event-ID"] = after
244
+ params = {"after": after}
245
+ async with client._session.get(
246
+ client._base_url + path,
247
+ headers=headers,
248
+ params=params,
249
+ timeout=timeout,
250
+ allow_redirects=False,
251
+ raise_for_status=False,
252
+ ) as response:
253
+ try:
254
+ if response.status == 401:
255
+ response.close()
256
+ if attempt:
257
+ raise CasaAuthError("Casa rejected the refreshed token (HTTP 401)")
258
+ client.auth.invalidate(token)
259
+ continue
260
+ if response.status == 204:
261
+ return
262
+ if response.status != 200:
263
+ # No error-body read, even for JSON. Status is safe metadata.
264
+ raise CasaApiError(response.status, "stream_http_error")
265
+ if response.content_type != "text/event-stream":
266
+ raise CasaResponseError("Expected text/event-stream response")
267
+ parser = SSEParser(last_event_id=after)
268
+ while chunk := await response.content.read(_CHUNK_BYTES):
269
+ for raw in parser.feed(chunk):
270
+ event = _typed_event(raw)
271
+ if isinstance(event, CasaAIResyncRequiredEvent):
272
+ response.close()
273
+ yield event
274
+ return
275
+ yield event
276
+ return
277
+ finally:
278
+ response.close()