diff --git a/pyproject.toml b/pyproject.toml index 024b4a4..020e002 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ requires-python = ">=3.13" dependencies = [ "anyio>=4.13.0", "httpx>=0.28.1", - "pydantic>=2.12.5", + "pydantic>=2.12.2", ] [project.optional-dependencies] diff --git a/src/divera247/endpoints/alarm.py b/src/divera247/endpoints/alarm.py index 1e8eb52..3bfadf2 100644 --- a/src/divera247/endpoints/alarm.py +++ b/src/divera247/endpoints/alarm.py @@ -135,7 +135,7 @@ async def close_alarm( payload: CloseAlarmPayload | None = None, ) -> SuccessResponse: """Close an alarm (POST /api/v2/alarms/close/{id}).""" - data = payload.model_dump(by_alias=False, exclude_none=True) if payload else None + data = payload.model_dump(mode='json', by_alias=False, exclude_none=True) if payload else None response = await self.client.post( f'v2/alarms/close/{alarm_id}', data=data, diff --git a/src/divera247/endpoints/event.py b/src/divera247/endpoints/event.py index e5bbcf6..9521818 100644 --- a/src/divera247/endpoints/event.py +++ b/src/divera247/endpoints/event.py @@ -31,7 +31,7 @@ async def create_event(self, payload: EventInput) -> EventSingleResponse: """Create a new event (POST /api/v2/events).""" response = await self.client.post( 'v2/events', - data=payload.model_dump(by_alias=False, exclude_none=True), + data=payload.model_dump(mode='json', by_alias=False, exclude_none=True), ) return EventSingleResponse.model_validate(response.json()) @@ -48,7 +48,7 @@ async def update_event( """Update an event (PUT /api/v2/events/{id}).""" response = await self.client.put( f'v2/events/{event_id}', - data=payload.model_dump(by_alias=False, exclude_none=True), + data=payload.model_dump(mode='json', by_alias=False, exclude_none=True), ) return EventSingleResponse.model_validate(response.json()) @@ -92,7 +92,7 @@ async def confirm_event( payload: EventConfirmPayload | None = None, ) -> SuccessResponse: """Create a response to an event (POST /api/v2/events/confirm/{id}).""" - data = payload.model_dump(by_alias=False, exclude_none=True) if payload else None + data = payload.model_dump(mode='json', by_alias=False, exclude_none=True) if payload else None response = await self.client.post( f'v2/events/confirm/{event_id}', data=data, diff --git a/src/divera247/endpoints/statusgeber.py b/src/divera247/endpoints/statusgeber.py index cf92320..7f09f25 100644 --- a/src/divera247/endpoints/statusgeber.py +++ b/src/divera247/endpoints/statusgeber.py @@ -14,5 +14,5 @@ async def set_status(self, payload: StatusgeberPayload) -> None: """Set status (POST /api/v2/statusgeber/set-status).""" await self.client.post( 'v2/statusgeber/set-status', - data=payload.model_dump(by_alias=False, exclude_none=True), + data=payload.model_dump(mode='json', by_alias=False, exclude_none=True), ) diff --git a/src/divera247/models/alarm.py b/src/divera247/models/alarm.py index 2181615..97237a2 100644 --- a/src/divera247/models/alarm.py +++ b/src/divera247/models/alarm.py @@ -6,6 +6,7 @@ from __future__ import annotations +import datetime # noqa: TC003 from collections.abc import Mapping, Sequence from pydantic import BaseModel, Field @@ -32,7 +33,7 @@ class SuccessResponse(BaseModel): class UcrAnsweredEntry(BaseModel): """Single user response entry in ucr_answered (status_id -> ucr_id -> this).""" - ts: int = Field(description='UNIX timestamp of response') + ts: datetime.datetime = Field(description='UNIX timestamp of response') note: str = Field(default='', description='Freitext-Rückmeldung') @@ -103,7 +104,7 @@ class ReachTransports(BaseModel): id: int = Field(description='ID of the transport') notification_type: int = Field(description='Notification type') count_send: int = Field(description='Count of send') - ts: int = Field(description='UNIX timestamp of the transport') + ts: datetime.datetime = Field(description='UNIX timestamp of the transport') class ReachReceived(BaseModel): @@ -120,14 +121,14 @@ class ReachViewed(BaseModel): """Reach viewed for GET /api/v2/alarms/reach/{id}.""" id: int = Field(description='ID of the viewed') - ts: int = Field(description='UNIX timestamp of the viewed') + ts: datetime.datetime = Field(description='UNIX timestamp of the viewed') class ReachConfirmed(BaseModel): """Reach confirmed for GET /api/v2/alarms/reach/{id}.""" id: int = Field(description='ID of the confirmed') - ts: int = Field(description='UNIX timestamp of the confirmed') + ts: datetime.datetime = Field(description='UNIX timestamp of the confirmed') note: str = Field(description='Note of the confirmed') @@ -195,7 +196,7 @@ class AlarmResult(BaseModel): default=None, description='ID der Alarmvorlage (Alarmcode)', ) - date: int | None = Field( + date: datetime.datetime | None = Field( default=None, description='Alarmierungszeit als UNIX-Timestamp', ) @@ -319,23 +320,23 @@ class AlarmResult(BaseModel): description='Anzahl der Empfänger', ) count_read: int | None = Field(default=None, description='Anzahl gelesen') - ts_response: int | None = Field( + ts_response: datetime.datetime | None = Field( default=None, description='Berechneter UNIX-Timestamp für Rückmeldung bis', ) - ts_publish: int | None = Field( + ts_publish: datetime.datetime | None = Field( default=None, description='UNIX-Timestamp für zeitgesteuerte Alarmierung', ) - ts_close: int | None = Field( + ts_close: datetime.datetime | None = Field( default=None, description='UNIX-Timestamp Einsatzende', ) - ts_create: int | None = Field( + ts_create: datetime.datetime | None = Field( default=None, description='UNIX-Timestamp des Erstelldatums', ) - ts_update: int | None = Field( + ts_update: datetime.datetime | None = Field( default=None, description='UNIX-Timestamp zuletzt bearbeitet', ) @@ -539,7 +540,7 @@ class CloseAlarmData(BaseModel): description='Zustand geschlossen oder geöffnet', ) report: str | None = Field(default=None, description='Einsatzbericht') - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Datum/Uhrzeit als Unix-Timestamp', ) diff --git a/src/divera247/models/event.py b/src/divera247/models/event.py index c48e6f4..7c17065 100644 --- a/src/divera247/models/event.py +++ b/src/divera247/models/event.py @@ -3,6 +3,7 @@ These models map to the schemas defined in ``api_v2_event.yaml``. """ +import datetime from collections.abc import Mapping, Sequence from pydantic import BaseModel, Field @@ -16,7 +17,7 @@ class EventResult(BaseModel): id: int | None = Field(default=None, description='ID/Primärschlüssel') foreign_id: str | None = Field(default=None, description='Fremdschlüssel') author_id: int | None = Field(default=None, description='ID des Nutzers') - date: int | None = Field(default=None, description='Terminszeit als UNIX-Timestamp') + date: datetime.datetime | None = Field(default=None, description='Terminszeit als UNIX-Timestamp') title: str | None = Field(default=None, description='Titel') text: str | None = Field(default=None, description='Meldung') address: str | None = Field(default=None, description='Ort') @@ -36,8 +37,8 @@ class EventResult(BaseModel): answerable: bool | None = Field(default=None, description='Beantwortbar') hidden: bool | None = Field(default=None, description='Entwurf') deleted: bool | None = Field(default=None, description='Im Archiv') - ts_create: int | None = Field(default=None, description='UNIX-Timestamp Erstelldatum') - ts_update: int | None = Field( + ts_create: datetime.datetime | None = Field(default=None, description='UNIX-Timestamp Erstelldatum') + ts_update: datetime.datetime | None = Field( default=None, description='UNIX-Timestamp zuletzt bearbeitet', ) @@ -117,11 +118,11 @@ class EventInputEvent(BaseModel): title: str = Field(description='Titel') text: str | None = Field(default=None, description='Meldung') address: str | None = Field(default=None, description='Ort') - ts_start: int | None = Field( + ts_start: datetime.datetime | None = Field( default=None, description='Beginn als UNIX-Timestamp', ) - ts_end: int | None = Field( + ts_end: datetime.datetime | None = Field( default=None, description='Ende als UNIX-Timestamp', ) @@ -153,7 +154,7 @@ class EventInputEvent(BaseModel): class EventInputReminder(BaseModel): """Reminder for event (event-input.Reminder).""" - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Zeitpunkt als UNIX-Timestamp', ) diff --git a/src/divera247/models/news.py b/src/divera247/models/news.py index 0d5d5a2..eb94904 100644 --- a/src/divera247/models/news.py +++ b/src/divera247/models/news.py @@ -3,6 +3,7 @@ These models map to the schemas defined in ``api_v2_news.yaml``. """ +import datetime from collections.abc import Mapping, Sequence from pydantic import BaseModel, Field @@ -16,7 +17,7 @@ class NewsResult(BaseModel): id: int | None = Field(default=None, description='ID/Primärschlüssel') foreign_id: str | None = Field(default=None, description='Fremdschlüssel') author_id: int | None = Field(default=None, description='ID des Nutzers') - date: int | None = Field( + date: datetime.datetime | None = Field( default=None, description='Mitteilungszeit als UNIX-Timestamp', ) @@ -39,8 +40,8 @@ class NewsResult(BaseModel): answerable: bool | None = Field(default=None, description='Beantwortbar') hidden: bool | None = Field(default=None, description='Entwurf') deleted: bool | None = Field(default=None, description='Im Archiv') - ts_create: int | None = Field(default=None, description='UNIX-Timestamp Erstelldatum') - ts_update: int | None = Field( + ts_create: datetime.datetime | None = Field(default=None, description='UNIX-Timestamp Erstelldatum') + ts_update: datetime.datetime | None = Field( default=None, description='UNIX-Timestamp zuletzt bearbeitet', ) diff --git a/src/divera247/models/pull.py b/src/divera247/models/pull.py index 515a287..1e69a9b 100644 --- a/src/divera247/models/pull.py +++ b/src/divera247/models/pull.py @@ -3,6 +3,7 @@ These models map to the schemas defined in ``api_v2_pull.yaml``. """ +import datetime from collections.abc import Mapping, Sequence from typing import cast @@ -87,7 +88,7 @@ class PullUser(BaseModel): default=None, description='Onboarding-Tour Status', ) - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Letzte Änderung als UNIX-Timestamp', ) @@ -96,7 +97,7 @@ class PullUser(BaseModel): class StatusChangeEntry(BaseModel): """Single entry in status.status_changes or status.status_log.""" - ts: int | None = Field(default=None, description='UNIX-Timestamp') + ts: datetime.datetime | None = Field(default=None, description='UNIX-Timestamp') status: int | None = Field(default=None, description='ID des Status') note: str | None = Field(default=None, description='Status-Notiz') vehicle: int | None = Field(default=None, description='ID des Fahrzeugs') @@ -119,11 +120,11 @@ class PullStatusData(BaseModel): default=None, description='Alle Geofences im Zeitraum ignorieren', ) - status_set_date: int | None = Field( + status_set_date: datetime.datetime | None = Field( default=None, description='UNIX-Timestamp der letzten Statusänderung', ) - status_reset_date: int | str | None = Field( + status_reset_date: datetime.datetime | str | None = Field( default=None, description='Nächstes Zurücksetzen gemäß UNIX-Timestamp', ) @@ -141,7 +142,7 @@ class PullStatusData(BaseModel): ) note: str | None = Field(default=None, description='Status-Notiz') vehicle: int | None = Field(default=None, description='ID des aktuellen Fahrzeugs') - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Letzte Änderung als UNIX-Timestamp', ) @@ -170,7 +171,7 @@ def _coerce_items(cls, v: object) -> Mapping[str, object]: default_factory=tuple, description='Reihenfolge aufsteigend', ) - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Letzte Änderung als UNIX-Timestamp', ) @@ -223,11 +224,11 @@ class StatusPlanEntry(BaseModel): description='ID der Kalender-Kategorie', ) title: str | None = Field(default=None, description='Beschreibung') - begin_ts: int | None = Field( + begin_ts: datetime.datetime | None = Field( default=None, description='Beginn als UNIX-Timestamp', ) - end_ts: int | None = Field( + end_ts: datetime.datetime | None = Field( default=None, description='Ende als UNIX-Timestamp', ) @@ -256,7 +257,7 @@ class StatusPlanEntry(BaseModel): default=None, description='Laufzeit begrenzen', ) - repeat_until_ts: int | None = Field( + repeat_until_ts: datetime.datetime | None = Field( default=None, description='Wiederholung bis als UNIX-Timestamp', ) @@ -301,7 +302,7 @@ class PullStatusplanData(BaseModel): default=None, description='Statusplan-Kategorien (deprecated)', ) - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Letzte Änderung als UNIX-Timestamp', ) @@ -356,7 +357,7 @@ class PullLocalmonitorData(BaseModel): default_factory=tuple, description='Reihenfolge', ) - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Letzte Änderung als UNIX-Timestamp', ) @@ -365,7 +366,7 @@ class PullLocalmonitorData(BaseModel): class PullMonitorData(BaseModel): """Personnel availability (monitor) in pull data.""" - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Letzte Änderung als UNIX-Timestamp', ) @@ -546,7 +547,7 @@ class PullMessageChannelData(BaseModel): default_factory=tuple, description='Reihenfolge', ) - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Letzte Änderung als UNIX-Timestamp', ) @@ -575,7 +576,7 @@ class PullMessageData(BaseModel): default=None, description='Limit', ) - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Letzte Änderung als UNIX-Timestamp', ) @@ -596,7 +597,7 @@ class PullData(BaseModel): default=None, description='ID der UserClusterRelation im aktuellen Request', ) - ts: int | None = Field( + ts: datetime.datetime | None = Field( default=None, description='Aktueller UNIX-Timestamp des Servers', ) @@ -670,7 +671,7 @@ class VehicleStatusItem(BaseModel): default=None, description='Notiz/Freitext-Rückmeldung', ) - fmsstatus_ts: int | None = Field(default=None, description='UNIX-Timestamp') + fmsstatus_ts: datetime.datetime | None = Field(default=None, description='UNIX-Timestamp') lat: float | None = Field(default=None, description='Breitengrad') lng: float | None = Field(default=None, description='Längengrad') diff --git a/src/divera247/models/statusgeber.py b/src/divera247/models/statusgeber.py index 49deb5a..5698ed0 100644 --- a/src/divera247/models/statusgeber.py +++ b/src/divera247/models/statusgeber.py @@ -5,6 +5,8 @@ from __future__ import annotations +import datetime # noqa: TC003 + from pydantic import BaseModel, Field @@ -20,7 +22,7 @@ class StatusgeberStatus(BaseModel): default=None, description='Freitext zur Statusmeldung', ) - reset_date: int | None = Field( + reset_date: datetime.datetime | None = Field( default=None, description='UNIX-Timestamp für Zurücksetzen', ) diff --git a/src/divera247/websocket/__init__.py b/src/divera247/websocket/__init__.py index 0f2bbc6..2d812a1 100644 --- a/src/divera247/websocket/__init__.py +++ b/src/divera247/websocket/__init__.py @@ -14,6 +14,7 @@ * Pydantic event envelopes (:class:`UserStatusEvent`, :class:`ClusterPullEvent` with its :class:`ClusterPullRef`, :class:`ClusterVehicleEvent` with :class:`ClusterVehicleState`, + :class:`ClusterMonitorEvent`, :class:`UnknownEvent`) and the :data:`DiveraEvent` discriminated union plus :func:`parse_event` for dispatching raw frames onto typed models. @@ -23,6 +24,7 @@ from divera247.websocket import ( ClusterPullEvent, + ClusterMonitorEvent, ClusterVehicleEvent, UnknownEvent, UserStatusEvent, @@ -37,11 +39,14 @@ ... # re-fetch pull.type / pull.id for this cluster case ClusterVehicleEvent(cluster=cluster, vehicle=vehicle): ... # vehicle.id / vehicle.fmsstatus_id changed + case ClusterMonitorEvent(cluster=cluster, monitor=monitor): + ... # monitor counters changed case UnknownEvent(type=msg_type): ... """ from divera247.websocket.models import ( + ClusterMonitorEvent, ClusterPullEvent, ClusterPullRef, ClusterVehicleEvent, @@ -58,6 +63,7 @@ ) __all__ = [ + 'ClusterMonitorEvent', 'ClusterPullEvent', 'ClusterPullRef', 'ClusterVehicleEvent', diff --git a/src/divera247/websocket/models.py b/src/divera247/websocket/models.py index baff3b3..0aa7d83 100644 --- a/src/divera247/websocket/models.py +++ b/src/divera247/websocket/models.py @@ -1,5 +1,6 @@ """Pydantic models for Divera 24/7 WebSocket push events.""" +import datetime import logging from collections.abc import Mapping from typing import Annotated, Any, Literal @@ -80,7 +81,7 @@ class ClusterVehicleState(BaseModel): 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') + fmsstatus_ts: datetime.datetime = Field(description='Zeitpunkt der letzten Statusaenderung') class ClusterVehicleEvent(BaseModel): @@ -116,6 +117,26 @@ class ClusterVehicleEvent(BaseModel): ) +class ClusterMonitorEvent(BaseModel): + """``cluster-monitor`` WebSocket event with monitor counters per status. + + ``monitor`` mirrors the structure from ``pull/all`` monitor data: + group/category -> status_id -> bucket map (e.g. ``all`` + ``qualification``). + """ + + model_config = ConfigDict(extra='allow') + + type: Literal['cluster-monitor'] = Field(description='Event-Typ') + monitor: Mapping[str, Any] = Field( + validation_alias=AliasPath('payload', 'monitor'), + description='Monitor-Daten je Gruppe und Status (aus payload.monitor)', + ) + 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. @@ -167,7 +188,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', 'cluster-vehicle'}) +_KNOWN_EVENT_TYPES: frozenset[str] = frozenset({'user-status', 'cluster-pull', 'cluster-vehicle', 'cluster-monitor'}) def _event_discriminator(value: Any) -> str: @@ -187,6 +208,7 @@ def _event_discriminator(value: Any) -> str: Annotated[UserStatusEvent, Tag('user-status')] | Annotated[ClusterPullEvent, Tag('cluster-pull')] | Annotated[ClusterVehicleEvent, Tag('cluster-vehicle')] + | Annotated[ClusterMonitorEvent, Tag('cluster-monitor')] | Annotated[UnknownEvent, Tag('unknown')], Discriminator(_event_discriminator), ] @@ -197,12 +219,14 @@ def _event_discriminator(value: Any) -> str: """ -_event_adapter: TypeAdapter[UserStatusEvent | ClusterPullEvent | ClusterVehicleEvent | UnknownEvent] = TypeAdapter( - DiveraEvent -) +_event_adapter: TypeAdapter[ + UserStatusEvent | ClusterPullEvent | ClusterVehicleEvent | ClusterMonitorEvent | UnknownEvent +] = TypeAdapter(DiveraEvent) -def parse_event(event: Mapping[str, Any]) -> UserStatusEvent | ClusterPullEvent | ClusterVehicleEvent | UnknownEvent: +def parse_event( + event: Mapping[str, Any], +) -> UserStatusEvent | ClusterPullEvent | ClusterVehicleEvent | ClusterMonitorEvent | 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 4de9918..ceceea5 100644 --- a/src/divera247/websocket/session.py +++ b/src/divera247/websocket/session.py @@ -148,7 +148,13 @@ 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.ClusterVehicleEvent | models.UnknownEvent]: +) -> AsyncIterator[ + models.UserStatusEvent + | models.ClusterPullEvent + | models.ClusterVehicleEvent + | models.ClusterMonitorEvent + | models.UnknownEvent +]: """Yield typed Divera 24/7 WebSocket events from a single session. Exits when the underlying socket disconnects (by raising @@ -169,7 +175,13 @@ 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.ClusterVehicleEvent | models.UnknownEvent]: +) -> AsyncIterator[ + models.UserStatusEvent + | models.ClusterPullEvent + | models.ClusterVehicleEvent + | models.ClusterMonitorEvent + | 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 efe4ee7..02c4e82 100644 --- a/tests/websocket/test_models.py +++ b/tests/websocket/test_models.py @@ -2,9 +2,12 @@ from __future__ import annotations +import datetime + import pytest from divera247.websocket.models import ( + ClusterMonitorEvent, ClusterPullEvent, ClusterPullRef, ClusterVehicleEvent, @@ -73,6 +76,20 @@ }, } +_CLUSTER_MONITOR_SAMPLE: dict = { + 'type': 'cluster-monitor', + 'payload': { + 'type': 'cluster-monitor', + 'monitor': { + '1': { + '1001': {'all': 10, 'qualification': {'2': 4, '3': 2}}, + '1002': {'all': 3, 'qualification': {'2': 1}}, + } + }, + 'cluster': SAMPLE_CLUSTER, + }, +} + def test_user_status_event_flattens_nested_payload() -> None: """UserStatusEvent hoists ``status`` and ``ucr`` out of the nested payload via AliasPath.""" @@ -140,6 +157,8 @@ def test_cluster_vehicle_event_flattens_nested_payload() -> None: assert isinstance(event.vehicle, ClusterVehicleState) assert event.vehicle.id == SAMPLE_VEHICLE_ID assert event.vehicle.fmsstatus_id == SAMPLE_VEHICLE_FMS + assert isinstance(event.vehicle.fmsstatus_ts, datetime.datetime) + assert event.vehicle.fmsstatus_ts == datetime.datetime.fromtimestamp(1700000120, tz=datetime.UTC) def test_cluster_vehicle_state_preserves_unknown_fields() -> None: @@ -156,6 +175,15 @@ def test_cluster_vehicle_state_preserves_unknown_fields() -> None: assert state.model_extra == {'name': 'LF 20'} +def test_cluster_monitor_event_flattens_nested_payload() -> None: + """ClusterMonitorEvent hoists ``monitor`` + ``cluster`` via AliasPath.""" + event = ClusterMonitorEvent.model_validate(_CLUSTER_MONITOR_SAMPLE) + assert event.type == 'cluster-monitor' + assert event.cluster == SAMPLE_CLUSTER + assert '1' in event.monitor + assert event.monitor['1']['1001']['all'] == 10 + + 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'}) @@ -201,6 +229,14 @@ def test_parse_event_dispatches_cluster_vehicle() -> None: assert parsed.vehicle.fmsstatus_id == SAMPLE_VEHICLE_FMS +def test_parse_event_dispatches_cluster_monitor() -> None: + """``parse_event`` routes ``cluster-monitor`` frames to ClusterMonitorEvent.""" + parsed = parse_event(_CLUSTER_MONITOR_SAMPLE) + assert isinstance(parsed, ClusterMonitorEvent) + assert parsed.cluster == SAMPLE_CLUSTER + assert parsed.monitor['1']['1002']['all'] == 3 + + @pytest.mark.parametrize( 'event_type', ['cluster-message', 'some-brand-new-event'],