Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,18 @@ Two entry points live in `divera247.websocket`:

```python
from divera247.websocket import (
ClusterPullEvent,
UnknownEvent,
UserStatusEvent,
stream_websocket,
)

async for event in stream_websocket(client, ucr_id=527_459):
match event:
case UserStatusEvent(payload=payload, ucr=ucr):
...
case UserStatusEvent(ucr=ucr, status=status):
... # status is a PullStatusData
case ClusterPullEvent(cluster=cluster, pull=pull):
... # re-fetch pull.type (e.g. "alarm") + pull.id
case UnknownEvent():
... # forward-compatible fallback
```
Expand Down
16 changes: 12 additions & 4 deletions src/divera247/websocket/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,37 @@
* :class:`WebSocketAuthenticationError` - raised when authentication keeps
failing so the caller can react instead of silently looping.
* Pydantic event envelopes (:class:`UserStatusEvent`,
:class:`ClusterPullEvent` with its :class:`ClusterPullRef`,
:class:`UnknownEvent`) and the :data:`DiveraEvent` discriminated union
plus :func:`parse_event` for dispatching raw frames onto typed models.
Other server-side event types (e.g. ``cluster-pull``,
``cluster-vehicle``) currently surface as :class:`UnknownEvent` until a
real sample is available to back a dedicated model.
Other server-side event types (e.g. ``cluster-vehicle``) currently
surface as :class:`UnknownEvent` until a real sample is available to
back a dedicated model.

Typical usage:

.. code-block:: python

from divera247.websocket import (
ClusterPullEvent,
UnknownEvent,
UserStatusEvent,
subscribe_websocket,
)

async for event in subscribe_websocket(client, ucr_id=ucr_id):
match event:
case UserStatusEvent(payload=payload, ucr=ucr):
case UserStatusEvent(ucr=ucr, status=status):
...
case ClusterPullEvent(cluster=cluster, pull=pull):
... # re-fetch pull.type / pull.id for this cluster
case UnknownEvent(type=msg_type):
...
"""

from divera247.websocket.models import (
ClusterPullEvent,
ClusterPullRef,
DiveraEvent,
UnknownEvent,
UserStatusEvent,
Expand All @@ -49,6 +55,8 @@
)

__all__ = [
'ClusterPullEvent',
'ClusterPullRef',
'DiveraEvent',
'UnknownEvent',
'UserStatusEvent',
Expand Down
103 changes: 90 additions & 13 deletions src/divera247/websocket/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Annotated, Any, Literal

from pydantic import (
AliasPath,
BaseModel,
ConfigDict,
Discriminator,
Expand All @@ -19,18 +20,92 @@
logger = logging.getLogger(__name__)


class ClusterPullRef(BaseModel):
"""Reference to the cluster resource that triggered a ``cluster-pull`` event.

The ``type`` names the resource family (observed: ``alarm``; likely
also ``message``, ``event``, ``news``, ``vehicle``, etc., i.e. the
same families exposed by ``GET /api/v2/pull/all``) and ``id`` is the
primary key within that family. Callers should re-fetch the matching
pull endpoint to get the full object.
"""

model_config = ConfigDict(extra='allow')

type: str = Field(description='Ressource-Typ, der neu gepullt werden soll (z. B. ``alarm``)')
id: int = Field(description='ID der betroffenen Ressource innerhalb ihres Typs')


class ClusterPullEvent(BaseModel):
"""``cluster-pull`` WebSocket event: a cluster-scoped resource changed.

The server tells us *that* something in a given cluster changed and
*which* resource to refresh (type + id). The actual object is not
inlined; the client is expected to re-fetch the relevant pull
endpoint to materialise the update.

Wire format, mirroring :class:`UserStatusEvent`:

.. code-block:: json

{
"type": "cluster-pull",
"payload": {
"type": "cluster-pull",
"pull": {"type": "alarm", "id": 123456},
"cluster": 1234
}
}

Flattened via :class:`~pydantic.AliasPath` so callers access
``event.pull`` / ``event.cluster`` directly.
"""

type: Literal['cluster-pull'] = Field(description='Event-Typ')
pull: ClusterPullRef = Field(
validation_alias=AliasPath('payload', 'pull'),
description='Referenz auf die geänderte Ressource (aus payload.pull)',
)
cluster: int = Field(
validation_alias=AliasPath('payload', 'cluster'),
description='ID des betroffenen Clusters (aus payload.cluster)',
)


class UserStatusEvent(BaseModel):
"""``user-status`` WebSocket event: own status changed for a given UCR.

The ``payload`` has the exact same shape as the ``status`` block of
``GET /api/v2/pull/all`` (see :class:`PullStatusData`); the envelope
adds the event ``type`` discriminator and the ``ucr`` of the affected
UserClusterRelation.
The server wire-format wraps the actual status (same fields as the
``status`` block of ``GET /api/v2/pull/all`` -- see
:class:`PullStatusData`) and the affected ``ucr`` inside a nested
``payload`` object, with the event type repeated redundantly at both
levels:

.. code-block:: json

{
"type": "user-status",
"payload": {
"type": "user-status",
"status": { ...PullStatusData... },
"ucr": 527459
}
}

We flatten this on validation via :class:`~pydantic.AliasPath`, so
callers only deal with ``event.status`` / ``event.ucr`` and never have
to reach through a redundant payload wrapper.
"""

type: Literal['user-status'] = Field(description='Event-Typ')
payload: PullStatusData = Field(description='Aktueller Status der UCR')
ucr: int = Field(description='ID der betroffenen UserClusterRelation')
status: PullStatusData = Field(
validation_alias=AliasPath('payload', 'status'),
description='Aktueller Status der UCR (aus payload.status)',
)
ucr: int = Field(
validation_alias=AliasPath('payload', 'ucr'),
description='ID der betroffenen UserClusterRelation (aus payload.ucr)',
)


class UnknownEvent(BaseModel):
Expand All @@ -39,17 +114,17 @@ class UnknownEvent(BaseModel):
Keeps the raw ``type`` string so callers can still dispatch on it, and
preserves every other top-level field as extras (accessible via
:attr:`model_extra` or direct attribute access). Used both for genuinely
unknown event types (e.g. ``cluster-pull``, ``cluster-vehicle``) and as
a defensive fallback when a known event's inner payload fails its
dedicated validation (see :func:`parse_event`).
unknown event types (e.g. ``cluster-vehicle``) and as a defensive
fallback when a known event's inner payload fails its dedicated
validation (see :func:`parse_event`).
"""

model_config = ConfigDict(extra='allow')

type: str = Field(description='Raw event type as sent by the server')


_KNOWN_EVENT_TYPES: frozenset[str] = frozenset({'user-status'})
_KNOWN_EVENT_TYPES: frozenset[str] = frozenset({'user-status', 'cluster-pull'})


def _event_discriminator(value: Any) -> str:
Expand All @@ -66,7 +141,9 @@ def _event_discriminator(value: Any) -> str:


DiveraEvent = Annotated[
Annotated[UserStatusEvent, Tag('user-status')] | Annotated[UnknownEvent, Tag('unknown')],
Annotated[UserStatusEvent, Tag('user-status')]
| Annotated[ClusterPullEvent, Tag('cluster-pull')]
| Annotated[UnknownEvent, Tag('unknown')],
Discriminator(_event_discriminator),
]
"""Discriminated union of every typed WebSocket event plus the catch-all.
Expand All @@ -76,10 +153,10 @@ def _event_discriminator(value: Any) -> str:
"""


_event_adapter: TypeAdapter[UserStatusEvent | UnknownEvent] = TypeAdapter(DiveraEvent)
_event_adapter: TypeAdapter[UserStatusEvent | ClusterPullEvent | UnknownEvent] = TypeAdapter(DiveraEvent)


def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | UnknownEvent:
def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | ClusterPullEvent | UnknownEvent:
"""Parse a raw WebSocket event into the matching typed model.

Dispatches on ``type`` via :data:`DiveraEvent`; unknown or missing types
Expand Down
4 changes: 2 additions & 2 deletions src/divera247/websocket/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ async def subscribe_websocket(
ucr_id: int | None = None,
ws_url: str = 'wss://ws.divera247.com/ws',
max_auth_attempts: int = 3,
) -> AsyncIterator[models.UserStatusEvent | models.UnknownEvent]:
) -> AsyncIterator[models.UserStatusEvent | models.ClusterPullEvent | models.UnknownEvent]:
"""Yield typed Divera 24/7 WebSocket events from a single session.

Exits when the underlying socket disconnects (by raising
Expand All @@ -169,7 +169,7 @@ async def stream_websocket( # noqa: PLR0913
max_backoff: float = 60.0,
backoff_factor: float = 2.0,
backoff_jitter: float = 0.2,
) -> AsyncIterator[models.UserStatusEvent | models.UnknownEvent]:
) -> AsyncIterator[models.UserStatusEvent | models.ClusterPullEvent | models.UnknownEvent]:
"""Yield events forever, transparently reconnecting on any disconnect.

Reconnect delay follows jittered exponential backoff bounded by
Expand Down
Loading