From 193cddb034c193fe55644764ebf5904cbb315c04 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Thu, 14 May 2026 15:45:42 +0200 Subject: [PATCH 01/18] phase1: add extension contracts for topics, filters, and id migration --- axis/interfaces/event_extension_contracts.py | 51 +++++++++++++++++ axis/interfaces/topic_normalizer.py | 58 ++++++++++++++++++++ axis/interfaces/unique_id_migration.py | 56 +++++++++++++++++++ tests/test_event_extension_contracts.py | 19 +++++++ tests/test_topic_normalizer.py | 32 +++++++++++ tests/test_unique_id_migration.py | 44 +++++++++++++++ 6 files changed, 260 insertions(+) create mode 100644 axis/interfaces/event_extension_contracts.py create mode 100644 axis/interfaces/topic_normalizer.py create mode 100644 axis/interfaces/unique_id_migration.py create mode 100644 tests/test_event_extension_contracts.py create mode 100644 tests/test_topic_normalizer.py create mode 100644 tests/test_unique_id_migration.py diff --git a/axis/interfaces/event_extension_contracts.py b/axis/interfaces/event_extension_contracts.py new file mode 100644 index 00000000..09c91726 --- /dev/null +++ b/axis/interfaces/event_extension_contracts.py @@ -0,0 +1,51 @@ +"""Extension contracts for transport filter planning.""" + +from __future__ import annotations + +from dataclasses import dataclass +import enum + + +class EventTransport(enum.StrEnum): + """Event transport types used for capability planning.""" + + RTSP = "rtsp" + WEBSOCKET = "websocket" + MQTT = "mqtt" + + +@dataclass(frozen=True) +class TransportFilterCapability: + """Describes upstream filter capabilities for a transport.""" + + transport: EventTransport + supports_upstream_topic_filter: bool + notes: str + + +TRANSPORT_FILTER_CAPABILITIES: dict[EventTransport, TransportFilterCapability] = { + EventTransport.RTSP: TransportFilterCapability( + transport=EventTransport.RTSP, + supports_upstream_topic_filter=False, + notes="RTSP supports event stream on/off only", + ), + EventTransport.WEBSOCKET: TransportFilterCapability( + transport=EventTransport.WEBSOCKET, + supports_upstream_topic_filter=True, + notes="WebSocket supports events:configure eventFilterList", + ), + EventTransport.MQTT: TransportFilterCapability( + transport=EventTransport.MQTT, + supports_upstream_topic_filter=True, + notes="MQTT event publication supports topicFilter list", + ), +} + + +@dataclass(frozen=True) +class DesiredEventSubscription: + """Transport-agnostic desired event subscription request.""" + + topic: str + source: str | None = None + source_ids: tuple[str, ...] | None = None diff --git a/axis/interfaces/topic_normalizer.py b/axis/interfaces/topic_normalizer.py new file mode 100644 index 00000000..19b61966 --- /dev/null +++ b/axis/interfaces/topic_normalizer.py @@ -0,0 +1,58 @@ +"""Topic normalization helpers for cross-transport event handling.""" + +from __future__ import annotations + +from typing import Literal + +_CANONICAL_PREFIXES = ("tns1", "tnsaxis") +_MQTT_PREFIXES = ("onvif", "axis") + +SegmentFormat = Literal["canonical", "mqtt", "unknown"] + + +def _convert_segment(segment: str, mapping: dict[str, str]) -> str: + for source, target in mapping.items(): + prefix = f"{source}:" + if segment.startswith(prefix): + return f"{target}:{segment[len(prefix) :]}" + return segment + + +def _convert_topic(topic: str, mapping: dict[str, str]) -> str: + return "/".join(_convert_segment(segment, mapping) for segment in topic.split("/")) + + +def to_canonical(topic: str) -> str: + """Convert topic namespaces to canonical (tns1/tnsaxis) representation.""" + return _convert_topic(topic, {"onvif": "tns1", "axis": "tnsaxis"}) + + +def to_mqtt(topic: str) -> str: + """Convert topic namespaces to MQTT (onvif/axis) representation.""" + return _convert_topic(topic, {"tns1": "onvif", "tnsaxis": "axis"}) + + +def to_topic_filter(topic: str) -> str: + """Convert canonical topic to topic-filter namespace representation.""" + return to_mqtt(topic) + + +def detect_format(topic: str) -> SegmentFormat: + """Detect topic namespace format from segment prefixes.""" + segments = topic.split("/") + + if any( + segment.startswith(f"{prefix}:") + for prefix in _CANONICAL_PREFIXES + for segment in segments + ): + return "canonical" + + if any( + segment.startswith(f"{prefix}:") + for prefix in _MQTT_PREFIXES + for segment in segments + ): + return "mqtt" + + return "unknown" diff --git a/axis/interfaces/unique_id_migration.py b/axis/interfaces/unique_id_migration.py new file mode 100644 index 00000000..5bddb1b5 --- /dev/null +++ b/axis/interfaces/unique_id_migration.py @@ -0,0 +1,56 @@ +"""Unique ID migration contract utilities for coordinated topic normalization.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re + +UNIQUE_ID_MIGRATION_VERSION = 1 + + +@dataclass(frozen=True) +class UniqueIdMigrationEntry: + """Single unique ID migration mapping.""" + + old_unique_id: str + new_unique_id: str + + +@dataclass(frozen=True) +class UniqueIdMigrationPlan: + """Deterministic migration plan for legacy unique IDs.""" + + version: int + entries: tuple[UniqueIdMigrationEntry, ...] + + +def _normalize_unique_id(unique_id: str) -> str: + """Normalize topic namespaces inside a unique ID string.""" + # Unique IDs commonly embed topics in "__" format. + # Convert only namespace prefixes at token boundaries to avoid rewriting + # already-canonical tokens such as "tnsaxis:". + return re.sub( + r"(^|[/_])axis:", + r"\1tnsaxis:", + re.sub(r"(^|[/_])onvif:", r"\1tns1:", unique_id), + ) + + +def build_unique_id_migration_plan(unique_ids: list[str]) -> UniqueIdMigrationPlan: + """Build deterministic migration plan for mixed-format unique IDs.""" + entries = [ + UniqueIdMigrationEntry(old_unique_id=unique_id, new_unique_id=new_unique_id) + for unique_id in sorted(set(unique_ids)) + if (new_unique_id := _normalize_unique_id(unique_id)) != unique_id + ] + + return UniqueIdMigrationPlan( + version=UNIQUE_ID_MIGRATION_VERSION, + entries=tuple(entries), + ) + + +def build_unique_id_alias_map(unique_ids: list[str]) -> dict[str, str]: + """Build lookup map from old unique IDs to normalized aliases.""" + plan = build_unique_id_migration_plan(unique_ids) + return {entry.old_unique_id: entry.new_unique_id for entry in plan.entries} diff --git a/tests/test_event_extension_contracts.py b/tests/test_event_extension_contracts.py new file mode 100644 index 00000000..bab6f6d0 --- /dev/null +++ b/tests/test_event_extension_contracts.py @@ -0,0 +1,19 @@ +"""Tests for extension contract capability matrix.""" + +from axis.interfaces.event_extension_contracts import ( + TRANSPORT_FILTER_CAPABILITIES, + EventTransport, +) + + +def test_transport_filter_capabilities() -> None: + """Capability matrix should reflect expected transport support.""" + assert not TRANSPORT_FILTER_CAPABILITIES[ + EventTransport.RTSP + ].supports_upstream_topic_filter + assert TRANSPORT_FILTER_CAPABILITIES[ + EventTransport.WEBSOCKET + ].supports_upstream_topic_filter + assert TRANSPORT_FILTER_CAPABILITIES[ + EventTransport.MQTT + ].supports_upstream_topic_filter diff --git a/tests/test_topic_normalizer.py b/tests/test_topic_normalizer.py new file mode 100644 index 00000000..f9679b4a --- /dev/null +++ b/tests/test_topic_normalizer.py @@ -0,0 +1,32 @@ +"""Tests for event topic normalization extension contract.""" + +from axis.interfaces.topic_normalizer import ( + detect_format, + to_canonical, + to_mqtt, + to_topic_filter, +) + + +def test_round_trip_topic_conversion() -> None: + """Canonical and MQTT representations should round-trip.""" + canonical = "tns1:Device/tnsaxis:Sensor/PIR" + + mqtt = to_mqtt(canonical) + assert mqtt == "onvif:Device/axis:Sensor/PIR" + assert to_canonical(mqtt) == canonical + + +def test_detect_topic_format() -> None: + """Format detection should classify known topic namespace variants.""" + assert detect_format("tns1:Device/tnsaxis:Sensor/PIR") == "canonical" + assert detect_format("onvif:Device/axis:Sensor/PIR") == "mqtt" + assert detect_format("Device/Sensor/PIR") == "unknown" + + +def test_to_topic_filter_uses_mqtt_form() -> None: + """Topic filter representation should use MQTT namespaces.""" + assert ( + to_topic_filter("tns1:Device/tnsaxis:Light/Status") + == "onvif:Device/axis:Light/Status" + ) diff --git a/tests/test_unique_id_migration.py b/tests/test_unique_id_migration.py new file mode 100644 index 00000000..fe7ded2f --- /dev/null +++ b/tests/test_unique_id_migration.py @@ -0,0 +1,44 @@ +"""Tests for unique ID migration contract utilities.""" + +from axis.interfaces.unique_id_migration import ( + UNIQUE_ID_MIGRATION_VERSION, + build_unique_id_alias_map, + build_unique_id_migration_plan, +) + + +def test_build_unique_id_migration_plan_is_deterministic() -> None: + """Migration plan ordering should be deterministic and idempotent.""" + input_ids = [ + "axis_onvif:Device/axis:Sensor/PIR_0", + "axis_tns1:Device/tnsaxis:Sensor/PIR_0", + "axis_onvif:Device/axis:Light/Status_0", + ] + + plan = build_unique_id_migration_plan(input_ids) + + assert plan.version == UNIQUE_ID_MIGRATION_VERSION + assert [entry.old_unique_id for entry in plan.entries] == sorted( + [ + "axis_onvif:Device/axis:Light/Status_0", + "axis_onvif:Device/axis:Sensor/PIR_0", + ] + ) + assert [entry.new_unique_id for entry in plan.entries] == [ + "axis_tns1:Device/tnsaxis:Light/Status_0", + "axis_tns1:Device/tnsaxis:Sensor/PIR_0", + ] + + +def test_build_unique_id_alias_map_returns_only_changed_entries() -> None: + """Alias map should only include legacy IDs that require migration.""" + aliases = build_unique_id_alias_map( + [ + "axis_tns1:Device/tnsaxis:Sensor/PIR_0", + "axis_onvif:Device/axis:Sensor/PIR_0", + ] + ) + + assert aliases == { + "axis_onvif:Device/axis:Sensor/PIR_0": "axis_tns1:Device/tnsaxis:Sensor/PIR_0" + } From 98ea75e4e66a17804f9bb3ab439f56d4e4e5b1bf Mon Sep 17 00:00:00 2001 From: Kane610 Date: Thu, 14 May 2026 15:49:31 +0200 Subject: [PATCH 02/18] phase2: add opt-in extension APIs and filter hooks --- axis/interfaces/event_instances.py | 56 +++++++++++++++++++++++++- axis/interfaces/event_manager.py | 27 +++++++++++++ axis/interfaces/mqtt.py | 21 +++++++++- axis/interfaces/vapix.py | 38 +++++++++++++++++ axis/stream_manager.py | 12 +++++- tests/test_event_instances.py | 29 +++++++++++++ tests/test_event_manager_extensions.py | 41 +++++++++++++++++++ tests/test_mqtt.py | 31 ++++++++++++++ tests/test_stream_manager.py | 25 ++++++++++++ tests/test_vapix.py | 21 ++++++++++ 10 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 tests/test_event_manager_extensions.py diff --git a/axis/interfaces/event_instances.py b/axis/interfaces/event_instances.py index 142e88a6..dad73699 100644 --- a/axis/interfaces/event_instances.py +++ b/axis/interfaces/event_instances.py @@ -1,6 +1,8 @@ """Event service and action service APIs available in Axis network device.""" -from typing import TYPE_CHECKING +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from ..models.event_instance import ( EventInstance, @@ -9,9 +11,11 @@ ) from .api_handler import ApiHandler from .event_manager import BLACK_LISTED_TOPICS +from .topic_normalizer import to_canonical, to_topic_filter if TYPE_CHECKING: from ..models.event import Event + from .event_extension_contracts import DesiredEventSubscription class EventInstanceHandler(ApiHandler[EventInstance]): @@ -43,3 +47,53 @@ def get_expected_events_per_topic( continue grouped[item.topic] = item.to_events() return grouped + + def get_supported_topics( + self, include_internal_topics: bool = False + ) -> tuple[str, ...]: + """Return canonical supported event topics from event instances.""" + expected = self.get_expected_events_per_topic( + include_internal_topics=include_internal_topics + ) + return tuple(sorted(to_canonical(topic) for topic in expected)) + + def get_supported_event_descriptors( + self, include_internal_topics: bool = False + ) -> dict[str, dict[str, Any]]: + """Return normalized supported-event descriptors for extension consumers.""" + descriptors: dict[str, dict[str, Any]] = {} + for topic, events in self.get_expected_events_per_topic( + include_internal_topics=include_internal_topics + ).items(): + canonical_topic = to_canonical(topic) + topic_filter = to_topic_filter(canonical_topic) + descriptors[canonical_topic] = { + "topic": canonical_topic, + "topic_filter": topic_filter, + "count": len(events), + "sources": tuple( + sorted({event.source for event in events if event.source}) + ), + } + return descriptors + + def build_transport_filter_payloads( + self, + subscriptions: list[DesiredEventSubscription] | None = None, + include_internal_topics: bool = False, + ) -> dict[str, list[str]]: + """Build no-op extension payloads for MQTT and WebSocket topic filters.""" + if subscriptions: + topics = sorted( + {to_canonical(subscription.topic) for subscription in subscriptions} + ) + else: + topics = list(self.get_supported_topics(include_internal_topics)) + + topic_filters = [to_topic_filter(topic) for topic in topics] + + return { + "canonical_topics": topics, + "mqtt_topics": topic_filters, + "websocket_topic_filters": topic_filters, + } diff --git a/axis/interfaces/event_manager.py b/axis/interfaces/event_manager.py index 4745d0e7..9b8db813 100644 --- a/axis/interfaces/event_manager.py +++ b/axis/interfaces/event_manager.py @@ -5,6 +5,7 @@ from typing import Any from ..models.event import Event, EventOperation, EventTopic +from .topic_normalizer import to_canonical SubscriptionCallback = Callable[[Event], None] SubscriptionType = tuple[ @@ -32,8 +33,28 @@ def __init__(self) -> None: """Ready information about events.""" self._known_topics: set[str] = set() self._unsupported_topics: set[str] = set() + self._allowed_topics: set[str] | None = None self._subscribers: dict[str, list[SubscriptionType]] = {ID_FILTER_ALL: []} + def set_allowed_topics(self, topics: list[str] | set[str] | None) -> None: + """Set optional canonical topic allow-list for runtime filtering. + + Default behavior remains unchanged until this extension is explicitly used. + """ + if topics is None: + self._allowed_topics = None + return + + self._allowed_topics = {to_canonical(topic) for topic in topics} + + def seed_known_events(self, events: list[Event]) -> None: + """Seed known topics from expected startup events. + + This extension hook is no-op unless explicitly invoked by a caller. + """ + for event in events: + self._known_topics.add(f"{to_canonical(event.topic)}_{event.id}") + def handler(self, data: bytes | dict[str, Any]) -> None: """Create event and pass it along to subscribers.""" event = Event.decode(data) @@ -49,6 +70,12 @@ def handler(self, data: bytes | dict[str, Any]) -> None: if event.topic in BLACK_LISTED_TOPICS: return + if ( + self._allowed_topics is not None + and to_canonical(event.topic) not in self._allowed_topics + ): + return + known = (unique_topic := f"{event.topic}_{event.id}") not in self._known_topics self._known_topics.add(unique_topic) diff --git a/axis/interfaces/mqtt.py b/axis/interfaces/mqtt.py index 882a021f..2f21ef18 100644 --- a/axis/interfaces/mqtt.py +++ b/axis/interfaces/mqtt.py @@ -19,6 +19,7 @@ GetEventPublicationConfigResponse, ) from .api_handler import ApiHandler, HandlerGroup +from .topic_normalizer import to_topic_filter DEFAULT_TOPICS = ["//."] @@ -66,12 +67,28 @@ async def get_event_publication_config(self) -> EventPublicationConfig: response = GetEventPublicationConfigResponse.decode(bytes_data) return response.data + def build_topic_filters( + self, topics: list[str], normalize_topics: bool = False + ) -> list[str]: + """Build MQTT topic filters. + + Defaults preserve previous behavior unless normalization is requested. + """ + normalized = ( + [to_topic_filter(topic) for topic in topics] if normalize_topics else topics + ) + # Preserve input order while deduplicating. + return list(dict.fromkeys(normalized)) + async def configure_event_publication( - self, topics: list[str] = DEFAULT_TOPICS + self, + topics: list[str] = DEFAULT_TOPICS, + normalize_topics: bool = False, ) -> None: """Configure MQTT Client event publication.""" + topic_filters = self.build_topic_filters(topics, normalize_topics) event_filters = EventFilter.from_list( - [{"topicFilter": topic} for topic in topics] + [{"topicFilter": topic} for topic in topic_filters] ) config = EventPublicationConfig(event_filter_list=event_filters) await self.vapix.api_request( diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index 54c2773f..baa77a5e 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -21,6 +21,12 @@ from .applications.object_analytics import ObjectAnalyticsHandler from .applications.vmd4 import Vmd4Handler from .basic_device_info import BasicDeviceInfoHandler +from .event_extension_contracts import ( + TRANSPORT_FILTER_CAPABILITIES, + DesiredEventSubscription, + EventTransport, + TransportFilterCapability, +) from .event_instances import EventInstanceHandler from .light_control import LightHandler from .mqtt import MqttClientHandler @@ -239,6 +245,38 @@ async def initialize_event_instances(self) -> None: """Initialize event instances of what events are supported by the device.""" await self.event_instances.update() + def get_event_transport_capabilities( + self, + ) -> dict[EventTransport, TransportFilterCapability]: + """Return extension capability matrix for event transports.""" + return dict(TRANSPORT_FILTER_CAPABILITIES) + + def get_supported_event_descriptors( + self, + include_internal_topics: bool = False, + ) -> dict[str, dict[str, Any]]: + """Return normalized supported-event descriptors from event instances. + + This method is extension-oriented and has no side effects. + """ + return self.event_instances.get_supported_event_descriptors( + include_internal_topics=include_internal_topics + ) + + def build_transport_filter_payloads( + self, + subscriptions: list[DesiredEventSubscription] | None = None, + include_internal_topics: bool = False, + ) -> dict[str, list[str]]: + """Build transport filter payloads from desired subscriptions. + + This method does not apply filters; it only returns extension payloads. + """ + return self.event_instances.build_transport_filter_payloads( + subscriptions=subscriptions, + include_internal_topics=include_internal_topics, + ) + async def initialize_users(self) -> None: """Load device user data and initialize user management.""" await self.users.update() diff --git a/axis/stream_manager.py b/axis/stream_manager.py index 5c329b69..949a1c11 100644 --- a/axis/stream_manager.py +++ b/axis/stream_manager.py @@ -25,12 +25,17 @@ class StreamManager: """Setup, start, stop and retry stream.""" - def __init__(self, device: AxisDevice) -> None: + def __init__( + self, + device: AxisDevice, + event_filter_list: list[dict[str, str]] | None = None, + ) -> None: """Initialize stream manager.""" self.device = device self.video = None # Unsupported self.audio = None # Unsupported self.event = False + self.event_filter_list = event_filter_list self.stream: StreamTransport | None = None self.connection_status_callback: list[Callable[[Signal], None]] = [] @@ -128,6 +133,7 @@ def _build_stream(self) -> StreamTransport: self.device, self.websocket_url, self.session_callback, + event_filter_list=self.event_filter_list, ) return RTSPClient( @@ -138,6 +144,10 @@ def _build_stream(self) -> StreamTransport: self.session_callback, ) + def set_event_filter_list(self, event_filter_list: list[dict[str, str]]) -> None: + """Set optional websocket event filter list for future stream sessions.""" + self.event_filter_list = event_filter_list + def session_callback(self, signal: Signal) -> None: """Signalling from stream session. diff --git a/tests/test_event_instances.py b/tests/test_event_instances.py index e2f6e616..9f893671 100644 --- a/tests/test_event_instances.py +++ b/tests/test_event_instances.py @@ -7,6 +7,7 @@ import pytest +from axis.interfaces.event_extension_contracts import DesiredEventSubscription from axis.models.event import Event from axis.models.event_instance import ( EventInstance, @@ -357,6 +358,34 @@ async def test_event_instance_synthesizes_unknown_topics( ) +async def test_supported_event_descriptors_and_filter_payloads( + http_route_mock, event_instances +) -> None: + """Extension helpers should expose normalized descriptors and payloads.""" + http_route_mock.post("/vapix/services").respond( + text=EVENT_INSTANCES, + headers={"Content-Type": "application/soap+xml; charset=utf-8"}, + ) + + await event_instances.update() + + descriptors = event_instances.get_supported_event_descriptors() + assert "tns1:Device/tnsaxis:Sensor/PIR" in descriptors + assert ( + descriptors["tns1:Device/tnsaxis:Sensor/PIR"]["topic_filter"] + == "onvif:Device/axis:Sensor/PIR" + ) + + payloads = event_instances.build_transport_filter_payloads( + subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")] + ) + assert payloads == { + "canonical_topics": ["tns1:Device/tnsaxis:Sensor/PIR"], + "mqtt_topics": ["onvif:Device/axis:Sensor/PIR"], + "websocket_topic_filters": ["onvif:Device/axis:Sensor/PIR"], + } + + async def test_expected_events_protocol_normalization(http_route_mock, event_instances): """Expected-event discovery is protocol-agnostic and deterministic.""" http_route_mock.post("/vapix/services").respond( diff --git a/tests/test_event_manager_extensions.py b/tests/test_event_manager_extensions.py new file mode 100644 index 00000000..ab2f7c23 --- /dev/null +++ b/tests/test_event_manager_extensions.py @@ -0,0 +1,41 @@ +"""Tests for EventManager extension hooks.""" + +from axis.interfaces.event_manager import EventManager + + +def test_allowed_topics_filter_is_opt_in() -> None: + """Allow-list should only apply when explicitly configured.""" + manager = EventManager() + received = [] + manager.subscribe(lambda event: received.append(event.topic)) + + pir_event = { + "topic": "tns1:Device/tnsaxis:Sensor/PIR", + "source": "sensor", + "source_idx": "0", + "type": "state", + "value": "0", + "operation": "Initialized", + } + light_event = { + "topic": "tns1:Device/tnsaxis:Light/Status", + "source": "id", + "source_idx": "0", + "type": "state", + "value": "OFF", + "operation": "Initialized", + } + + manager.handler(pir_event) + manager.handler(light_event) + assert received == [ + "tns1:Device/tnsaxis:Sensor/PIR", + "tns1:Device/tnsaxis:Light/Status", + ] + + manager.set_allowed_topics(["tns1:Device/tnsaxis:Sensor/PIR"]) + manager.handler(light_event) + assert received == [ + "tns1:Device/tnsaxis:Sensor/PIR", + "tns1:Device/tnsaxis:Light/Status", + ] diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index bfd00916..c03cb884 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -355,6 +355,37 @@ async def test_configure_event_publication_specific_topics( } +async def test_build_topic_filters_deduplicates_preserving_order( + mqtt_client: MqttClientHandler, +) -> None: + """Topic filter builder should keep order while removing duplicates.""" + assert mqtt_client.build_topic_filters(["a", "b", "a"]) == ["a", "b"] + + +async def test_configure_event_publication_normalizes_topics( + mock_mqtt_client_request, + mock_mqtt_event_request, + mqtt_client: MqttClientHandler, +): + """Optional normalize flag should convert canonical topics to filter format.""" + route = mock_mqtt_event_request({}) + + await mqtt_client.configure_event_publication( + ["tns1:Device/tnsaxis:Sensor/PIR"], + normalize_topics=True, + ) + + assert route.called + assert json.loads(route.calls.last.request.content) == { + "apiVersion": "1.0", + "context": "Axis library", + "method": "configureEventPublication", + "params": { + "eventFilterList": [{"topicFilter": "onvif:Device/axis:Sensor/PIR"}] + }, + } + + async def test_convert_json_to_event(): """Verify conversion from json to event.""" event = mqtt_json_to_event( diff --git a/tests/test_stream_manager.py b/tests/test_stream_manager.py index fb3e57bd..84a28a78 100644 --- a/tests/test_stream_manager.py +++ b/tests/test_stream_manager.py @@ -121,6 +121,31 @@ async def test_start_uses_websocket_when_supported( rtsp_client.assert_not_called() +@patch("axis.stream_manager.RTSPClient") +@patch("axis.stream_manager.WebSocketClient") +async def test_stream_manager_passes_event_filter_list_to_websocket( + websocket_client, rtsp_client, stream_manager +): + """Optional event_filter_list should be forwarded to websocket transport.""" + websocket_client.return_value.start = AsyncMock() + stream_manager.event = True + stream_manager.device.config.websocket_enabled = True + stream_manager.device.vapix.api_discovery._items[ + ApiId.EVENT_STREAMING_OVER_WEBSOCKET + ] = MagicMock() + stream_manager.set_event_filter_list([{"topicFilter": "onvif:Device//."}]) + + stream_manager.start() + + websocket_client.assert_called_once_with( + stream_manager.device, + stream_manager.websocket_url, + stream_manager.session_callback, + event_filter_list=[{"topicFilter": "onvif:Device//."}], + ) + rtsp_client.assert_not_called() + + @patch("axis.stream_manager.RTSPClient") @patch("axis.stream_manager.WebSocketClient") async def test_initialize_stream(websocket_client, rtsp_client, stream_manager): diff --git a/tests/test_vapix.py b/tests/test_vapix.py index 4b915cac..1803e282 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -15,6 +15,10 @@ Unauthorized, ) from axis.interfaces.api_handler import HandlerGroup +from axis.interfaces.event_extension_contracts import ( + DesiredEventSubscription, + EventTransport, +) from axis.models.api_discovery import ListApisRequest from axis.models.applications.application import ( ApplicationStatus, @@ -127,6 +131,23 @@ def test_vapix_not_initialized(vapix: Vapix) -> None: assert not vapix.users.supported +def test_vapix_extension_helpers(vapix: Vapix) -> None: + """Extension helper APIs should be available without side effects.""" + capabilities = vapix.get_event_transport_capabilities() + assert EventTransport.RTSP in capabilities + assert EventTransport.WEBSOCKET in capabilities + assert EventTransport.MQTT in capabilities + + assert vapix.get_supported_event_descriptors() == {} + assert vapix.build_transport_filter_payloads( + subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")] + ) == { + "canonical_topics": ["tns1:Device/tnsaxis:Sensor/PIR"], + "mqtt_topics": ["onvif:Device/axis:Sensor/PIR"], + "websocket_topic_filters": ["onvif:Device/axis:Sensor/PIR"], + } + + def test_api_discovery_handlers_registration(vapix: Vapix) -> None: """Verify grouped API-discovery handlers matches the startup contract.""" handlers = vapix._handlers_by_group(HandlerGroup.API_DISCOVERY) From 1d0793b678a44cb728b61804681e71f17e0179ee Mon Sep 17 00:00:00 2001 From: Kane610 Date: Thu, 14 May 2026 15:52:54 +0200 Subject: [PATCH 03/18] phase3: add explicit transport filter planning and apply orchestration --- axis/interfaces/vapix.py | 61 +++++++++++++++++++++++++++ tests/test_vapix.py | 91 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index baa77a5e..16b1566b 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -37,6 +37,7 @@ from .ptz import PtzControl from .pwdgrp_cgi import Users from .stream_profiles import StreamProfilesHandler +from .topic_normalizer import to_canonical from .user_groups import UserGroups from .view_areas import ViewAreaHandler @@ -277,6 +278,66 @@ def build_transport_filter_payloads( include_internal_topics=include_internal_topics, ) + def plan_event_transport_filters( + self, + subscriptions: list[DesiredEventSubscription] | None = None, + include_internal_topics: bool = False, + ) -> dict[str, list[str]]: + """Plan validated transport filter payloads from event-instance support data.""" + payloads = self.build_transport_filter_payloads( + subscriptions=subscriptions, + include_internal_topics=include_internal_topics, + ) + + supported_topics = set( + self.event_instances.get_supported_topics( + include_internal_topics=include_internal_topics + ) + ) + requested_topics = { + to_canonical(topic) for topic in payloads["canonical_topics"] + } + unknown_topics = sorted(requested_topics - supported_topics) + if unknown_topics: + message = f"Requested unsupported topics: {', '.join(unknown_topics)}" + raise ValueError(message) + + return payloads + + async def apply_event_transport_filters( + self, + subscriptions: list[DesiredEventSubscription] | None = None, + include_internal_topics: bool = False, + apply_mqtt: bool = True, + apply_websocket: bool = True, + apply_local_fallback: bool = True, + ) -> dict[str, list[str]]: + """Apply planned transport filters through optional extension hooks. + + This method performs no implicit event-instance initialization. Callers are + responsible for invoking ``initialize_event_instances`` before planning. + """ + payloads = self.plan_event_transport_filters( + subscriptions=subscriptions, + include_internal_topics=include_internal_topics, + ) + + if apply_websocket: + self.device.stream.set_event_filter_list( + [ + {"topicFilter": topic} + for topic in payloads["websocket_topic_filters"] + ] + ) + + if apply_mqtt and self.mqtt.supported: + await self.mqtt.configure_event_publication(payloads["mqtt_topics"]) + + if apply_local_fallback: + self.device.event.set_allowed_topics(payloads["canonical_topics"]) + + return payloads + async def initialize_users(self) -> None: """Load device user data and initialize user management.""" await self.users.update() diff --git a/tests/test_vapix.py b/tests/test_vapix.py index 1803e282..b09b55b3 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -19,12 +19,17 @@ DesiredEventSubscription, EventTransport, ) -from axis.models.api_discovery import ListApisRequest +from axis.models.api_discovery import ApiId, ListApisRequest from axis.models.applications.application import ( ApplicationStatus, ListApplicationsRequest, ) from axis.models.basic_device_info import GetAllPropertiesRequest +from axis.models.event_instance import ( + EventInstance, + EventInstanceData, + EventInstanceSource, +) from axis.models.light_control import GetLightInformationRequest from axis.models.parameters.param_cgi import ParamRequest from axis.models.port_management import GetPortsRequest @@ -148,6 +153,90 @@ def test_vapix_extension_helpers(vapix: Vapix) -> None: } +async def test_plan_event_transport_filters_validates_supported_topics( + vapix: Vapix, +) -> None: + """Planning should reject requested topics that are not in event instances.""" + topic = "tns1:Device/tnsaxis:Sensor/PIR" + vapix.event_instances._items = { + topic: EventInstance( + id=topic, + topic=topic, + topic_filter="onvif:Device/axis:Sensor/PIR", + is_available=True, + is_application_data=False, + name="pir", + stateful=True, + stateless=False, + source=EventInstanceSource(), + data=EventInstanceData(), + ) + } + + payloads = vapix.plan_event_transport_filters( + subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")] + ) + assert payloads["canonical_topics"] == [topic] + + with pytest.raises(ValueError, match="Requested unsupported topics"): + vapix.plan_event_transport_filters( + subscriptions=[ + DesiredEventSubscription(topic="onvif:Device/axis:Status/SystemReady") + ] + ) + + +async def test_apply_event_transport_filters_calls_transport_hooks( + vapix: Vapix, +) -> None: + """Apply should use websocket, mqtt, and local fallback hooks when enabled.""" + topic = "tns1:Device/tnsaxis:Sensor/PIR" + vapix.event_instances._items = { + topic: EventInstance( + id=topic, + topic=topic, + topic_filter="onvif:Device/axis:Sensor/PIR", + is_available=True, + is_application_data=False, + name="pir", + stateful=True, + stateless=False, + source=EventInstanceSource(), + data=EventInstanceData(), + ) + } + + mqtt_calls: list[list[str]] = [] + + vapix.api_discovery._items[ApiId.MQTT_CLIENT] = type( + "_Api", (), {"version": "1.0"} + )() + + async def _configure_event_publication( + topics: list[str], *_args, **_kwargs + ) -> None: + mqtt_calls.append(topics) + + vapix.mqtt.configure_event_publication = _configure_event_publication # type: ignore[method-assign] + vapix.device.stream.set_event_filter_list = lambda payload: setattr( + vapix.device.stream, "_captured_filters", payload + ) + vapix.device.event.set_allowed_topics = lambda topics: setattr( + vapix.device.event, "_captured_allowed_topics", topics + ) + + payloads = await vapix.apply_event_transport_filters( + subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")] + ) + + assert payloads["canonical_topics"] == [topic] + assert mqtt_calls == [["onvif:Device/axis:Sensor/PIR"]] + assert vapix.device.stream._captured_filters == [ + {"topicFilter": "onvif:Device/axis:Sensor/PIR"} + ] + assert vapix.device.event._captured_allowed_topics == [topic] + + def test_api_discovery_handlers_registration(vapix: Vapix) -> None: """Verify grouped API-discovery handlers matches the startup contract.""" handlers = vapix._handlers_by_group(HandlerGroup.API_DISCOVERY) From 5b101fa76b05591234c8edd1d8aa80132bfffa7a Mon Sep 17 00:00:00 2001 From: Kane610 Date: Thu, 14 May 2026 15:56:16 +0200 Subject: [PATCH 04/18] phase4: add unique-id migration hooks for extension rollout --- axis/interfaces/vapix.py | 26 ++++++++++++++++++++++++++ tests/test_vapix.py | 24 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index 16b1566b..2e556ee8 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -38,6 +38,12 @@ from .pwdgrp_cgi import Users from .stream_profiles import StreamProfilesHandler from .topic_normalizer import to_canonical +from .unique_id_migration import ( + UNIQUE_ID_MIGRATION_VERSION, + UniqueIdMigrationPlan, + build_unique_id_alias_map, + build_unique_id_migration_plan, +) from .user_groups import UserGroups from .view_areas import ViewAreaHandler @@ -278,6 +284,18 @@ def build_transport_filter_payloads( include_internal_topics=include_internal_topics, ) + def get_unique_id_migration_version(self) -> int: + """Return current unique ID migration contract version.""" + return UNIQUE_ID_MIGRATION_VERSION + + def plan_unique_id_migration(self, unique_ids: list[str]) -> UniqueIdMigrationPlan: + """Build deterministic migration plan for extension-managed unique IDs.""" + return build_unique_id_migration_plan(unique_ids) + + def build_unique_id_alias_map(self, unique_ids: list[str]) -> dict[str, str]: + """Build old-to-new unique ID alias map for rollout compatibility.""" + return build_unique_id_alias_map(unique_ids) + def plan_event_transport_filters( self, subscriptions: list[DesiredEventSubscription] | None = None, @@ -336,6 +354,14 @@ async def apply_event_transport_filters( if apply_local_fallback: self.device.event.set_allowed_topics(payloads["canonical_topics"]) + LOGGER.debug( + "Applied event transport filters: websocket=%s mqtt=%s local=%s topics=%d", + apply_websocket, + apply_mqtt and self.mqtt.supported, + apply_local_fallback, + len(payloads["canonical_topics"]), + ) + return payloads async def initialize_users(self) -> None: diff --git a/tests/test_vapix.py b/tests/test_vapix.py index b09b55b3..5553ad22 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -19,6 +19,7 @@ DesiredEventSubscription, EventTransport, ) +from axis.interfaces.unique_id_migration import UNIQUE_ID_MIGRATION_VERSION from axis.models.api_discovery import ApiId, ListApisRequest from axis.models.applications.application import ( ApplicationStatus, @@ -153,6 +154,29 @@ def test_vapix_extension_helpers(vapix: Vapix) -> None: } +def test_vapix_unique_id_migration_helpers(vapix: Vapix) -> None: + """Migration helpers should provide deterministic versioned rollout data.""" + unique_ids = [ + "sensor_onvif:Device/axis:Sensor/PIR_state", + "sensor_tns1:Device/tnsaxis:Sensor/PIR_state", + ] + + assert vapix.get_unique_id_migration_version() == UNIQUE_ID_MIGRATION_VERSION + + plan = vapix.plan_unique_id_migration(unique_ids) + assert plan.version == UNIQUE_ID_MIGRATION_VERSION + assert [entry.old_unique_id for entry in plan.entries] == [ + "sensor_onvif:Device/axis:Sensor/PIR_state" + ] + assert [entry.new_unique_id for entry in plan.entries] == [ + "sensor_tns1:Device/tnsaxis:Sensor/PIR_state" + ] + + assert vapix.build_unique_id_alias_map(unique_ids) == { + "sensor_onvif:Device/axis:Sensor/PIR_state": "sensor_tns1:Device/tnsaxis:Sensor/PIR_state" + } + + async def test_plan_event_transport_filters_validates_supported_topics( vapix: Vapix, ) -> None: From f52d6164fedba1bcdd160dc2c13ad19164ccaab6 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Thu, 14 May 2026 15:58:04 +0200 Subject: [PATCH 05/18] phase5: harden transport filter orchestration tests --- tests/test_vapix.py | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/test_vapix.py b/tests/test_vapix.py index 5553ad22..659468bb 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -261,6 +261,86 @@ async def _configure_event_publication( assert vapix.device.event._captured_allowed_topics == [topic] +async def test_apply_event_transport_filters_respects_apply_flags(vapix: Vapix) -> None: + """Apply should skip hooks when apply flags are disabled.""" + topic = "tns1:Device/tnsaxis:Sensor/PIR" + vapix.event_instances._items = { + topic: EventInstance( + id=topic, + topic=topic, + topic_filter="onvif:Device/axis:Sensor/PIR", + is_available=True, + is_application_data=False, + name="pir", + stateful=True, + stateless=False, + source=EventInstanceSource(), + data=EventInstanceData(), + ) + } + + mqtt_calls: list[list[str]] = [] + vapix.api_discovery._items[ApiId.MQTT_CLIENT] = type( + "_Api", (), {"version": "1.0"} + )() + + async def _configure_event_publication( + topics: list[str], *_args, **_kwargs + ) -> None: + mqtt_calls.append(topics) + + vapix.mqtt.configure_event_publication = _configure_event_publication # type: ignore[method-assign] + + payloads = await vapix.apply_event_transport_filters( + subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")], + apply_mqtt=False, + apply_websocket=False, + apply_local_fallback=False, + ) + + assert payloads["canonical_topics"] == [topic] + assert mqtt_calls == [] + + +async def test_apply_event_transport_filters_skips_unsupported_mqtt( + vapix: Vapix, +) -> None: + """Apply should not call MQTT hook when MQTT API is unsupported.""" + topic = "tns1:Device/tnsaxis:Sensor/PIR" + vapix.event_instances._items = { + topic: EventInstance( + id=topic, + topic=topic, + topic_filter="onvif:Device/axis:Sensor/PIR", + is_available=True, + is_application_data=False, + name="pir", + stateful=True, + stateless=False, + source=EventInstanceSource(), + data=EventInstanceData(), + ) + } + + mqtt_calls: list[list[str]] = [] + + async def _configure_event_publication( + topics: list[str], *_args, **_kwargs + ) -> None: + mqtt_calls.append(topics) + + vapix.mqtt.configure_event_publication = _configure_event_publication # type: ignore[method-assign] + + payloads = await vapix.apply_event_transport_filters( + subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")], + apply_websocket=False, + apply_local_fallback=False, + ) + + assert payloads["canonical_topics"] == [topic] + assert mqtt_calls == [] + + def test_api_discovery_handlers_registration(vapix: Vapix) -> None: """Verify grouped API-discovery handlers matches the startup contract.""" handlers = vapix._handlers_by_group(HandlerGroup.API_DISCOVERY) From 948373db525cc4ab53fbeb03e1bfc3e2d68335a7 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Thu, 14 May 2026 21:34:32 +0200 Subject: [PATCH 06/18] refactor: move new event extension modules into events subpackages --- axis/interfaces/event_instances.py | 4 ++-- axis/interfaces/event_manager.py | 2 +- axis/interfaces/events/__init__.py | 1 + .../{ => events}/event_extension_contracts.py | 0 .../{ => events}/topic_normalizer.py | 0 .../{ => events}/unique_id_migration.py | 0 axis/interfaces/mqtt.py | 2 +- axis/interfaces/vapix.py | 18 +++++++++--------- axis/models/events/__init__.py | 1 + tests/test_event_extension_contracts.py | 2 +- tests/test_event_instances.py | 2 +- tests/test_topic_normalizer.py | 2 +- tests/test_unique_id_migration.py | 2 +- tests/test_vapix.py | 4 ++-- 14 files changed, 21 insertions(+), 19 deletions(-) create mode 100644 axis/interfaces/events/__init__.py rename axis/interfaces/{ => events}/event_extension_contracts.py (100%) rename axis/interfaces/{ => events}/topic_normalizer.py (100%) rename axis/interfaces/{ => events}/unique_id_migration.py (100%) create mode 100644 axis/models/events/__init__.py diff --git a/axis/interfaces/event_instances.py b/axis/interfaces/event_instances.py index dad73699..ee785150 100644 --- a/axis/interfaces/event_instances.py +++ b/axis/interfaces/event_instances.py @@ -11,11 +11,11 @@ ) from .api_handler import ApiHandler from .event_manager import BLACK_LISTED_TOPICS -from .topic_normalizer import to_canonical, to_topic_filter +from .events.topic_normalizer import to_canonical, to_topic_filter if TYPE_CHECKING: from ..models.event import Event - from .event_extension_contracts import DesiredEventSubscription + from .events.event_extension_contracts import DesiredEventSubscription class EventInstanceHandler(ApiHandler[EventInstance]): diff --git a/axis/interfaces/event_manager.py b/axis/interfaces/event_manager.py index 9b8db813..bbd46a46 100644 --- a/axis/interfaces/event_manager.py +++ b/axis/interfaces/event_manager.py @@ -5,7 +5,7 @@ from typing import Any from ..models.event import Event, EventOperation, EventTopic -from .topic_normalizer import to_canonical +from .events.topic_normalizer import to_canonical SubscriptionCallback = Callable[[Event], None] SubscriptionType = tuple[ diff --git a/axis/interfaces/events/__init__.py b/axis/interfaces/events/__init__.py new file mode 100644 index 00000000..91fa27b5 --- /dev/null +++ b/axis/interfaces/events/__init__.py @@ -0,0 +1 @@ +"""Event-related interface helpers and contracts.""" diff --git a/axis/interfaces/event_extension_contracts.py b/axis/interfaces/events/event_extension_contracts.py similarity index 100% rename from axis/interfaces/event_extension_contracts.py rename to axis/interfaces/events/event_extension_contracts.py diff --git a/axis/interfaces/topic_normalizer.py b/axis/interfaces/events/topic_normalizer.py similarity index 100% rename from axis/interfaces/topic_normalizer.py rename to axis/interfaces/events/topic_normalizer.py diff --git a/axis/interfaces/unique_id_migration.py b/axis/interfaces/events/unique_id_migration.py similarity index 100% rename from axis/interfaces/unique_id_migration.py rename to axis/interfaces/events/unique_id_migration.py diff --git a/axis/interfaces/mqtt.py b/axis/interfaces/mqtt.py index 2f21ef18..eb3f6be2 100644 --- a/axis/interfaces/mqtt.py +++ b/axis/interfaces/mqtt.py @@ -19,7 +19,7 @@ GetEventPublicationConfigResponse, ) from .api_handler import ApiHandler, HandlerGroup -from .topic_normalizer import to_topic_filter +from .events.topic_normalizer import to_topic_filter DEFAULT_TOPICS = ["//."] diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index 2e556ee8..a9b69eee 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -21,13 +21,20 @@ from .applications.object_analytics import ObjectAnalyticsHandler from .applications.vmd4 import Vmd4Handler from .basic_device_info import BasicDeviceInfoHandler -from .event_extension_contracts import ( +from .event_instances import EventInstanceHandler +from .events.event_extension_contracts import ( TRANSPORT_FILTER_CAPABILITIES, DesiredEventSubscription, EventTransport, TransportFilterCapability, ) -from .event_instances import EventInstanceHandler +from .events.topic_normalizer import to_canonical +from .events.unique_id_migration import ( + UNIQUE_ID_MIGRATION_VERSION, + UniqueIdMigrationPlan, + build_unique_id_alias_map, + build_unique_id_migration_plan, +) from .light_control import LightHandler from .mqtt import MqttClientHandler from .parameters.param_cgi import Params @@ -37,13 +44,6 @@ from .ptz import PtzControl from .pwdgrp_cgi import Users from .stream_profiles import StreamProfilesHandler -from .topic_normalizer import to_canonical -from .unique_id_migration import ( - UNIQUE_ID_MIGRATION_VERSION, - UniqueIdMigrationPlan, - build_unique_id_alias_map, - build_unique_id_migration_plan, -) from .user_groups import UserGroups from .view_areas import ViewAreaHandler diff --git a/axis/models/events/__init__.py b/axis/models/events/__init__.py new file mode 100644 index 00000000..5aa07bf5 --- /dev/null +++ b/axis/models/events/__init__.py @@ -0,0 +1 @@ +"""Event-related models namespace.""" diff --git a/tests/test_event_extension_contracts.py b/tests/test_event_extension_contracts.py index bab6f6d0..86de9fab 100644 --- a/tests/test_event_extension_contracts.py +++ b/tests/test_event_extension_contracts.py @@ -1,6 +1,6 @@ """Tests for extension contract capability matrix.""" -from axis.interfaces.event_extension_contracts import ( +from axis.interfaces.events.event_extension_contracts import ( TRANSPORT_FILTER_CAPABILITIES, EventTransport, ) diff --git a/tests/test_event_instances.py b/tests/test_event_instances.py index 9f893671..f6db8433 100644 --- a/tests/test_event_instances.py +++ b/tests/test_event_instances.py @@ -7,7 +7,7 @@ import pytest -from axis.interfaces.event_extension_contracts import DesiredEventSubscription +from axis.interfaces.events.event_extension_contracts import DesiredEventSubscription from axis.models.event import Event from axis.models.event_instance import ( EventInstance, diff --git a/tests/test_topic_normalizer.py b/tests/test_topic_normalizer.py index f9679b4a..5755df60 100644 --- a/tests/test_topic_normalizer.py +++ b/tests/test_topic_normalizer.py @@ -1,6 +1,6 @@ """Tests for event topic normalization extension contract.""" -from axis.interfaces.topic_normalizer import ( +from axis.interfaces.events.topic_normalizer import ( detect_format, to_canonical, to_mqtt, diff --git a/tests/test_unique_id_migration.py b/tests/test_unique_id_migration.py index fe7ded2f..e2b796be 100644 --- a/tests/test_unique_id_migration.py +++ b/tests/test_unique_id_migration.py @@ -1,6 +1,6 @@ """Tests for unique ID migration contract utilities.""" -from axis.interfaces.unique_id_migration import ( +from axis.interfaces.events.unique_id_migration import ( UNIQUE_ID_MIGRATION_VERSION, build_unique_id_alias_map, build_unique_id_migration_plan, diff --git a/tests/test_vapix.py b/tests/test_vapix.py index 659468bb..5daa98e7 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -15,11 +15,11 @@ Unauthorized, ) from axis.interfaces.api_handler import HandlerGroup -from axis.interfaces.event_extension_contracts import ( +from axis.interfaces.events.event_extension_contracts import ( DesiredEventSubscription, EventTransport, ) -from axis.interfaces.unique_id_migration import UNIQUE_ID_MIGRATION_VERSION +from axis.interfaces.events.unique_id_migration import UNIQUE_ID_MIGRATION_VERSION from axis.models.api_discovery import ApiId, ListApisRequest from axis.models.applications.application import ( ApplicationStatus, From b7efd20dfae837ce24b5a27b6676e0127b994f66 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Thu, 14 May 2026 21:37:08 +0200 Subject: [PATCH 07/18] refactor: move event models into models.events with shims --- axis/models/event.py | 222 +------------- axis/models/event_instance.py | 420 +-------------------------- axis/models/events/event.py | 221 ++++++++++++++ axis/models/events/event_instance.py | 419 ++++++++++++++++++++++++++ 4 files changed, 644 insertions(+), 638 deletions(-) create mode 100644 axis/models/events/event.py create mode 100644 axis/models/events/event_instance.py diff --git a/axis/models/event.py b/axis/models/event.py index 5aaf2302..f08f03bd 100644 --- a/axis/models/event.py +++ b/axis/models/event.py @@ -1,221 +1,3 @@ -"""Python library to enable Axis devices to integrate with Home Assistant.""" +"""Backward-compatible re-export for event models.""" -from dataclasses import dataclass -import enum -from typing import Any, Self - -import xmltodict - - -class EventOperation(enum.StrEnum): - """Possible operations of an event.""" - - INITIALIZED = "Initialized" - CHANGED = "Changed" - DELETED = "Deleted" - UNKNOWN = "Unknown" - - @classmethod - def _missing_(cls, value: object) -> EventOperation: - """Set default enum member if an unknown value is provided.""" - return EventOperation.UNKNOWN - - -class EventTopic(enum.StrEnum): - """Supported event topics.""" - - DAY_NIGHT_VISION = "tns1:VideoSource/tnsaxis:DayNightVision" - FENCE_GUARD = "tnsaxis:CameraApplicationPlatform/FenceGuard" - LIGHT_STATUS = "tns1:Device/tnsaxis:Light/Status" - LOITERING_GUARD = "tnsaxis:CameraApplicationPlatform/LoiteringGuard" - MOTION_DETECTION = "tns1:VideoAnalytics/tnsaxis:MotionDetection" - MOTION_DETECTION_2 = "tns1:RuleEngine/tnsaxis:VideoMotionDetection" - MOTION_DETECTION_3 = "tns1:RuleEngine/tnsaxis:VMD3" - MOTION_DETECTION_4 = "tnsaxis:CameraApplicationPlatform/VMD" - MOTION_GUARD = "tnsaxis:CameraApplicationPlatform/MotionGuard" - OBJECT_ANALYTICS = "tnsaxis:CameraApplicationPlatform/ObjectAnalytics" - PIR = "tns1:Device/tnsaxis:Sensor/PIR" - PORT_INPUT = "tns1:Device/tnsaxis:IO/Port" - PORT_SUPERVISED_INPUT = "tns1:Device/tnsaxis:IO/SupervisedPort" - PTZ_IS_MOVING = "tns1:PTZController/tnsaxis:Move" - PTZ_ON_PRESET = "tns1:PTZController/tnsaxis:PTZPresets" - RELAY = "tns1:Device/Trigger/Relay" - SOUND_TRIGGER_LEVEL = "tns1:AudioSource/tnsaxis:TriggerLevel" - UNKNOWN = "unknown" - - @classmethod - def _missing_(cls, value: object) -> EventTopic: - """Set default enum member if an unknown value is provided.""" - return EventTopic.UNKNOWN - - -TOPIC_TO_STATE = { - EventTopic.LIGHT_STATUS: "ON", - EventTopic.RELAY: "active", -} - -KNOWN_FALSE_STATES = {"", "0", "false", "inactive", "low", "off"} -KNOWN_TRUE_STATES = {"1", "active", "high", "on", "true"} - -TOPIC_TO_INACTIVE_STATE = { - EventTopic.LIGHT_STATUS: "OFF", - EventTopic.RELAY: "inactive", -} - -EVENT_OPERATION = "operation" -EVENT_SOURCE = "source" -EVENT_SOURCE_IDX = "source_idx" -EVENT_TOPIC = "topic" -EVENT_TYPE = "type" -EVENT_VALUE = "value" - -NOTIFICATION_MESSAGE = ("MetadataStream", "Event", "NotificationMessage") -MESSAGE = (*NOTIFICATION_MESSAGE, "Message", "Message") -TOPIC = (*NOTIFICATION_MESSAGE, "Topic", "#text") -OPERATION = (*MESSAGE, "PropertyOperation") -SOURCE = (*MESSAGE, "Source") -DATA = (*MESSAGE, "Data") - -XML_NAMESPACES = { - "http://www.onvif.org/ver10/schema": None, - "http://docs.oasis-open.org/wsn/b-2": None, -} - - -def traverse(data: Any, keys: tuple[str, ...] | list[str]) -> Any: - """Traverse dictionary using keys to retrieve last item.""" - if not isinstance(data, dict): - return {} - - head, *tail = keys - item = data.get(head, {}) - return traverse(item, tail) if tail else item - - -def extract_name_value( - data: dict[str, list[dict[str, str]] | dict[str, str]], prefer: str | None = None -) -> tuple[str, str]: - """Extract name and value from a simple item, take first dictionary if it is a list.""" - item = data.get("SimpleItem", {}) - if isinstance(item, list): - if not item: - return "", "" - if prefer is None: - item = item[0] - else: - item = next( - (item for item in item if item.get("Name", "") == prefer), item[0] - ) - return item.get("Name", ""), item.get("Value", "") - - -def is_tripped(value: object, topic_base: EventTopic, event_type: object) -> bool: - """Return whether an event value should be considered active/tripped.""" - if (expected_state := TOPIC_TO_STATE.get(topic_base)) is not None: - return str(value) == expected_state - - value_text = str(value).strip() - state = value_text.casefold() - - if state in KNOWN_FALSE_STATES: - return False - - if state in KNOWN_TRUE_STATES: - return True - - if str(event_type).casefold() == "active": - return False - - # Non-empty semantic values (for example classification values like "human") - # are treated as stateless event triggers. - return bool(value_text) - - -@dataclass -class Event: - """Event data from Axis device.""" - - id: str - is_tripped: bool - operation: EventOperation - source: str - state: str - topic: str - topic_base: EventTopic - data: dict[str, Any] - - @classmethod - def decode(cls, data: bytes | dict[str, Any]) -> Self: - """Decode data to an event object.""" - if isinstance(data, dict): - return cls._decode_from_dict(data) - return cls._decode_from_bytes(data) - - @classmethod - def _decode_from_dict(cls, data: dict[str, Any]) -> Self: - """Create event instance from dict.""" - operation = EventOperation(data.get(EVENT_OPERATION, "")) - topic = data.get(EVENT_TOPIC, "") - source = data.get(EVENT_SOURCE, "") - source_idx = data.get(EVENT_SOURCE_IDX, "") - event_type = data.get(EVENT_TYPE, "") - value = data.get(EVENT_VALUE, "") - - if (topic_base := EventTopic(topic)) is EventTopic.UNKNOWN: - _topic_base, _, _source_idx = topic.rpartition("/") - topic_base = EventTopic(_topic_base) - if source_idx == "": - source_idx = _source_idx - - if source_idx == "-1": - source_idx = "ANY" if source != "port" else "" - - return cls( - id=source_idx, - is_tripped=is_tripped(value, topic_base, event_type), - operation=operation, - source=source, - state=value, - topic=topic, - topic_base=topic_base, - data=data, - ) - - @classmethod - def _decode_from_bytes(cls, data: bytes) -> Self: - """Parse metadata xml.""" - raw = xmltodict.parse( - data, - attr_prefix="", # Remove "@" prefix from XML attributes for easier access - process_namespaces=True, - namespaces=XML_NAMESPACES, - ) - - # Normalize the ONVIF metadata root: always use a dict, drop any stray - # XML namespace attribute ("xmlns") added by xmltodict, and bail out - # early if the payload is empty. - stream = raw.get("MetadataStream") or {} - if not stream or not any(key != "xmlns" for key in stream): - return cls._decode_from_dict({}) - - topic = traverse(raw, TOPIC) - operation = traverse(raw, OPERATION) - - source = source_idx = "" - if match := traverse(raw, SOURCE): - source, source_idx = extract_name_value(match) - - data_type = data_value = "" - if match := traverse(raw, DATA): - data_type, data_value = extract_name_value(match, "active") - - return cls._decode_from_dict( - { - EVENT_OPERATION: operation, - EVENT_TOPIC: topic, - EVENT_SOURCE: source, - EVENT_SOURCE_IDX: source_idx, - EVENT_TYPE: data_type, - EVENT_VALUE: data_value, - } - ) +from .events.event import * # noqa: F403 diff --git a/axis/models/event_instance.py b/axis/models/event_instance.py index 9152f8d5..4f57ffdf 100644 --- a/axis/models/event_instance.py +++ b/axis/models/event_instance.py @@ -1,419 +1,3 @@ -"""Event service and action service APIs available in Axis network device.""" +"""Backward-compatible re-export for event-instance models.""" -from dataclasses import dataclass -import enum -from typing import Any, Self - -import xmltodict - -from .api import ApiItem, ApiRequest, ApiResponse -from .event import ( - EVENT_OPERATION, - EVENT_SOURCE, - EVENT_SOURCE_IDX, - EVENT_TOPIC, - EVENT_VALUE, - Event, - EventOperation, - EventTopic, - traverse, -) - -EVENT_INSTANCE = ( - "http://www.w3.org/2003/05/soap-envelope:Envelope", - "http://www.w3.org/2003/05/soap-envelope:Body", - "GetEventInstancesResponse", - "TopicSet", -) - -# Namespace filter for xmltodict -NAMESPACES = { - "http://docs.oasis-open.org/wsn/t-1": None, - # "http://www.onvif.org/ver10/topics": "onvif", - # "http://www.axis.com/2009/event/topics": "axis", - "http://www.onvif.org/ver10/topics": "tns1", - "http://www.axis.com/2009/event/topics": "tnsaxis", - "http://www.axis.com/vapix/ws/event1": None, -} - - -def get_events(data: dict[str, Any]) -> list[dict[str, Any]]: - """Get all events. - - Ignore keys with "@" while traversing structure. Indicates an attribute in value. - When @topic is reached, return event data and build topic structure. - """ - events = [] - for key, value in data.items(): - if key.startswith("@"): # Value is an attribute so skip - continue - - if "@topic" in value: # Designates the end of the topic structure - events.append({"topic": key, "data": value}) - continue - - event_list = get_events(value) # Recursive call - - for event in event_list: - event["topic"] = f"{key}/{event['topic']}" # Compose the topic - events.append(event) - - return events - - -def _as_simple_item_list(data: object) -> list[dict[str, Any]]: - """Return a list representation for a simple-item payload.""" - if isinstance(data, list): - return data - if isinstance(data, dict) and data: - return [data] - return [] - - -def _as_dict(data: object) -> dict[str, Any]: - """Return dict for mapping-like payloads and normalize other values to empty.""" - if isinstance(data, dict): - return data - return {} - - -@dataclass(frozen=True) -class EventInstanceSimpleItem: - """Typed representation of a single event-instance simple item.""" - - name: str = "" - nice_name: str = "" - value_type: str = "" - values: tuple[str, ...] = () - is_property_state: bool = False - - @classmethod - def from_raw(cls, data: object) -> Self: - """Decode a raw simple-item payload into a typed item.""" - item = _as_dict(data) - raw_values = item.get("Value", "") - if isinstance(raw_values, list): - values = tuple(str(value) for value in raw_values) - elif raw_values in (None, ""): - values = () - else: - values = (str(raw_values),) - - return cls( - name=str(item.get("@Name", "")), - nice_name=str(item.get("@NiceName", "")), - value_type=str(item.get("@Type", "")), - values=values, - is_property_state=item.get("@isPropertyState") == "true", - ) - - @property - def value(self) -> str: - """Return the first value when available.""" - return self.values[0] if self.values else "" - - def as_raw(self) -> dict[str, Any]: - """Return the backward-compatible raw dictionary representation.""" - data: dict[str, Any] = { - "@Name": self.name, - "@NiceName": self.nice_name, - "@Type": self.value_type, - } - if self.values: - data["Value"] = ( - self.values[0] if len(self.values) == 1 else list(self.values) - ) - if self.is_property_state: - data["@isPropertyState"] = "true" - return {key: value for key, value in data.items() if value != ""} - - -@dataclass(frozen=True) -class EventInstanceSource: - """Structured source definition for an event instance.""" - - items: tuple[EventInstanceSimpleItem, ...] = () - - @classmethod - def from_raw(cls, data: object) -> Self: - """Decode raw source-instance payload into typed items.""" - return cls( - items=tuple( - EventInstanceSimpleItem.from_raw(item) - for item in _as_simple_item_list(data) - ) - ) - - @property - def primary_item(self) -> EventInstanceSimpleItem | None: - """Return the first source item when available.""" - return self.items[0] if self.items else None - - @property - def name(self) -> str: - """Return the primary source name.""" - return self.primary_item.name if self.primary_item else "" - - @property - def values(self) -> tuple[str, ...]: - """Return source values for event synthesis.""" - if self.primary_item is None: - return ("",) - return self.primary_item.values or ("",) - - def as_raw(self) -> dict[str, Any] | list[dict[str, Any]]: - """Return source in the historical dict-or-list shape.""" - raw_items = [item.as_raw() for item in self.items] - if not raw_items: - return {} - if len(raw_items) == 1: - return raw_items[0] - return raw_items - - -@dataclass(frozen=True) -class EventInstanceData: - """Structured data definition for an event instance.""" - - items: tuple[EventInstanceSimpleItem, ...] = () - - @classmethod - def from_raw(cls, data: object) -> Self: - """Decode raw data-instance payload into typed items.""" - return cls( - items=tuple( - EventInstanceSimpleItem.from_raw(item) - for item in _as_simple_item_list(data) - ) - ) - - @property - def primary_item(self) -> EventInstanceSimpleItem | None: - """Return the first data item when available.""" - return self.items[0] if self.items else None - - @property - def state_item(self) -> EventInstanceSimpleItem | None: - """Prefer the active item to align with stream event decoding.""" - return next( - (item for item in self.items if item.name == "active"), self.primary_item - ) - - @property - def state_value(self) -> str: - """Return a representative value for state synthesis.""" - if self.state_item is None: - return "" - return self.state_item.value - - def as_raw(self) -> dict[str, Any] | list[dict[str, Any]]: - """Return data in the historical dict-or-list shape.""" - raw_items = [item.as_raw() for item in self.items] - if not raw_items: - return {} - if len(raw_items) == 1: - return raw_items[0] - return raw_items - - -def _extract_source_values(source: EventInstanceSource) -> tuple[str, list[str]]: - """Extract the source name and source values. - - Keep behavior aligned with event stream parsing by selecting the first source item - when multiple source items exist. - """ - return source.name, list(source.values) - - -def _extract_data_value(data: EventInstanceData) -> str: - """Extract a representative state value from data definition. - - Prefer the "active" item when available to align with Event._decode_from_bytes(). - """ - return data.state_value - - -TOPIC_TO_INACTIVE_STATE = { - EventTopic.LIGHT_STATUS.value: "OFF", - EventTopic.RELAY.value: "inactive", -} - - -class EventProtocol(enum.StrEnum): - """Protocols that consume normalized expected events.""" - - METADATA_STREAM = "metadata_stream" - WEBSOCKET = "websocket" - MQTT = "mqtt" - - -def _default_inactive_state(topic: str) -> str: - """Return a default inactive state for expected event synthesis.""" - return TOPIC_TO_INACTIVE_STATE.get(topic, "0") - - -@dataclass(frozen=True) -class EventInstance(ApiItem): - """Events are emitted when the Axis product detects an occurrence of some kind. - - For example motion in camera field of view or a change of status from an I/O port. - The events can be used to trigger actions in the Axis product or in other systems - and can also be stored together with video and audio data for later access. - """ - - topic: str - """Event topic. - - Event declaration namespace. - """ - - topic_filter: str - """Event topic. - - Event topic filter namespace. - """ - - is_available: bool - """Means the event is available.""" - - is_application_data: bool - """Indicate event and/or data is produced for specific system or application. - - Events with isApplicationData=true are usually intended - to be used only by the specific system or application, that is, - they are not intended to be used as triggers in an action rule in the Axis product. - """ - - name: str - """User-friendly and human-readable name describing the event.""" - - stateful: bool - """Stateful event is a property (a state variable) with a number of states. - - The event is always in one of its states. - Example: The Motion detection event is in state true when motion is detected - and in state false when motion is not detected. - """ - - stateless: bool - """Stateless event is a momentary occurrence (a pulse). - - Example: Storage device removed. - """ - - source: EventInstanceSource - """Event source information.""" - - data: EventInstanceData - """Event data description.""" - - @property - def raw_source(self) -> dict[str, Any] | list[dict[str, Any]]: - """Return the source field in the previous raw structure.""" - return self.source.as_raw() - - @property - def raw_data(self) -> dict[str, Any] | list[dict[str, Any]]: - """Return the data field in the previous raw structure.""" - return self.data.as_raw() - - @classmethod - def decode(cls, data: dict[str, Any]) -> Self: - """Decode dict to class object.""" - event_data = _as_dict(data.get("data")) - message = _as_dict(event_data.get("MessageInstance")) - source_instance = _as_dict(message.get("SourceInstance")) - data_instance = _as_dict(message.get("DataInstance")) - - return cls( - id=data["topic"], - topic=data["topic"], - topic_filter=data["topic"] - .replace("tns1", "onvif") - .replace("tnsaxis", "axis"), - is_available=event_data.get("@topic") == "true", - is_application_data=event_data.get("@isApplicationData") == "true", - name=event_data.get("@NiceName", ""), - stateful=message.get("@isProperty") == "true", - stateless=message.get("@isProperty") != "true", - source=EventInstanceSource.from_raw( - source_instance.get("SimpleItemInstance", {}) - ), - data=EventInstanceData.from_raw( - data_instance.get("SimpleItemInstance", {}) - ), - ) - - def to_events(self) -> list[Event]: - """Synthesize normalized expected events from event-instance schema data. - - Topics are preserved exactly as they are declared by event instances so topic - representation stays identical to emitted event topics. - """ - source_name, source_values = _extract_source_values(self.source) - state_value = _extract_data_value(self.data) - if state_value == "": - state_value = _default_inactive_state(self.topic) - - return [ - Event.decode( - { - EVENT_OPERATION: EventOperation.INITIALIZED, - EVENT_TOPIC: self.topic, - EVENT_SOURCE: source_name, - EVENT_SOURCE_IDX: source_value, - EVENT_VALUE: state_value, - } - ) - for source_value in source_values - ] - - -@dataclass -class ListEventInstancesRequest(ApiRequest): - """Request object for listing installed applications.""" - - method = "post" - path = "/vapix/services" - content_type = "application/soap+xml" - - @property - def content(self) -> bytes: - """Request content.""" - return ( - b'' - b'' - b'' - b"" - b"" - ) - - @property - def headers(self) -> dict[str, str]: - """Request headers.""" - return { - "Content-Type": "application/soap+xml", - "SOAPAction": "http://www.axis.com/vapix/ws/event1/GetEventInstances", - } - - -@dataclass -class ListEventInstancesResponse(ApiResponse[dict[str, Any]]): - """Response object for listing all applications.""" - - data: dict[str, Any] - - @classmethod - def decode(cls, bytes_data: bytes) -> Self: - """Prepare API description dictionary.""" - data = xmltodict.parse( - bytes_data, - # attr_prefix="", - dict_constructor=dict, # Use dict rather than ordered_dict - namespaces=NAMESPACES, # Replace or remove defined namespaces - process_namespaces=True, - ) - raw_events = traverse(data, EVENT_INSTANCE) # Move past the irrelevant keys - events = get_events(raw_events) # Create topic/data dictionary of events - return cls(data=EventInstance.decode_to_dict(events)) +from .events.event_instance import * # noqa: F403 diff --git a/axis/models/events/event.py b/axis/models/events/event.py new file mode 100644 index 00000000..5aaf2302 --- /dev/null +++ b/axis/models/events/event.py @@ -0,0 +1,221 @@ +"""Python library to enable Axis devices to integrate with Home Assistant.""" + +from dataclasses import dataclass +import enum +from typing import Any, Self + +import xmltodict + + +class EventOperation(enum.StrEnum): + """Possible operations of an event.""" + + INITIALIZED = "Initialized" + CHANGED = "Changed" + DELETED = "Deleted" + UNKNOWN = "Unknown" + + @classmethod + def _missing_(cls, value: object) -> EventOperation: + """Set default enum member if an unknown value is provided.""" + return EventOperation.UNKNOWN + + +class EventTopic(enum.StrEnum): + """Supported event topics.""" + + DAY_NIGHT_VISION = "tns1:VideoSource/tnsaxis:DayNightVision" + FENCE_GUARD = "tnsaxis:CameraApplicationPlatform/FenceGuard" + LIGHT_STATUS = "tns1:Device/tnsaxis:Light/Status" + LOITERING_GUARD = "tnsaxis:CameraApplicationPlatform/LoiteringGuard" + MOTION_DETECTION = "tns1:VideoAnalytics/tnsaxis:MotionDetection" + MOTION_DETECTION_2 = "tns1:RuleEngine/tnsaxis:VideoMotionDetection" + MOTION_DETECTION_3 = "tns1:RuleEngine/tnsaxis:VMD3" + MOTION_DETECTION_4 = "tnsaxis:CameraApplicationPlatform/VMD" + MOTION_GUARD = "tnsaxis:CameraApplicationPlatform/MotionGuard" + OBJECT_ANALYTICS = "tnsaxis:CameraApplicationPlatform/ObjectAnalytics" + PIR = "tns1:Device/tnsaxis:Sensor/PIR" + PORT_INPUT = "tns1:Device/tnsaxis:IO/Port" + PORT_SUPERVISED_INPUT = "tns1:Device/tnsaxis:IO/SupervisedPort" + PTZ_IS_MOVING = "tns1:PTZController/tnsaxis:Move" + PTZ_ON_PRESET = "tns1:PTZController/tnsaxis:PTZPresets" + RELAY = "tns1:Device/Trigger/Relay" + SOUND_TRIGGER_LEVEL = "tns1:AudioSource/tnsaxis:TriggerLevel" + UNKNOWN = "unknown" + + @classmethod + def _missing_(cls, value: object) -> EventTopic: + """Set default enum member if an unknown value is provided.""" + return EventTopic.UNKNOWN + + +TOPIC_TO_STATE = { + EventTopic.LIGHT_STATUS: "ON", + EventTopic.RELAY: "active", +} + +KNOWN_FALSE_STATES = {"", "0", "false", "inactive", "low", "off"} +KNOWN_TRUE_STATES = {"1", "active", "high", "on", "true"} + +TOPIC_TO_INACTIVE_STATE = { + EventTopic.LIGHT_STATUS: "OFF", + EventTopic.RELAY: "inactive", +} + +EVENT_OPERATION = "operation" +EVENT_SOURCE = "source" +EVENT_SOURCE_IDX = "source_idx" +EVENT_TOPIC = "topic" +EVENT_TYPE = "type" +EVENT_VALUE = "value" + +NOTIFICATION_MESSAGE = ("MetadataStream", "Event", "NotificationMessage") +MESSAGE = (*NOTIFICATION_MESSAGE, "Message", "Message") +TOPIC = (*NOTIFICATION_MESSAGE, "Topic", "#text") +OPERATION = (*MESSAGE, "PropertyOperation") +SOURCE = (*MESSAGE, "Source") +DATA = (*MESSAGE, "Data") + +XML_NAMESPACES = { + "http://www.onvif.org/ver10/schema": None, + "http://docs.oasis-open.org/wsn/b-2": None, +} + + +def traverse(data: Any, keys: tuple[str, ...] | list[str]) -> Any: + """Traverse dictionary using keys to retrieve last item.""" + if not isinstance(data, dict): + return {} + + head, *tail = keys + item = data.get(head, {}) + return traverse(item, tail) if tail else item + + +def extract_name_value( + data: dict[str, list[dict[str, str]] | dict[str, str]], prefer: str | None = None +) -> tuple[str, str]: + """Extract name and value from a simple item, take first dictionary if it is a list.""" + item = data.get("SimpleItem", {}) + if isinstance(item, list): + if not item: + return "", "" + if prefer is None: + item = item[0] + else: + item = next( + (item for item in item if item.get("Name", "") == prefer), item[0] + ) + return item.get("Name", ""), item.get("Value", "") + + +def is_tripped(value: object, topic_base: EventTopic, event_type: object) -> bool: + """Return whether an event value should be considered active/tripped.""" + if (expected_state := TOPIC_TO_STATE.get(topic_base)) is not None: + return str(value) == expected_state + + value_text = str(value).strip() + state = value_text.casefold() + + if state in KNOWN_FALSE_STATES: + return False + + if state in KNOWN_TRUE_STATES: + return True + + if str(event_type).casefold() == "active": + return False + + # Non-empty semantic values (for example classification values like "human") + # are treated as stateless event triggers. + return bool(value_text) + + +@dataclass +class Event: + """Event data from Axis device.""" + + id: str + is_tripped: bool + operation: EventOperation + source: str + state: str + topic: str + topic_base: EventTopic + data: dict[str, Any] + + @classmethod + def decode(cls, data: bytes | dict[str, Any]) -> Self: + """Decode data to an event object.""" + if isinstance(data, dict): + return cls._decode_from_dict(data) + return cls._decode_from_bytes(data) + + @classmethod + def _decode_from_dict(cls, data: dict[str, Any]) -> Self: + """Create event instance from dict.""" + operation = EventOperation(data.get(EVENT_OPERATION, "")) + topic = data.get(EVENT_TOPIC, "") + source = data.get(EVENT_SOURCE, "") + source_idx = data.get(EVENT_SOURCE_IDX, "") + event_type = data.get(EVENT_TYPE, "") + value = data.get(EVENT_VALUE, "") + + if (topic_base := EventTopic(topic)) is EventTopic.UNKNOWN: + _topic_base, _, _source_idx = topic.rpartition("/") + topic_base = EventTopic(_topic_base) + if source_idx == "": + source_idx = _source_idx + + if source_idx == "-1": + source_idx = "ANY" if source != "port" else "" + + return cls( + id=source_idx, + is_tripped=is_tripped(value, topic_base, event_type), + operation=operation, + source=source, + state=value, + topic=topic, + topic_base=topic_base, + data=data, + ) + + @classmethod + def _decode_from_bytes(cls, data: bytes) -> Self: + """Parse metadata xml.""" + raw = xmltodict.parse( + data, + attr_prefix="", # Remove "@" prefix from XML attributes for easier access + process_namespaces=True, + namespaces=XML_NAMESPACES, + ) + + # Normalize the ONVIF metadata root: always use a dict, drop any stray + # XML namespace attribute ("xmlns") added by xmltodict, and bail out + # early if the payload is empty. + stream = raw.get("MetadataStream") or {} + if not stream or not any(key != "xmlns" for key in stream): + return cls._decode_from_dict({}) + + topic = traverse(raw, TOPIC) + operation = traverse(raw, OPERATION) + + source = source_idx = "" + if match := traverse(raw, SOURCE): + source, source_idx = extract_name_value(match) + + data_type = data_value = "" + if match := traverse(raw, DATA): + data_type, data_value = extract_name_value(match, "active") + + return cls._decode_from_dict( + { + EVENT_OPERATION: operation, + EVENT_TOPIC: topic, + EVENT_SOURCE: source, + EVENT_SOURCE_IDX: source_idx, + EVENT_TYPE: data_type, + EVENT_VALUE: data_value, + } + ) diff --git a/axis/models/events/event_instance.py b/axis/models/events/event_instance.py new file mode 100644 index 00000000..69e3654a --- /dev/null +++ b/axis/models/events/event_instance.py @@ -0,0 +1,419 @@ +"""Event service and action service APIs available in Axis network device.""" + +from dataclasses import dataclass +import enum +from typing import Any, Self + +import xmltodict + +from ..api import ApiItem, ApiRequest, ApiResponse +from .event import ( + EVENT_OPERATION, + EVENT_SOURCE, + EVENT_SOURCE_IDX, + EVENT_TOPIC, + EVENT_VALUE, + Event, + EventOperation, + EventTopic, + traverse, +) + +EVENT_INSTANCE = ( + "http://www.w3.org/2003/05/soap-envelope:Envelope", + "http://www.w3.org/2003/05/soap-envelope:Body", + "GetEventInstancesResponse", + "TopicSet", +) + +# Namespace filter for xmltodict +NAMESPACES = { + "http://docs.oasis-open.org/wsn/t-1": None, + # "http://www.onvif.org/ver10/topics": "onvif", + # "http://www.axis.com/2009/event/topics": "axis", + "http://www.onvif.org/ver10/topics": "tns1", + "http://www.axis.com/2009/event/topics": "tnsaxis", + "http://www.axis.com/vapix/ws/event1": None, +} + + +def get_events(data: dict[str, Any]) -> list[dict[str, Any]]: + """Get all events. + + Ignore keys with "@" while traversing structure. Indicates an attribute in value. + When @topic is reached, return event data and build topic structure. + """ + events = [] + for key, value in data.items(): + if key.startswith("@"): # Value is an attribute so skip + continue + + if "@topic" in value: # Designates the end of the topic structure + events.append({"topic": key, "data": value}) + continue + + event_list = get_events(value) # Recursive call + + for event in event_list: + event["topic"] = f"{key}/{event['topic']}" # Compose the topic + events.append(event) + + return events + + +def _as_simple_item_list(data: object) -> list[dict[str, Any]]: + """Return a list representation for a simple-item payload.""" + if isinstance(data, list): + return data + if isinstance(data, dict) and data: + return [data] + return [] + + +def _as_dict(data: object) -> dict[str, Any]: + """Return dict for mapping-like payloads and normalize other values to empty.""" + if isinstance(data, dict): + return data + return {} + + +@dataclass(frozen=True) +class EventInstanceSimpleItem: + """Typed representation of a single event-instance simple item.""" + + name: str = "" + nice_name: str = "" + value_type: str = "" + values: tuple[str, ...] = () + is_property_state: bool = False + + @classmethod + def from_raw(cls, data: object) -> Self: + """Decode a raw simple-item payload into a typed item.""" + item = _as_dict(data) + raw_values = item.get("Value", "") + if isinstance(raw_values, list): + values = tuple(str(value) for value in raw_values) + elif raw_values in (None, ""): + values = () + else: + values = (str(raw_values),) + + return cls( + name=str(item.get("@Name", "")), + nice_name=str(item.get("@NiceName", "")), + value_type=str(item.get("@Type", "")), + values=values, + is_property_state=item.get("@isPropertyState") == "true", + ) + + @property + def value(self) -> str: + """Return the first value when available.""" + return self.values[0] if self.values else "" + + def as_raw(self) -> dict[str, Any]: + """Return the backward-compatible raw dictionary representation.""" + data: dict[str, Any] = { + "@Name": self.name, + "@NiceName": self.nice_name, + "@Type": self.value_type, + } + if self.values: + data["Value"] = ( + self.values[0] if len(self.values) == 1 else list(self.values) + ) + if self.is_property_state: + data["@isPropertyState"] = "true" + return {key: value for key, value in data.items() if value != ""} + + +@dataclass(frozen=True) +class EventInstanceSource: + """Structured source definition for an event instance.""" + + items: tuple[EventInstanceSimpleItem, ...] = () + + @classmethod + def from_raw(cls, data: object) -> Self: + """Decode raw source-instance payload into typed items.""" + return cls( + items=tuple( + EventInstanceSimpleItem.from_raw(item) + for item in _as_simple_item_list(data) + ) + ) + + @property + def primary_item(self) -> EventInstanceSimpleItem | None: + """Return the first source item when available.""" + return self.items[0] if self.items else None + + @property + def name(self) -> str: + """Return the primary source name.""" + return self.primary_item.name if self.primary_item else "" + + @property + def values(self) -> tuple[str, ...]: + """Return source values for event synthesis.""" + if self.primary_item is None: + return ("",) + return self.primary_item.values or ("",) + + def as_raw(self) -> dict[str, Any] | list[dict[str, Any]]: + """Return source in the historical dict-or-list shape.""" + raw_items = [item.as_raw() for item in self.items] + if not raw_items: + return {} + if len(raw_items) == 1: + return raw_items[0] + return raw_items + + +@dataclass(frozen=True) +class EventInstanceData: + """Structured data definition for an event instance.""" + + items: tuple[EventInstanceSimpleItem, ...] = () + + @classmethod + def from_raw(cls, data: object) -> Self: + """Decode raw data-instance payload into typed items.""" + return cls( + items=tuple( + EventInstanceSimpleItem.from_raw(item) + for item in _as_simple_item_list(data) + ) + ) + + @property + def primary_item(self) -> EventInstanceSimpleItem | None: + """Return the first data item when available.""" + return self.items[0] if self.items else None + + @property + def state_item(self) -> EventInstanceSimpleItem | None: + """Prefer the active item to align with stream event decoding.""" + return next( + (item for item in self.items if item.name == "active"), self.primary_item + ) + + @property + def state_value(self) -> str: + """Return a representative value for state synthesis.""" + if self.state_item is None: + return "" + return self.state_item.value + + def as_raw(self) -> dict[str, Any] | list[dict[str, Any]]: + """Return data in the historical dict-or-list shape.""" + raw_items = [item.as_raw() for item in self.items] + if not raw_items: + return {} + if len(raw_items) == 1: + return raw_items[0] + return raw_items + + +def _extract_source_values(source: EventInstanceSource) -> tuple[str, list[str]]: + """Extract the source name and source values. + + Keep behavior aligned with event stream parsing by selecting the first source item + when multiple source items exist. + """ + return source.name, list(source.values) + + +def _extract_data_value(data: EventInstanceData) -> str: + """Extract a representative state value from data definition. + + Prefer the "active" item when available to align with Event._decode_from_bytes(). + """ + return data.state_value + + +TOPIC_TO_INACTIVE_STATE = { + EventTopic.LIGHT_STATUS.value: "OFF", + EventTopic.RELAY.value: "inactive", +} + + +class EventProtocol(enum.StrEnum): + """Protocols that consume normalized expected events.""" + + METADATA_STREAM = "metadata_stream" + WEBSOCKET = "websocket" + MQTT = "mqtt" + + +def _default_inactive_state(topic: str) -> str: + """Return a default inactive state for expected event synthesis.""" + return TOPIC_TO_INACTIVE_STATE.get(topic, "0") + + +@dataclass(frozen=True) +class EventInstance(ApiItem): + """Events are emitted when the Axis product detects an occurrence of some kind. + + For example motion in camera field of view or a change of status from an I/O port. + The events can be used to trigger actions in the Axis product or in other systems + and can also be stored together with video and audio data for later access. + """ + + topic: str + """Event topic. + + Event declaration namespace. + """ + + topic_filter: str + """Event topic. + + Event topic filter namespace. + """ + + is_available: bool + """Means the event is available.""" + + is_application_data: bool + """Indicate event and/or data is produced for specific system or application. + + Events with isApplicationData=true are usually intended + to be used only by the specific system or application, that is, + they are not intended to be used as triggers in an action rule in the Axis product. + """ + + name: str + """User-friendly and human-readable name describing the event.""" + + stateful: bool + """Stateful event is a property (a state variable) with a number of states. + + The event is always in one of its states. + Example: The Motion detection event is in state true when motion is detected + and in state false when motion is not detected. + """ + + stateless: bool + """Stateless event is a momentary occurrence (a pulse). + + Example: Storage device removed. + """ + + source: EventInstanceSource + """Event source information.""" + + data: EventInstanceData + """Event data description.""" + + @property + def raw_source(self) -> dict[str, Any] | list[dict[str, Any]]: + """Return the source field in the previous raw structure.""" + return self.source.as_raw() + + @property + def raw_data(self) -> dict[str, Any] | list[dict[str, Any]]: + """Return the data field in the previous raw structure.""" + return self.data.as_raw() + + @classmethod + def decode(cls, data: dict[str, Any]) -> Self: + """Decode dict to class object.""" + event_data = _as_dict(data.get("data")) + message = _as_dict(event_data.get("MessageInstance")) + source_instance = _as_dict(message.get("SourceInstance")) + data_instance = _as_dict(message.get("DataInstance")) + + return cls( + id=data["topic"], + topic=data["topic"], + topic_filter=data["topic"] + .replace("tns1", "onvif") + .replace("tnsaxis", "axis"), + is_available=event_data.get("@topic") == "true", + is_application_data=event_data.get("@isApplicationData") == "true", + name=event_data.get("@NiceName", ""), + stateful=message.get("@isProperty") == "true", + stateless=message.get("@isProperty") != "true", + source=EventInstanceSource.from_raw( + source_instance.get("SimpleItemInstance", {}) + ), + data=EventInstanceData.from_raw( + data_instance.get("SimpleItemInstance", {}) + ), + ) + + def to_events(self) -> list[Event]: + """Synthesize normalized expected events from event-instance schema data. + + Topics are preserved exactly as they are declared by event instances so topic + representation stays identical to emitted event topics. + """ + source_name, source_values = _extract_source_values(self.source) + state_value = _extract_data_value(self.data) + if state_value == "": + state_value = _default_inactive_state(self.topic) + + return [ + Event.decode( + { + EVENT_OPERATION: EventOperation.INITIALIZED, + EVENT_TOPIC: self.topic, + EVENT_SOURCE: source_name, + EVENT_SOURCE_IDX: source_value, + EVENT_VALUE: state_value, + } + ) + for source_value in source_values + ] + + +@dataclass +class ListEventInstancesRequest(ApiRequest): + """Request object for listing installed applications.""" + + method = "post" + path = "/vapix/services" + content_type = "application/soap+xml" + + @property + def content(self) -> bytes: + """Request content.""" + return ( + b'' + b'' + b'' + b"" + b"" + ) + + @property + def headers(self) -> dict[str, str]: + """Request headers.""" + return { + "Content-Type": "application/soap+xml", + "SOAPAction": "http://www.axis.com/vapix/ws/event1/GetEventInstances", + } + + +@dataclass +class ListEventInstancesResponse(ApiResponse[dict[str, Any]]): + """Response object for listing all applications.""" + + data: dict[str, Any] + + @classmethod + def decode(cls, bytes_data: bytes) -> Self: + """Prepare API description dictionary.""" + data = xmltodict.parse( + bytes_data, + # attr_prefix="", + dict_constructor=dict, # Use dict rather than ordered_dict + namespaces=NAMESPACES, # Replace or remove defined namespaces + process_namespaces=True, + ) + raw_events = traverse(data, EVENT_INSTANCE) # Move past the irrelevant keys + events = get_events(raw_events) # Create topic/data dictionary of events + return cls(data=EventInstance.decode_to_dict(events)) From e342bc6d8c89ff22b78be729a3d72064c4344924 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Fri, 15 May 2026 20:40:02 +0200 Subject: [PATCH 08/18] refactor: split event contracts by layer --- axis/interfaces/event_instances.py | 2 +- .../events/event_extension_contracts.py | 52 +------------------ .../events/transport_capabilities.py | 35 +++++++++++++ axis/interfaces/vapix.py | 12 +++-- axis/models/events/subscription_contracts.py | 23 ++++++++ tests/test_event_extension_contracts.py | 4 +- tests/test_event_instances.py | 2 +- tests/test_vapix.py | 8 +-- 8 files changed, 74 insertions(+), 64 deletions(-) create mode 100644 axis/interfaces/events/transport_capabilities.py create mode 100644 axis/models/events/subscription_contracts.py diff --git a/axis/interfaces/event_instances.py b/axis/interfaces/event_instances.py index ee785150..43f5219c 100644 --- a/axis/interfaces/event_instances.py +++ b/axis/interfaces/event_instances.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from ..models.event import Event - from .events.event_extension_contracts import DesiredEventSubscription + from ..models.events.subscription_contracts import DesiredEventSubscription class EventInstanceHandler(ApiHandler[EventInstance]): diff --git a/axis/interfaces/events/event_extension_contracts.py b/axis/interfaces/events/event_extension_contracts.py index 09c91726..659d14d4 100644 --- a/axis/interfaces/events/event_extension_contracts.py +++ b/axis/interfaces/events/event_extension_contracts.py @@ -1,51 +1 @@ -"""Extension contracts for transport filter planning.""" - -from __future__ import annotations - -from dataclasses import dataclass -import enum - - -class EventTransport(enum.StrEnum): - """Event transport types used for capability planning.""" - - RTSP = "rtsp" - WEBSOCKET = "websocket" - MQTT = "mqtt" - - -@dataclass(frozen=True) -class TransportFilterCapability: - """Describes upstream filter capabilities for a transport.""" - - transport: EventTransport - supports_upstream_topic_filter: bool - notes: str - - -TRANSPORT_FILTER_CAPABILITIES: dict[EventTransport, TransportFilterCapability] = { - EventTransport.RTSP: TransportFilterCapability( - transport=EventTransport.RTSP, - supports_upstream_topic_filter=False, - notes="RTSP supports event stream on/off only", - ), - EventTransport.WEBSOCKET: TransportFilterCapability( - transport=EventTransport.WEBSOCKET, - supports_upstream_topic_filter=True, - notes="WebSocket supports events:configure eventFilterList", - ), - EventTransport.MQTT: TransportFilterCapability( - transport=EventTransport.MQTT, - supports_upstream_topic_filter=True, - notes="MQTT event publication supports topicFilter list", - ), -} - - -@dataclass(frozen=True) -class DesiredEventSubscription: - """Transport-agnostic desired event subscription request.""" - - topic: str - source: str | None = None - source_ids: tuple[str, ...] | None = None +"""Backward-compatible re-export for split event contracts.""" diff --git a/axis/interfaces/events/transport_capabilities.py b/axis/interfaces/events/transport_capabilities.py new file mode 100644 index 00000000..80f396ad --- /dev/null +++ b/axis/interfaces/events/transport_capabilities.py @@ -0,0 +1,35 @@ +"""Transport capability metadata for event filter planning.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ...models.events.subscription_contracts import EventTransport + + +@dataclass(frozen=True) +class TransportFilterCapability: + """Describes upstream filter capabilities for a transport.""" + + transport: EventTransport + supports_upstream_topic_filter: bool + notes: str + + +TRANSPORT_FILTER_CAPABILITIES: dict[EventTransport, TransportFilterCapability] = { + EventTransport.RTSP: TransportFilterCapability( + transport=EventTransport.RTSP, + supports_upstream_topic_filter=False, + notes="RTSP supports event stream on/off only", + ), + EventTransport.WEBSOCKET: TransportFilterCapability( + transport=EventTransport.WEBSOCKET, + supports_upstream_topic_filter=True, + notes="WebSocket supports events:configure eventFilterList", + ), + EventTransport.MQTT: TransportFilterCapability( + transport=EventTransport.MQTT, + supports_upstream_topic_filter=True, + notes="MQTT event publication supports topicFilter list", + ), +} diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index a9b69eee..02d6aea4 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -22,13 +22,10 @@ from .applications.vmd4 import Vmd4Handler from .basic_device_info import BasicDeviceInfoHandler from .event_instances import EventInstanceHandler -from .events.event_extension_contracts import ( +from .events.topic_normalizer import to_canonical +from .events.transport_capabilities import ( TRANSPORT_FILTER_CAPABILITIES, - DesiredEventSubscription, - EventTransport, - TransportFilterCapability, ) -from .events.topic_normalizer import to_canonical from .events.unique_id_migration import ( UNIQUE_ID_MIGRATION_VERSION, UniqueIdMigrationPlan, @@ -50,7 +47,12 @@ if TYPE_CHECKING: from ..device import AxisDevice from ..models.api import ApiRequest + from ..models.events.subscription_contracts import ( + DesiredEventSubscription, + EventTransport, + ) from ..models.stream_profile import StreamProfile + from .events.transport_capabilities import TransportFilterCapability LOGGER = logging.getLogger(__name__) diff --git a/axis/models/events/subscription_contracts.py b/axis/models/events/subscription_contracts.py new file mode 100644 index 00000000..6c7ea0f7 --- /dev/null +++ b/axis/models/events/subscription_contracts.py @@ -0,0 +1,23 @@ +"""Domain contracts for event-subscription planning.""" + +from __future__ import annotations + +from dataclasses import dataclass +import enum + + +class EventTransport(enum.StrEnum): + """Event transport types used for capability planning.""" + + RTSP = "rtsp" + WEBSOCKET = "websocket" + MQTT = "mqtt" + + +@dataclass(frozen=True) +class DesiredEventSubscription: + """Transport-agnostic desired event subscription request.""" + + topic: str + source: str | None = None + source_ids: tuple[str, ...] | None = None diff --git a/tests/test_event_extension_contracts.py b/tests/test_event_extension_contracts.py index 86de9fab..c309c846 100644 --- a/tests/test_event_extension_contracts.py +++ b/tests/test_event_extension_contracts.py @@ -1,9 +1,9 @@ """Tests for extension contract capability matrix.""" -from axis.interfaces.events.event_extension_contracts import ( +from axis.interfaces.events.transport_capabilities import ( TRANSPORT_FILTER_CAPABILITIES, - EventTransport, ) +from axis.models.events.subscription_contracts import EventTransport def test_transport_filter_capabilities() -> None: diff --git a/tests/test_event_instances.py b/tests/test_event_instances.py index f6db8433..f7f7cb6d 100644 --- a/tests/test_event_instances.py +++ b/tests/test_event_instances.py @@ -7,7 +7,6 @@ import pytest -from axis.interfaces.events.event_extension_contracts import DesiredEventSubscription from axis.models.event import Event from axis.models.event_instance import ( EventInstance, @@ -16,6 +15,7 @@ EventInstanceSource, get_events, ) +from axis.models.events.subscription_contracts import DesiredEventSubscription from .event_fixtures import ( EVENT_INSTANCE_PIR_SENSOR, diff --git a/tests/test_vapix.py b/tests/test_vapix.py index 5daa98e7..1c823d63 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -15,10 +15,6 @@ Unauthorized, ) from axis.interfaces.api_handler import HandlerGroup -from axis.interfaces.events.event_extension_contracts import ( - DesiredEventSubscription, - EventTransport, -) from axis.interfaces.events.unique_id_migration import UNIQUE_ID_MIGRATION_VERSION from axis.models.api_discovery import ApiId, ListApisRequest from axis.models.applications.application import ( @@ -31,6 +27,10 @@ EventInstanceData, EventInstanceSource, ) +from axis.models.events.subscription_contracts import ( + DesiredEventSubscription, + EventTransport, +) from axis.models.light_control import GetLightInformationRequest from axis.models.parameters.param_cgi import ParamRequest from axis.models.port_management import GetPortsRequest From e210aee461d08beafe678237a15d7c2f31117675 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Fri, 15 May 2026 20:45:33 +0200 Subject: [PATCH 09/18] refactor: remove event extension contract shim --- axis/interfaces/events/event_extension_contracts.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 axis/interfaces/events/event_extension_contracts.py diff --git a/axis/interfaces/events/event_extension_contracts.py b/axis/interfaces/events/event_extension_contracts.py deleted file mode 100644 index 659d14d4..00000000 --- a/axis/interfaces/events/event_extension_contracts.py +++ /dev/null @@ -1 +0,0 @@ -"""Backward-compatible re-export for split event contracts.""" From 491c2fbfadb93e04ae6fa2f8799e36082d71ba08 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Fri, 15 May 2026 20:50:02 +0200 Subject: [PATCH 10/18] refactor: move event utilities from interfaces/events to models/events --- axis/interfaces/event_instances.py | 2 +- axis/interfaces/event_manager.py | 2 +- axis/interfaces/events/__init__.py | 1 - axis/interfaces/mqtt.py | 2 +- axis/interfaces/vapix.py | 22 +++++++++---------- .../events/topic_normalizer.py | 0 .../events/transport_capabilities.py | 2 +- .../events/unique_id_migration.py | 0 tests/test_event_extension_contracts.py | 4 ++-- tests/test_topic_normalizer.py | 2 +- tests/test_unique_id_migration.py | 2 +- tests/test_vapix.py | 2 +- 12 files changed, 20 insertions(+), 21 deletions(-) delete mode 100644 axis/interfaces/events/__init__.py rename axis/{interfaces => models}/events/topic_normalizer.py (100%) rename axis/{interfaces => models}/events/transport_capabilities.py (94%) rename axis/{interfaces => models}/events/unique_id_migration.py (100%) diff --git a/axis/interfaces/event_instances.py b/axis/interfaces/event_instances.py index 43f5219c..795b7414 100644 --- a/axis/interfaces/event_instances.py +++ b/axis/interfaces/event_instances.py @@ -9,9 +9,9 @@ ListEventInstancesRequest, ListEventInstancesResponse, ) +from ..models.events.topic_normalizer import to_canonical, to_topic_filter from .api_handler import ApiHandler from .event_manager import BLACK_LISTED_TOPICS -from .events.topic_normalizer import to_canonical, to_topic_filter if TYPE_CHECKING: from ..models.event import Event diff --git a/axis/interfaces/event_manager.py b/axis/interfaces/event_manager.py index bbd46a46..5911bd85 100644 --- a/axis/interfaces/event_manager.py +++ b/axis/interfaces/event_manager.py @@ -5,7 +5,7 @@ from typing import Any from ..models.event import Event, EventOperation, EventTopic -from .events.topic_normalizer import to_canonical +from ..models.events.topic_normalizer import to_canonical SubscriptionCallback = Callable[[Event], None] SubscriptionType = tuple[ diff --git a/axis/interfaces/events/__init__.py b/axis/interfaces/events/__init__.py deleted file mode 100644 index 91fa27b5..00000000 --- a/axis/interfaces/events/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Event-related interface helpers and contracts.""" diff --git a/axis/interfaces/mqtt.py b/axis/interfaces/mqtt.py index eb3f6be2..57152657 100644 --- a/axis/interfaces/mqtt.py +++ b/axis/interfaces/mqtt.py @@ -3,6 +3,7 @@ from typing import Any from ..models.api_discovery import ApiId +from ..models.events.topic_normalizer import to_topic_filter from ..models.mqtt import ( API_VERSION, ActivateClientRequest, @@ -19,7 +20,6 @@ GetEventPublicationConfigResponse, ) from .api_handler import ApiHandler, HandlerGroup -from .events.topic_normalizer import to_topic_filter DEFAULT_TOPICS = ["//."] diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index 02d6aea4..18293e37 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -10,6 +10,16 @@ from ..errors import RequestError, raise_error from ..models.configuration import AuthScheme +from ..models.events.topic_normalizer import to_canonical +from ..models.events.transport_capabilities import ( + TRANSPORT_FILTER_CAPABILITIES, +) +from ..models.events.unique_id_migration import ( + UNIQUE_ID_MIGRATION_VERSION, + UniqueIdMigrationPlan, + build_unique_id_alias_map, + build_unique_id_migration_plan, +) from ..models.pwdgrp_cgi import SecondaryGroup from .aiohttp_digest import AiohttpDigestAuth from .api_discovery import ApiDiscoveryHandler @@ -22,16 +32,6 @@ from .applications.vmd4 import Vmd4Handler from .basic_device_info import BasicDeviceInfoHandler from .event_instances import EventInstanceHandler -from .events.topic_normalizer import to_canonical -from .events.transport_capabilities import ( - TRANSPORT_FILTER_CAPABILITIES, -) -from .events.unique_id_migration import ( - UNIQUE_ID_MIGRATION_VERSION, - UniqueIdMigrationPlan, - build_unique_id_alias_map, - build_unique_id_migration_plan, -) from .light_control import LightHandler from .mqtt import MqttClientHandler from .parameters.param_cgi import Params @@ -51,8 +51,8 @@ DesiredEventSubscription, EventTransport, ) + from ..models.events.transport_capabilities import TransportFilterCapability from ..models.stream_profile import StreamProfile - from .events.transport_capabilities import TransportFilterCapability LOGGER = logging.getLogger(__name__) diff --git a/axis/interfaces/events/topic_normalizer.py b/axis/models/events/topic_normalizer.py similarity index 100% rename from axis/interfaces/events/topic_normalizer.py rename to axis/models/events/topic_normalizer.py diff --git a/axis/interfaces/events/transport_capabilities.py b/axis/models/events/transport_capabilities.py similarity index 94% rename from axis/interfaces/events/transport_capabilities.py rename to axis/models/events/transport_capabilities.py index 80f396ad..e85a0f3e 100644 --- a/axis/interfaces/events/transport_capabilities.py +++ b/axis/models/events/transport_capabilities.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from ...models.events.subscription_contracts import EventTransport +from .subscription_contracts import EventTransport @dataclass(frozen=True) diff --git a/axis/interfaces/events/unique_id_migration.py b/axis/models/events/unique_id_migration.py similarity index 100% rename from axis/interfaces/events/unique_id_migration.py rename to axis/models/events/unique_id_migration.py diff --git a/tests/test_event_extension_contracts.py b/tests/test_event_extension_contracts.py index c309c846..bc6a65bb 100644 --- a/tests/test_event_extension_contracts.py +++ b/tests/test_event_extension_contracts.py @@ -1,9 +1,9 @@ """Tests for extension contract capability matrix.""" -from axis.interfaces.events.transport_capabilities import ( +from axis.models.events.subscription_contracts import EventTransport +from axis.models.events.transport_capabilities import ( TRANSPORT_FILTER_CAPABILITIES, ) -from axis.models.events.subscription_contracts import EventTransport def test_transport_filter_capabilities() -> None: diff --git a/tests/test_topic_normalizer.py b/tests/test_topic_normalizer.py index 5755df60..fb7cc66a 100644 --- a/tests/test_topic_normalizer.py +++ b/tests/test_topic_normalizer.py @@ -1,6 +1,6 @@ """Tests for event topic normalization extension contract.""" -from axis.interfaces.events.topic_normalizer import ( +from axis.models.events.topic_normalizer import ( detect_format, to_canonical, to_mqtt, diff --git a/tests/test_unique_id_migration.py b/tests/test_unique_id_migration.py index e2b796be..1c0c811a 100644 --- a/tests/test_unique_id_migration.py +++ b/tests/test_unique_id_migration.py @@ -1,6 +1,6 @@ """Tests for unique ID migration contract utilities.""" -from axis.interfaces.events.unique_id_migration import ( +from axis.models.events.unique_id_migration import ( UNIQUE_ID_MIGRATION_VERSION, build_unique_id_alias_map, build_unique_id_migration_plan, diff --git a/tests/test_vapix.py b/tests/test_vapix.py index 1c823d63..8401b8b5 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -15,7 +15,6 @@ Unauthorized, ) from axis.interfaces.api_handler import HandlerGroup -from axis.interfaces.events.unique_id_migration import UNIQUE_ID_MIGRATION_VERSION from axis.models.api_discovery import ApiId, ListApisRequest from axis.models.applications.application import ( ApplicationStatus, @@ -31,6 +30,7 @@ DesiredEventSubscription, EventTransport, ) +from axis.models.events.unique_id_migration import UNIQUE_ID_MIGRATION_VERSION from axis.models.light_control import GetLightInformationRequest from axis.models.parameters.param_cgi import ParamRequest from axis.models.port_management import GetPortsRequest From 670068269f6de18decbeeccaae3d5d8a2d29ae84 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Fri, 15 May 2026 21:15:41 +0200 Subject: [PATCH 11/18] refactor: remove unique ID migration module (managed by HA) --- axis/interfaces/vapix.py | 18 -------- axis/models/events/unique_id_migration.py | 56 ----------------------- tests/test_unique_id_migration.py | 44 ------------------ tests/test_vapix.py | 24 ---------- 4 files changed, 142 deletions(-) delete mode 100644 axis/models/events/unique_id_migration.py delete mode 100644 tests/test_unique_id_migration.py diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index 18293e37..d479c0dd 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -14,12 +14,6 @@ from ..models.events.transport_capabilities import ( TRANSPORT_FILTER_CAPABILITIES, ) -from ..models.events.unique_id_migration import ( - UNIQUE_ID_MIGRATION_VERSION, - UniqueIdMigrationPlan, - build_unique_id_alias_map, - build_unique_id_migration_plan, -) from ..models.pwdgrp_cgi import SecondaryGroup from .aiohttp_digest import AiohttpDigestAuth from .api_discovery import ApiDiscoveryHandler @@ -286,18 +280,6 @@ def build_transport_filter_payloads( include_internal_topics=include_internal_topics, ) - def get_unique_id_migration_version(self) -> int: - """Return current unique ID migration contract version.""" - return UNIQUE_ID_MIGRATION_VERSION - - def plan_unique_id_migration(self, unique_ids: list[str]) -> UniqueIdMigrationPlan: - """Build deterministic migration plan for extension-managed unique IDs.""" - return build_unique_id_migration_plan(unique_ids) - - def build_unique_id_alias_map(self, unique_ids: list[str]) -> dict[str, str]: - """Build old-to-new unique ID alias map for rollout compatibility.""" - return build_unique_id_alias_map(unique_ids) - def plan_event_transport_filters( self, subscriptions: list[DesiredEventSubscription] | None = None, diff --git a/axis/models/events/unique_id_migration.py b/axis/models/events/unique_id_migration.py deleted file mode 100644 index 5bddb1b5..00000000 --- a/axis/models/events/unique_id_migration.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Unique ID migration contract utilities for coordinated topic normalization.""" - -from __future__ import annotations - -from dataclasses import dataclass -import re - -UNIQUE_ID_MIGRATION_VERSION = 1 - - -@dataclass(frozen=True) -class UniqueIdMigrationEntry: - """Single unique ID migration mapping.""" - - old_unique_id: str - new_unique_id: str - - -@dataclass(frozen=True) -class UniqueIdMigrationPlan: - """Deterministic migration plan for legacy unique IDs.""" - - version: int - entries: tuple[UniqueIdMigrationEntry, ...] - - -def _normalize_unique_id(unique_id: str) -> str: - """Normalize topic namespaces inside a unique ID string.""" - # Unique IDs commonly embed topics in "__" format. - # Convert only namespace prefixes at token boundaries to avoid rewriting - # already-canonical tokens such as "tnsaxis:". - return re.sub( - r"(^|[/_])axis:", - r"\1tnsaxis:", - re.sub(r"(^|[/_])onvif:", r"\1tns1:", unique_id), - ) - - -def build_unique_id_migration_plan(unique_ids: list[str]) -> UniqueIdMigrationPlan: - """Build deterministic migration plan for mixed-format unique IDs.""" - entries = [ - UniqueIdMigrationEntry(old_unique_id=unique_id, new_unique_id=new_unique_id) - for unique_id in sorted(set(unique_ids)) - if (new_unique_id := _normalize_unique_id(unique_id)) != unique_id - ] - - return UniqueIdMigrationPlan( - version=UNIQUE_ID_MIGRATION_VERSION, - entries=tuple(entries), - ) - - -def build_unique_id_alias_map(unique_ids: list[str]) -> dict[str, str]: - """Build lookup map from old unique IDs to normalized aliases.""" - plan = build_unique_id_migration_plan(unique_ids) - return {entry.old_unique_id: entry.new_unique_id for entry in plan.entries} diff --git a/tests/test_unique_id_migration.py b/tests/test_unique_id_migration.py deleted file mode 100644 index 1c0c811a..00000000 --- a/tests/test_unique_id_migration.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Tests for unique ID migration contract utilities.""" - -from axis.models.events.unique_id_migration import ( - UNIQUE_ID_MIGRATION_VERSION, - build_unique_id_alias_map, - build_unique_id_migration_plan, -) - - -def test_build_unique_id_migration_plan_is_deterministic() -> None: - """Migration plan ordering should be deterministic and idempotent.""" - input_ids = [ - "axis_onvif:Device/axis:Sensor/PIR_0", - "axis_tns1:Device/tnsaxis:Sensor/PIR_0", - "axis_onvif:Device/axis:Light/Status_0", - ] - - plan = build_unique_id_migration_plan(input_ids) - - assert plan.version == UNIQUE_ID_MIGRATION_VERSION - assert [entry.old_unique_id for entry in plan.entries] == sorted( - [ - "axis_onvif:Device/axis:Light/Status_0", - "axis_onvif:Device/axis:Sensor/PIR_0", - ] - ) - assert [entry.new_unique_id for entry in plan.entries] == [ - "axis_tns1:Device/tnsaxis:Light/Status_0", - "axis_tns1:Device/tnsaxis:Sensor/PIR_0", - ] - - -def test_build_unique_id_alias_map_returns_only_changed_entries() -> None: - """Alias map should only include legacy IDs that require migration.""" - aliases = build_unique_id_alias_map( - [ - "axis_tns1:Device/tnsaxis:Sensor/PIR_0", - "axis_onvif:Device/axis:Sensor/PIR_0", - ] - ) - - assert aliases == { - "axis_onvif:Device/axis:Sensor/PIR_0": "axis_tns1:Device/tnsaxis:Sensor/PIR_0" - } diff --git a/tests/test_vapix.py b/tests/test_vapix.py index 8401b8b5..a2e0dd7b 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -30,7 +30,6 @@ DesiredEventSubscription, EventTransport, ) -from axis.models.events.unique_id_migration import UNIQUE_ID_MIGRATION_VERSION from axis.models.light_control import GetLightInformationRequest from axis.models.parameters.param_cgi import ParamRequest from axis.models.port_management import GetPortsRequest @@ -154,29 +153,6 @@ def test_vapix_extension_helpers(vapix: Vapix) -> None: } -def test_vapix_unique_id_migration_helpers(vapix: Vapix) -> None: - """Migration helpers should provide deterministic versioned rollout data.""" - unique_ids = [ - "sensor_onvif:Device/axis:Sensor/PIR_state", - "sensor_tns1:Device/tnsaxis:Sensor/PIR_state", - ] - - assert vapix.get_unique_id_migration_version() == UNIQUE_ID_MIGRATION_VERSION - - plan = vapix.plan_unique_id_migration(unique_ids) - assert plan.version == UNIQUE_ID_MIGRATION_VERSION - assert [entry.old_unique_id for entry in plan.entries] == [ - "sensor_onvif:Device/axis:Sensor/PIR_state" - ] - assert [entry.new_unique_id for entry in plan.entries] == [ - "sensor_tns1:Device/tnsaxis:Sensor/PIR_state" - ] - - assert vapix.build_unique_id_alias_map(unique_ids) == { - "sensor_onvif:Device/axis:Sensor/PIR_state": "sensor_tns1:Device/tnsaxis:Sensor/PIR_state" - } - - async def test_plan_event_transport_filters_validates_supported_topics( vapix: Vapix, ) -> None: From d51bff670b04b64a53e915048e2532836a6457fe Mon Sep 17 00:00:00 2001 From: Kane610 Date: Fri, 15 May 2026 21:22:00 +0200 Subject: [PATCH 12/18] refactor: move event_manager and event_instances under interfaces/events/ --- axis/device.py | 2 +- axis/interfaces/events/__init__.py | 1 + axis/interfaces/{ => events}/event_instances.py | 10 +++++----- axis/interfaces/{ => events}/event_manager.py | 4 ++-- axis/interfaces/vapix.py | 2 +- tests/test_event_instances.py | 2 +- tests/test_event_instances_protocol_parity.py | 2 +- tests/test_event_manager_extensions.py | 2 +- tests/test_event_stream.py | 2 +- 9 files changed, 14 insertions(+), 13 deletions(-) create mode 100644 axis/interfaces/events/__init__.py rename axis/interfaces/{ => events}/event_instances.py (92%) rename axis/interfaces/{ => events}/event_manager.py (97%) diff --git a/axis/device.py b/axis/device.py index 009946c0..6da0ee25 100644 --- a/axis/device.py +++ b/axis/device.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from .interfaces.event_manager import EventManager +from .interfaces.events.event_manager import EventManager from .interfaces.vapix import Vapix from .stream_manager import StreamManager diff --git a/axis/interfaces/events/__init__.py b/axis/interfaces/events/__init__.py new file mode 100644 index 00000000..3dad02bd --- /dev/null +++ b/axis/interfaces/events/__init__.py @@ -0,0 +1 @@ +"""Event interface handlers for Axis devices.""" diff --git a/axis/interfaces/event_instances.py b/axis/interfaces/events/event_instances.py similarity index 92% rename from axis/interfaces/event_instances.py rename to axis/interfaces/events/event_instances.py index 795b7414..b343288d 100644 --- a/axis/interfaces/event_instances.py +++ b/axis/interfaces/events/event_instances.py @@ -4,18 +4,18 @@ from typing import TYPE_CHECKING, Any -from ..models.event_instance import ( +from ...models.event_instance import ( EventInstance, ListEventInstancesRequest, ListEventInstancesResponse, ) -from ..models.events.topic_normalizer import to_canonical, to_topic_filter -from .api_handler import ApiHandler +from ...models.events.topic_normalizer import to_canonical, to_topic_filter +from ..api_handler import ApiHandler from .event_manager import BLACK_LISTED_TOPICS if TYPE_CHECKING: - from ..models.event import Event - from ..models.events.subscription_contracts import DesiredEventSubscription + from ...models.event import Event + from ...models.events.subscription_contracts import DesiredEventSubscription class EventInstanceHandler(ApiHandler[EventInstance]): diff --git a/axis/interfaces/event_manager.py b/axis/interfaces/events/event_manager.py similarity index 97% rename from axis/interfaces/event_manager.py rename to axis/interfaces/events/event_manager.py index 5911bd85..4134d90d 100644 --- a/axis/interfaces/event_manager.py +++ b/axis/interfaces/events/event_manager.py @@ -4,8 +4,8 @@ import logging from typing import Any -from ..models.event import Event, EventOperation, EventTopic -from ..models.events.topic_normalizer import to_canonical +from ...models.event import Event, EventOperation, EventTopic +from ...models.events.topic_normalizer import to_canonical SubscriptionCallback = Callable[[Event], None] SubscriptionType = tuple[ diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index d479c0dd..f216bbff 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -25,7 +25,7 @@ from .applications.object_analytics import ObjectAnalyticsHandler from .applications.vmd4 import Vmd4Handler from .basic_device_info import BasicDeviceInfoHandler -from .event_instances import EventInstanceHandler +from .events.event_instances import EventInstanceHandler from .light_control import LightHandler from .mqtt import MqttClientHandler from .parameters.param_cgi import Params diff --git a/tests/test_event_instances.py b/tests/test_event_instances.py index f7f7cb6d..f0e674a5 100644 --- a/tests/test_event_instances.py +++ b/tests/test_event_instances.py @@ -28,7 +28,7 @@ ) if TYPE_CHECKING: - from axis.interfaces.event_instances import EventInstanceHandler + from axis.interfaces.events.event_instances import EventInstanceHandler @pytest.fixture diff --git a/tests/test_event_instances_protocol_parity.py b/tests/test_event_instances_protocol_parity.py index f9fa1b71..a9dc6677 100644 --- a/tests/test_event_instances_protocol_parity.py +++ b/tests/test_event_instances_protocol_parity.py @@ -15,7 +15,7 @@ from .event_fixtures import EVENT_INSTANCES, LIGHT_STATUS_INIT, PIR_INIT, VMD4_C1P1_INIT if TYPE_CHECKING: - from axis.interfaces.event_instances import EventInstanceHandler + from axis.interfaces.events.event_instances import EventInstanceHandler @pytest.fixture diff --git a/tests/test_event_manager_extensions.py b/tests/test_event_manager_extensions.py index ab2f7c23..216d022e 100644 --- a/tests/test_event_manager_extensions.py +++ b/tests/test_event_manager_extensions.py @@ -1,6 +1,6 @@ """Tests for EventManager extension hooks.""" -from axis.interfaces.event_manager import EventManager +from axis.interfaces.events.event_manager import EventManager def test_allowed_topics_filter_is_opt_in() -> None: diff --git a/tests/test_event_stream.py b/tests/test_event_stream.py index 32b01c04..b36f6727 100644 --- a/tests/test_event_stream.py +++ b/tests/test_event_stream.py @@ -43,7 +43,7 @@ if TYPE_CHECKING: from axis.device import AxisDevice - from axis.interfaces.event_manager import EventManager + from axis.interfaces.events.event_manager import EventManager @pytest.fixture From 773a460d1655502648a638db1fdfe936a22ba12b Mon Sep 17 00:00:00 2001 From: Kane610 Date: Fri, 15 May 2026 22:03:55 +0200 Subject: [PATCH 13/18] refactor: remove event_instance compat shim, use canonical models/events path --- .github/agents/axis-review-verify.agent.md | 2 +- .github/agents/axis-review.agent.md | 2 +- axis/interfaces/events/event_instances.py | 2 +- axis/models/event_instance.py | 3 --- tests/test_event_instances.py | 2 +- tests/test_vapix.py | 2 +- 6 files changed, 5 insertions(+), 8 deletions(-) delete mode 100644 axis/models/event_instance.py diff --git a/.github/agents/axis-review-verify.agent.md b/.github/agents/axis-review-verify.agent.md index 1ea3c589..955448d2 100644 --- a/.github/agents/axis-review-verify.agent.md +++ b/.github/agents/axis-review-verify.agent.md @@ -65,7 +65,7 @@ You are a verification-focused reviewer for the Axis Python repository. Your job ## Review Priorities 1. Correctness and regressions: - Handler initialization phase behavior in `axis/interfaces/` (`API_DISCOVERY`, `PARAM_CGI_FALLBACK`, `APPLICATION`). -- Event/state behavior in `axis/models/event.py` and `axis/models/event_instance.py`. +- Event/state behavior in `axis/models/event.py` and `axis/models/events/event_instance.py`. - Model input normalization and default handling at boundaries. 2. Reliability and compatibility: - Unknown enum and external input handling (`_missing_`, safe defaults). diff --git a/.github/agents/axis-review.agent.md b/.github/agents/axis-review.agent.md index 867b44bb..7085b05f 100644 --- a/.github/agents/axis-review.agent.md +++ b/.github/agents/axis-review.agent.md @@ -67,7 +67,7 @@ user-invocable: true ## Review Priorities 1. Correctness and regressions: - Behavior changes in `axis/interfaces/` handlers and initialization flow (`API_DISCOVERY`, `PARAM_CGI_FALLBACK`, `APPLICATION`). -- State/event semantics in `axis/models/event.py` and `axis/models/event_instance.py`. +- State/event semantics in `axis/models/event.py` and `axis/models/events/event_instance.py`. - Input boundary normalization in model constructors or `__post_init__`. 2. Reliability and compatibility: - Unknown enum and external input handling (`_missing_` fallbacks, safe defaults). diff --git a/axis/interfaces/events/event_instances.py b/axis/interfaces/events/event_instances.py index b343288d..c27e7de6 100644 --- a/axis/interfaces/events/event_instances.py +++ b/axis/interfaces/events/event_instances.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any -from ...models.event_instance import ( +from ...models.events.event_instance import ( EventInstance, ListEventInstancesRequest, ListEventInstancesResponse, diff --git a/axis/models/event_instance.py b/axis/models/event_instance.py deleted file mode 100644 index 4f57ffdf..00000000 --- a/axis/models/event_instance.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Backward-compatible re-export for event-instance models.""" - -from .events.event_instance import * # noqa: F403 diff --git a/tests/test_event_instances.py b/tests/test_event_instances.py index f0e674a5..58e86fc4 100644 --- a/tests/test_event_instances.py +++ b/tests/test_event_instances.py @@ -8,7 +8,7 @@ import pytest from axis.models.event import Event -from axis.models.event_instance import ( +from axis.models.events.event_instance import ( EventInstance, EventInstanceData, EventInstanceSimpleItem, diff --git a/tests/test_vapix.py b/tests/test_vapix.py index a2e0dd7b..c528bc91 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -21,7 +21,7 @@ ListApplicationsRequest, ) from axis.models.basic_device_info import GetAllPropertiesRequest -from axis.models.event_instance import ( +from axis.models.events.event_instance import ( EventInstance, EventInstanceData, EventInstanceSource, From 570bd8bedd16d75547d7dbe3a7f562fba2a97772 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Sun, 17 May 2026 20:31:05 +0200 Subject: [PATCH 14/18] Add EventTopicFilter value object, replace stringly-typed filter API - Add axis/models/events/topic_filter.py with EventTopicFilter: frozen dataclass with explicit None wildcard sentinel, from_topics() classmethod (raises on empty), is_wildcard/canonical_topics/ mqtt_topic_filters properties, and from_desired_subscriptions() compat helper - Export EventTopicFilter from axis/models/events/__init__.py - Replace StreamManager._event_filter_list with _event_subscription: EventTopicFilter; add set_event_subscription(); keep deprecated set_event_filter_list() bridge shim; project wire-format dicts in _build_stream() (transport layer, not model) - Simplify apply_event_transport_filters() to return None; build EventTopicFilter directly instead of raw dict payloads - Deprecate DesiredEventSubscription with docstring - Add tests/test_event_topic_filter.py (100% coverage of new class) - Update test_stream_manager.py and test_vapix.py --- axis/interfaces/events/event_manager.py | 8 - axis/interfaces/vapix.py | 111 ++++++-------- axis/models/events/__init__.py | 4 + axis/models/events/subscription_contracts.py | 8 +- axis/models/events/topic_filter.py | 101 +++++++++++++ axis/stream_manager.py | 25 +++- tests/test_event_topic_filter.py | 146 +++++++++++++++++++ tests/test_stream_manager.py | 53 ++++++- tests/test_vapix.py | 112 ++++++-------- 9 files changed, 419 insertions(+), 149 deletions(-) create mode 100644 axis/models/events/topic_filter.py create mode 100644 tests/test_event_topic_filter.py diff --git a/axis/interfaces/events/event_manager.py b/axis/interfaces/events/event_manager.py index 4134d90d..4bfb65e7 100644 --- a/axis/interfaces/events/event_manager.py +++ b/axis/interfaces/events/event_manager.py @@ -47,14 +47,6 @@ def set_allowed_topics(self, topics: list[str] | set[str] | None) -> None: self._allowed_topics = {to_canonical(topic) for topic in topics} - def seed_known_events(self, events: list[Event]) -> None: - """Seed known topics from expected startup events. - - This extension hook is no-op unless explicitly invoked by a caller. - """ - for event in events: - self._known_topics.add(f"{to_canonical(event.topic)}_{event.id}") - def handler(self, data: bytes | dict[str, Any]) -> None: """Create event and pass it along to subscribers.""" event = Event.decode(data) diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index f216bbff..6bb53dbb 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -10,7 +10,7 @@ from ..errors import RequestError, raise_error from ..models.configuration import AuthScheme -from ..models.events.topic_normalizer import to_canonical +from ..models.events.topic_filter import EventTopicFilter, from_desired_subscriptions from ..models.events.transport_capabilities import ( TRANSPORT_FILTER_CAPABILITIES, ) @@ -266,88 +266,65 @@ def get_supported_event_descriptors( include_internal_topics=include_internal_topics ) - def build_transport_filter_payloads( + async def apply_event_transport_filters( self, subscriptions: list[DesiredEventSubscription] | None = None, include_internal_topics: bool = False, - ) -> dict[str, list[str]]: - """Build transport filter payloads from desired subscriptions. + ) -> None: + """Apply validated transport filters to websocket, MQTT, and local fallback. - This method does not apply filters; it only returns extension payloads. + This method performs no implicit event-instance initialization. Callers must + invoke ``initialize_event_instances`` before applying filters. """ - return self.event_instances.build_transport_filter_payloads( - subscriptions=subscriptions, - include_internal_topics=include_internal_topics, - ) - - def plan_event_transport_filters( - self, - subscriptions: list[DesiredEventSubscription] | None = None, - include_internal_topics: bool = False, - ) -> dict[str, list[str]]: - """Plan validated transport filter payloads from event-instance support data.""" - payloads = self.build_transport_filter_payloads( - subscriptions=subscriptions, - include_internal_topics=include_internal_topics, - ) + if not self.event_instances.initialized: + msg = ( + "Event instances are not initialized. " + "Call initialize_event_instances() first." + ) + raise RuntimeError(msg) - supported_topics = set( - self.event_instances.get_supported_topics( + if subscriptions: + event_filter = from_desired_subscriptions(subscriptions) + else: + supported = self.event_instances.get_supported_topics( include_internal_topics=include_internal_topics ) - ) - requested_topics = { - to_canonical(topic) for topic in payloads["canonical_topics"] - } - unknown_topics = sorted(requested_topics - supported_topics) - if unknown_topics: - message = f"Requested unsupported topics: {', '.join(unknown_topics)}" - raise ValueError(message) - - return payloads - - async def apply_event_transport_filters( - self, - subscriptions: list[DesiredEventSubscription] | None = None, - include_internal_topics: bool = False, - apply_mqtt: bool = True, - apply_websocket: bool = True, - apply_local_fallback: bool = True, - ) -> dict[str, list[str]]: - """Apply planned transport filters through optional extension hooks. - - This method performs no implicit event-instance initialization. Callers are - responsible for invoking ``initialize_event_instances`` before planning. - """ - payloads = self.plan_event_transport_filters( - subscriptions=subscriptions, - include_internal_topics=include_internal_topics, - ) + event_filter = ( + EventTopicFilter.from_topics(list(supported)) + if supported + else EventTopicFilter.for_all_events() + ) - if apply_websocket: - self.device.stream.set_event_filter_list( - [ - {"topicFilter": topic} - for topic in payloads["websocket_topic_filters"] - ] + if not event_filter.is_wildcard: + supported_topics = set( + self.event_instances.get_supported_topics( + include_internal_topics=include_internal_topics + ) ) + unknown_topics = sorted( + set(event_filter.canonical_topics) - supported_topics + ) + if unknown_topics: + message = f"Requested unsupported topics: {', '.join(unknown_topics)}" + raise ValueError(message) + + self.device.stream.set_event_subscription(event_filter) - if apply_mqtt and self.mqtt.supported: - await self.mqtt.configure_event_publication(payloads["mqtt_topics"]) + if self.mqtt.supported: + await self.mqtt.configure_event_publication(event_filter.mqtt_topic_filters) - if apply_local_fallback: - self.device.event.set_allowed_topics(payloads["canonical_topics"]) + self.device.event.set_allowed_topics(event_filter.canonical_topics or None) LOGGER.debug( - "Applied event transport filters: websocket=%s mqtt=%s local=%s topics=%d", - apply_websocket, - apply_mqtt and self.mqtt.supported, - apply_local_fallback, - len(payloads["canonical_topics"]), + "Applied event transport filters: websocket=%s mqtt=%s local=%s topics=%s", + True, + self.mqtt.supported, + True, + len(event_filter.canonical_topics) + if not event_filter.is_wildcard + else "all", ) - return payloads - async def initialize_users(self) -> None: """Load device user data and initialize user management.""" await self.users.update() diff --git a/axis/models/events/__init__.py b/axis/models/events/__init__.py index 5aa07bf5..afeff13a 100644 --- a/axis/models/events/__init__.py +++ b/axis/models/events/__init__.py @@ -1 +1,5 @@ """Event-related models namespace.""" + +from .topic_filter import EventTopicFilter, from_desired_subscriptions + +__all__ = ["EventTopicFilter", "from_desired_subscriptions"] diff --git a/axis/models/events/subscription_contracts.py b/axis/models/events/subscription_contracts.py index 6c7ea0f7..0ccf5d18 100644 --- a/axis/models/events/subscription_contracts.py +++ b/axis/models/events/subscription_contracts.py @@ -16,8 +16,10 @@ class EventTransport(enum.StrEnum): @dataclass(frozen=True) class DesiredEventSubscription: - """Transport-agnostic desired event subscription request.""" + """Transport-agnostic desired event subscription request. + + Deprecated: Use EventTopicFilter.from_topics() instead. + Accepted by apply_event_transport_filters() for backward compatibility. + """ topic: str - source: str | None = None - source_ids: tuple[str, ...] | None = None diff --git a/axis/models/events/topic_filter.py b/axis/models/events/topic_filter.py new file mode 100644 index 00000000..b049a644 --- /dev/null +++ b/axis/models/events/topic_filter.py @@ -0,0 +1,101 @@ +"""Transport-agnostic event topic filter value object.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from .topic_normalizer import detect_format, to_canonical, to_topic_filter + +if TYPE_CHECKING: + from collections.abc import Sequence + + from .event import EventTopic + from .subscription_contracts import DesiredEventSubscription + + +@dataclass(frozen=True) +class EventTopicFilter: + """Immutable, transport-agnostic event topic filter. + + Normalizes topic inputs to canonical form and exposes per-transport + output properties. Wire-format construction (e.g. WebSocket dict shape) + is the responsibility of the transport layer. + + Do not construct directly — use the provided classmethods. + """ + + # None means wildcard (subscribe to all); never empty frozenset. + _topics: frozenset[str] | None = field(default=None, repr=True) + + @classmethod + def for_all_events(cls) -> EventTopicFilter: + """Return a wildcard filter — subscribe to all events. + + On WebSocket, equivalent to eventFilterList [{"topicFilter": "//."}]. + On MQTT, no topic filter is applied. + EventManager allows all topics through. + """ + return cls(_topics=None) + + @classmethod + def from_topics( + cls, + topics: Sequence[str | EventTopic], + ) -> EventTopicFilter: + """Build from a sequence of topic strings and/or EventTopic enum values. + + Accepts topics in any format: canonical (tns1/tnsaxis), MQTT (onvif/axis), + or EventTopic enum. All inputs are normalized to canonical form. + + Raises ValueError if topics is empty — use for_all_events() for wildcard. + """ + if not topics: + msg = "topics must not be empty; use EventTopicFilter.for_all_events() to subscribe to all events" + raise ValueError(msg) + + canonical: set[str] = set() + for topic in topics: + raw: str = topic.value if hasattr(topic, "value") else topic + canonical.add(to_canonical(raw) if detect_format(raw) == "mqtt" else raw) + return cls(_topics=frozenset(canonical)) + + @property + def is_wildcard(self) -> bool: + """True when this filter represents 'subscribe to all events'.""" + return self._topics is None + + @property + def canonical_topics(self) -> list[str]: + """Sorted canonical (tns1/tnsaxis) topic strings. + + Consumed by EventManager.set_allowed_topics(). + Returns an empty list for wildcard filters — callers interpret + empty as 'allow all'. + """ + if self._topics is None: + return [] + return sorted(self._topics) + + @property + def mqtt_topic_filters(self) -> list[str]: + """Sorted MQTT (onvif/axis) topic filter strings. + + Consumed by MqttClientHandler.configure_event_publication(). + Returns an empty list for wildcard filters — callers should skip + setting an MQTT filter when this is empty. + """ + if self._topics is None: + return [] + return sorted(to_topic_filter(t) for t in self._topics) + + +def from_desired_subscriptions( + subscriptions: Sequence[DesiredEventSubscription], +) -> EventTopicFilter: + """Build an EventTopicFilter from legacy DesiredEventSubscription objects. + + Compatibility helper for callers that construct DesiredEventSubscription. + Prefer EventTopicFilter.from_topics() for new code. + """ + return EventTopicFilter.from_topics([s.topic for s in subscriptions]) diff --git a/axis/stream_manager.py b/axis/stream_manager.py index 949a1c11..c05a64c0 100644 --- a/axis/stream_manager.py +++ b/axis/stream_manager.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any from .models.configuration import WebProtocol +from .models.events.topic_filter import EventTopicFilter from .rtsp import RTSPClient, Signal, State from .websocket import WebSocketClient @@ -28,14 +29,13 @@ class StreamManager: def __init__( self, device: AxisDevice, - event_filter_list: list[dict[str, str]] | None = None, ) -> None: """Initialize stream manager.""" self.device = device self.video = None # Unsupported self.audio = None # Unsupported self.event = False - self.event_filter_list = event_filter_list + self._event_subscription: EventTopicFilter = EventTopicFilter.for_all_events() self.stream: StreamTransport | None = None self.connection_status_callback: list[Callable[[Signal], None]] = [] @@ -129,11 +129,13 @@ def _is_stream_stopped(self) -> bool: def _build_stream(self) -> StreamTransport: """Build transport based on device capabilities and manager settings.""" if self.use_websocket: + mqtt_filters = self._event_subscription.mqtt_topic_filters return WebSocketClient( self.device, self.websocket_url, self.session_callback, - event_filter_list=self.event_filter_list, + event_filter_list=[{"topicFilter": t} for t in mqtt_filters] + or [{"topicFilter": "//."}], ) return RTSPClient( @@ -144,9 +146,22 @@ def _build_stream(self) -> StreamTransport: self.session_callback, ) + def set_event_subscription(self, request: EventTopicFilter) -> None: + """Set the event topic filter for future WebSocket stream sessions.""" + self._event_subscription = request + def set_event_filter_list(self, event_filter_list: list[dict[str, str]]) -> None: - """Set optional websocket event filter list for future stream sessions.""" - self.event_filter_list = event_filter_list + """Set websocket event filter list (deprecated, use set_event_subscription).""" + topics = [ + entry["topicFilter"] + for entry in event_filter_list + if entry.get("topicFilter") and entry["topicFilter"] != "//." + ] + self._event_subscription = ( + EventTopicFilter.from_topics(topics) + if topics + else EventTopicFilter.for_all_events() + ) def session_callback(self, signal: Signal) -> None: """Signalling from stream session. diff --git a/tests/test_event_topic_filter.py b/tests/test_event_topic_filter.py new file mode 100644 index 00000000..d3828e95 --- /dev/null +++ b/tests/test_event_topic_filter.py @@ -0,0 +1,146 @@ +"""Tests for EventTopicFilter value object. + +pytest --cov-report term-missing --cov=axis.models.events.topic_filter tests/test_event_topic_filter.py +""" + +import pytest + +from axis.models.events.event import EventTopic +from axis.models.events.subscription_contracts import DesiredEventSubscription +from axis.models.events.topic_filter import EventTopicFilter, from_desired_subscriptions + + +class TestForAllEvents: + """Tests for EventTopicFilter.for_all_events().""" + + def test_is_wildcard(self) -> None: + """for_all_events() produces a wildcard filter.""" + f = EventTopicFilter.for_all_events() + assert f.is_wildcard is True + + def test_canonical_topics_empty(self) -> None: + """Wildcard returns empty canonical_topics list.""" + f = EventTopicFilter.for_all_events() + assert f.canonical_topics == [] + + def test_mqtt_topic_filters_empty(self) -> None: + """Wildcard returns empty mqtt_topic_filters list.""" + f = EventTopicFilter.for_all_events() + assert f.mqtt_topic_filters == [] + + def test_equality(self) -> None: + """Two wildcard instances are equal.""" + assert EventTopicFilter.for_all_events() == EventTopicFilter.for_all_events() + + def test_hashable(self) -> None: + """Wildcard filter is hashable (frozen dataclass).""" + f = EventTopicFilter.for_all_events() + assert hash(f) == hash(EventTopicFilter.for_all_events()) + + +class TestFromTopics: + """Tests for EventTopicFilter.from_topics().""" + + def test_empty_raises(self) -> None: + """from_topics([]) raises ValueError.""" + with pytest.raises(ValueError, match="topics must not be empty"): + EventTopicFilter.from_topics([]) + + def test_canonical_string_stored_as_is(self) -> None: + """Canonical-format strings are stored without conversion.""" + topic = "tns1:Device/tnsaxis:Sensor/PIR" + f = EventTopicFilter.from_topics([topic]) + assert f.canonical_topics == [topic] + + def test_mqtt_string_converted_to_canonical(self) -> None: + """MQTT-format strings are converted to canonical.""" + f = EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]) + assert f.canonical_topics == ["tns1:Device/tnsaxis:Sensor/PIR"] + + def test_event_topic_enum(self) -> None: + """EventTopic enum values are accepted and stored as canonical.""" + f = EventTopicFilter.from_topics([EventTopic.PIR]) + assert f.canonical_topics == [EventTopic.PIR.value] + + def test_mixed_input(self) -> None: + """Mixed str and EventTopic inputs are normalized together.""" + f = EventTopicFilter.from_topics([EventTopic.PIR, "onvif:Device/axis:IO/Port"]) + assert f.canonical_topics == [ + "tns1:Device/tnsaxis:IO/Port", + "tns1:Device/tnsaxis:Sensor/PIR", + ] + + def test_is_not_wildcard(self) -> None: + """from_topics() result is never a wildcard.""" + f = EventTopicFilter.from_topics(["tns1:Device/tnsaxis:Sensor/PIR"]) + assert f.is_wildcard is False + + def test_deduplication(self) -> None: + """Duplicate topics (after normalization) are deduplicated.""" + f = EventTopicFilter.from_topics( + [ + "tns1:Device/tnsaxis:Sensor/PIR", + "onvif:Device/axis:Sensor/PIR", # same as above in MQTT form + ] + ) + assert f.canonical_topics == ["tns1:Device/tnsaxis:Sensor/PIR"] + + def test_sorted_output(self) -> None: + """canonical_topics and mqtt_topic_filters return sorted lists.""" + f = EventTopicFilter.from_topics( + [ + "tns1:Device/tnsaxis:Sensor/PIR", + "tns1:Device/tnsaxis:IO/Port", + ] + ) + assert f.canonical_topics == [ + "tns1:Device/tnsaxis:IO/Port", + "tns1:Device/tnsaxis:Sensor/PIR", + ] + assert f.mqtt_topic_filters == [ + "onvif:Device/axis:IO/Port", + "onvif:Device/axis:Sensor/PIR", + ] + + def test_mqtt_topic_filters_conversion(self) -> None: + """mqtt_topic_filters returns MQTT-format strings.""" + f = EventTopicFilter.from_topics(["tns1:Device/tnsaxis:Sensor/PIR"]) + assert f.mqtt_topic_filters == ["onvif:Device/axis:Sensor/PIR"] + + def test_immutable(self) -> None: + """EventTopicFilter is immutable (frozen dataclass).""" + f = EventTopicFilter.from_topics(["tns1:Device/tnsaxis:Sensor/PIR"]) + with pytest.raises(AttributeError): + f._topics = frozenset() # type: ignore[misc] + + def test_equality(self) -> None: + """Two filters with the same topics are equal.""" + f1 = EventTopicFilter.from_topics(["tns1:Device/tnsaxis:Sensor/PIR"]) + f2 = EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]) + assert f1 == f2 + + def test_hashable(self) -> None: + """from_topics result is hashable.""" + f = EventTopicFilter.from_topics(["tns1:Device/tnsaxis:Sensor/PIR"]) + assert isinstance(hash(f), int) + + +class TestFromDesiredSubscriptions: + """Tests for the from_desired_subscriptions() compat helper.""" + + def test_builds_from_desired_subscriptions(self) -> None: + """from_desired_subscriptions bridges DesiredEventSubscription objects.""" + subscriptions = [ + DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR"), + DesiredEventSubscription(topic="tns1:Device/tnsaxis:IO/Port"), + ] + f = from_desired_subscriptions(subscriptions) + assert f.canonical_topics == [ + "tns1:Device/tnsaxis:IO/Port", + "tns1:Device/tnsaxis:Sensor/PIR", + ] + + def test_empty_raises(self) -> None: + """Empty subscriptions list raises ValueError.""" + with pytest.raises(ValueError, match="topics must not be empty"): + from_desired_subscriptions([]) diff --git a/tests/test_stream_manager.py b/tests/test_stream_manager.py index 84a28a78..5ae56c82 100644 --- a/tests/test_stream_manager.py +++ b/tests/test_stream_manager.py @@ -9,6 +9,7 @@ import pytest from axis.models.api_discovery import ApiId +from axis.models.events.topic_filter import EventTopicFilter from axis.rtsp import Signal, State from axis.stream_manager import RETRY_TIMER, StreamManager from axis.websocket import WebSocketClient, WebSocketFailureReason @@ -126,7 +127,7 @@ async def test_start_uses_websocket_when_supported( async def test_stream_manager_passes_event_filter_list_to_websocket( websocket_client, rtsp_client, stream_manager ): - """Optional event_filter_list should be forwarded to websocket transport.""" + """Deprecated set_event_filter_list bridge should forward filter to websocket.""" websocket_client.return_value.start = AsyncMock() stream_manager.event = True stream_manager.device.config.websocket_enabled = True @@ -146,6 +147,56 @@ async def test_stream_manager_passes_event_filter_list_to_websocket( rtsp_client.assert_not_called() +@patch("axis.stream_manager.RTSPClient") +@patch("axis.stream_manager.WebSocketClient") +async def test_stream_manager_set_event_subscription( + websocket_client, rtsp_client, stream_manager +): + """set_event_subscription should pass correct filter dicts to websocket.""" + websocket_client.return_value.start = AsyncMock() + stream_manager.event = True + stream_manager.device.config.websocket_enabled = True + stream_manager.device.vapix.api_discovery._items[ + ApiId.EVENT_STREAMING_OVER_WEBSOCKET + ] = MagicMock() + request = EventTopicFilter.from_topics(["tns1:Device/tnsaxis:Sensor/PIR"]) + stream_manager.set_event_subscription(request) + + stream_manager.start() + + websocket_client.assert_called_once_with( + stream_manager.device, + stream_manager.websocket_url, + stream_manager.session_callback, + event_filter_list=[{"topicFilter": "onvif:Device/axis:Sensor/PIR"}], + ) + rtsp_client.assert_not_called() + + +@patch("axis.stream_manager.RTSPClient") +@patch("axis.stream_manager.WebSocketClient") +async def test_stream_manager_wildcard_uses_default_filter( + websocket_client, rtsp_client, stream_manager +): + """Wildcard EventTopicFilter should produce the default //. filter.""" + websocket_client.return_value.start = AsyncMock() + stream_manager.event = True + stream_manager.device.config.websocket_enabled = True + stream_manager.device.vapix.api_discovery._items[ + ApiId.EVENT_STREAMING_OVER_WEBSOCKET + ] = MagicMock() + # Default state is already for_all_events; no explicit set needed + + stream_manager.start() + + websocket_client.assert_called_once_with( + stream_manager.device, + stream_manager.websocket_url, + stream_manager.session_callback, + event_filter_list=[{"topicFilter": "//."}], + ) + + @patch("axis.stream_manager.RTSPClient") @patch("axis.stream_manager.WebSocketClient") async def test_initialize_stream(websocket_client, rtsp_client, stream_manager): diff --git a/tests/test_vapix.py b/tests/test_vapix.py index c528bc91..ec92f199 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -30,6 +30,7 @@ DesiredEventSubscription, EventTransport, ) +from axis.models.events.topic_filter import EventTopicFilter from axis.models.light_control import GetLightInformationRequest from axis.models.parameters.param_cgi import ParamRequest from axis.models.port_management import GetPortsRequest @@ -144,19 +145,24 @@ def test_vapix_extension_helpers(vapix: Vapix) -> None: assert EventTransport.MQTT in capabilities assert vapix.get_supported_event_descriptors() == {} - assert vapix.build_transport_filter_payloads( - subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")] - ) == { - "canonical_topics": ["tns1:Device/tnsaxis:Sensor/PIR"], - "mqtt_topics": ["onvif:Device/axis:Sensor/PIR"], - "websocket_topic_filters": ["onvif:Device/axis:Sensor/PIR"], - } -async def test_plan_event_transport_filters_validates_supported_topics( +async def test_apply_event_transport_filters_requires_initialized_event_instances( + vapix: Vapix, +) -> None: + """Applying filters should require event-instance initialization.""" + with pytest.raises(RuntimeError, match="Event instances are not initialized"): + await vapix.apply_event_transport_filters( + subscriptions=[ + DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR") + ] + ) + + +async def test_apply_event_transport_filters_validates_supported_topics( vapix: Vapix, ) -> None: - """Planning should reject requested topics that are not in event instances.""" + """Apply should reject requested topics not present in event instances.""" topic = "tns1:Device/tnsaxis:Sensor/PIR" vapix.event_instances._items = { topic: EventInstance( @@ -172,14 +178,15 @@ async def test_plan_event_transport_filters_validates_supported_topics( data=EventInstanceData(), ) } + vapix.event_instances.initialized = True - payloads = vapix.plan_event_transport_filters( + payloads = await vapix.apply_event_transport_filters( subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")] ) - assert payloads["canonical_topics"] == [topic] + assert payloads is None with pytest.raises(ValueError, match="Requested unsupported topics"): - vapix.plan_event_transport_filters( + await vapix.apply_event_transport_filters( subscriptions=[ DesiredEventSubscription(topic="onvif:Device/axis:Status/SystemReady") ] @@ -205,6 +212,7 @@ async def test_apply_event_transport_filters_calls_transport_hooks( data=EventInstanceData(), ) } + vapix.event_instances.initialized = True mqtt_calls: list[list[str]] = [] @@ -218,66 +226,28 @@ async def _configure_event_publication( mqtt_calls.append(topics) vapix.mqtt.configure_event_publication = _configure_event_publication # type: ignore[method-assign] - vapix.device.stream.set_event_filter_list = lambda payload: setattr( - vapix.device.stream, "_captured_filters", payload - ) + captured_subscriptions: list[object] = [] + + def _capture_subscription(req: object) -> None: + captured_subscriptions.append(req) + + vapix.device.stream.set_event_subscription = _capture_subscription # type: ignore[method-assign] vapix.device.event.set_allowed_topics = lambda topics: setattr( vapix.device.event, "_captured_allowed_topics", topics ) - payloads = await vapix.apply_event_transport_filters( + await vapix.apply_event_transport_filters( subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")] ) - assert payloads["canonical_topics"] == [topic] + assert len(captured_subscriptions) == 1 + event_filter = captured_subscriptions[0] + assert isinstance(event_filter, EventTopicFilter) + assert event_filter.canonical_topics == [topic] assert mqtt_calls == [["onvif:Device/axis:Sensor/PIR"]] - assert vapix.device.stream._captured_filters == [ - {"topicFilter": "onvif:Device/axis:Sensor/PIR"} - ] assert vapix.device.event._captured_allowed_topics == [topic] -async def test_apply_event_transport_filters_respects_apply_flags(vapix: Vapix) -> None: - """Apply should skip hooks when apply flags are disabled.""" - topic = "tns1:Device/tnsaxis:Sensor/PIR" - vapix.event_instances._items = { - topic: EventInstance( - id=topic, - topic=topic, - topic_filter="onvif:Device/axis:Sensor/PIR", - is_available=True, - is_application_data=False, - name="pir", - stateful=True, - stateless=False, - source=EventInstanceSource(), - data=EventInstanceData(), - ) - } - - mqtt_calls: list[list[str]] = [] - vapix.api_discovery._items[ApiId.MQTT_CLIENT] = type( - "_Api", (), {"version": "1.0"} - )() - - async def _configure_event_publication( - topics: list[str], *_args, **_kwargs - ) -> None: - mqtt_calls.append(topics) - - vapix.mqtt.configure_event_publication = _configure_event_publication # type: ignore[method-assign] - - payloads = await vapix.apply_event_transport_filters( - subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")], - apply_mqtt=False, - apply_websocket=False, - apply_local_fallback=False, - ) - - assert payloads["canonical_topics"] == [topic] - assert mqtt_calls == [] - - async def test_apply_event_transport_filters_skips_unsupported_mqtt( vapix: Vapix, ) -> None: @@ -297,6 +267,7 @@ async def test_apply_event_transport_filters_skips_unsupported_mqtt( data=EventInstanceData(), ) } + vapix.event_instances.initialized = True mqtt_calls: list[list[str]] = [] @@ -306,15 +277,26 @@ async def _configure_event_publication( mqtt_calls.append(topics) vapix.mqtt.configure_event_publication = _configure_event_publication # type: ignore[method-assign] + captured_subscriptions: list[object] = [] - payloads = await vapix.apply_event_transport_filters( + def _capture_subscription2(req: object) -> None: + captured_subscriptions.append(req) + + vapix.device.stream.set_event_subscription = _capture_subscription2 # type: ignore[method-assign] + vapix.device.event.set_allowed_topics = lambda topics: setattr( + vapix.device.event, "_captured_allowed_topics", topics + ) + + await vapix.apply_event_transport_filters( subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")], - apply_websocket=False, - apply_local_fallback=False, ) - assert payloads["canonical_topics"] == [topic] + assert len(captured_subscriptions) == 1 + event_filter = captured_subscriptions[0] + assert isinstance(event_filter, EventTopicFilter) + assert event_filter.canonical_topics == [topic] assert mqtt_calls == [] + assert vapix.device.event._captured_allowed_topics == [topic] def test_api_discovery_handlers_registration(vapix: Vapix) -> None: From ef168c5473f4fa038b4826f06e606f7633cf149f Mon Sep 17 00:00:00 2001 From: Kane610 Date: Sun, 17 May 2026 20:47:35 +0200 Subject: [PATCH 15/18] refactor: absorb topic_normalizer into topic_filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move SegmentFormat, to_canonical, to_mqtt, to_topic_filter, detect_format and helpers into topic_filter.py — they are all part of the same topic- format domain that EventTopicFilter owns. - Delete axis/models/events/topic_normalizer.py - Update three interface importers (event_instances, event_manager, mqtt) - Update tests/test_topic_normalizer.py to import from topic_filter - Export new symbols from axis/models/events/__init__.py No behaviour change; all 463 tests pass, coverage 98.01%. --- axis/interfaces/events/event_instances.py | 2 +- axis/interfaces/events/event_manager.py | 2 +- axis/interfaces/mqtt.py | 2 +- axis/models/events/__init__.py | 20 ++++++- axis/models/events/topic_filter.py | 68 +++++++++++++++++++++-- axis/models/events/topic_normalizer.py | 58 ------------------- tests/test_topic_normalizer.py | 2 +- 7 files changed, 86 insertions(+), 68 deletions(-) delete mode 100644 axis/models/events/topic_normalizer.py diff --git a/axis/interfaces/events/event_instances.py b/axis/interfaces/events/event_instances.py index c27e7de6..eb1da14c 100644 --- a/axis/interfaces/events/event_instances.py +++ b/axis/interfaces/events/event_instances.py @@ -9,7 +9,7 @@ ListEventInstancesRequest, ListEventInstancesResponse, ) -from ...models.events.topic_normalizer import to_canonical, to_topic_filter +from ...models.events.topic_filter import to_canonical, to_topic_filter from ..api_handler import ApiHandler from .event_manager import BLACK_LISTED_TOPICS diff --git a/axis/interfaces/events/event_manager.py b/axis/interfaces/events/event_manager.py index 4bfb65e7..717317c8 100644 --- a/axis/interfaces/events/event_manager.py +++ b/axis/interfaces/events/event_manager.py @@ -5,7 +5,7 @@ from typing import Any from ...models.event import Event, EventOperation, EventTopic -from ...models.events.topic_normalizer import to_canonical +from ...models.events.topic_filter import to_canonical SubscriptionCallback = Callable[[Event], None] SubscriptionType = tuple[ diff --git a/axis/interfaces/mqtt.py b/axis/interfaces/mqtt.py index 57152657..aabbc702 100644 --- a/axis/interfaces/mqtt.py +++ b/axis/interfaces/mqtt.py @@ -3,7 +3,7 @@ from typing import Any from ..models.api_discovery import ApiId -from ..models.events.topic_normalizer import to_topic_filter +from ..models.events.topic_filter import to_topic_filter from ..models.mqtt import ( API_VERSION, ActivateClientRequest, diff --git a/axis/models/events/__init__.py b/axis/models/events/__init__.py index afeff13a..dd1d4ce8 100644 --- a/axis/models/events/__init__.py +++ b/axis/models/events/__init__.py @@ -1,5 +1,21 @@ """Event-related models namespace.""" -from .topic_filter import EventTopicFilter, from_desired_subscriptions +from .topic_filter import ( + EventTopicFilter, + SegmentFormat, + detect_format, + from_desired_subscriptions, + to_canonical, + to_mqtt, + to_topic_filter, +) -__all__ = ["EventTopicFilter", "from_desired_subscriptions"] +__all__ = [ + "EventTopicFilter", + "SegmentFormat", + "detect_format", + "from_desired_subscriptions", + "to_canonical", + "to_mqtt", + "to_topic_filter", +] diff --git a/axis/models/events/topic_filter.py b/axis/models/events/topic_filter.py index b049a644..277519e4 100644 --- a/axis/models/events/topic_filter.py +++ b/axis/models/events/topic_filter.py @@ -1,11 +1,9 @@ -"""Transport-agnostic event topic filter value object.""" +"""Event topic filter and namespace conversion utilities.""" from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -from .topic_normalizer import detect_format, to_canonical, to_topic_filter +from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: from collections.abc import Sequence @@ -13,6 +11,68 @@ from .event import EventTopic from .subscription_contracts import DesiredEventSubscription +# --------------------------------------------------------------------------- +# Topic namespace conversion +# --------------------------------------------------------------------------- + +_CANONICAL_PREFIXES = ("tns1", "tnsaxis") +_MQTT_PREFIXES = ("onvif", "axis") + +SegmentFormat = Literal["canonical", "mqtt", "unknown"] + + +def _convert_segment(segment: str, mapping: dict[str, str]) -> str: + for source, target in mapping.items(): + prefix = f"{source}:" + if segment.startswith(prefix): + return f"{target}:{segment[len(prefix) :]}" + return segment + + +def _convert_topic(topic: str, mapping: dict[str, str]) -> str: + return "/".join(_convert_segment(segment, mapping) for segment in topic.split("/")) + + +def to_canonical(topic: str) -> str: + """Convert topic namespaces to canonical (tns1/tnsaxis) representation.""" + return _convert_topic(topic, {"onvif": "tns1", "axis": "tnsaxis"}) + + +def to_mqtt(topic: str) -> str: + """Convert topic namespaces to MQTT (onvif/axis) representation.""" + return _convert_topic(topic, {"tns1": "onvif", "tnsaxis": "axis"}) + + +def to_topic_filter(topic: str) -> str: + """Convert canonical topic to topic-filter namespace representation.""" + return to_mqtt(topic) + + +def detect_format(topic: str) -> SegmentFormat: + """Detect topic namespace format from segment prefixes.""" + segments = topic.split("/") + + if any( + segment.startswith(f"{prefix}:") + for prefix in _CANONICAL_PREFIXES + for segment in segments + ): + return "canonical" + + if any( + segment.startswith(f"{prefix}:") + for prefix in _MQTT_PREFIXES + for segment in segments + ): + return "mqtt" + + return "unknown" + + +# --------------------------------------------------------------------------- +# EventTopicFilter value object +# --------------------------------------------------------------------------- + @dataclass(frozen=True) class EventTopicFilter: diff --git a/axis/models/events/topic_normalizer.py b/axis/models/events/topic_normalizer.py deleted file mode 100644 index 19b61966..00000000 --- a/axis/models/events/topic_normalizer.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Topic normalization helpers for cross-transport event handling.""" - -from __future__ import annotations - -from typing import Literal - -_CANONICAL_PREFIXES = ("tns1", "tnsaxis") -_MQTT_PREFIXES = ("onvif", "axis") - -SegmentFormat = Literal["canonical", "mqtt", "unknown"] - - -def _convert_segment(segment: str, mapping: dict[str, str]) -> str: - for source, target in mapping.items(): - prefix = f"{source}:" - if segment.startswith(prefix): - return f"{target}:{segment[len(prefix) :]}" - return segment - - -def _convert_topic(topic: str, mapping: dict[str, str]) -> str: - return "/".join(_convert_segment(segment, mapping) for segment in topic.split("/")) - - -def to_canonical(topic: str) -> str: - """Convert topic namespaces to canonical (tns1/tnsaxis) representation.""" - return _convert_topic(topic, {"onvif": "tns1", "axis": "tnsaxis"}) - - -def to_mqtt(topic: str) -> str: - """Convert topic namespaces to MQTT (onvif/axis) representation.""" - return _convert_topic(topic, {"tns1": "onvif", "tnsaxis": "axis"}) - - -def to_topic_filter(topic: str) -> str: - """Convert canonical topic to topic-filter namespace representation.""" - return to_mqtt(topic) - - -def detect_format(topic: str) -> SegmentFormat: - """Detect topic namespace format from segment prefixes.""" - segments = topic.split("/") - - if any( - segment.startswith(f"{prefix}:") - for prefix in _CANONICAL_PREFIXES - for segment in segments - ): - return "canonical" - - if any( - segment.startswith(f"{prefix}:") - for prefix in _MQTT_PREFIXES - for segment in segments - ): - return "mqtt" - - return "unknown" diff --git a/tests/test_topic_normalizer.py b/tests/test_topic_normalizer.py index fb7cc66a..8b31cdcb 100644 --- a/tests/test_topic_normalizer.py +++ b/tests/test_topic_normalizer.py @@ -1,6 +1,6 @@ """Tests for event topic normalization extension contract.""" -from axis.models.events.topic_normalizer import ( +from axis.models.events.topic_filter import ( detect_format, to_canonical, to_mqtt, From 3433f14cc6b0acfc43df342a095cf7b6b4f29308 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Mon, 18 May 2026 21:17:03 +0200 Subject: [PATCH 16/18] refactor: remove deprecated event subscription wrappers - remove DesiredEventSubscription compatibility model - remove from_desired_subscriptions and set_event_filter_list bridges - move EventTransport into transport_capabilities - delete subscription_contracts.py - update vapix/event_instances APIs and tests to use EventTopicFilter directly - expand module docs and add focused coverage tests --- axis/interfaces/events/event_instances.py | 17 ++++--- axis/interfaces/vapix.py | 13 ++--- axis/models/events/__init__.py | 2 - axis/models/events/subscription_contracts.py | 25 ---------- axis/models/events/topic_filter.py | 51 ++++++++++++-------- axis/models/events/transport_capabilities.py | 28 ++++++++--- axis/stream_manager.py | 13 ----- tests/test_event_extension_contracts.py | 2 +- tests/test_event_instances.py | 25 +++++++++- tests/test_event_manager_extensions.py | 8 +++ tests/test_event_topic_filter.py | 24 +-------- tests/test_stream_manager.py | 6 ++- tests/test_vapix.py | 21 +++----- 13 files changed, 113 insertions(+), 122 deletions(-) delete mode 100644 axis/models/events/subscription_contracts.py diff --git a/axis/interfaces/events/event_instances.py b/axis/interfaces/events/event_instances.py index eb1da14c..a3ccd098 100644 --- a/axis/interfaces/events/event_instances.py +++ b/axis/interfaces/events/event_instances.py @@ -9,13 +9,16 @@ ListEventInstancesRequest, ListEventInstancesResponse, ) -from ...models.events.topic_filter import to_canonical, to_topic_filter +from ...models.events.topic_filter import ( + EventTopicFilter, + to_canonical, + to_topic_filter, +) from ..api_handler import ApiHandler from .event_manager import BLACK_LISTED_TOPICS if TYPE_CHECKING: from ...models.event import Event - from ...models.events.subscription_contracts import DesiredEventSubscription class EventInstanceHandler(ApiHandler[EventInstance]): @@ -79,16 +82,14 @@ def get_supported_event_descriptors( def build_transport_filter_payloads( self, - subscriptions: list[DesiredEventSubscription] | None = None, + event_filter: EventTopicFilter | None = None, include_internal_topics: bool = False, ) -> dict[str, list[str]]: """Build no-op extension payloads for MQTT and WebSocket topic filters.""" - if subscriptions: - topics = sorted( - {to_canonical(subscription.topic) for subscription in subscriptions} - ) - else: + if event_filter is None or event_filter.is_wildcard: topics = list(self.get_supported_topics(include_internal_topics)) + else: + topics = event_filter.canonical_topics topic_filters = [to_topic_filter(topic) for topic in topics] diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index 6bb53dbb..a636f8f4 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -10,7 +10,7 @@ from ..errors import RequestError, raise_error from ..models.configuration import AuthScheme -from ..models.events.topic_filter import EventTopicFilter, from_desired_subscriptions +from ..models.events.topic_filter import EventTopicFilter from ..models.events.transport_capabilities import ( TRANSPORT_FILTER_CAPABILITIES, ) @@ -41,11 +41,10 @@ if TYPE_CHECKING: from ..device import AxisDevice from ..models.api import ApiRequest - from ..models.events.subscription_contracts import ( - DesiredEventSubscription, + from ..models.events.transport_capabilities import ( EventTransport, + TransportFilterCapability, ) - from ..models.events.transport_capabilities import TransportFilterCapability from ..models.stream_profile import StreamProfile LOGGER = logging.getLogger(__name__) @@ -268,7 +267,7 @@ def get_supported_event_descriptors( async def apply_event_transport_filters( self, - subscriptions: list[DesiredEventSubscription] | None = None, + event_filter: EventTopicFilter | None = None, include_internal_topics: bool = False, ) -> None: """Apply validated transport filters to websocket, MQTT, and local fallback. @@ -283,9 +282,7 @@ async def apply_event_transport_filters( ) raise RuntimeError(msg) - if subscriptions: - event_filter = from_desired_subscriptions(subscriptions) - else: + if event_filter is None: supported = self.event_instances.get_supported_topics( include_internal_topics=include_internal_topics ) diff --git a/axis/models/events/__init__.py b/axis/models/events/__init__.py index dd1d4ce8..0e5aaae7 100644 --- a/axis/models/events/__init__.py +++ b/axis/models/events/__init__.py @@ -4,7 +4,6 @@ EventTopicFilter, SegmentFormat, detect_format, - from_desired_subscriptions, to_canonical, to_mqtt, to_topic_filter, @@ -14,7 +13,6 @@ "EventTopicFilter", "SegmentFormat", "detect_format", - "from_desired_subscriptions", "to_canonical", "to_mqtt", "to_topic_filter", diff --git a/axis/models/events/subscription_contracts.py b/axis/models/events/subscription_contracts.py deleted file mode 100644 index 0ccf5d18..00000000 --- a/axis/models/events/subscription_contracts.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Domain contracts for event-subscription planning.""" - -from __future__ import annotations - -from dataclasses import dataclass -import enum - - -class EventTransport(enum.StrEnum): - """Event transport types used for capability planning.""" - - RTSP = "rtsp" - WEBSOCKET = "websocket" - MQTT = "mqtt" - - -@dataclass(frozen=True) -class DesiredEventSubscription: - """Transport-agnostic desired event subscription request. - - Deprecated: Use EventTopicFilter.from_topics() instead. - Accepted by apply_event_transport_filters() for backward compatibility. - """ - - topic: str diff --git a/axis/models/events/topic_filter.py b/axis/models/events/topic_filter.py index 277519e4..3fe16faa 100644 --- a/axis/models/events/topic_filter.py +++ b/axis/models/events/topic_filter.py @@ -1,4 +1,11 @@ -"""Event topic filter and namespace conversion utilities.""" +"""Topic-domain primitives for event filtering. + +This module owns two related concerns: +1) Topic namespace conversion helpers between canonical (tns1/tnsaxis) + and transport-facing MQTT/websocket format (onvif/axis). +2) The EventTopicFilter value object used by callers to describe a + wildcard subscription or a normalized set of explicit topics. +""" from __future__ import annotations @@ -9,7 +16,6 @@ from collections.abc import Sequence from .event import EventTopic - from .subscription_contracts import DesiredEventSubscription # --------------------------------------------------------------------------- # Topic namespace conversion @@ -34,22 +40,38 @@ def _convert_topic(topic: str, mapping: dict[str, str]) -> str: def to_canonical(topic: str) -> str: - """Convert topic namespaces to canonical (tns1/tnsaxis) representation.""" + """Convert topic namespaces to canonical (tns1/tnsaxis) representation. + + This is the internal normalization format used across filtering and + local allow-list comparisons. + """ return _convert_topic(topic, {"onvif": "tns1", "axis": "tnsaxis"}) def to_mqtt(topic: str) -> str: - """Convert topic namespaces to MQTT (onvif/axis) representation.""" + """Convert topic namespaces to MQTT (onvif/axis) representation. + + Used for outbound transport configuration where the device API expects + onvif/axis prefixes. + """ return _convert_topic(topic, {"tns1": "onvif", "tnsaxis": "axis"}) def to_topic_filter(topic: str) -> str: - """Convert canonical topic to topic-filter namespace representation.""" + """Convert canonical topic to transport topic-filter representation. + + Today this is equivalent to MQTT representation and is kept as a named + helper to make intent explicit at call sites. + """ return to_mqtt(topic) def detect_format(topic: str) -> SegmentFormat: - """Detect topic namespace format from segment prefixes.""" + """Detect topic namespace format from segment prefixes. + + Returns canonical, mqtt, or unknown. Unknown means no known namespace + prefixes were found in any topic segment. + """ segments = topic.split("/") if any( @@ -82,7 +104,8 @@ class EventTopicFilter: output properties. Wire-format construction (e.g. WebSocket dict shape) is the responsibility of the transport layer. - Do not construct directly — use the provided classmethods. + Do not construct directly; prefer for_all_events() and from_topics(). + This keeps wildcard and normalization behavior centralized. """ # None means wildcard (subscribe to all); never empty frozenset. @@ -108,7 +131,8 @@ def from_topics( Accepts topics in any format: canonical (tns1/tnsaxis), MQTT (onvif/axis), or EventTopic enum. All inputs are normalized to canonical form. - Raises ValueError if topics is empty — use for_all_events() for wildcard. + Duplicate logical topics are collapsed after normalization. + Raises ValueError if topics is empty; use for_all_events() for wildcard. """ if not topics: msg = "topics must not be empty; use EventTopicFilter.for_all_events() to subscribe to all events" @@ -148,14 +172,3 @@ def mqtt_topic_filters(self) -> list[str]: if self._topics is None: return [] return sorted(to_topic_filter(t) for t in self._topics) - - -def from_desired_subscriptions( - subscriptions: Sequence[DesiredEventSubscription], -) -> EventTopicFilter: - """Build an EventTopicFilter from legacy DesiredEventSubscription objects. - - Compatibility helper for callers that construct DesiredEventSubscription. - Prefer EventTopicFilter.from_topics() for new code. - """ - return EventTopicFilter.from_topics([s.topic for s in subscriptions]) diff --git a/axis/models/events/transport_capabilities.py b/axis/models/events/transport_capabilities.py index e85a0f3e..5b288439 100644 --- a/axis/models/events/transport_capabilities.py +++ b/axis/models/events/transport_capabilities.py @@ -1,15 +1,31 @@ -"""Transport capability metadata for event filter planning.""" +"""Transport capability metadata for event filter planning. + +This module defines the transport contract used by extension-facing planning +APIs: supported event transports and whether each transport can apply topic +filters upstream. +""" from __future__ import annotations from dataclasses import dataclass +import enum + -from .subscription_contracts import EventTransport +class EventTransport(enum.StrEnum): + """Event transport types used by capability matrix and public contracts.""" + + RTSP = "rtsp" + WEBSOCKET = "websocket" + MQTT = "mqtt" @dataclass(frozen=True) class TransportFilterCapability: - """Describes upstream filter capabilities for a transport.""" + """Describes upstream filter capabilities for a transport. + + supports_upstream_topic_filter indicates whether the transport API can + accept topic filters at source, reducing downstream event volume. + """ transport: EventTransport supports_upstream_topic_filter: bool @@ -20,16 +36,16 @@ class TransportFilterCapability: EventTransport.RTSP: TransportFilterCapability( transport=EventTransport.RTSP, supports_upstream_topic_filter=False, - notes="RTSP supports event stream on/off only", + notes="RTSP can only toggle event stream on/off; no topic-level filter endpoint.", ), EventTransport.WEBSOCKET: TransportFilterCapability( transport=EventTransport.WEBSOCKET, supports_upstream_topic_filter=True, - notes="WebSocket supports events:configure eventFilterList", + notes="WebSocket supports events:configure with eventFilterList topic filters.", ), EventTransport.MQTT: TransportFilterCapability( transport=EventTransport.MQTT, supports_upstream_topic_filter=True, - notes="MQTT event publication supports topicFilter list", + notes="MQTT publication setup supports a topicFilter list for upstream filtering.", ), } diff --git a/axis/stream_manager.py b/axis/stream_manager.py index c05a64c0..5d82e369 100644 --- a/axis/stream_manager.py +++ b/axis/stream_manager.py @@ -150,19 +150,6 @@ def set_event_subscription(self, request: EventTopicFilter) -> None: """Set the event topic filter for future WebSocket stream sessions.""" self._event_subscription = request - def set_event_filter_list(self, event_filter_list: list[dict[str, str]]) -> None: - """Set websocket event filter list (deprecated, use set_event_subscription).""" - topics = [ - entry["topicFilter"] - for entry in event_filter_list - if entry.get("topicFilter") and entry["topicFilter"] != "//." - ] - self._event_subscription = ( - EventTopicFilter.from_topics(topics) - if topics - else EventTopicFilter.for_all_events() - ) - def session_callback(self, signal: Signal) -> None: """Signalling from stream session. diff --git a/tests/test_event_extension_contracts.py b/tests/test_event_extension_contracts.py index bc6a65bb..a1bd2205 100644 --- a/tests/test_event_extension_contracts.py +++ b/tests/test_event_extension_contracts.py @@ -1,8 +1,8 @@ """Tests for extension contract capability matrix.""" -from axis.models.events.subscription_contracts import EventTransport from axis.models.events.transport_capabilities import ( TRANSPORT_FILTER_CAPABILITIES, + EventTransport, ) diff --git a/tests/test_event_instances.py b/tests/test_event_instances.py index 58e86fc4..f4f9de3b 100644 --- a/tests/test_event_instances.py +++ b/tests/test_event_instances.py @@ -15,7 +15,7 @@ EventInstanceSource, get_events, ) -from axis.models.events.subscription_contracts import DesiredEventSubscription +from axis.models.events.topic_filter import EventTopicFilter, to_topic_filter from .event_fixtures import ( EVENT_INSTANCE_PIR_SENSOR, @@ -377,7 +377,7 @@ async def test_supported_event_descriptors_and_filter_payloads( ) payloads = event_instances.build_transport_filter_payloads( - subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")] + event_filter=EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]) ) assert payloads == { "canonical_topics": ["tns1:Device/tnsaxis:Sensor/PIR"], @@ -386,6 +386,27 @@ async def test_supported_event_descriptors_and_filter_payloads( } +async def test_transport_filter_payloads_default_to_supported_topics( + http_route_mock, event_instances +) -> None: + """No explicit filter should build payloads from supported topics.""" + http_route_mock.post("/vapix/services").respond( + text=EVENT_INSTANCES, + headers={"Content-Type": "application/soap+xml; charset=utf-8"}, + ) + + await event_instances.update() + + expected_topics = list(event_instances.get_supported_topics()) + payloads = event_instances.build_transport_filter_payloads() + + assert payloads["canonical_topics"] == expected_topics + assert payloads["mqtt_topics"] == [ + to_topic_filter(topic) for topic in expected_topics + ] + assert payloads["websocket_topic_filters"] == payloads["mqtt_topics"] + + async def test_expected_events_protocol_normalization(http_route_mock, event_instances): """Expected-event discovery is protocol-agnostic and deterministic.""" http_route_mock.post("/vapix/services").respond( diff --git a/tests/test_event_manager_extensions.py b/tests/test_event_manager_extensions.py index 216d022e..11221a72 100644 --- a/tests/test_event_manager_extensions.py +++ b/tests/test_event_manager_extensions.py @@ -39,3 +39,11 @@ def test_allowed_topics_filter_is_opt_in() -> None: "tns1:Device/tnsaxis:Sensor/PIR", "tns1:Device/tnsaxis:Light/Status", ] + + manager.set_allowed_topics(None) + manager.handler(light_event) + assert received == [ + "tns1:Device/tnsaxis:Sensor/PIR", + "tns1:Device/tnsaxis:Light/Status", + "tns1:Device/tnsaxis:Light/Status", + ] diff --git a/tests/test_event_topic_filter.py b/tests/test_event_topic_filter.py index d3828e95..85e6c391 100644 --- a/tests/test_event_topic_filter.py +++ b/tests/test_event_topic_filter.py @@ -6,8 +6,7 @@ import pytest from axis.models.events.event import EventTopic -from axis.models.events.subscription_contracts import DesiredEventSubscription -from axis.models.events.topic_filter import EventTopicFilter, from_desired_subscriptions +from axis.models.events.topic_filter import EventTopicFilter class TestForAllEvents: @@ -123,24 +122,3 @@ def test_hashable(self) -> None: """from_topics result is hashable.""" f = EventTopicFilter.from_topics(["tns1:Device/tnsaxis:Sensor/PIR"]) assert isinstance(hash(f), int) - - -class TestFromDesiredSubscriptions: - """Tests for the from_desired_subscriptions() compat helper.""" - - def test_builds_from_desired_subscriptions(self) -> None: - """from_desired_subscriptions bridges DesiredEventSubscription objects.""" - subscriptions = [ - DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR"), - DesiredEventSubscription(topic="tns1:Device/tnsaxis:IO/Port"), - ] - f = from_desired_subscriptions(subscriptions) - assert f.canonical_topics == [ - "tns1:Device/tnsaxis:IO/Port", - "tns1:Device/tnsaxis:Sensor/PIR", - ] - - def test_empty_raises(self) -> None: - """Empty subscriptions list raises ValueError.""" - with pytest.raises(ValueError, match="topics must not be empty"): - from_desired_subscriptions([]) diff --git a/tests/test_stream_manager.py b/tests/test_stream_manager.py index 5ae56c82..1795a9bf 100644 --- a/tests/test_stream_manager.py +++ b/tests/test_stream_manager.py @@ -127,14 +127,16 @@ async def test_start_uses_websocket_when_supported( async def test_stream_manager_passes_event_filter_list_to_websocket( websocket_client, rtsp_client, stream_manager ): - """Deprecated set_event_filter_list bridge should forward filter to websocket.""" + """set_event_subscription should forward filter to websocket.""" websocket_client.return_value.start = AsyncMock() stream_manager.event = True stream_manager.device.config.websocket_enabled = True stream_manager.device.vapix.api_discovery._items[ ApiId.EVENT_STREAMING_OVER_WEBSOCKET ] = MagicMock() - stream_manager.set_event_filter_list([{"topicFilter": "onvif:Device//."}]) + stream_manager.set_event_subscription( + EventTopicFilter.from_topics(["onvif:Device//."]) + ) stream_manager.start() diff --git a/tests/test_vapix.py b/tests/test_vapix.py index ec92f199..135a04bc 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -26,11 +26,8 @@ EventInstanceData, EventInstanceSource, ) -from axis.models.events.subscription_contracts import ( - DesiredEventSubscription, - EventTransport, -) from axis.models.events.topic_filter import EventTopicFilter +from axis.models.events.transport_capabilities import EventTransport from axis.models.light_control import GetLightInformationRequest from axis.models.parameters.param_cgi import ParamRequest from axis.models.port_management import GetPortsRequest @@ -153,9 +150,7 @@ async def test_apply_event_transport_filters_requires_initialized_event_instance """Applying filters should require event-instance initialization.""" with pytest.raises(RuntimeError, match="Event instances are not initialized"): await vapix.apply_event_transport_filters( - subscriptions=[ - DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR") - ] + event_filter=EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]) ) @@ -181,15 +176,15 @@ async def test_apply_event_transport_filters_validates_supported_topics( vapix.event_instances.initialized = True payloads = await vapix.apply_event_transport_filters( - subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")] + event_filter=EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]) ) assert payloads is None with pytest.raises(ValueError, match="Requested unsupported topics"): await vapix.apply_event_transport_filters( - subscriptions=[ - DesiredEventSubscription(topic="onvif:Device/axis:Status/SystemReady") - ] + event_filter=EventTopicFilter.from_topics( + ["onvif:Device/axis:Status/SystemReady"] + ) ) @@ -237,7 +232,7 @@ def _capture_subscription(req: object) -> None: ) await vapix.apply_event_transport_filters( - subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")] + event_filter=EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]) ) assert len(captured_subscriptions) == 1 @@ -288,7 +283,7 @@ def _capture_subscription2(req: object) -> None: ) await vapix.apply_event_transport_filters( - subscriptions=[DesiredEventSubscription(topic="onvif:Device/axis:Sensor/PIR")], + event_filter=EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]), ) assert len(captured_subscriptions) == 1 From 6de4cab05a4d84d21477de81f875d65ae26de77a Mon Sep 17 00:00:00 2001 From: Kane610 Date: Mon, 18 May 2026 22:20:30 +0200 Subject: [PATCH 17/18] refactor: move event transport methods out of vapix - add EventTransportManager in interfaces/events/transport_manager.py - remove event transport capability/descriptor/apply methods from Vapix - expose event_transport manager on AxisDevice - update tests to call new ownership path - add coverage test for default filter resolution branch --- axis/device.py | 7 ++ axis/interfaces/events/transport_manager.py | 115 ++++++++++++++++++++ axis/interfaces/vapix.py | 83 -------------- tests/test_vapix.py | 68 ++++++++++-- 4 files changed, 182 insertions(+), 91 deletions(-) create mode 100644 axis/interfaces/events/transport_manager.py diff --git a/axis/device.py b/axis/device.py index 6da0ee25..68bfc547 100644 --- a/axis/device.py +++ b/axis/device.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from .interfaces.events.event_manager import EventManager +from .interfaces.events.transport_manager import EventTransportManager from .interfaces.vapix import Vapix from .stream_manager import StreamManager @@ -19,6 +20,12 @@ def __init__(self, configuration: Configuration) -> None: self.vapix = Vapix(self) self.stream = StreamManager(self) self.event = EventManager() + self.event_transport = EventTransportManager( + event_instances=self.vapix.event_instances, + stream=self.stream, + event=self.event, + mqtt=self.vapix.mqtt, + ) def enable_events(self) -> None: """Enable events for stream.""" diff --git a/axis/interfaces/events/transport_manager.py b/axis/interfaces/events/transport_manager.py new file mode 100644 index 00000000..6d4a144e --- /dev/null +++ b/axis/interfaces/events/transport_manager.py @@ -0,0 +1,115 @@ +"""Event transport orchestration manager.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from ...models.events.topic_filter import EventTopicFilter +from ...models.events.transport_capabilities import ( + TRANSPORT_FILTER_CAPABILITIES, + EventTransport, + TransportFilterCapability, +) + +if TYPE_CHECKING: + from ...stream_manager import StreamManager + from ..mqtt import MqttClientHandler + from .event_instances import EventInstanceHandler + from .event_manager import EventManager + +LOGGER = logging.getLogger(__name__) + + +class EventTransportManager: + """Owns event transport filter orchestration across subsystems.""" + + def __init__( + self, + event_instances: EventInstanceHandler, + stream: StreamManager, + event: EventManager, + mqtt: MqttClientHandler, + ) -> None: + """Store subsystem dependencies used for transport filter application.""" + self._event_instances = event_instances + self._stream = stream + self._event = event + self._mqtt = mqtt + + def get_event_transport_capabilities( + self, + ) -> dict[EventTransport, TransportFilterCapability]: + """Return extension capability matrix for event transports.""" + return dict(TRANSPORT_FILTER_CAPABILITIES) + + def get_supported_event_descriptors( + self, + include_internal_topics: bool = False, + ) -> dict[str, dict[str, Any]]: + """Return normalized supported-event descriptors from event instances. + + This method is extension-oriented and has no side effects. + """ + return self._event_instances.get_supported_event_descriptors( + include_internal_topics=include_internal_topics + ) + + async def apply_event_transport_filters( + self, + event_filter: EventTopicFilter | None = None, + include_internal_topics: bool = False, + ) -> None: + """Apply validated transport filters to websocket, MQTT, and local fallback. + + This method performs no implicit event-instance initialization. Callers must + invoke ``initialize_event_instances`` before applying filters. + """ + if not self._event_instances.initialized: + msg = ( + "Event instances are not initialized. " + "Call initialize_event_instances() first." + ) + raise RuntimeError(msg) + + if event_filter is None: + supported = self._event_instances.get_supported_topics( + include_internal_topics=include_internal_topics + ) + event_filter = ( + EventTopicFilter.from_topics(list(supported)) + if supported + else EventTopicFilter.for_all_events() + ) + + if not event_filter.is_wildcard: + supported_topics = set( + self._event_instances.get_supported_topics( + include_internal_topics=include_internal_topics + ) + ) + unknown_topics = sorted( + set(event_filter.canonical_topics) - supported_topics + ) + if unknown_topics: + message = f"Requested unsupported topics: {', '.join(unknown_topics)}" + raise ValueError(message) + + self._stream.set_event_subscription(event_filter) + + if self._mqtt.supported: + await self._mqtt.configure_event_publication( + event_filter.mqtt_topic_filters + ) + + self._event.set_allowed_topics(event_filter.canonical_topics or None) + + LOGGER.debug( + "Applied event transport filters: websocket=%s mqtt=%s local=%s topics=%s", + True, + self._mqtt.supported, + True, + len(event_filter.canonical_topics) + if not event_filter.is_wildcard + else "all", + ) diff --git a/axis/interfaces/vapix.py b/axis/interfaces/vapix.py index a636f8f4..9b0cb971 100644 --- a/axis/interfaces/vapix.py +++ b/axis/interfaces/vapix.py @@ -10,10 +10,6 @@ from ..errors import RequestError, raise_error from ..models.configuration import AuthScheme -from ..models.events.topic_filter import EventTopicFilter -from ..models.events.transport_capabilities import ( - TRANSPORT_FILTER_CAPABILITIES, -) from ..models.pwdgrp_cgi import SecondaryGroup from .aiohttp_digest import AiohttpDigestAuth from .api_discovery import ApiDiscoveryHandler @@ -41,10 +37,6 @@ if TYPE_CHECKING: from ..device import AxisDevice from ..models.api import ApiRequest - from ..models.events.transport_capabilities import ( - EventTransport, - TransportFilterCapability, - ) from ..models.stream_profile import StreamProfile LOGGER = logging.getLogger(__name__) @@ -247,81 +239,6 @@ async def initialize_event_instances(self) -> None: """Initialize event instances of what events are supported by the device.""" await self.event_instances.update() - def get_event_transport_capabilities( - self, - ) -> dict[EventTransport, TransportFilterCapability]: - """Return extension capability matrix for event transports.""" - return dict(TRANSPORT_FILTER_CAPABILITIES) - - def get_supported_event_descriptors( - self, - include_internal_topics: bool = False, - ) -> dict[str, dict[str, Any]]: - """Return normalized supported-event descriptors from event instances. - - This method is extension-oriented and has no side effects. - """ - return self.event_instances.get_supported_event_descriptors( - include_internal_topics=include_internal_topics - ) - - async def apply_event_transport_filters( - self, - event_filter: EventTopicFilter | None = None, - include_internal_topics: bool = False, - ) -> None: - """Apply validated transport filters to websocket, MQTT, and local fallback. - - This method performs no implicit event-instance initialization. Callers must - invoke ``initialize_event_instances`` before applying filters. - """ - if not self.event_instances.initialized: - msg = ( - "Event instances are not initialized. " - "Call initialize_event_instances() first." - ) - raise RuntimeError(msg) - - if event_filter is None: - supported = self.event_instances.get_supported_topics( - include_internal_topics=include_internal_topics - ) - event_filter = ( - EventTopicFilter.from_topics(list(supported)) - if supported - else EventTopicFilter.for_all_events() - ) - - if not event_filter.is_wildcard: - supported_topics = set( - self.event_instances.get_supported_topics( - include_internal_topics=include_internal_topics - ) - ) - unknown_topics = sorted( - set(event_filter.canonical_topics) - supported_topics - ) - if unknown_topics: - message = f"Requested unsupported topics: {', '.join(unknown_topics)}" - raise ValueError(message) - - self.device.stream.set_event_subscription(event_filter) - - if self.mqtt.supported: - await self.mqtt.configure_event_publication(event_filter.mqtt_topic_filters) - - self.device.event.set_allowed_topics(event_filter.canonical_topics or None) - - LOGGER.debug( - "Applied event transport filters: websocket=%s mqtt=%s local=%s topics=%s", - True, - self.mqtt.supported, - True, - len(event_filter.canonical_topics) - if not event_filter.is_wildcard - else "all", - ) - async def initialize_users(self) -> None: """Load device user data and initialize user management.""" await self.users.update() diff --git a/tests/test_vapix.py b/tests/test_vapix.py index 135a04bc..e5e7639e 100644 --- a/tests/test_vapix.py +++ b/tests/test_vapix.py @@ -67,6 +67,7 @@ if TYPE_CHECKING: from axis.device import AxisDevice + from axis.interfaces.events.transport_manager import EventTransportManager from axis.interfaces.vapix import Vapix @@ -94,6 +95,12 @@ def vapix(axis_device: AxisDevice) -> Vapix: return axis_device.vapix +@pytest.fixture +def event_transport(axis_device: AxisDevice) -> EventTransportManager: + """Return the event transport manager object.""" + return axis_device.event_transport + + @pytest.fixture def vapix_companion_device(axis_companion_device: AxisDevice) -> Vapix: """Return the vapix object.""" @@ -134,28 +141,30 @@ def test_vapix_not_initialized(vapix: Vapix) -> None: assert not vapix.users.supported -def test_vapix_extension_helpers(vapix: Vapix) -> None: +def test_vapix_extension_helpers(event_transport: EventTransportManager) -> None: """Extension helper APIs should be available without side effects.""" - capabilities = vapix.get_event_transport_capabilities() + capabilities = event_transport.get_event_transport_capabilities() assert EventTransport.RTSP in capabilities assert EventTransport.WEBSOCKET in capabilities assert EventTransport.MQTT in capabilities - assert vapix.get_supported_event_descriptors() == {} + assert event_transport.get_supported_event_descriptors() == {} async def test_apply_event_transport_filters_requires_initialized_event_instances( vapix: Vapix, + event_transport: EventTransportManager, ) -> None: """Applying filters should require event-instance initialization.""" with pytest.raises(RuntimeError, match="Event instances are not initialized"): - await vapix.apply_event_transport_filters( + await event_transport.apply_event_transport_filters( event_filter=EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]) ) async def test_apply_event_transport_filters_validates_supported_topics( vapix: Vapix, + event_transport: EventTransportManager, ) -> None: """Apply should reject requested topics not present in event instances.""" topic = "tns1:Device/tnsaxis:Sensor/PIR" @@ -175,13 +184,13 @@ async def test_apply_event_transport_filters_validates_supported_topics( } vapix.event_instances.initialized = True - payloads = await vapix.apply_event_transport_filters( + payloads = await event_transport.apply_event_transport_filters( event_filter=EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]) ) assert payloads is None with pytest.raises(ValueError, match="Requested unsupported topics"): - await vapix.apply_event_transport_filters( + await event_transport.apply_event_transport_filters( event_filter=EventTopicFilter.from_topics( ["onvif:Device/axis:Status/SystemReady"] ) @@ -190,6 +199,7 @@ async def test_apply_event_transport_filters_validates_supported_topics( async def test_apply_event_transport_filters_calls_transport_hooks( vapix: Vapix, + event_transport: EventTransportManager, ) -> None: """Apply should use websocket, mqtt, and local fallback hooks when enabled.""" topic = "tns1:Device/tnsaxis:Sensor/PIR" @@ -231,7 +241,7 @@ def _capture_subscription(req: object) -> None: vapix.device.event, "_captured_allowed_topics", topics ) - await vapix.apply_event_transport_filters( + await event_transport.apply_event_transport_filters( event_filter=EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]) ) @@ -243,8 +253,50 @@ def _capture_subscription(req: object) -> None: assert vapix.device.event._captured_allowed_topics == [topic] +async def test_apply_event_transport_filters_builds_default_filter_from_supported( + vapix: Vapix, + event_transport: EventTransportManager, +) -> None: + """Apply should derive filter from supported topics when no filter is provided.""" + topic = "tns1:Device/tnsaxis:Sensor/PIR" + vapix.event_instances._items = { + topic: EventInstance( + id=topic, + topic=topic, + topic_filter="onvif:Device/axis:Sensor/PIR", + is_available=True, + is_application_data=False, + name="pir", + stateful=True, + stateless=False, + source=EventInstanceSource(), + data=EventInstanceData(), + ) + } + vapix.event_instances.initialized = True + + captured_subscriptions: list[object] = [] + + def _capture_subscription(req: object) -> None: + captured_subscriptions.append(req) + + vapix.device.stream.set_event_subscription = _capture_subscription # type: ignore[method-assign] + vapix.device.event.set_allowed_topics = lambda topics: setattr( + vapix.device.event, "_captured_allowed_topics", topics + ) + + await event_transport.apply_event_transport_filters() + + assert len(captured_subscriptions) == 1 + event_filter = captured_subscriptions[0] + assert isinstance(event_filter, EventTopicFilter) + assert event_filter.canonical_topics == [topic] + assert vapix.device.event._captured_allowed_topics == [topic] + + async def test_apply_event_transport_filters_skips_unsupported_mqtt( vapix: Vapix, + event_transport: EventTransportManager, ) -> None: """Apply should not call MQTT hook when MQTT API is unsupported.""" topic = "tns1:Device/tnsaxis:Sensor/PIR" @@ -282,7 +334,7 @@ def _capture_subscription2(req: object) -> None: vapix.device.event, "_captured_allowed_topics", topics ) - await vapix.apply_event_transport_filters( + await event_transport.apply_event_transport_filters( event_filter=EventTopicFilter.from_topics(["onvif:Device/axis:Sensor/PIR"]), ) From c8c655cc993d2a4cf0bf5ae2b05aca9bd334e297 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Tue, 19 May 2026 20:50:20 +0200 Subject: [PATCH 18/18] refactor: rename mqtt_topic_filters to transport_topic_filters --- axis/interfaces/events/transport_manager.py | 2 +- axis/models/events/topic_filter.py | 2 +- axis/stream_manager.py | 4 ++-- tests/test_event_topic_filter.py | 16 ++++++++-------- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/axis/interfaces/events/transport_manager.py b/axis/interfaces/events/transport_manager.py index 6d4a144e..2ed3ddce 100644 --- a/axis/interfaces/events/transport_manager.py +++ b/axis/interfaces/events/transport_manager.py @@ -99,7 +99,7 @@ async def apply_event_transport_filters( if self._mqtt.supported: await self._mqtt.configure_event_publication( - event_filter.mqtt_topic_filters + event_filter.transport_topic_filters ) self._event.set_allowed_topics(event_filter.canonical_topics or None) diff --git a/axis/models/events/topic_filter.py b/axis/models/events/topic_filter.py index 3fe16faa..bf251664 100644 --- a/axis/models/events/topic_filter.py +++ b/axis/models/events/topic_filter.py @@ -162,7 +162,7 @@ def canonical_topics(self) -> list[str]: return sorted(self._topics) @property - def mqtt_topic_filters(self) -> list[str]: + def transport_topic_filters(self) -> list[str]: """Sorted MQTT (onvif/axis) topic filter strings. Consumed by MqttClientHandler.configure_event_publication(). diff --git a/axis/stream_manager.py b/axis/stream_manager.py index 5d82e369..b32fed93 100644 --- a/axis/stream_manager.py +++ b/axis/stream_manager.py @@ -129,12 +129,12 @@ def _is_stream_stopped(self) -> bool: def _build_stream(self) -> StreamTransport: """Build transport based on device capabilities and manager settings.""" if self.use_websocket: - mqtt_filters = self._event_subscription.mqtt_topic_filters + topic_filters = self._event_subscription.transport_topic_filters return WebSocketClient( self.device, self.websocket_url, self.session_callback, - event_filter_list=[{"topicFilter": t} for t in mqtt_filters] + event_filter_list=[{"topicFilter": t} for t in topic_filters] or [{"topicFilter": "//."}], ) diff --git a/tests/test_event_topic_filter.py b/tests/test_event_topic_filter.py index 85e6c391..4a8ebafd 100644 --- a/tests/test_event_topic_filter.py +++ b/tests/test_event_topic_filter.py @@ -22,10 +22,10 @@ def test_canonical_topics_empty(self) -> None: f = EventTopicFilter.for_all_events() assert f.canonical_topics == [] - def test_mqtt_topic_filters_empty(self) -> None: - """Wildcard returns empty mqtt_topic_filters list.""" + def test_transport_topic_filters_empty(self) -> None: + """Wildcard returns empty transport_topic_filters list.""" f = EventTopicFilter.for_all_events() - assert f.mqtt_topic_filters == [] + assert f.transport_topic_filters == [] def test_equality(self) -> None: """Two wildcard instances are equal.""" @@ -85,7 +85,7 @@ def test_deduplication(self) -> None: assert f.canonical_topics == ["tns1:Device/tnsaxis:Sensor/PIR"] def test_sorted_output(self) -> None: - """canonical_topics and mqtt_topic_filters return sorted lists.""" + """canonical_topics and transport_topic_filters return sorted lists.""" f = EventTopicFilter.from_topics( [ "tns1:Device/tnsaxis:Sensor/PIR", @@ -96,15 +96,15 @@ def test_sorted_output(self) -> None: "tns1:Device/tnsaxis:IO/Port", "tns1:Device/tnsaxis:Sensor/PIR", ] - assert f.mqtt_topic_filters == [ + assert f.transport_topic_filters == [ "onvif:Device/axis:IO/Port", "onvif:Device/axis:Sensor/PIR", ] - def test_mqtt_topic_filters_conversion(self) -> None: - """mqtt_topic_filters returns MQTT-format strings.""" + def test_transport_topic_filters_conversion(self) -> None: + """transport_topic_filters returns MQTT-format strings.""" f = EventTopicFilter.from_topics(["tns1:Device/tnsaxis:Sensor/PIR"]) - assert f.mqtt_topic_filters == ["onvif:Device/axis:Sensor/PIR"] + assert f.transport_topic_filters == ["onvif:Device/axis:Sensor/PIR"] def test_immutable(self) -> None: """EventTopicFilter is immutable (frozen dataclass)."""