From 6d5e3d43c6428ce6205073532e8b99fd99f685e5 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 00:41:49 +0000 Subject: [PATCH 01/18] Slim Event base to universal fields, add owning_partners --- pyoverkiz/models.py | 37 +++++++------------------------------ tests/test_models.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index a9df61fc..f7f5c7e4 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -15,7 +15,6 @@ ExecutionState, ExecutionSubType, ExecutionType, - FailureType, GatewaySubType, GatewayType, ProductType, @@ -558,39 +557,17 @@ def id(self) -> str: @define(kw_only=True) class Event: - """Represents an Overkiz event containing metadata and device states.""" + """Base Overkiz event. Carries fields common to every event. + + Concrete events are structured into a subtype based on ``name`` (see the + discriminator in pyoverkiz.converter). Unknown / unmodeled event names + structure into this base class. + """ name: EventName timestamp: int | None = None setup_oid: str | None = field(repr=obfuscate_id, default=None) - 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_code: FailureType | None = None - 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 + owning_partners: list[str] | None = None @define(kw_only=True) diff --git a/tests/test_models.py b/tests/test_models.py index 8875fc2e..58c76181 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1150,6 +1150,26 @@ def test_event_fixture_structures_all_events(self): 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": "GatewaySynchronizationEndedEvent", + "timestamp": 1631130645998, + "setupOID": "741bc89f-a47b-4ad6-894d-a785c06956c2", + "owningPartners": ["partner-a"], + }, + Event, + ) + + assert type(event) is Event + assert event.name == EventName.GATEWAY_SYNCHRONIZATION_ENDED + 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") + class TestActionGroup: """Tests for ActionGroup and PersistedActionGroup model split.""" From 995ac18a5e7593b0bdca958f65340eaed83e2824 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 01:05:21 +0000 Subject: [PATCH 02/18] Add DeviceStateChangedEvent subtype --- pyoverkiz/models.py | 13 +++++++++++++ tests/test_models.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index f7f5c7e4..437088b2 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -570,6 +570,19 @@ class Event: owning_partners: list[str] | None = None +@define(kw_only=True) +class DeviceStateChangedEvent(Event): + """One or more states of a device changed (high-level).""" + + device_url: str = field(repr=obfuscate_id, default="") + device_states: list[EventState] = field( + factory=list, + converter=lambda states: [ + EventState(**s) if isinstance(s, dict) else s for s in states + ], + ) + + @define(kw_only=True) class Execution: """Execution occurrence with owner, state and action group metadata.""" diff --git a/tests/test_models.py b/tests/test_models.py index 58c76181..6053d4fa 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -29,6 +29,7 @@ CommandDefinitions, Definition, Device, + DeviceStateChangedEvent, Event, EventState, Gateway, @@ -1170,6 +1171,24 @@ def test_base_event_has_universal_fields_only(self): 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 + class TestActionGroup: """Tests for ActionGroup and PersistedActionGroup model split.""" From d47265a49cf0688f331614f7ea66bc546f844d2a Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 03:59:34 +0200 Subject: [PATCH 03/18] Add execution event subtypes --- pyoverkiz/models.py | 31 +++++++++++++++++++++++++++++++ tests/test_models.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 437088b2..3d4a889f 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -15,6 +15,7 @@ ExecutionState, ExecutionSubType, ExecutionType, + FailureType, GatewaySubType, GatewayType, ProductType, @@ -583,6 +584,36 @@ class DeviceStateChangedEvent(Event): ) +@define(kw_only=True) +class ExecutionRegisteredEvent(Event): + """A new execution was registered.""" + + exec_id: str | None = None + 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(Event): + """An execution state has changed.""" + + exec_id: str | None = None + new_state: ExecutionState | None = None + old_state: ExecutionState | None = None + 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 Execution: """Execution occurrence with owner, state and action group metadata.""" diff --git a/tests/test_models.py b/tests/test_models.py index 6053d4fa..58277967 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -32,6 +32,8 @@ DeviceStateChangedEvent, Event, EventState, + ExecutionRegisteredEvent, + ExecutionStateChangedEvent, Gateway, PersistedActionGroup, Setup, @@ -1189,6 +1191,35 @@ def test_device_state_changed_event_subtype(self): 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 + class TestActionGroup: """Tests for ActionGroup and PersistedActionGroup model split.""" From 1fadcf624f909db77a1557d743d28cd62d2435df Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 05:43:50 +0200 Subject: [PATCH 04/18] Add FailureEvent subtype --- pyoverkiz/models.py | 8 ++++++++ tests/test_models.py | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 3d4a889f..8ed33ef4 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -614,6 +614,14 @@ class ExecutionStateChangedEvent(Event): failure_type_code: FailureType | None = None +@define(kw_only=True) +class FailureEvent(Event): + """Base for events reporting a failure (the various *FailedEvent names).""" + + failure_type: str | None = None + failure_type_code: FailureType | None = None + + @define(kw_only=True) class Execution: """Execution occurrence with owner, state and action group metadata.""" diff --git a/tests/test_models.py b/tests/test_models.py index 58277967..2f808980 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -34,6 +34,7 @@ EventState, ExecutionRegisteredEvent, ExecutionStateChangedEvent, + FailureEvent, Gateway, PersistedActionGroup, Setup, @@ -1220,6 +1221,17 @@ def test_execution_registered_event_subtype(self): assert event.exec_id == "c6f83624-ac10-3e01-653e-2b025fee956d" assert len(event.actions) == 1 + def test_failure_event_subtype(self): + """FailureEvent carries failure_type and failure_type_code together.""" + event = FailureEvent( + name=EventName.GATEWAY_SYNCHRONIZATION_FAILED, + failure_type="some failure", + failure_type_code=FailureType.NO_FAILURE, + ) + assert isinstance(event, Event) + assert event.failure_type == "some failure" + assert event.failure_type_code is FailureType.NO_FAILURE + class TestActionGroup: """Tests for ActionGroup and PersistedActionGroup model split.""" From 063c00f0d7d66e3c2161a33520634cf0a25a5286 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 09:03:15 +0200 Subject: [PATCH 05/18] Add device lifecycle event subtypes --- pyoverkiz/models.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_models.py | 20 ++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 8ed33ef4..3ee34caf 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -622,6 +622,55 @@ class FailureEvent(Event): failure_type_code: FailureType | None = None +@define(kw_only=True) +class _DeviceEvent(Event): + """Shared base for device lifecycle events (carries device_url).""" + + device_url: str = field(repr=obfuscate_id, default="") + + +@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 DeviceCreatedEvent(_DeviceEvent): + """A device was created.""" + + 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 DeviceUpdatedEvent(_DeviceEvent): + """A device was updated.""" + + 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 DeviceRemovedEvent(_DeviceEvent): + """A device was removed.""" + + controllable_name: str | None = None + + @define(kw_only=True) class Execution: """Execution occurrence with owner, state and action group metadata.""" diff --git a/tests/test_models.py b/tests/test_models.py index 2f808980..9e31f786 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -29,6 +29,8 @@ CommandDefinitions, Definition, Device, + DeviceAvailableEvent, + DeviceCreatedEvent, DeviceStateChangedEvent, Event, EventState, @@ -1232,6 +1234,24 @@ def test_failure_event_subtype(self): assert event.failure_type == "some failure" assert event.failure_type_code is FailureType.NO_FAILURE + 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" + + available = DeviceAvailableEvent( + name=EventName.DEVICE_AVAILABLE, + device_url="io://1234-5678-9012/4468654", + ) + assert isinstance(available, Event) + assert available.device_url == "io://1234-5678-9012/4468654" + assert not hasattr(available, "controllable_name") + class TestActionGroup: """Tests for ActionGroup and PersistedActionGroup model split.""" From 818e881402b7a7b3ada6fa833ebf306a254c87d5 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 09:28:31 +0200 Subject: [PATCH 06/18] Exercise all device lifecycle subtypes in test --- tests/test_models.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 9e31f786..73c00e96 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -31,7 +31,11 @@ Device, DeviceAvailableEvent, DeviceCreatedEvent, + DeviceDisabledEvent, + DeviceRemovedEvent, DeviceStateChangedEvent, + DeviceUnavailableEvent, + DeviceUpdatedEvent, Event, EventState, ExecutionRegisteredEvent, @@ -1244,13 +1248,30 @@ def test_device_lifecycle_subtypes(self): assert created.device_url == "io://1234-5678-9012/4468654" assert created.controllable_name == "io:RollerShutterGenericIOComponent" - available = DeviceAvailableEvent( - name=EventName.DEVICE_AVAILABLE, + updated = DeviceUpdatedEvent( + name=EventName.DEVICE_UPDATED, device_url="io://1234-5678-9012/4468654", + controllable_name="io:RollerShutterGenericIOComponent", ) - assert isinstance(available, Event) - assert available.device_url == "io://1234-5678-9012/4468654" - assert not hasattr(available, "controllable_name") + 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") class TestActionGroup: From c1f1316ad7594b7f0a17ff117dea460f84f27234 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 09:49:02 +0200 Subject: [PATCH 07/18] Add zone event subtypes --- pyoverkiz/models.py | 29 +++++++++++++++++++++++++++++ tests/test_models.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 3ee34caf..25b5c786 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -671,6 +671,35 @@ class DeviceRemovedEvent(_DeviceEvent): controllable_name: str | None = None +@define(kw_only=True) +class ZoneDeletedEvent(Event): + """A zone was deleted.""" + + zone_oid: str | None = None + + +@define(kw_only=True) +class ZoneCreatedEvent(Event): + """A zone was created.""" + + zone_oid: str | None = None + 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 ZoneUpdatedEvent(Event): + """A zone was updated.""" + + zone_oid: str | None = None + 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 Execution: """Execution occurrence with owner, state and action group metadata.""" diff --git a/tests/test_models.py b/tests/test_models.py index 73c00e96..f8608d4d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -48,6 +48,9 @@ StateDefinition, StateDefinitions, States, + ZoneCreatedEvent, + ZoneDeletedEvent, + ZoneUpdatedEvent, ) from pyoverkiz.obfuscate import obfuscate_id @@ -1273,6 +1276,32 @@ def test_device_lifecycle_subtypes(self): 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") + class TestActionGroup: """Tests for ActionGroup and PersistedActionGroup model split.""" From 826c9926796ecc5ec12195462b60ce28a54a3026 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 09:51:19 +0200 Subject: [PATCH 08/18] Wire cattrs discriminator dispatching Event by name --- pyoverkiz/converter.py | 24 ++++++++++++++++++- pyoverkiz/models.py | 18 ++++++++++++++ tests/test_models.py | 53 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/pyoverkiz/converter.py b/pyoverkiz/converter.py index b0f2c79a..ecb51eab 100644 --- a/pyoverkiz/converter.py +++ b/pyoverkiz/converter.py @@ -11,10 +11,12 @@ 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, @@ -103,6 +105,26 @@ def _structure_state_definitions(val: Any, _: type) -> StateDefinitions: _rename_hook_factory, ) + # Event is a discriminated union keyed on the "name" field. Resolve the + # concrete subtype, then delegate to that class's generated (rename-aware) + # hook. Unknown / unmodeled names fall back to the base Event. + # + # We pre-build each class's hook and call it directly. Routing through + # c.structure(val, subtype) would re-enter this hook (cattrs dispatches a + # subclass to its base class's registered hook), causing infinite recursion. + event_hooks: dict[type[Event], Any] = {Event: _rename_hook_factory(Event, c)} + for subtype in set(EVENT_TYPE_BY_NAME.values()): + event_hooks[subtype] = _rename_hook_factory(subtype, c) + + 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) + return event_hooks[target](val, target) # type: ignore[no-any-return] + + c.register_structure_hook(Event, _structure_event) + return c diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 25b5c786..622e1a35 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -700,6 +700,24 @@ class ZoneUpdatedEvent(Event): place_oids: list[str] = field(factory=list) +# Maps an event name to the subtype it should structure into. Names not listed +# here structure into the base Event (forward-compatible for unmodeled events). +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, +} + + @define(kw_only=True) class Execution: """Execution occurrence with owner, state and action group metadata.""" diff --git a/tests/test_models.py b/tests/test_models.py index f8608d4d..c9762cb5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1302,6 +1302,59 @@ def test_zone_event_subtypes(self): assert deleted.zone_oid == "zone-1" assert not hasattr(deleted, "device_urls") + 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_falls_back_to_base_event(self): + """Unmodeled event names structure into the base Event.""" + event = converter.structure( + {"name": "GatewaySynchronizationEndedEvent", "timestamp": 1}, + Event, + ) + assert type(event) is Event + assert event.name == EventName.GATEWAY_SYNCHRONIZATION_ENDED + + 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 + class TestActionGroup: """Tests for ActionGroup and PersistedActionGroup model split.""" From f790b626682ca1a955605afcc3790ff2ebff580a Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 09:54:48 +0200 Subject: [PATCH 09/18] Update existing Event tests for typed subtypes --- tests/test_models.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index c9762cb5..932a25cc 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1093,6 +1093,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 @@ -1109,23 +1110,24 @@ def test_failure_type_code_structured_as_enum(self): 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", "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.""" @@ -1146,6 +1148,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) @@ -1157,8 +1160,9 @@ def test_event_fixture_structures_all_events(self): assert len(events) == len(raw_events) state_changed = [ - e for e in events if e.name == EventName.EXECUTION_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) From f82c6158f1287681d0a5660e82beb005c82501a7 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 09:57:18 +0200 Subject: [PATCH 10/18] Cover local_events.json fixture end-to-end --- tests/test_models.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_models.py b/tests/test_models.py index 932a25cc..ffcf0f53 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1359,6 +1359,24 @@ def test_converter_unknown_event_name_falls_back_to_base(self): assert type(event) is Event assert event.name == EventName.UNKNOWN + 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.""" From 24901e48559ae9bdabf856cdbc7bff5660876f96 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 10:09:38 +0200 Subject: [PATCH 11/18] Fix test_client event iteration for typed subtypes --- tests/test_client.py | 3 +++ tests/test_models.py | 4 +--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index c70a62bd..95825dd9 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,8 @@ async def test_fetch_events_casting(self, client: OverkizClient, fixture_name: s events = await client.fetch_events() for event in events: + 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 ffcf0f53..0a4f0604 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1159,9 +1159,7 @@ 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 isinstance(e, ExecutionStateChangedEvent) - ] + 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) From 1e57f31e8464cef511de6bb514f01b43ed9a55f8 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 10:16:36 +0200 Subject: [PATCH 12/18] Map *FailedEvent names to FailureEvent The FailureEvent subtype existed but no event names mapped to it, leaving it dead code. Derive failure-event mappings from the EventName enum by suffix so all *FailedEvent payloads gain failure_type/failure_type_code, with new failure events covered automatically. --- pyoverkiz/models.py | 9 +++++++++ tests/test_client.py | 1 + tests/test_models.py | 15 +++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 622e1a35..ab96f888 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -717,6 +717,15 @@ class ZoneUpdatedEvent(Event): EventName.ZONE_DELETED: ZoneDeletedEvent, } +# Every "*FailedEvent" structures into FailureEvent so failure_type and +# failure_type_code travel together. Derived from the enum so new failure +# events are covered automatically; explicit mappings above take precedence. +# Failure events carrying extra payload can grow a dedicated subtype later. +for _event_name in EventName: + if _event_name.value.endswith("FailedEvent"): + EVENT_TYPE_BY_NAME.setdefault(_event_name, FailureEvent) +del _event_name + @define(kw_only=True) class Execution: diff --git a/tests/test_client.py b/tests/test_client.py index 95825dd9..d46e1667 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -202,6 +202,7 @@ 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: diff --git a/tests/test_models.py b/tests/test_models.py index 0a4f0604..8f794edd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1357,6 +1357,21 @@ def test_converter_unknown_event_name_falls_back_to_base(self): assert type(event) is Event assert event.name == EventName.UNKNOWN + def test_converter_dispatches_failed_event_to_failure_event(self): + """Any *FailedEvent structures into FailureEvent with the failure fields.""" + event = converter.structure( + { + "name": "GatewaySynchronizationFailedEvent", + "timestamp": 1, + "failureType": "some failure", + "failureTypeCode": 0, + }, + Event, + ) + assert isinstance(event, FailureEvent) + assert event.failure_type == "some failure" + assert event.failure_type_code is FailureType.NO_FAILURE + def test_local_event_fixture_structures_all_events(self): """All events in the local-API fixture structure into DeviceStateChangedEvent.""" raw_events = json.loads( From ed1d05bfade9fce0fffb1b0807230b2a37f7a0cf Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 10:47:00 +0000 Subject: [PATCH 13/18] Add Gateway events, strict typed event subtypes, resilient dispatch Model all Gateway* events via a single GatewayEvent carrying gateway_id, enrich FailureEvent with gateway_id/device_url/protocol_type so failure payloads keep their scope, and drop the never-sent failure_type_code from failures (it stays on ExecutionStateChangedEvent, where the API sends it). Make identity fields the API reliably sends required (device_url, gateway_id, zone_oid, exec_id/new_state/old_state) so consumers get non-None guarantees after isinstance narrowing. To keep strictness from making batches fragile, the discriminator now degrades a single malformed event to the base Event with a logged warning instead of failing the whole fetch_events() list. Also: tolerate null deviceStates, share _DeviceMetadataEvent/_ZoneEvent bases, and document the event changes in the v2 migration guide. --- docs/migration-v2.md | 69 ++++++++++++++++++- pyoverkiz/converter.py | 43 ++++++++++-- pyoverkiz/models.py | 149 ++++++++++++++++++++++++++++------------- tests/test_models.py | 124 ++++++++++++++++++++++++++++++---- 4 files changed, 320 insertions(+), 65 deletions(-) diff --git a/docs/migration-v2.md b/docs/migration-v2.md index 6541d431..0d21fd98 100644 --- a/docs/migration-v2.md +++ b/docs/migration-v2.md @@ -334,6 +334,73 @@ 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`, `GatewayEvent`, +`FailureEvent`). The base `Event` keeps only the fields common to every event +(`name`, `timestamp`, `setup_oid`, `owning_partners`); everything else lives on +the relevant subtype. + +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: + ... + ``` + +Event names without a dedicated subtype (including any new name the API adds +later) structure into the base `Event`, so unknown events never raise. + +### 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 +430,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/converter.py b/pyoverkiz/converter.py index ecb51eab..e4ccea4d 100644 --- a/pyoverkiz/converter.py +++ b/pyoverkiz/converter.py @@ -2,12 +2,14 @@ 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 @@ -17,12 +19,15 @@ CommandDefinition, CommandDefinitions, Event, + EventState, 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). @@ -97,6 +102,18 @@ def _structure_state_definitions(val: Any, _: type) -> StateDefinitions: c.register_structure_hook(CommandDefinitions, _structure_command_definitions) c.register_structure_hook(StateDefinitions, _structure_state_definitions) + # DeviceStateChangedEvent.device_states: the API may send "deviceStates": null + # instead of omitting the key. cattrs' default list hook would choke on None, + # so tolerate it as an empty list. + def _structure_event_states(val: Any, _: type) -> list[EventState]: + if not val: + return [] + return [c.structure(s, EventState) for s in val] + + c.register_structure_hook_func( + lambda t: t == list[EventState], _structure_event_states + ) + # For all other attrs classes: lazily generate a hook that renames camelCase # API keys to snake_case on first use. This avoids manual dependency ordering. skip = {States, CommandDefinitions, StateDefinitions} @@ -112,16 +129,34 @@ def _structure_state_definitions(val: Any, _: type) -> StateDefinitions: # We pre-build each class's hook and call it directly. Routing through # c.structure(val, subtype) would re-enter this hook (cattrs dispatches a # subclass to its base class's registered hook), causing infinite recursion. - event_hooks: dict[type[Event], Any] = {Event: _rename_hook_factory(Event, c)} - for subtype in set(EVENT_TYPE_BY_NAME.values()): - event_hooks[subtype] = _rename_hook_factory(subtype, c) + 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) - return event_hooks[target](val, target) # type: ignore[no-any-return] + try: + return event_hooks[target](val, target) # type: ignore[no-any-return] + except ClassValidationError as err: + # Subtypes declare the fields the API is documented to send as + # required. If a payload omits one (undocumented quirk, partial + # data), degrade that single event to the base Event rather than + # failing the whole batch. The warning flags a field worth + # loosening — strictness stays the default, resilience the + # fallback. + 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) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index ab96f888..5c10e68b 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -556,6 +556,17 @@ def id(self) -> str: return self.oid +def _to_event_states(states: list[Any] | None) -> list[EventState]: + """Structure a raw deviceStates list into EventState instances. + + Tolerates a missing / null ``deviceStates`` (the API may send ``null`` + rather than omitting the key) by treating it as an empty list. + """ + if not states: + return [] + return [EventState(**s) if isinstance(s, dict) else s for s in states] + + @define(kw_only=True) class Event: """Base Overkiz event. Carries fields common to every event. @@ -572,23 +583,33 @@ class Event: @define(kw_only=True) -class DeviceStateChangedEvent(Event): +class _DeviceEvent(Event): + """Shared base for device-scoped events (carries device_url). + + ``device_url`` is required: a device-scoped event is, by definition, about a + device. A payload missing it degrades to the base Event (see the + discriminator in pyoverkiz.converter). + """ + + 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_url: str = field(repr=obfuscate_id, default="") - device_states: list[EventState] = field( - factory=list, - converter=lambda states: [ - EventState(**s) if isinstance(s, dict) else s for s in states - ], - ) + device_states: list[EventState] = field(factory=list, converter=_to_event_states) @define(kw_only=True) class ExecutionRegisteredEvent(Event): - """A new execution was registered.""" + """A new execution was registered. - exec_id: str | None = None + ``exec_id`` identifies the registered execution and is required; a payload + missing it degrades to the base Event. + """ + + exec_id: str label: str | None = None metadata: str | None = None type: int | None = None @@ -600,11 +621,16 @@ class ExecutionRegisteredEvent(Event): @define(kw_only=True) class ExecutionStateChangedEvent(Event): - """An execution state has changed.""" + """An execution state has changed. + + ``exec_id``, ``new_state`` and ``old_state`` are the identity of the + transition and are required; a payload missing any of them degrades to the + base Event. + """ - exec_id: str | None = None - new_state: ExecutionState | None = None - old_state: ExecutionState | None = None + exec_id: str + 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 @@ -616,17 +642,34 @@ class ExecutionStateChangedEvent(Event): @define(kw_only=True) class FailureEvent(Event): - """Base for events reporting a failure (the various *FailedEvent names).""" + """Any ``*FailedEvent``. + + Carries the failure reason plus whichever identifier the failing operation + was scoped to. Across the documented failure events the extra fields are + ``gatewayId`` (gateway / protocol / network operations), ``deviceURL`` + (device-scoped operations) and ``protocolType`` (discovery / refresh); + these are surfaced here so no failure context is silently dropped. + ``failureTypeCode`` is intentionally absent: the API only sends it on + ExecutionStateChangedEvent, never on a ``*FailedEvent``. + """ failure_type: str | None = None - failure_type_code: FailureType | None = None + gateway_id: str | None = field(repr=obfuscate_id, default=None) + device_url: str | None = field(repr=obfuscate_id, default=None) + protocol_type: int | None = None @define(kw_only=True) -class _DeviceEvent(Event): - """Shared base for device lifecycle events (carries device_url).""" +class GatewayEvent(Event): + """A gateway lifecycle event (down, alive, synchronization, mode, ...). + + Every documented ``Gateway*`` event carries ``gatewayId``, so it is required; + a payload missing it degrades to the base Event. The few events that add more + (e.g. firmware/function/timeout details) are not modeled here yet and can grow + a dedicated subtype when needed. + """ - device_url: str = field(repr=obfuscate_id, default="") + gateway_id: str = field(repr=obfuscate_id) @define(kw_only=True) @@ -645,8 +688,8 @@ class DeviceDisabledEvent(_DeviceEvent): @define(kw_only=True) -class DeviceCreatedEvent(_DeviceEvent): - """A device was created.""" +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) @@ -655,13 +698,13 @@ class DeviceCreatedEvent(_DeviceEvent): @define(kw_only=True) -class DeviceUpdatedEvent(_DeviceEvent): - """A device was updated.""" +class DeviceCreatedEvent(_DeviceMetadataEvent): + """A device was created.""" - 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 DeviceUpdatedEvent(_DeviceMetadataEvent): + """A device was updated.""" @define(kw_only=True) @@ -672,17 +715,25 @@ class DeviceRemovedEvent(_DeviceEvent): @define(kw_only=True) -class ZoneDeletedEvent(Event): - """A zone was deleted.""" +class _ZoneEvent(Event): + """Shared base for zone events (carries zone_oid). + + ``zone_oid`` is required: a zone event identifies the zone it concerns. A + payload missing it degrades to the base Event. + """ - zone_oid: str | None = None + zone_oid: str @define(kw_only=True) -class ZoneCreatedEvent(Event): - """A zone was created.""" +class ZoneDeletedEvent(_ZoneEvent): + """A zone was deleted.""" + + +@define(kw_only=True) +class _ZoneMutationEvent(_ZoneEvent): + """Shared base for zone create/update events (carry membership lists).""" - zone_oid: str | None = None type: int | None = None label: str | None = field(repr=obfuscate_string, default=None) device_urls: list[str] = field(factory=list) @@ -690,14 +741,13 @@ class ZoneCreatedEvent(Event): @define(kw_only=True) -class ZoneUpdatedEvent(Event): - """A zone was updated.""" +class ZoneCreatedEvent(_ZoneMutationEvent): + """A zone was created.""" - zone_oid: str | None = None - 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 ZoneUpdatedEvent(_ZoneMutationEvent): + """A zone was updated.""" # Maps an event name to the subtype it should structure into. Names not listed @@ -717,14 +767,17 @@ class ZoneUpdatedEvent(Event): EventName.ZONE_DELETED: ZoneDeletedEvent, } -# Every "*FailedEvent" structures into FailureEvent so failure_type and -# failure_type_code travel together. Derived from the enum so new failure -# events are covered automatically; explicit mappings above take precedence. -# Failure events carrying extra payload can grow a dedicated subtype later. -for _event_name in EventName: - if _event_name.value.endswith("FailedEvent"): - EVENT_TYPE_BY_NAME.setdefault(_event_name, FailureEvent) -del _event_name +# Derive the name-based mappings from the enum so new events are covered +# automatically. Explicit mappings above take precedence (setdefault), so an +# event needing a richer subtype can simply be added to the dict above. +# * "Gateway*" (non-failure) -> GatewayEvent, to keep gateway_id. +# * "*FailedEvent" -> FailureEvent, to keep failure_type + gateway_id/device_url. +for _name in EventName: + if _name.value.endswith("FailedEvent"): + EVENT_TYPE_BY_NAME.setdefault(_name, FailureEvent) + elif _name.value.startswith("Gateway"): + EVENT_TYPE_BY_NAME.setdefault(_name, GatewayEvent) +del _name @define(kw_only=True) diff --git a/tests/test_models.py b/tests/test_models.py index 8f794edd..7033da6c 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 @@ -42,6 +43,7 @@ ExecutionStateChangedEvent, FailureEvent, Gateway, + GatewayEvent, PersistedActionGroup, Setup, State, @@ -1105,6 +1107,9 @@ 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, @@ -1118,7 +1123,7 @@ def test_optional_enum_fields_none_when_absent(self): """Base events do not expose subtype-only enum fields.""" event = converter.structure( { - "name": "GatewaySynchronizationEndedEvent", + "name": "CommandExecutionStateChangedEvent", "timestamp": 1631130645998, }, Event, @@ -1169,7 +1174,7 @@ 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": "GatewaySynchronizationEndedEvent", + "name": "CommandExecutionStateChangedEvent", "timestamp": 1631130645998, "setupOID": "741bc89f-a47b-4ad6-894d-a785c06956c2", "owningPartners": ["partner-a"], @@ -1178,7 +1183,7 @@ def test_base_event_has_universal_fields_only(self): ) assert type(event) is Event - assert event.name == EventName.GATEWAY_SYNCHRONIZATION_ENDED + 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"] @@ -1233,15 +1238,17 @@ def test_execution_registered_event_subtype(self): assert len(event.actions) == 1 def test_failure_event_subtype(self): - """FailureEvent carries failure_type and failure_type_code together.""" + """FailureEvent carries failure_type plus the operation's scope id.""" event = FailureEvent( name=EventName.GATEWAY_SYNCHRONIZATION_FAILED, failure_type="some failure", - failure_type_code=FailureType.NO_FAILURE, + gateway_id="9876-1234-8767", ) assert isinstance(event, Event) assert event.failure_type == "some failure" - assert event.failure_type_code is FailureType.NO_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.""" @@ -1339,14 +1346,28 @@ def test_converter_dispatches_execution_state_changed(self): assert isinstance(event, ExecutionStateChangedEvent) assert event.new_state is ExecutionState.IN_PROGRESS + def test_converter_dispatches_gateway_event(self): + """Gateway* events structure into GatewayEvent and keep gateway_id.""" + event = converter.structure( + { + "name": "GatewaySynchronizationEndedEvent", + "timestamp": 1631130645998, + "gatewayId": "9876-1234-8767", + }, + Event, + ) + assert isinstance(event, GatewayEvent) + assert event.name == EventName.GATEWAY_SYNCHRONIZATION_ENDED + assert event.gateway_id == "9876-1234-8767" + def test_converter_falls_back_to_base_event(self): - """Unmodeled event names structure into the base Event.""" + """Unmodeled, non-gateway, non-failure event names structure into base Event.""" event = converter.structure( - {"name": "GatewaySynchronizationEndedEvent", "timestamp": 1}, + {"name": "CommandExecutionStateChangedEvent", "timestamp": 1}, Event, ) assert type(event) is Event - assert event.name == EventName.GATEWAY_SYNCHRONIZATION_ENDED + 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.""" @@ -1358,19 +1379,98 @@ def test_converter_unknown_event_name_falls_back_to_base(self): assert event.name == EventName.UNKNOWN def test_converter_dispatches_failed_event_to_failure_event(self): - """Any *FailedEvent structures into FailureEvent with the failure fields.""" + """A gateway *FailedEvent structures into FailureEvent and keeps gateway_id.""" event = converter.structure( { "name": "GatewaySynchronizationFailedEvent", "timestamp": 1, "failureType": "some failure", - "failureTypeCode": 0, + "gatewayId": "9876-1234-8767", }, Event, ) assert isinstance(event, FailureEvent) assert event.failure_type == "some failure" - assert event.failure_type_code is FailureType.NO_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_device_state_changed_tolerates_null_device_states(self): + """A present-but-null deviceStates structures into an empty list, not a crash.""" + event = converter.structure( + { + "name": "DeviceStateChangedEvent", + "deviceURL": "io://1234-5678-9012/4468654#1", + "deviceStates": None, + }, + Event, + ) + assert isinstance(event, DeviceStateChangedEvent) + assert event.device_states == [] + + 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], GatewayEvent) def test_local_event_fixture_structures_all_events(self): """All events in the local-API fixture structure into DeviceStateChangedEvent.""" From 3e06289ff4955d7198820aaefbbac7973ff15eb8 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 10:52:48 +0000 Subject: [PATCH 14/18] Tighten event comments and docstrings --- pyoverkiz/converter.py | 22 ++++---------- pyoverkiz/models.py | 69 ++++++++++-------------------------------- 2 files changed, 22 insertions(+), 69 deletions(-) diff --git a/pyoverkiz/converter.py b/pyoverkiz/converter.py index e4ccea4d..d436b237 100644 --- a/pyoverkiz/converter.py +++ b/pyoverkiz/converter.py @@ -102,9 +102,7 @@ def _structure_state_definitions(val: Any, _: type) -> StateDefinitions: c.register_structure_hook(CommandDefinitions, _structure_command_definitions) c.register_structure_hook(StateDefinitions, _structure_state_definitions) - # DeviceStateChangedEvent.device_states: the API may send "deviceStates": null - # instead of omitting the key. cattrs' default list hook would choke on None, - # so tolerate it as an empty list. + # Treat a missing / null deviceStates as empty; cattrs' list hook raises on None. def _structure_event_states(val: Any, _: type) -> list[EventState]: if not val: return [] @@ -122,13 +120,9 @@ def _structure_event_states(val: Any, _: type) -> list[EventState]: _rename_hook_factory, ) - # Event is a discriminated union keyed on the "name" field. Resolve the - # concrete subtype, then delegate to that class's generated (rename-aware) - # hook. Unknown / unmodeled names fall back to the base Event. - # - # We pre-build each class's hook and call it directly. Routing through - # c.structure(val, subtype) would re-enter this hook (cattrs dispatches a - # subclass to its base class's registered hook), causing infinite recursion. + # 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 @@ -142,12 +136,8 @@ def _structure_event(val: Any, _: type) -> Event: try: return event_hooks[target](val, target) # type: ignore[no-any-return] except ClassValidationError as err: - # Subtypes declare the fields the API is documented to send as - # required. If a payload omits one (undocumented quirk, partial - # data), degrade that single event to the base Event rather than - # failing the whole batch. The warning flags a field worth - # loosening — strictness stays the default, resilience the - # fallback. + # 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( diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 5c10e68b..2f902101 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -557,11 +557,7 @@ def id(self) -> str: def _to_event_states(states: list[Any] | None) -> list[EventState]: - """Structure a raw deviceStates list into EventState instances. - - Tolerates a missing / null ``deviceStates`` (the API may send ``null`` - rather than omitting the key) by treating it as an empty list. - """ + """Structure a raw deviceStates list into EventState instances (None -> []).""" if not states: return [] return [EventState(**s) if isinstance(s, dict) else s for s in states] @@ -569,12 +565,7 @@ def _to_event_states(states: list[Any] | None) -> list[EventState]: @define(kw_only=True) class Event: - """Base Overkiz event. Carries fields common to every event. - - Concrete events are structured into a subtype based on ``name`` (see the - discriminator in pyoverkiz.converter). Unknown / unmodeled event names - structure into this base class. - """ + """Base Overkiz event; structured into a subtype by ``name`` (see converter).""" name: EventName timestamp: int | None = None @@ -584,12 +575,7 @@ class Event: @define(kw_only=True) class _DeviceEvent(Event): - """Shared base for device-scoped events (carries device_url). - - ``device_url`` is required: a device-scoped event is, by definition, about a - device. A payload missing it degrades to the base Event (see the - discriminator in pyoverkiz.converter). - """ + """Shared base for device-scoped events; device_url is required.""" device_url: str = field(repr=obfuscate_id) @@ -603,11 +589,7 @@ class DeviceStateChangedEvent(_DeviceEvent): @define(kw_only=True) class ExecutionRegisteredEvent(Event): - """A new execution was registered. - - ``exec_id`` identifies the registered execution and is required; a payload - missing it degrades to the base Event. - """ + """A new execution was registered; exec_id is required.""" exec_id: str label: str | None = None @@ -621,12 +603,7 @@ class ExecutionRegisteredEvent(Event): @define(kw_only=True) class ExecutionStateChangedEvent(Event): - """An execution state has changed. - - ``exec_id``, ``new_state`` and ``old_state`` are the identity of the - transition and are required; a payload missing any of them degrades to the - base Event. - """ + """An execution state has changed; exec_id/new_state/old_state are required.""" exec_id: str new_state: ExecutionState @@ -642,15 +619,11 @@ class ExecutionStateChangedEvent(Event): @define(kw_only=True) class FailureEvent(Event): - """Any ``*FailedEvent``. - - Carries the failure reason plus whichever identifier the failing operation - was scoped to. Across the documented failure events the extra fields are - ``gatewayId`` (gateway / protocol / network operations), ``deviceURL`` - (device-scoped operations) and ``protocolType`` (discovery / refresh); - these are surfaced here so no failure context is silently dropped. - ``failureTypeCode`` is intentionally absent: the API only sends it on - ExecutionStateChangedEvent, never on a ``*FailedEvent``. + """Any ``*FailedEvent``: failure reason plus the operation's scope id. + + gateway_id / device_url / protocol_type cover the documented failure + payloads. failureTypeCode is omitted; the API only sends it on + ExecutionStateChangedEvent. """ failure_type: str | None = None @@ -663,10 +636,8 @@ class FailureEvent(Event): class GatewayEvent(Event): """A gateway lifecycle event (down, alive, synchronization, mode, ...). - Every documented ``Gateway*`` event carries ``gatewayId``, so it is required; - a payload missing it degrades to the base Event. The few events that add more - (e.g. firmware/function/timeout details) are not modeled here yet and can grow - a dedicated subtype when needed. + gateway_id is required. The few events with extra payload (firmware, mode, + timeout) can grow a dedicated subtype when needed. """ gateway_id: str = field(repr=obfuscate_id) @@ -716,11 +687,7 @@ class DeviceRemovedEvent(_DeviceEvent): @define(kw_only=True) class _ZoneEvent(Event): - """Shared base for zone events (carries zone_oid). - - ``zone_oid`` is required: a zone event identifies the zone it concerns. A - payload missing it degrades to the base Event. - """ + """Shared base for zone events; zone_oid is required.""" zone_oid: str @@ -750,8 +717,7 @@ class ZoneUpdatedEvent(_ZoneMutationEvent): """A zone was updated.""" -# Maps an event name to the subtype it should structure into. Names not listed -# here structure into the base Event (forward-compatible for unmodeled events). +# 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, @@ -767,11 +733,8 @@ class ZoneUpdatedEvent(_ZoneMutationEvent): EventName.ZONE_DELETED: ZoneDeletedEvent, } -# Derive the name-based mappings from the enum so new events are covered -# automatically. Explicit mappings above take precedence (setdefault), so an -# event needing a richer subtype can simply be added to the dict above. -# * "Gateway*" (non-failure) -> GatewayEvent, to keep gateway_id. -# * "*FailedEvent" -> FailureEvent, to keep failure_type + gateway_id/device_url. +# Auto-map by name so new events are covered; explicit mappings above win. +# "*FailedEvent" -> FailureEvent, other "Gateway*" -> GatewayEvent. for _name in EventName: if _name.value.endswith("FailedEvent"): EVENT_TYPE_BY_NAME.setdefault(_name, FailureEvent) From f2d3db12b4cbc957e2115d3f7f3ef7bcc54af4cc Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 11:38:10 +0000 Subject: [PATCH 15/18] Split gateway events into per-name classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway events now mirror device events: a private _GatewayEvent base plus one public class per name (GatewayDownEvent, GatewayAliveEvent, ...), with the three documented extra-field events carrying their payload (timeout, firmware_type, function_type/enabled). This drops the GatewayEvent grab-bag that collapsed 22 distinct events a consumer would want to tell apart into one type. Failures stay as the single shared FailureEvent by design — consumers branch on "did it fail, and why" (failure_type), not on which operation failed; the docstring and migration guide now state this explicitly. The event model is three rules: each modeled event is its own class, all *FailedEvent -> FailureEvent, anything unmodeled -> base Event. --- docs/migration-v2.md | 21 ++++-- pyoverkiz/models.py | 159 ++++++++++++++++++++++++++++++++++++++++--- tests/test_models.py | 24 +++++-- 3 files changed, 183 insertions(+), 21 deletions(-) diff --git a/docs/migration-v2.md b/docs/migration-v2.md index 0d21fd98..d3697450 100644 --- a/docs/migration-v2.md +++ b/docs/migration-v2.md @@ -340,10 +340,20 @@ 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`, `GatewayEvent`, -`FailureEvent`). The base `Event` keeps only the fields common to every event -(`name`, `timestamp`, `setup_oid`, `owning_partners`); everything else lives on -the relevant subtype. +`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. +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: @@ -368,9 +378,6 @@ Narrow with `isinstance` before accessing subtype-specific fields: ... ``` -Event names without a dedicated subtype (including any new name the API adds -later) structure into the base `Event`, so unknown events never raise. - ### Strict subtypes, resilient batches Subtypes mark the fields the API is documented to always send for that event as diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 2f902101..84341da2 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -621,6 +621,11 @@ class ExecutionStateChangedEvent(Event): 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. failureTypeCode is omitted; the API only sends it on ExecutionStateChangedEvent. @@ -633,16 +638,129 @@ class FailureEvent(Event): @define(kw_only=True) -class GatewayEvent(Event): - """A gateway lifecycle event (down, alive, synchronization, mode, ...). - - gateway_id is required. The few events with extra payload (firmware, mode, - timeout) can grow a dedicated subtype when needed. - """ +class _GatewayEvent(Event): + """Shared base for gateway events; gateway_id is required.""" 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.""" @@ -731,15 +849,36 @@ class ZoneUpdatedEvent(_ZoneMutationEvent): 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, } -# Auto-map by name so new events are covered; explicit mappings above win. -# "*FailedEvent" -> FailureEvent, other "Gateway*" -> GatewayEvent. +# 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) - elif _name.value.startswith("Gateway"): - EVENT_TYPE_BY_NAME.setdefault(_name, GatewayEvent) del _name diff --git a/tests/test_models.py b/tests/test_models.py index 7033da6c..76dd5c62 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -43,7 +43,8 @@ ExecutionStateChangedEvent, FailureEvent, Gateway, - GatewayEvent, + GatewayFunctionChangedEvent, + GatewaySynchronizationEndedEvent, PersistedActionGroup, Setup, State, @@ -1347,7 +1348,7 @@ def test_converter_dispatches_execution_state_changed(self): assert event.new_state is ExecutionState.IN_PROGRESS def test_converter_dispatches_gateway_event(self): - """Gateway* events structure into GatewayEvent and keep gateway_id.""" + """A Gateway* event structures into its own per-name class with gateway_id.""" event = converter.structure( { "name": "GatewaySynchronizationEndedEvent", @@ -1356,10 +1357,25 @@ def test_converter_dispatches_gateway_event(self): }, Event, ) - assert isinstance(event, GatewayEvent) + 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( @@ -1470,7 +1486,7 @@ def test_one_malformed_event_does_not_fail_the_batch(self, caplog): assert len(events) == 3 assert isinstance(events[0], DeviceStateChangedEvent) assert type(events[1]) is Event # degraded - assert isinstance(events[2], GatewayEvent) + assert isinstance(events[2], GatewaySynchronizationEndedEvent) def test_local_event_fixture_structures_all_events(self): """All events in the local-API fixture structure into DeviceStateChangedEvent.""" From b0f4054c35aeeed10572d53699eecff1aef6b40a Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 12:10:08 +0000 Subject: [PATCH 16/18] Add public category bases for events; fix OID/URL key casing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the per-category event bases to public (DeviceEvent, GatewayEvent, ZoneEvent) and add ExecutionEvent, each carrying that category's identity field (device_url / gateway_id / zone_oid / exec_id). Consumers can now narrow broadly (any gateway event) or to a leaf (GatewayDownEvent) for full payload. The Created/Updated DRY helpers stay private (_DeviceMetadataEvent, _ZoneMutationEvent) — they are not a category anyone narrows to. Fix a latent camelize bug surfaced by the now-required zone_oid: the API sends zoneOID / deviceURLs / placeOIDs, but camelize produced zoneOid / deviceUrls / placeOids, so zone events silently lost those fields (and now degraded to base Event). Added the missing acronym overrides. --- docs/migration-v2.md | 12 ++++- pyoverkiz/_case.py | 3 ++ pyoverkiz/models.py | 103 ++++++++++++++++++++++++++----------------- tests/test_case.py | 8 ++++ tests/test_models.py | 17 +++++++ 5 files changed, 101 insertions(+), 42 deletions(-) diff --git a/docs/migration-v2.md b/docs/migration-v2.md index d3697450..8a08ad9a 100644 --- a/docs/migration-v2.md +++ b/docs/migration-v2.md @@ -347,7 +347,17 @@ base `Event` keeps only the fields common to every event (`name`, `timestamp`, 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. + 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 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/models.py b/pyoverkiz/models.py index 84341da2..419c6055 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -574,24 +574,38 @@ class Event: @define(kw_only=True) -class _DeviceEvent(Event): - """Shared base for device-scoped events; device_url is required.""" +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): +class DeviceStateChangedEvent(DeviceEvent): """One or more states of a device changed (high-level).""" device_states: list[EventState] = field(factory=list, converter=_to_event_states) @define(kw_only=True) -class ExecutionRegisteredEvent(Event): - """A new execution was registered; exec_id is required.""" +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 @@ -602,10 +616,9 @@ class ExecutionRegisteredEvent(Event): @define(kw_only=True) -class ExecutionStateChangedEvent(Event): - """An execution state has changed; exec_id/new_state/old_state are required.""" +class ExecutionStateChangedEvent(ExecutionEvent): + """An execution state has changed; new_state/old_state are required.""" - exec_id: str new_state: ExecutionState old_state: ExecutionState owner_key: str | None = field(repr=obfuscate_id, default=None) @@ -638,83 +651,87 @@ class FailureEvent(Event): @define(kw_only=True) -class _GatewayEvent(Event): - """Shared base for gateway events; gateway_id is required.""" +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): +class GatewayActivatedEvent(GatewayEvent): """A gateway was activated.""" @define(kw_only=True) -class GatewayActiveProtocolsChangedEvent(_GatewayEvent): +class GatewayActiveProtocolsChangedEvent(GatewayEvent): """A gateway's active protocols changed.""" @define(kw_only=True) -class GatewayAliveEvent(_GatewayEvent): +class GatewayAliveEvent(GatewayEvent): """A gateway became reachable.""" @define(kw_only=True) -class GatewayAssociatedEvent(_GatewayEvent): +class GatewayAssociatedEvent(GatewayEvent): """A gateway was associated with the setup.""" @define(kw_only=True) -class GatewayAttachedEvent(_GatewayEvent): +class GatewayAttachedEvent(GatewayEvent): """A gateway was attached.""" @define(kw_only=True) -class GatewayBootEvent(_GatewayEvent): +class GatewayBootEvent(GatewayEvent): """A gateway booted.""" @define(kw_only=True) -class GatewayDeactivatedEvent(_GatewayEvent): +class GatewayDeactivatedEvent(GatewayEvent): """A gateway was deactivated.""" @define(kw_only=True) -class GatewayDetachedEvent(_GatewayEvent): +class GatewayDetachedEvent(GatewayEvent): """A gateway was detached.""" @define(kw_only=True) -class GatewayDissociatedEvent(_GatewayEvent): +class GatewayDissociatedEvent(GatewayEvent): """A gateway was dissociated from the setup.""" @define(kw_only=True) -class GatewayDownEvent(_GatewayEvent): +class GatewayDownEvent(GatewayEvent): """A gateway became unreachable.""" @define(kw_only=True) -class GatewayDownOptionsChangedEvent(_GatewayEvent): +class GatewayDownOptionsChangedEvent(GatewayEvent): """A gateway's down-detection timeout changed.""" timeout: int | None = None @define(kw_only=True) -class GatewayFirmwareUpdatedEvent(_GatewayEvent): +class GatewayFirmwareUpdatedEvent(GatewayEvent): """A gateway's firmware was updated.""" @define(kw_only=True) -class GatewayFirmwareUpdateCompletedEvent(_GatewayEvent): +class GatewayFirmwareUpdateCompletedEvent(GatewayEvent): """A gateway firmware update completed.""" firmware_type: str | None = None @define(kw_only=True) -class GatewayFunctionChangedEvent(_GatewayEvent): +class GatewayFunctionChangedEvent(GatewayEvent): """A gateway function was enabled or disabled.""" function_type: int | None = None @@ -722,62 +739,62 @@ class GatewayFunctionChangedEvent(_GatewayEvent): @define(kw_only=True) -class GatewayMigratedEvent(_GatewayEvent): +class GatewayMigratedEvent(GatewayEvent): """A gateway was migrated.""" @define(kw_only=True) -class GatewayModeChangedEvent(_GatewayEvent): +class GatewayModeChangedEvent(GatewayEvent): """A gateway's mode changed.""" @define(kw_only=True) -class GatewayPlaceUpdatedEvent(_GatewayEvent): +class GatewayPlaceUpdatedEvent(GatewayEvent): """A gateway's place was updated.""" @define(kw_only=True) -class GatewayProtocolDownEvent(_GatewayEvent): +class GatewayProtocolDownEvent(GatewayEvent): """A gateway protocol became unavailable.""" @define(kw_only=True) -class GatewayProtocolReadyEvent(_GatewayEvent): +class GatewayProtocolReadyEvent(GatewayEvent): """A gateway protocol became available.""" @define(kw_only=True) -class GatewaySynchronizationEndedEvent(_GatewayEvent): +class GatewaySynchronizationEndedEvent(GatewayEvent): """A gateway synchronization ended.""" @define(kw_only=True) -class GatewaySynchronizationStartedEvent(_GatewayEvent): +class GatewaySynchronizationStartedEvent(GatewayEvent): """A gateway synchronization started.""" @define(kw_only=True) -class GatewayTimeReliabilityChangedEvent(_GatewayEvent): +class GatewayTimeReliabilityChangedEvent(GatewayEvent): """A gateway's time reliability changed.""" @define(kw_only=True) -class DeviceAvailableEvent(_DeviceEvent): +class DeviceAvailableEvent(DeviceEvent): """A device became available.""" @define(kw_only=True) -class DeviceUnavailableEvent(_DeviceEvent): +class DeviceUnavailableEvent(DeviceEvent): """A device became unavailable.""" @define(kw_only=True) -class DeviceDisabledEvent(_DeviceEvent): +class DeviceDisabledEvent(DeviceEvent): """A device was disabled.""" @define(kw_only=True) -class _DeviceMetadataEvent(_DeviceEvent): +class _DeviceMetadataEvent(DeviceEvent): """Shared base for device create/update events (carry label/place metadata).""" controllable_name: str | None = None @@ -797,26 +814,30 @@ class DeviceUpdatedEvent(_DeviceMetadataEvent): @define(kw_only=True) -class DeviceRemovedEvent(_DeviceEvent): +class DeviceRemovedEvent(DeviceEvent): """A device was removed.""" controllable_name: str | None = None @define(kw_only=True) -class _ZoneEvent(Event): - """Shared base for zone events; zone_oid is required.""" +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): +class ZoneDeletedEvent(ZoneEvent): """A zone was deleted.""" @define(kw_only=True) -class _ZoneMutationEvent(_ZoneEvent): +class _ZoneMutationEvent(ZoneEvent): """Shared base for zone create/update events (carry membership lists).""" type: int | None = None 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_models.py b/tests/test_models.py index 76dd5c62..733f9700 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1312,6 +1312,23 @@ def test_zone_event_subtypes(self): 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( From 6582053f04bc01061c00d5bed13e225743b1c852 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 13:11:41 +0000 Subject: [PATCH 17/18] Make event batch resilient; drop deviceStates null special-case Lift per-item resilience to the list[Event] level so a single unstructurable event (missing name, non-dict) is dropped and logged instead of failing the whole fetch. Remove the speculative deviceStates null coercion, which never occurs in real data and is now redundant with subtype-degradation. --- pyoverkiz/converter.py | 27 ++++++++++++++++----------- pyoverkiz/models.py | 9 +-------- tests/test_models.py | 36 +++++++++++++++++++++++------------- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/pyoverkiz/converter.py b/pyoverkiz/converter.py index d436b237..91c02f4a 100644 --- a/pyoverkiz/converter.py +++ b/pyoverkiz/converter.py @@ -19,7 +19,6 @@ CommandDefinition, CommandDefinitions, Event, - EventState, State, StateDefinition, StateDefinitions, @@ -102,16 +101,6 @@ def _structure_state_definitions(val: Any, _: type) -> StateDefinitions: c.register_structure_hook(CommandDefinitions, _structure_command_definitions) c.register_structure_hook(StateDefinitions, _structure_state_definitions) - # Treat a missing / null deviceStates as empty; cattrs' list hook raises on None. - def _structure_event_states(val: Any, _: type) -> list[EventState]: - if not val: - return [] - return [c.structure(s, EventState) for s in val] - - c.register_structure_hook_func( - lambda t: t == list[EventState], _structure_event_states - ) - # For all other attrs classes: lazily generate a hook that renames camelCase # API keys to snake_case on first use. This avoids manual dependency ordering. skip = {States, CommandDefinitions, StateDefinitions} @@ -150,6 +139,22 @@ def _structure_event(val: Any, _: type) -> Event: 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 419c6055..507520ea 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -556,13 +556,6 @@ def id(self) -> str: return self.oid -def _to_event_states(states: list[Any] | None) -> list[EventState]: - """Structure a raw deviceStates list into EventState instances (None -> []).""" - if not states: - return [] - return [EventState(**s) if isinstance(s, dict) else s for s in states] - - @define(kw_only=True) class Event: """Base Overkiz event; structured into a subtype by ``name`` (see converter).""" @@ -588,7 +581,7 @@ class DeviceEvent(Event): class DeviceStateChangedEvent(DeviceEvent): """One or more states of a device changed (high-level).""" - device_states: list[EventState] = field(factory=list, converter=_to_event_states) + device_states: list[EventState] = field(factory=list) @define(kw_only=True) diff --git a/tests/test_models.py b/tests/test_models.py index 733f9700..42e60e37 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1456,19 +1456,6 @@ def test_converter_dispatches_failed_event_keeps_protocol_type(self): assert event.gateway_id == "9876-1234-8767" assert event.protocol_type == 8 - def test_device_state_changed_tolerates_null_device_states(self): - """A present-but-null deviceStates structures into an empty list, not a crash.""" - event = converter.structure( - { - "name": "DeviceStateChangedEvent", - "deviceURL": "io://1234-5678-9012/4468654#1", - "deviceStates": None, - }, - Event, - ) - assert isinstance(event, DeviceStateChangedEvent) - assert event.device_states == [] - 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"): @@ -1505,6 +1492,29 @@ def test_one_malformed_event_does_not_fail_the_batch(self, caplog): 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( From 6abc0631103b8415d9f512680258c6e311182225 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Fri, 5 Jun 2026 13:16:31 +0000 Subject: [PATCH 18/18] Drop failureTypeCode note from FailureEvent docstring --- pyoverkiz/models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index 507520ea..2308e450 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -633,8 +633,7 @@ class FailureEvent(Event): operation is identified by ``event.name``. gateway_id / device_url / protocol_type cover the documented failure - payloads. failureTypeCode is omitted; the API only sends it on - ExecutionStateChangedEvent. + payloads. """ failure_type: str | None = None