diff --git a/docs/migration-v2.md b/docs/migration-v2.md index 6541d431..8a08ad9a 100644 --- a/docs/migration-v2.md +++ b/docs/migration-v2.md @@ -334,6 +334,90 @@ and `device.definition.commands`. - `Gateway.connectivity` is now `Connectivity | None` (was always set in v1). - `Gateway.id` and `Place.id` are now read-only properties. +## Events + +In v1, `Event` was a single flat class that carried every field any event could +possibly have, so every field was optional and present on every event regardless +of its `name`. In v2, `Event` is the base of a **typed hierarchy**: structuring an +event payload returns a concrete subtype chosen by its `name` (e.g. +`DeviceStateChangedEvent`, `ExecutionStateChangedEvent`, `GatewayDownEvent`). The +base `Event` keeps only the fields common to every event (`name`, `timestamp`, +`setup_oid`, `owning_partners`); everything else lives on the relevant subtype. + +The model is three rules: + +1. **Each modeled event is its own typed class** whose fields are that event's + payload — narrow with `isinstance` and the present fields are typed. Related + events share a **category base** carrying that category's identity field, so + you can narrow broadly or precisely: + + | Category base | Identity field | Leaf subtypes | + |---------------|----------------|---------------| + | `DeviceEvent` | `device_url` | `DeviceStateChangedEvent`, `DeviceAvailableEvent`, … | + | `GatewayEvent` | `gateway_id` | `GatewayDownEvent`, `GatewayAliveEvent`, … | + | `ZoneEvent` | `zone_oid` | `ZoneCreatedEvent`, `ZoneUpdatedEvent`, `ZoneDeletedEvent` | + | `ExecutionEvent` | `exec_id` | `ExecutionRegisteredEvent`, `ExecutionStateChangedEvent` | + +2. **All `*FailedEvent` names structure into one `FailureEvent`.** Consumers + branch on *did it fail, and why* (`failure_type`), not on which of the ~30 + operations failed; the specific operation is in `event.name`. (See the + `FailureEvent` docstring.) +3. **Anything unmodeled is the base `Event`** — including new names the API adds + later — so unknown events never raise. Check `event.name`. + +Narrow with `isinstance` before accessing subtype-specific fields: + +=== "v1" + + ```python + for event in events: + # Every field existed on every Event (None when not applicable) + if event.device_states: + ... + ``` + +=== "v2" + + ```python + from pyoverkiz.models import DeviceStateChangedEvent + + for event in events: + # device_states only exists on DeviceStateChangedEvent + if isinstance(event, DeviceStateChangedEvent): + for state in event.device_states: + ... + ``` + +### Strict subtypes, resilient batches + +Subtypes mark the fields the API is documented to always send for that event as +**required** (no `None` default): `device_url` on device events, `gateway_id` on +gateway events, `zone_oid` on zone events, and `exec_id` / `new_state` / +`old_state` on execution-state events. This means once you have narrowed to a +subtype, those fields are guaranteed non-`None` — no defensive checks needed. + +To keep that strictness from making event fetching fragile, structuring degrades +**per event**: if a single payload is missing a required field (an undocumented +API quirk or partial data), that one event falls back to the base `Event` and a +warning is logged — the rest of the batch is unaffected. A flood of such warnings +is a signal that a field marked required should be loosened. + +### Removed fields + +These v1 `Event` fields are not carried by any v2 event subtype and are no longer +available: + +| Field | v1 events that carried it | +|-------|---------------------------| +| `camera_id` | `CameraDiscoveredEvent`, `CameraUploadPhotoEvent` | +| `condition_groupoid` | `ConditionGroup*Event` | +| `deleted_raw_devices_count` | `PurgePartialRawDevicesEvent` | + +`failure_type_code` is no longer on failure events either: the API only ever +sends it on `ExecutionStateChangedEvent`, where it remains. The `*FailedEvent` +payloads carry `failure_type` (plus `gateway_id` / `device_url` / `protocol_type` +where applicable), all surfaced on `FailureEvent`. + ## Authentication methods The per-server login helpers on `OverkizClient` have been removed. Authentication is now handled internally by the credential/strategy system — call the single `login()` method, which dispatches to the correct strategy based on the `Credentials` you passed to the constructor. @@ -363,7 +447,7 @@ These changes affect you if you subclass `OverkizClient` or use internal APIs: | `deviceurl` | `device_url` | | `Event.setupoid` | `Event.setup_oid` | -Update any keyword arguments and attribute accesses using the old spelling. `Event` also gains `actions`, `owner`, and `source` fields. +Update any keyword arguments and attribute accesses using the old spelling. The `actions`, `owner`, and `source` fields now live on `ExecutionRegisteredEvent` (see [Events](#events)). ## Model defaults diff --git a/pyoverkiz/_case.py b/pyoverkiz/_case.py index d1d5b79f..1ac05f48 100644 --- a/pyoverkiz/_case.py +++ b/pyoverkiz/_case.py @@ -18,8 +18,11 @@ def recursive_key_map(data: Any, key_fn: Callable[[str], str]) -> Any: _CAMELIZE_OVERRIDES: dict[str, str] = { "device_url": "deviceURL", + "device_urls": "deviceURLs", "place_oid": "placeOID", + "place_oids": "placeOIDs", "setup_oid": "setupOID", + "zone_oid": "zoneOID", } diff --git a/pyoverkiz/converter.py b/pyoverkiz/converter.py index b0f2c79a..91c02f4a 100644 --- a/pyoverkiz/converter.py +++ b/pyoverkiz/converter.py @@ -2,25 +2,31 @@ from __future__ import annotations +import logging import types from enum import Enum from typing import Any, Union, get_args, get_origin import attr import cattrs +from cattrs.errors import ClassValidationError from cattrs.gen import make_dict_structure_fn, override from pyoverkiz._case import camelize_key -from pyoverkiz.enums import GatewaySubType +from pyoverkiz.enums import EventName, GatewaySubType from pyoverkiz.models import ( + EVENT_TYPE_BY_NAME, CommandDefinition, CommandDefinitions, + Event, State, StateDefinition, StateDefinitions, States, ) +_LOGGER = logging.getLogger(__name__) + def _is_primitive_union(t: Any) -> bool: """True for unions of JSON-native types (e.g. StateType). @@ -103,6 +109,52 @@ def _structure_state_definitions(val: Any, _: type) -> StateDefinitions: _rename_hook_factory, ) + # Event is a discriminated union keyed on "name". Pre-build each subtype's + # hook and call it directly; routing via c.structure(val, subtype) would + # re-enter this hook (subclass dispatches to its base) and recurse forever. + event_types: set[type[Event]] = {Event, *EVENT_TYPE_BY_NAME.values()} + event_hooks: dict[type[Event], Any] = { + cls: _rename_hook_factory(cls, c) for cls in event_types + } + + def _structure_event(val: Any, _: type) -> Event: + name = val.get("name") if isinstance(val, dict) else None + target: type[Event] = Event + if name is not None: + target = EVENT_TYPE_BY_NAME.get(EventName(name), Event) + try: + return event_hooks[target](val, target) # type: ignore[no-any-return] + except ClassValidationError as err: + # A payload missing a required field degrades to base Event rather + # than failing the whole batch; the warning flags a field to loosen. + if target is Event: + raise + _LOGGER.warning( + "Could not structure %s as %s (%s); falling back to base Event", + name, + target.__name__, + err, + ) + return event_hooks[Event](val, Event) # type: ignore[no-any-return] + + c.register_structure_hook(Event, _structure_event) + + def _structure_event_list(val: Any, _: type) -> list[Event]: + # A single unstructurable event (e.g. missing "name", or not a dict) must + # not sink the whole poll. _structure_event already degrades known + # subtypes to base Event; here we drop the few items it can't build at all. + if not val: + return [] + events: list[Event] = [] + for raw in val: + try: + events.append(_structure_event(raw, Event)) + except (ClassValidationError, ValueError, TypeError) as err: + _LOGGER.warning("Dropping unstructurable event %r (%s)", raw, err) + return events + + c.register_structure_hook_func(lambda t: t == list[Event], _structure_event_list) + return c diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index a9df61fc..2308e450 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -558,39 +558,341 @@ def id(self) -> str: @define(kw_only=True) class Event: - """Represents an Overkiz event containing metadata and device states.""" + """Base Overkiz event; structured into a subtype by ``name`` (see converter).""" name: EventName timestamp: int | None = None setup_oid: str | None = field(repr=obfuscate_id, default=None) + owning_partners: list[str] | None = None + + +@define(kw_only=True) +class DeviceEvent(Event): + """Any event about a device; ``device_url`` identifies it (required). + + Narrow to this to handle all device events uniformly, or to a leaf subtype + (e.g. DeviceStateChangedEvent) for that event's full payload. + """ + + device_url: str = field(repr=obfuscate_id) + + +@define(kw_only=True) +class DeviceStateChangedEvent(DeviceEvent): + """One or more states of a device changed (high-level).""" + + device_states: list[EventState] = field(factory=list) + + +@define(kw_only=True) +class ExecutionEvent(Event): + """Any event about an execution; ``exec_id`` identifies it (required). + + Narrow to this to handle all execution events uniformly, or to a leaf + subtype (e.g. ExecutionStateChangedEvent) for that event's full payload. + """ + + exec_id: str + + +@define(kw_only=True) +class ExecutionRegisteredEvent(ExecutionEvent): + """A new execution was registered.""" + + label: str | None = None + metadata: str | None = None + type: int | None = None + sub_type: int | None = None + actions: list[Action] = field(factory=list) + source: str | None = None + owner: str | None = field(repr=obfuscate_email, default=None) + + +@define(kw_only=True) +class ExecutionStateChangedEvent(ExecutionEvent): + """An execution state has changed; new_state/old_state are required.""" + + new_state: ExecutionState + old_state: ExecutionState owner_key: str | None = field(repr=obfuscate_id, default=None) type: int | None = None sub_type: int | None = None time_to_next_state: int | None = None failed_commands: list[dict[str, Any]] | None = None + failure_type: str | None = None failure_type_code: FailureType | None = None + + +@define(kw_only=True) +class FailureEvent(Event): + """Any ``*FailedEvent``: failure reason plus the operation's scope id. + + By design every ``*FailedEvent`` name structures into this one type rather + than a per-name class: consumers branch on "did it fail, and why" + (``failure_type``), not on which of the ~30 operations failed. The specific + operation is identified by ``event.name``. + + gateway_id / device_url / protocol_type cover the documented failure + payloads. + """ + failure_type: str | None = None - condition_groupoid: str | None = None - place_oid: str | None = None - label: str | None = None - metadata: str | None = None - camera_id: str | None = None - deleted_raw_devices_count: int | None = None - protocol_type: int | None = None gateway_id: str | None = field(repr=obfuscate_id, default=None) - exec_id: str | None = None device_url: str | None = field(repr=obfuscate_id, default=None) - device_states: list[EventState] = field( - factory=list, - converter=lambda states: [ - EventState(**s) if isinstance(s, dict) else s for s in states - ], - ) - old_state: ExecutionState | None = None - new_state: ExecutionState | None = None - actions: list[Action] | None = None - owner: str | None = field(repr=obfuscate_email, default=None) - source: str | None = None + protocol_type: int | None = None + + +@define(kw_only=True) +class GatewayEvent(Event): + """Any gateway event; ``gateway_id`` identifies it (required). + + Narrow to this to handle all gateway events uniformly, or to a leaf subtype + (e.g. GatewayDownEvent) for that event's full payload. + """ + + gateway_id: str = field(repr=obfuscate_id) + + +@define(kw_only=True) +class GatewayActivatedEvent(GatewayEvent): + """A gateway was activated.""" + + +@define(kw_only=True) +class GatewayActiveProtocolsChangedEvent(GatewayEvent): + """A gateway's active protocols changed.""" + + +@define(kw_only=True) +class GatewayAliveEvent(GatewayEvent): + """A gateway became reachable.""" + + +@define(kw_only=True) +class GatewayAssociatedEvent(GatewayEvent): + """A gateway was associated with the setup.""" + + +@define(kw_only=True) +class GatewayAttachedEvent(GatewayEvent): + """A gateway was attached.""" + + +@define(kw_only=True) +class GatewayBootEvent(GatewayEvent): + """A gateway booted.""" + + +@define(kw_only=True) +class GatewayDeactivatedEvent(GatewayEvent): + """A gateway was deactivated.""" + + +@define(kw_only=True) +class GatewayDetachedEvent(GatewayEvent): + """A gateway was detached.""" + + +@define(kw_only=True) +class GatewayDissociatedEvent(GatewayEvent): + """A gateway was dissociated from the setup.""" + + +@define(kw_only=True) +class GatewayDownEvent(GatewayEvent): + """A gateway became unreachable.""" + + +@define(kw_only=True) +class GatewayDownOptionsChangedEvent(GatewayEvent): + """A gateway's down-detection timeout changed.""" + + timeout: int | None = None + + +@define(kw_only=True) +class GatewayFirmwareUpdatedEvent(GatewayEvent): + """A gateway's firmware was updated.""" + + +@define(kw_only=True) +class GatewayFirmwareUpdateCompletedEvent(GatewayEvent): + """A gateway firmware update completed.""" + + firmware_type: str | None = None + + +@define(kw_only=True) +class GatewayFunctionChangedEvent(GatewayEvent): + """A gateway function was enabled or disabled.""" + + function_type: int | None = None + enabled: bool | None = None + + +@define(kw_only=True) +class GatewayMigratedEvent(GatewayEvent): + """A gateway was migrated.""" + + +@define(kw_only=True) +class GatewayModeChangedEvent(GatewayEvent): + """A gateway's mode changed.""" + + +@define(kw_only=True) +class GatewayPlaceUpdatedEvent(GatewayEvent): + """A gateway's place was updated.""" + + +@define(kw_only=True) +class GatewayProtocolDownEvent(GatewayEvent): + """A gateway protocol became unavailable.""" + + +@define(kw_only=True) +class GatewayProtocolReadyEvent(GatewayEvent): + """A gateway protocol became available.""" + + +@define(kw_only=True) +class GatewaySynchronizationEndedEvent(GatewayEvent): + """A gateway synchronization ended.""" + + +@define(kw_only=True) +class GatewaySynchronizationStartedEvent(GatewayEvent): + """A gateway synchronization started.""" + + +@define(kw_only=True) +class GatewayTimeReliabilityChangedEvent(GatewayEvent): + """A gateway's time reliability changed.""" + + +@define(kw_only=True) +class DeviceAvailableEvent(DeviceEvent): + """A device became available.""" + + +@define(kw_only=True) +class DeviceUnavailableEvent(DeviceEvent): + """A device became unavailable.""" + + +@define(kw_only=True) +class DeviceDisabledEvent(DeviceEvent): + """A device was disabled.""" + + +@define(kw_only=True) +class _DeviceMetadataEvent(DeviceEvent): + """Shared base for device create/update events (carry label/place metadata).""" + + controllable_name: str | None = None + label: str | None = field(repr=obfuscate_string, default=None) + place_oid: str | None = None + metadata: str | None = None + + +@define(kw_only=True) +class DeviceCreatedEvent(_DeviceMetadataEvent): + """A device was created.""" + + +@define(kw_only=True) +class DeviceUpdatedEvent(_DeviceMetadataEvent): + """A device was updated.""" + + +@define(kw_only=True) +class DeviceRemovedEvent(DeviceEvent): + """A device was removed.""" + + controllable_name: str | None = None + + +@define(kw_only=True) +class ZoneEvent(Event): + """Any zone event; ``zone_oid`` identifies it (required). + + Narrow to this to handle all zone events uniformly, or to a leaf subtype + (e.g. ZoneCreatedEvent) for that event's full payload. + """ + + zone_oid: str + + +@define(kw_only=True) +class ZoneDeletedEvent(ZoneEvent): + """A zone was deleted.""" + + +@define(kw_only=True) +class _ZoneMutationEvent(ZoneEvent): + """Shared base for zone create/update events (carry membership lists).""" + + type: int | None = None + label: str | None = field(repr=obfuscate_string, default=None) + device_urls: list[str] = field(factory=list) + place_oids: list[str] = field(factory=list) + + +@define(kw_only=True) +class ZoneCreatedEvent(_ZoneMutationEvent): + """A zone was created.""" + + +@define(kw_only=True) +class ZoneUpdatedEvent(_ZoneMutationEvent): + """A zone was updated.""" + + +# Event name -> subtype. Unlisted names structure into the base Event. +EVENT_TYPE_BY_NAME: dict[EventName, type[Event]] = { + EventName.DEVICE_STATE_CHANGED: DeviceStateChangedEvent, + EventName.EXECUTION_REGISTERED: ExecutionRegisteredEvent, + EventName.EXECUTION_STATE_CHANGED: ExecutionStateChangedEvent, + EventName.DEVICE_AVAILABLE: DeviceAvailableEvent, + EventName.DEVICE_UNAVAILABLE: DeviceUnavailableEvent, + EventName.DEVICE_DISABLED: DeviceDisabledEvent, + EventName.DEVICE_CREATED: DeviceCreatedEvent, + EventName.DEVICE_UPDATED: DeviceUpdatedEvent, + EventName.DEVICE_REMOVED: DeviceRemovedEvent, + EventName.ZONE_CREATED: ZoneCreatedEvent, + EventName.ZONE_UPDATED: ZoneUpdatedEvent, + EventName.ZONE_DELETED: ZoneDeletedEvent, + EventName.GATEWAY_ACTIVATED: GatewayActivatedEvent, + EventName.GATEWAY_ACTIVE_PROTOCOLS_CHANGED: GatewayActiveProtocolsChangedEvent, + EventName.GATEWAY_ALIVE: GatewayAliveEvent, + EventName.GATEWAY_ASSOCIATED: GatewayAssociatedEvent, + EventName.GATEWAY_ATTACHED: GatewayAttachedEvent, + EventName.GATEWAY_BOOT: GatewayBootEvent, + EventName.GATEWAY_DEACTIVATED: GatewayDeactivatedEvent, + EventName.GATEWAY_DETACHED: GatewayDetachedEvent, + EventName.GATEWAY_DISSOCIATED: GatewayDissociatedEvent, + EventName.GATEWAY_DOWN: GatewayDownEvent, + EventName.GATEWAY_DOWN_OPTIONS_CHANGED: GatewayDownOptionsChangedEvent, + EventName.GATEWAY_FIRMWARE_UPDATED: GatewayFirmwareUpdatedEvent, + EventName.GATEWAY_FIRMWARE_UPDATE_COMPLETED: GatewayFirmwareUpdateCompletedEvent, + EventName.GATEWAY_FUNCTION_CHANGED: GatewayFunctionChangedEvent, + EventName.GATEWAY_MIGRATED: GatewayMigratedEvent, + EventName.GATEWAY_MODE_CHANGED: GatewayModeChangedEvent, + EventName.GATEWAY_PLACE_UPDATED: GatewayPlaceUpdatedEvent, + EventName.GATEWAY_PROTOCOL_DOWN: GatewayProtocolDownEvent, + EventName.GATEWAY_PROTOCOL_READY: GatewayProtocolReadyEvent, + EventName.GATEWAY_SYNCHRONIZATION_ENDED: GatewaySynchronizationEndedEvent, + EventName.GATEWAY_SYNCHRONIZATION_STARTED: GatewaySynchronizationStartedEvent, + EventName.GATEWAY_TIME_RELIABILITY_CHANGED: GatewayTimeReliabilityChangedEvent, +} + +# Every "*FailedEvent" structures into the shared FailureEvent (see its +# docstring); derived from the enum so new failure names are covered too. +# Explicit mappings above win. +for _name in EventName: + if _name.value.endswith("FailedEvent"): + EVENT_TYPE_BY_NAME.setdefault(_name, FailureEvent) +del _name @define(kw_only=True) diff --git a/tests/test_case.py b/tests/test_case.py index 72895b00..93d2aff5 100644 --- a/tests/test_case.py +++ b/tests/test_case.py @@ -19,3 +19,11 @@ def test_single_word(self): def test_device_url(self): """device_url camelizes to deviceURL (non-standard API casing).""" assert camelize_key("device_url") == "deviceURL" + + def test_acronym_overrides(self): + """Keys ending in OID/URL(s) use the API's uppercase acronym casing.""" + assert camelize_key("setup_oid") == "setupOID" + assert camelize_key("place_oid") == "placeOID" + assert camelize_key("zone_oid") == "zoneOID" + assert camelize_key("device_urls") == "deviceURLs" + assert camelize_key("place_oids") == "placeOIDs" diff --git a/tests/test_client.py b/tests/test_client.py index c70a62bd..d46e1667 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -30,6 +30,7 @@ Action, Command, DeveloperMode, + DeviceStateChangedEvent, Execution, HistoryExecution, LocalToken, @@ -201,6 +202,9 @@ async def test_fetch_events_casting(self, client: OverkizClient, fixture_name: s events = await client.fetch_events() for event in events: + # Only device state changed events carry device_states. + if not isinstance(event, DeviceStateChangedEvent): + continue for state in event.device_states: if state.type == 0: assert state.value is None diff --git a/tests/test_models.py b/tests/test_models.py index 8875fc2e..42e60e37 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging from pathlib import Path import cattrs.errors @@ -29,15 +30,30 @@ CommandDefinitions, Definition, Device, + DeviceAvailableEvent, + DeviceCreatedEvent, + DeviceDisabledEvent, + DeviceRemovedEvent, + DeviceStateChangedEvent, + DeviceUnavailableEvent, + DeviceUpdatedEvent, Event, EventState, + ExecutionRegisteredEvent, + ExecutionStateChangedEvent, + FailureEvent, Gateway, + GatewayFunctionChangedEvent, + GatewaySynchronizationEndedEvent, PersistedActionGroup, Setup, State, StateDefinition, StateDefinitions, States, + ZoneCreatedEvent, + ZoneDeletedEvent, + ZoneUpdatedEvent, ) from pyoverkiz.obfuscate import obfuscate_id @@ -1080,6 +1096,7 @@ def test_execution_state_changed_event(self): Event, ) + assert isinstance(event, ExecutionStateChangedEvent) assert event.name == EventName.EXECUTION_STATE_CHANGED assert event.old_state is ExecutionState.TRANSMITTED assert event.new_state is ExecutionState.IN_PROGRESS @@ -1091,28 +1108,32 @@ def test_failure_type_code_structured_as_enum(self): { "name": "ExecutionStateChangedEvent", "timestamp": 123, + "execId": "c6f83624-ac10-3e01-653e-2b025fee956d", + "newState": "FAILED", + "oldState": "IN_PROGRESS", "failureTypeCode": 0, }, Event, ) + assert isinstance(event, ExecutionStateChangedEvent) assert isinstance(event.failure_type_code, FailureType) assert event.failure_type_code is FailureType.NO_FAILURE def test_optional_enum_fields_none_when_absent(self): - """Optional enum fields default to None when not present in the payload.""" + """Base events do not expose subtype-only enum fields.""" event = converter.structure( { - "name": "GatewaySynchronizationEndedEvent", + "name": "CommandExecutionStateChangedEvent", "timestamp": 1631130645998, - "gatewayId": "9876-1234-8767", }, Event, ) - assert event.old_state is None - assert event.new_state is None - assert event.failure_type_code is None + assert type(event) is Event + assert not hasattr(event, "old_state") + assert not hasattr(event, "new_state") + assert not hasattr(event, "failure_type_code") def test_device_state_changed_event_with_states(self): """DeviceStateChangedEvent payload structures device_states as EventState.""" @@ -1133,6 +1154,7 @@ def test_device_state_changed_event_with_states(self): Event, ) + assert isinstance(event, DeviceStateChangedEvent) assert event.name == EventName.DEVICE_STATE_CHANGED assert len(event.device_states) == 1 assert isinstance(event.device_states[0], EventState) @@ -1143,13 +1165,374 @@ def test_event_fixture_structures_all_events(self): events = [converter.structure(e, Event) for e in raw_events] assert len(events) == len(raw_events) - state_changed = [ - e for e in events if e.name == EventName.EXECUTION_STATE_CHANGED - ] + state_changed = [e for e in events if isinstance(e, ExecutionStateChangedEvent)] + assert state_changed # fixture contains ExecutionStateChangedEvent entries for e in state_changed: assert isinstance(e.old_state, ExecutionState) assert isinstance(e.new_state, ExecutionState) + def test_base_event_has_universal_fields_only(self): + """Base Event keeps universal fields incl. new owning_partners; no subtype fields.""" + event = converter.structure( + { + "name": "CommandExecutionStateChangedEvent", + "timestamp": 1631130645998, + "setupOID": "741bc89f-a47b-4ad6-894d-a785c06956c2", + "owningPartners": ["partner-a"], + }, + Event, + ) + + assert type(event) is Event + assert event.name == EventName.COMMAND_EXECUTION_STATE_CHANGE + assert event.timestamp == 1631130645998 + assert event.setup_oid == "741bc89f-a47b-4ad6-894d-a785c06956c2" + assert event.owning_partners == ["partner-a"] + assert not hasattr(event, "device_states") + assert not hasattr(event, "new_state") + + def test_device_state_changed_event_subtype(self): + """DeviceStateChangedEvent has required device_url and device_states.""" + event = DeviceStateChangedEvent( + name=EventName.DEVICE_STATE_CHANGED, + device_url="io://1234-5678-9012/4468654#1", + device_states=[ + EventState( + name="core:ElectricEnergyConsumptionState", + type=DataType.INTEGER, + value=23247220, + ) + ], + ) + + assert isinstance(event, Event) + assert event.device_url == "io://1234-5678-9012/4468654#1" + assert len(event.device_states) == 1 + + def test_execution_state_changed_event_subtype(self): + """ExecutionStateChangedEvent carries execution transition fields.""" + event = ExecutionStateChangedEvent( + name=EventName.EXECUTION_STATE_CHANGED, + exec_id="c6f83624-ac10-3e01-653e-2b025fee956d", + new_state=ExecutionState.IN_PROGRESS, + old_state=ExecutionState.TRANSMITTED, + ) + assert isinstance(event, Event) + assert event.new_state is ExecutionState.IN_PROGRESS + assert event.old_state is ExecutionState.TRANSMITTED + + def test_execution_registered_event_subtype(self): + """ExecutionRegisteredEvent carries actions and exec metadata.""" + event = ExecutionRegisteredEvent( + name=EventName.EXECUTION_REGISTERED, + exec_id="c6f83624-ac10-3e01-653e-2b025fee956d", + label="Volet salon", + actions=[ + Action( + device_url="io://1234-5678-9012/11212197", + commands=[Command(name="setClosure")], + ) + ], + ) + assert isinstance(event, Event) + assert event.exec_id == "c6f83624-ac10-3e01-653e-2b025fee956d" + assert len(event.actions) == 1 + + def test_failure_event_subtype(self): + """FailureEvent carries failure_type plus the operation's scope id.""" + event = FailureEvent( + name=EventName.GATEWAY_SYNCHRONIZATION_FAILED, + failure_type="some failure", + gateway_id="9876-1234-8767", + ) + assert isinstance(event, Event) + assert event.failure_type == "some failure" + assert event.gateway_id == "9876-1234-8767" + # failureTypeCode is never sent on *FailedEvent; it is not modeled here. + assert not hasattr(event, "failure_type_code") + + def test_device_lifecycle_subtypes(self): + """Device lifecycle events carry device_url; created/updated/removed add controllable_name.""" + created = DeviceCreatedEvent( + name=EventName.DEVICE_CREATED, + device_url="io://1234-5678-9012/4468654", + controllable_name="io:RollerShutterGenericIOComponent", + ) + assert created.device_url == "io://1234-5678-9012/4468654" + assert created.controllable_name == "io:RollerShutterGenericIOComponent" + + updated = DeviceUpdatedEvent( + name=EventName.DEVICE_UPDATED, + device_url="io://1234-5678-9012/4468654", + controllable_name="io:RollerShutterGenericIOComponent", + ) + assert updated.controllable_name == "io:RollerShutterGenericIOComponent" + + removed = DeviceRemovedEvent( + name=EventName.DEVICE_REMOVED, + device_url="io://1234-5678-9012/4468654", + controllable_name="io:RollerShutterGenericIOComponent", + ) + assert removed.controllable_name == "io:RollerShutterGenericIOComponent" + + # Available / Unavailable / Disabled carry only device_url (no controllable_name). + for cls, event_name in ( + (DeviceAvailableEvent, EventName.DEVICE_AVAILABLE), + (DeviceUnavailableEvent, EventName.DEVICE_UNAVAILABLE), + (DeviceDisabledEvent, EventName.DEVICE_DISABLED), + ): + event = cls(name=event_name, device_url="io://1234-5678-9012/4468654") + assert isinstance(event, Event) + assert event.device_url == "io://1234-5678-9012/4468654" + assert not hasattr(event, "controllable_name") + + def test_zone_event_subtypes(self): + """Zone events carry zone_oid; create/update add device_urls and place_oids.""" + created = ZoneCreatedEvent( + name=EventName.ZONE_CREATED, + zone_oid="zone-1", + device_urls=["io://1234-5678-9012/1"], + place_oids=["place-1"], + ) + assert created.zone_oid == "zone-1" + assert created.device_urls == ["io://1234-5678-9012/1"] + assert created.place_oids == ["place-1"] + + updated = ZoneUpdatedEvent( + name=EventName.ZONE_UPDATED, + zone_oid="zone-1", + device_urls=["io://1234-5678-9012/1"], + place_oids=["place-1"], + ) + assert updated.zone_oid == "zone-1" + assert updated.device_urls == ["io://1234-5678-9012/1"] + + deleted = ZoneDeletedEvent(name=EventName.ZONE_DELETED, zone_oid="zone-1") + assert isinstance(deleted, Event) + assert deleted.zone_oid == "zone-1" + assert not hasattr(deleted, "device_urls") + + def test_converter_dispatches_zone_created_with_api_casing(self): + """Zone payload keys (zoneOID/deviceURLs/placeOIDs) map to snake_case fields.""" + event = converter.structure( + { + "name": "ZoneCreatedEvent", + "zoneOID": "zone-1", + "type": 1, + "deviceURLs": ["io://1234-5678-9012/1"], + "placeOIDs": ["place-1"], + }, + Event, + ) + assert isinstance(event, ZoneCreatedEvent) + assert event.zone_oid == "zone-1" + assert event.device_urls == ["io://1234-5678-9012/1"] + assert event.place_oids == ["place-1"] + + def test_converter_dispatches_device_state_changed(self): + """Structuring a DeviceStateChangedEvent payload yields the subtype.""" + event = converter.structure( + { + "name": "DeviceStateChangedEvent", + "timestamp": 1631130646544, + "deviceURL": "io://1234-5678-9012/4468654#1", + "deviceStates": [ + { + "name": "core:ElectricEnergyConsumptionState", + "type": 1, + "value": "23247220", + } + ], + }, + Event, + ) + assert isinstance(event, DeviceStateChangedEvent) + assert event.device_url == "io://1234-5678-9012/4468654#1" + assert event.device_states[0].value == 23247220 # cast by EventState + + def test_converter_dispatches_execution_state_changed(self): + """Structuring an ExecutionStateChangedEvent payload yields the subtype.""" + event = converter.structure( + { + "name": "ExecutionStateChangedEvent", + "newState": "IN_PROGRESS", + "oldState": "TRANSMITTED", + "execId": "abc", + }, + Event, + ) + assert isinstance(event, ExecutionStateChangedEvent) + assert event.new_state is ExecutionState.IN_PROGRESS + + def test_converter_dispatches_gateway_event(self): + """A Gateway* event structures into its own per-name class with gateway_id.""" + event = converter.structure( + { + "name": "GatewaySynchronizationEndedEvent", + "timestamp": 1631130645998, + "gatewayId": "9876-1234-8767", + }, + Event, + ) + assert isinstance(event, GatewaySynchronizationEndedEvent) + assert event.name == EventName.GATEWAY_SYNCHRONIZATION_ENDED + assert event.gateway_id == "9876-1234-8767" + + def test_converter_dispatches_gateway_event_with_extra_fields(self): + """A gateway event with documented extra payload exposes it on its subtype.""" + event = converter.structure( + { + "name": "GatewayFunctionChangedEvent", + "gatewayId": "9876-1234-8767", + "functionType": 1, + "enabled": False, + }, + Event, + ) + assert isinstance(event, GatewayFunctionChangedEvent) + assert event.function_type == 1 + assert event.enabled is False + + def test_converter_falls_back_to_base_event(self): + """Unmodeled, non-gateway, non-failure event names structure into base Event.""" + event = converter.structure( + {"name": "CommandExecutionStateChangedEvent", "timestamp": 1}, + Event, + ) + assert type(event) is Event + assert event.name == EventName.COMMAND_EXECUTION_STATE_CHANGE + + def test_converter_unknown_event_name_falls_back_to_base(self): + """Genuinely unknown event names use UnknownEnumMixin and base Event.""" + event = converter.structure( + {"name": "SomeBrandNewEvent", "timestamp": 1}, + Event, + ) + assert type(event) is Event + assert event.name == EventName.UNKNOWN + + def test_converter_dispatches_failed_event_to_failure_event(self): + """A gateway *FailedEvent structures into FailureEvent and keeps gateway_id.""" + event = converter.structure( + { + "name": "GatewaySynchronizationFailedEvent", + "timestamp": 1, + "failureType": "some failure", + "gatewayId": "9876-1234-8767", + }, + Event, + ) + assert isinstance(event, FailureEvent) + assert event.failure_type == "some failure" + assert event.gateway_id == "9876-1234-8767" + + def test_converter_dispatches_device_failed_event_keeps_device_url(self): + """A device-scoped *FailedEvent keeps its deviceURL via FailureEvent.""" + event = converter.structure( + { + "name": "DeviceFirmwareUpdateFailedEvent", + "timestamp": 1, + "failureType": "some failure", + "deviceURL": "io://1234-5678-9012/4468654", + }, + Event, + ) + assert isinstance(event, FailureEvent) + assert event.device_url == "io://1234-5678-9012/4468654" + + def test_converter_dispatches_failed_event_keeps_protocol_type(self): + """A discovery/refresh *FailedEvent keeps its protocolType via FailureEvent.""" + event = converter.structure( + { + "name": "RefreshAllDevicesStatesFailedEvent", + "timestamp": 1, + "failureType": "some failure", + "gatewayId": "9876-1234-8767", + "protocolType": 8, + }, + Event, + ) + assert isinstance(event, FailureEvent) + assert event.gateway_id == "9876-1234-8767" + assert event.protocol_type == 8 + + def test_subtype_missing_required_field_degrades_to_base_event(self, caplog): + """A subtype payload missing a required field degrades to base Event, with a warning.""" + with caplog.at_level(logging.WARNING, logger="pyoverkiz.converter"): + event = converter.structure( + # DeviceStateChangedEvent without the required deviceURL. + {"name": "DeviceStateChangedEvent", "deviceStates": []}, + Event, + ) + + assert type(event) is Event + assert event.name == EventName.DEVICE_STATE_CHANGED + assert "falling back to base Event" in caplog.text + + def test_one_malformed_event_does_not_fail_the_batch(self, caplog): + """A malformed event in a list degrades alone; the rest structure normally.""" + raw_events = [ + { + "name": "DeviceStateChangedEvent", + "deviceURL": "io://1234-5678-9012/1", + "deviceStates": [], + }, + # Missing the required deviceURL -> degrades to base Event. + {"name": "DeviceStateChangedEvent", "deviceStates": []}, + { + "name": "GatewaySynchronizationEndedEvent", + "gatewayId": "9876-1234-8767", + }, + ] + with caplog.at_level(logging.WARNING, logger="pyoverkiz.converter"): + events = converter.structure(raw_events, list[Event]) + + assert len(events) == 3 + assert isinstance(events[0], DeviceStateChangedEvent) + assert type(events[1]) is Event # degraded + assert isinstance(events[2], GatewaySynchronizationEndedEvent) + + def test_unstructurable_event_is_dropped_from_the_batch(self, caplog): + """Events that cannot be built at all (no name, not a dict) are dropped, not raised.""" + raw_events = [ + { + "name": "DeviceStateChangedEvent", + "deviceURL": "io://1234-5678-9012/1", + "deviceStates": [], + }, + {"deviceURL": "io://1234-5678-9012/2"}, # no "name" -> unstructurable + "garbage", # not a dict -> unstructurable + { + "name": "GatewaySynchronizationEndedEvent", + "gatewayId": "9876-1234-8767", + }, + ] + with caplog.at_level(logging.WARNING, logger="pyoverkiz.converter"): + events = converter.structure(raw_events, list[Event]) + + assert len(events) == 2 # the two unstructurable entries are dropped + assert isinstance(events[0], DeviceStateChangedEvent) + assert isinstance(events[1], GatewaySynchronizationEndedEvent) + assert "Dropping unstructurable event" in caplog.text + + def test_local_event_fixture_structures_all_events(self): + """All events in the local-API fixture structure into DeviceStateChangedEvent.""" + raw_events = json.loads( + (Path("tests/fixtures/event/local_events.json")).read_text() + ) + events = [converter.structure(e, Event) for e in raw_events] + + assert len(events) == len(raw_events) + for e in events: + assert isinstance(e, DeviceStateChangedEvent) + assert e.device_url # non-empty + assert e.device_states # non-empty + + # Local API returns already-typed values; nested object value is preserved. + manufacturer = events[0].device_states[0] + assert manufacturer.name == "core:ManufacturerSettingsState" + assert manufacturer.value == {"current_position": 0} + class TestActionGroup: """Tests for ActionGroup and PersistedActionGroup model split."""