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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,17 +155,20 @@ Two entry points live in `divera247.websocket`:
```python
from divera247.websocket import (
ClusterPullEvent,
ClusterVehicleEvent,
UnknownEvent,
UserStatusEvent,
stream_websocket,
)

async for event in stream_websocket(client, ucr_id=527_459):
async for event in stream_websocket(client, ucr_id=1_234):
match event:
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 ClusterVehicleEvent(cluster=cluster, vehicle=vehicle):
... # vehicle.id / vehicle.fmsstatus_id update
case UnknownEvent():
... # forward-compatible fallback
```
Expand Down
11 changes: 8 additions & 3 deletions src/divera247/websocket/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,17 @@
failing so the caller can react instead of silently looping.
* Pydantic event envelopes (:class:`UserStatusEvent`,
:class:`ClusterPullEvent` with its :class:`ClusterPullRef`,
:class:`ClusterVehicleEvent` with :class:`ClusterVehicleState`,
: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-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,
ClusterVehicleEvent,
UnknownEvent,
UserStatusEvent,
subscribe_websocket,
Expand All @@ -36,13 +35,17 @@
...
case ClusterPullEvent(cluster=cluster, pull=pull):
... # re-fetch pull.type / pull.id for this cluster
case ClusterVehicleEvent(cluster=cluster, vehicle=vehicle):
... # vehicle.id / vehicle.fmsstatus_id changed
case UnknownEvent(type=msg_type):
...
"""

from divera247.websocket.models import (
ClusterPullEvent,
ClusterPullRef,
ClusterVehicleEvent,
ClusterVehicleState,
DiveraEvent,
UnknownEvent,
UserStatusEvent,
Expand All @@ -57,6 +60,8 @@
__all__ = [
'ClusterPullEvent',
'ClusterPullRef',
'ClusterVehicleEvent',
'ClusterVehicleState',
'DiveraEvent',
'UnknownEvent',
'UserStatusEvent',
Expand Down
62 changes: 54 additions & 8 deletions src/divera247/websocket/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class ClusterPullEvent(BaseModel):
"type": "cluster-pull",
"payload": {
"type": "cluster-pull",
"pull": {"type": "alarm", "id": 123456},
"pull": {"type": "alarm", "id": 1234},
"cluster": 1234
}
}
Expand All @@ -72,6 +72,50 @@ class ClusterPullEvent(BaseModel):
)


class ClusterVehicleState(BaseModel):
"""Vehicle status snapshot carried by a ``cluster-vehicle`` event."""

model_config = ConfigDict(extra='allow')

id: int = Field(description='ID des betroffenen Fahrzeugs')
fmsstatus_id: int = Field(description='Aktuelle FMS-Status-ID')
fmsstatus_note: str = Field(description='Optionaler Freitext zum FMS-Status')
fmsstatus_ts: int = Field(description='Unix-Timestamp der letzten Statusaenderung')


class ClusterVehicleEvent(BaseModel):
"""``cluster-vehicle`` WebSocket event: vehicle status changed in a cluster.

Wire format:

.. code-block:: json

{
"type": "cluster-vehicle",
"payload": {
"type": "cluster-vehicle",
"vehicle": {
"id": 1234,
"fmsstatus_id": 6,
"fmsstatus_note": "",
"fmsstatus_ts": 1700000000
},
"cluster": 1234
}
}
"""

type: Literal['cluster-vehicle'] = Field(description='Event-Typ')
vehicle: ClusterVehicleState = Field(
validation_alias=AliasPath('payload', 'vehicle'),
description='Aktueller Fahrzeugstatus (aus payload.vehicle)',
)
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.

Expand All @@ -88,7 +132,7 @@ class UserStatusEvent(BaseModel):
"payload": {
"type": "user-status",
"status": { ...PullStatusData... },
"ucr": 527459
"ucr": 1234
}
}

Expand All @@ -114,17 +158,16 @@ 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-vehicle``) and as a defensive
fallback when a known event's inner payload fails its dedicated
validation (see :func:`parse_event`).
unknown event types 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', 'cluster-pull'})
_KNOWN_EVENT_TYPES: frozenset[str] = frozenset({'user-status', 'cluster-pull', 'cluster-vehicle'})


def _event_discriminator(value: Any) -> str:
Expand All @@ -143,6 +186,7 @@ def _event_discriminator(value: Any) -> str:
DiveraEvent = Annotated[
Annotated[UserStatusEvent, Tag('user-status')]
| Annotated[ClusterPullEvent, Tag('cluster-pull')]
| Annotated[ClusterVehicleEvent, Tag('cluster-vehicle')]
| Annotated[UnknownEvent, Tag('unknown')],
Discriminator(_event_discriminator),
]
Expand All @@ -153,10 +197,12 @@ def _event_discriminator(value: Any) -> str:
"""


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


def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | ClusterPullEvent | UnknownEvent:
def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | ClusterPullEvent | ClusterVehicleEvent | 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.ClusterPullEvent | models.UnknownEvent]:
) -> AsyncIterator[models.UserStatusEvent | models.ClusterPullEvent | models.ClusterVehicleEvent | 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.ClusterPullEvent | models.UnknownEvent]:
) -> AsyncIterator[models.UserStatusEvent | models.ClusterPullEvent | models.ClusterVehicleEvent | models.UnknownEvent]:
"""Yield events forever, transparently reconnecting on any disconnect.

Reconnect delay follows jittered exponential backoff bounded by
Expand Down
69 changes: 60 additions & 9 deletions tests/websocket/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,37 @@
from divera247.websocket.models import (
ClusterPullEvent,
ClusterPullRef,
ClusterVehicleEvent,
ClusterVehicleState,
UnknownEvent,
UserStatusEvent,
parse_event,
)

SAMPLE_UCR = 527459
SAMPLE_STATUS_ID = 33035
SAMPLE_CLUSTER = 8381
SAMPLE_PULL_ID = 33688274
SAMPLE_UCR = 1234
SAMPLE_STATUS_ID = 1001
SAMPLE_CLUSTER = 1234
SAMPLE_PULL_ID = 1234
SAMPLE_PULL_TYPE = 'alarm'
SAMPLE_VEHICLE_ID = 1234
SAMPLE_VEHICLE_FMS = 6
SAMPLE_REF_ID = 42

_STATUS_BLOCK: dict = {
'status_id': SAMPLE_STATUS_ID,
'status_skip_statusplan': False,
'status_skip_geofence': False,
'status_set_date': 1776767153,
'status_set_date': 1700000000,
'status_reset_date': '',
'status_reset_id': 0,
'status_log': [],
'status_changes': [
{'ts': 1776767114, 'status': 33035, 'note': '', 'vehicle': 0, 'event': 0, 'type': 0},
{'ts': 1776767152, 'status': 33036, 'note': '', 'vehicle': 0, 'event': 0, 'type': 0},
{'ts': 1700000000, 'status': 1001, 'note': '', 'vehicle': 0, 'event': 0, 'type': 0},
{'ts': 1700000060, 'status': 1002, 'note': '', 'vehicle': 0, 'event': 0, 'type': 0},
],
'note': '',
'vehicle': 0,
'ts': 1776767153,
'ts': 1700000060,
'cached': False,
}

Expand All @@ -55,6 +59,20 @@
},
}

_CLUSTER_VEHICLE_SAMPLE: dict = {
'type': 'cluster-vehicle',
'payload': {
'type': 'cluster-vehicle',
'vehicle': {
'id': SAMPLE_VEHICLE_ID,
'fmsstatus_id': SAMPLE_VEHICLE_FMS,
'fmsstatus_note': '',
'fmsstatus_ts': 1700000120,
},
'cluster': SAMPLE_CLUSTER,
},
}


def test_user_status_event_flattens_nested_payload() -> None:
"""UserStatusEvent hoists ``status`` and ``ucr`` out of the nested payload via AliasPath."""
Expand Down Expand Up @@ -114,6 +132,30 @@ def test_cluster_pull_ref_preserves_unknown_fields() -> None:
assert ref.model_extra == {'title': 'Einsatz', 'prio': 1}


def test_cluster_vehicle_event_flattens_nested_payload() -> None:
"""ClusterVehicleEvent hoists ``vehicle`` + ``cluster`` via AliasPath."""
event = ClusterVehicleEvent.model_validate(_CLUSTER_VEHICLE_SAMPLE)
assert event.type == 'cluster-vehicle'
assert event.cluster == SAMPLE_CLUSTER
assert isinstance(event.vehicle, ClusterVehicleState)
assert event.vehicle.id == SAMPLE_VEHICLE_ID
assert event.vehicle.fmsstatus_id == SAMPLE_VEHICLE_FMS


def test_cluster_vehicle_state_preserves_unknown_fields() -> None:
"""Extra vehicle fields are tolerated and kept in ``model_extra``."""
raw = {
'id': SAMPLE_VEHICLE_ID,
'fmsstatus_id': SAMPLE_VEHICLE_FMS,
'fmsstatus_note': '',
'fmsstatus_ts': 1700000120,
'name': 'LF 20',
}
state = ClusterVehicleState.model_validate(raw)
assert state.id == SAMPLE_VEHICLE_ID
assert state.model_extra == {'name': 'LF 20'}


def test_unknown_event_keeps_arbitrary_type() -> None:
"""UnknownEvent stores the original ``type`` string instead of overwriting it."""
event = UnknownEvent.model_validate({'type': 'cluster-vehicle'})
Expand Down Expand Up @@ -150,9 +192,18 @@ def test_parse_event_dispatches_cluster_pull() -> None:
assert parsed.pull.id == SAMPLE_PULL_ID


def test_parse_event_dispatches_cluster_vehicle() -> None:
"""``parse_event`` routes ``cluster-vehicle`` frames to ClusterVehicleEvent."""
parsed = parse_event(_CLUSTER_VEHICLE_SAMPLE)
assert isinstance(parsed, ClusterVehicleEvent)
assert parsed.cluster == SAMPLE_CLUSTER
assert parsed.vehicle.id == SAMPLE_VEHICLE_ID
assert parsed.vehicle.fmsstatus_id == SAMPLE_VEHICLE_FMS


@pytest.mark.parametrize(
'event_type',
['cluster-message', 'cluster-vehicle', 'some-brand-new-event'],
['cluster-message', 'some-brand-new-event'],
)
def test_parse_event_falls_back_to_unknown_and_preserves_type(event_type: str) -> None:
"""Unknown ``type`` values route to UnknownEvent with the original string + extras intact."""
Expand Down