From 7defaa75b4c057cd03aec2052d6f227968d0b8da Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 22 Apr 2026 20:30:30 +0200 Subject: [PATCH] add vehicel status websocket event --- README.md | 5 ++- src/divera247/websocket/__init__.py | 11 +++-- src/divera247/websocket/models.py | 62 ++++++++++++++++++++++---- src/divera247/websocket/session.py | 4 +- tests/websocket/test_models.py | 69 +++++++++++++++++++++++++---- 5 files changed, 128 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 15b50f9..88343e6 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/src/divera247/websocket/__init__.py b/src/divera247/websocket/__init__.py index e5c1cba..0f2bbc6 100644 --- a/src/divera247/websocket/__init__.py +++ b/src/divera247/websocket/__init__.py @@ -13,11 +13,9 @@ 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: @@ -25,6 +23,7 @@ from divera247.websocket import ( ClusterPullEvent, + ClusterVehicleEvent, UnknownEvent, UserStatusEvent, subscribe_websocket, @@ -36,6 +35,8 @@ ... 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): ... """ @@ -43,6 +44,8 @@ from divera247.websocket.models import ( ClusterPullEvent, ClusterPullRef, + ClusterVehicleEvent, + ClusterVehicleState, DiveraEvent, UnknownEvent, UserStatusEvent, @@ -57,6 +60,8 @@ __all__ = [ 'ClusterPullEvent', 'ClusterPullRef', + 'ClusterVehicleEvent', + 'ClusterVehicleState', 'DiveraEvent', 'UnknownEvent', 'UserStatusEvent', diff --git a/src/divera247/websocket/models.py b/src/divera247/websocket/models.py index a3b1c26..baff3b3 100644 --- a/src/divera247/websocket/models.py +++ b/src/divera247/websocket/models.py @@ -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 } } @@ -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. @@ -88,7 +132,7 @@ class UserStatusEvent(BaseModel): "payload": { "type": "user-status", "status": { ...PullStatusData... }, - "ucr": 527459 + "ucr": 1234 } } @@ -114,9 +158,8 @@ 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') @@ -124,7 +167,7 @@ class UnknownEvent(BaseModel): 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: @@ -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), ] @@ -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 diff --git a/src/divera247/websocket/session.py b/src/divera247/websocket/session.py index ffbffcb..4de9918 100644 --- a/src/divera247/websocket/session.py +++ b/src/divera247/websocket/session.py @@ -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 @@ -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 diff --git a/tests/websocket/test_models.py b/tests/websocket/test_models.py index ae20ce5..efe4ee7 100644 --- a/tests/websocket/test_models.py +++ b/tests/websocket/test_models.py @@ -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, } @@ -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.""" @@ -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'}) @@ -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."""