From d31c697ed25cd0aacb92a00d2d75d5a58732f561 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 1 Apr 2024 16:21:27 +0200 Subject: [PATCH 1/4] Remove EventGroup as that was only metadata for the axis integration which is no longer needed --- axis/models/event.py | 33 --------------------------------- tests/test_event.py | 20 +------------------- tests/test_event_stream.py | 19 +------------------ 3 files changed, 2 insertions(+), 70 deletions(-) diff --git a/axis/models/event.py b/axis/models/event.py index 61e3d139..9ed32d50 100644 --- a/axis/models/event.py +++ b/axis/models/event.py @@ -10,18 +10,6 @@ LOGGER = logging.getLogger(__name__) -class EventGroup(enum.StrEnum): - """Logical grouping of events.""" - - INPUT = "input" - LIGHT = "light" - MOTION = "motion" - OUTPUT = "output" - PTZ = "ptz" - SOUND = "sound" - NONE = "none" - - class EventOperation(enum.StrEnum): """Possible operations of an event.""" @@ -67,25 +55,6 @@ def _missing_(cls, value: object) -> "EventTopic": return EventTopic.UNKNOWN -TOPIC_TO_GROUP = { - EventTopic.DAY_NIGHT_VISION: EventGroup.LIGHT, - EventTopic.FENCE_GUARD: EventGroup.MOTION, - EventTopic.LIGHT_STATUS: EventGroup.LIGHT, - EventTopic.LOITERING_GUARD: EventGroup.MOTION, - EventTopic.MOTION_DETECTION: EventGroup.MOTION, - EventTopic.MOTION_DETECTION_3: EventGroup.MOTION, - EventTopic.MOTION_DETECTION_4: EventGroup.MOTION, - EventTopic.MOTION_GUARD: EventGroup.MOTION, - EventTopic.OBJECT_ANALYTICS: EventGroup.MOTION, - EventTopic.PIR: EventGroup.MOTION, - EventTopic.PORT_INPUT: EventGroup.INPUT, - EventTopic.PORT_SUPERVISED_INPUT: EventGroup.INPUT, - EventTopic.PTZ_IS_MOVING: EventGroup.PTZ, - EventTopic.PTZ_ON_PRESET: EventGroup.PTZ, - EventTopic.RELAY: EventGroup.OUTPUT, - EventTopic.SOUND_TRIGGER_LEVEL: EventGroup.SOUND, -} - TOPIC_TO_STATE = { EventTopic.LIGHT_STATUS: "ON", EventTopic.RELAY: "active", @@ -137,7 +106,6 @@ class Event: """Event data from Axis device.""" data: dict[str, Any] - group: EventGroup id: str is_tripped: bool operation: EventOperation @@ -173,7 +141,6 @@ def _decode_from_dict(cls, data: dict[str, Any]) -> Self: return cls( data=data, - group=TOPIC_TO_GROUP.get(topic_base, EventGroup.NONE), id=source_idx, is_tripped=value == TOPIC_TO_STATE.get(topic_base, "1"), operation=operation, diff --git a/tests/test_event.py b/tests/test_event.py index 06f2c690..b1cfea83 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -7,7 +7,7 @@ import pytest -from axis.models.event import Event, EventGroup, EventOperation +from axis.models.event import Event, EventOperation from .event_fixtures import ( AUDIO_INIT, @@ -43,7 +43,6 @@ "topic": "", "source": "", "source_idx": "", - "group": EventGroup.NONE, "type": "", "state": "", "tripped": False, @@ -55,7 +54,6 @@ "topic": "tns1:AudioSource/tnsaxis:TriggerLevel", "source": "channel", "source_idx": "1", - "group": EventGroup.SOUND, "type": "Sound", "state": "0", "tripped": False, @@ -67,7 +65,6 @@ "topic": "tns1:VideoSource/tnsaxis:DayNightVision", "source": "VideoSourceConfigurationToken", "source_idx": "1", - "group": EventGroup.LIGHT, "type": "DayNight", "state": "1", "tripped": True, @@ -79,7 +76,6 @@ "topic": "tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1Profile1", "source": "", "source_idx": "Camera1Profile1", - "group": EventGroup.MOTION, "type": "Fence Guard", "state": "0", "tripped": False, @@ -91,7 +87,6 @@ "topic": "tns1:Device/tnsaxis:Light/Status", "source": "id", "source_idx": "0", - "group": EventGroup.LIGHT, "type": "Light", "state": "OFF", "tripped": False, @@ -103,7 +98,6 @@ "topic": "tnsaxis:CameraApplicationPlatform/LoiteringGuard/Camera1Profile1", "source": "", "source_idx": "Camera1Profile1", - "group": EventGroup.MOTION, "type": "Loitering Guard", "state": "0", "tripped": False, @@ -115,7 +109,6 @@ "topic": "tnsaxis:CameraApplicationPlatform/MotionGuard/Camera1ProfileANY", "source": "", "source_idx": "Camera1ProfileANY", - "group": EventGroup.MOTION, "type": "Motion Guard", "state": "0", "tripped": False, @@ -127,7 +120,6 @@ "topic": "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", "source": "", "source_idx": "Device1Scenario1", - "group": EventGroup.MOTION, "type": "Object Analytics", "state": "0", "tripped": False, @@ -139,7 +131,6 @@ "topic": "tns1:Device/tnsaxis:Sensor/PIR", "source": "sensor", "source_idx": "0", - "group": EventGroup.MOTION, "type": "PIR", "state": "0", "tripped": False, @@ -151,7 +142,6 @@ "topic": "tns1:Device/tnsaxis:IO/Port", "source": "port", "source_idx": "1", - "group": EventGroup.INPUT, "type": "Input", "state": "0", "tripped": False, @@ -163,7 +153,6 @@ "topic": "tns1:Device/tnsaxis:IO/Port", "source": "port", "source_idx": "", - "group": EventGroup.INPUT, "type": "Input", "state": "0", "tripped": False, @@ -175,7 +164,6 @@ "topic": "tns1:PTZController/tnsaxis:Move/Channel_1", "source": "PTZConfigurationToken", "source_idx": "1", - "group": EventGroup.PTZ, "type": "is_moving", "state": "0", "tripped": False, @@ -187,7 +175,6 @@ "topic": "tns1:PTZController/tnsaxis:PTZPresets/Channel_1", "source": "PresetToken", "source_idx": "1", - "group": EventGroup.PTZ, "type": "on_preset", "state": "1", "tripped": True, @@ -199,7 +186,6 @@ "topic": "tns1:Device/Trigger/Relay", "source": "RelayToken", "source_idx": "3", - "group": EventGroup.OUTPUT, "type": "Relay", "state": "inactive", "tripped": False, @@ -211,7 +197,6 @@ "topic": "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1", "source": "areaid", "source_idx": "0", - "group": EventGroup.MOTION, "type": "VMD3", "state": "0", "tripped": False, @@ -223,7 +208,6 @@ "topic": "tnsaxis:CameraApplicationPlatform/VMD/Camera1ProfileANY", "source": "", "source_idx": "Camera1ProfileANY", - "group": EventGroup.MOTION, "type": "VMD4", "state": "0", "tripped": False, @@ -236,7 +220,6 @@ "topic": "tns1:VideoSource/GlobalSceneChange/ImagingService", "source": "Source", "source_idx": "0", - "group": EventGroup.NONE, "type": "VMD4", "state": "0", "tripped": False, @@ -251,7 +234,6 @@ def test_create_event(input: bytes, expected: tuple) -> None: assert event.topic == expected["topic"] assert event.source == expected["source"] assert event.id == expected["source_idx"] - assert event.group == expected["group"] assert event.state == expected["state"] assert event.is_tripped is expected["tripped"] diff --git a/tests/test_event_stream.py b/tests/test_event_stream.py index 3e5f3a43..6df39d75 100644 --- a/tests/test_event_stream.py +++ b/tests/test_event_stream.py @@ -9,7 +9,7 @@ from axis.device import AxisDevice from axis.interfaces.event_manager import EventManager -from axis.models.event import Event, EventGroup, EventOperation, EventTopic +from axis.models.event import Event, EventOperation, EventTopic from .event_fixtures import ( AUDIO_INIT, @@ -66,7 +66,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tns1:AudioSource/tnsaxis:TriggerLevel", "source": "channel", "source_idx": "1", - "group": EventGroup.SOUND, "type": "Sound", "state": "0", "tripped": False, @@ -78,7 +77,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tns1:VideoSource/tnsaxis:DayNightVision", "source": "VideoSourceConfigurationToken", "source_idx": "1", - "group": EventGroup.LIGHT, "type": "DayNight", "state": "1", "tripped": True, @@ -90,7 +88,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1Profile1", "source": "", "source_idx": "Camera1Profile1", - "group": EventGroup.MOTION, "type": "Fence Guard", "state": "0", "tripped": False, @@ -102,7 +99,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tns1:Device/tnsaxis:Light/Status", "source": "id", "source_idx": "0", - "group": EventGroup.LIGHT, "type": "Light", "state": "OFF", "tripped": False, @@ -114,7 +110,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tnsaxis:CameraApplicationPlatform/LoiteringGuard/Camera1Profile1", "source": "", "source_idx": "Camera1Profile1", - "group": EventGroup.MOTION, "type": "Loitering Guard", "state": "0", "tripped": False, @@ -126,7 +121,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tnsaxis:CameraApplicationPlatform/MotionGuard/Camera1ProfileANY", "source": "", "source_idx": "Camera1ProfileANY", - "group": EventGroup.MOTION, "type": "Motion Guard", "state": "0", "tripped": False, @@ -138,7 +132,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", "source": "", "source_idx": "Device1Scenario1", - "group": EventGroup.MOTION, "type": "Object Analytics", "state": "0", "tripped": False, @@ -150,7 +143,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tns1:Device/tnsaxis:Sensor/PIR", "source": "sensor", "source_idx": "0", - "group": EventGroup.MOTION, "type": "PIR", "state": "0", "tripped": False, @@ -162,7 +154,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tns1:Device/tnsaxis:IO/Port", "source": "port", "source_idx": "1", - "group": EventGroup.INPUT, "type": "Input", "state": "0", "tripped": False, @@ -174,7 +165,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tns1:Device/tnsaxis:IO/Port", "source": "port", "source_idx": "", - "group": EventGroup.INPUT, "type": "Input", "state": "0", "tripped": False, @@ -186,7 +176,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tns1:PTZController/tnsaxis:Move/Channel_1", "source": "PTZConfigurationToken", "source_idx": "1", - "group": EventGroup.PTZ, "type": "is_moving", "state": "0", "tripped": False, @@ -198,7 +187,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tns1:PTZController/tnsaxis:PTZPresets/Channel_1", "source": "PresetToken", "source_idx": "1", - "group": EventGroup.PTZ, "type": "on_preset", "state": "1", "tripped": True, @@ -210,7 +198,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tns1:Device/Trigger/Relay", "source": "RelayToken", "source_idx": "3", - "group": EventGroup.OUTPUT, "type": "Relay", "state": "inactive", "tripped": False, @@ -222,7 +209,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1", "source": "areaid", "source_idx": "0", - "group": EventGroup.MOTION, "type": "VMD3", "state": "0", "tripped": False, @@ -234,7 +220,6 @@ def subscriber(event_manager: EventManager) -> Mock: "topic": "tnsaxis:CameraApplicationPlatform/VMD/Camera1ProfileANY", "source": "", "source_idx": "Camera1ProfileANY", - "group": EventGroup.MOTION, "type": "VMD4", "state": "0", "tripped": False, @@ -253,7 +238,6 @@ def test_create_event( assert event.topic == expected["topic"] assert event.source == expected["source"] assert event.id == expected["source_idx"] - assert event.group == expected["group"] assert event.state == expected["state"] assert event.is_tripped is expected["tripped"] @@ -323,7 +307,6 @@ def test_ptz_move(event_manager: EventManager, subscriber: Mock) -> None: assert event.topic == "tns1:PTZController/tnsaxis:Move/Channel_1" assert event.source == "PTZConfigurationToken" assert event.id == "1" - assert event.group == EventGroup.PTZ assert event.state == "0" event_manager.handler(PTZ_MOVE_START) From 5c7c368fc9c32f8d4787829b9a4f0a852b498366 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 1 Apr 2024 16:23:04 +0200 Subject: [PATCH 2/4] Move data to the bottom as its only the raw data --- axis/models/event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/axis/models/event.py b/axis/models/event.py index 9ed32d50..d7719a38 100644 --- a/axis/models/event.py +++ b/axis/models/event.py @@ -105,7 +105,6 @@ def extract_name_value( class Event: """Event data from Axis device.""" - data: dict[str, Any] id: str is_tripped: bool operation: EventOperation @@ -113,6 +112,7 @@ class Event: state: str topic: str topic_base: EventTopic + data: dict[str, Any] @classmethod def decode(cls, data: bytes | dict[str, Any]) -> Self: @@ -140,7 +140,6 @@ def _decode_from_dict(cls, data: dict[str, Any]) -> Self: source_idx = "ANY" if source != "port" else "" return cls( - data=data, id=source_idx, is_tripped=value == TOPIC_TO_STATE.get(topic_base, "1"), operation=operation, @@ -148,6 +147,7 @@ def _decode_from_dict(cls, data: dict[str, Any]) -> Self: state=value, topic=topic, topic_base=topic_base, + data=data, ) @classmethod From df110ea8e3f3024f19e6746ee7e52942ba5d84b7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 3 Apr 2024 21:18:54 +0200 Subject: [PATCH 3/4] Remove xml attribute prefixes Rename topics to use the more modern topic structure of mqtt and websocket --- axis/interfaces/mqtt.py | 3 +-- axis/models/event.py | 52 +++++++++++++++++++------------------- tests/test_event.py | 46 ++++++++++++++++----------------- tests/test_event_stream.py | 48 +++++++++++++++++------------------ tests/test_mqtt.py | 2 +- 5 files changed, 75 insertions(+), 76 deletions(-) diff --git a/axis/interfaces/mqtt.py b/axis/interfaces/mqtt.py index 2a3fa834..fda10311 100644 --- a/axis/interfaces/mqtt.py +++ b/axis/interfaces/mqtt.py @@ -28,7 +28,6 @@ def mqtt_json_to_event(msg: bytes | str) -> dict[str, Any]: """Convert JSON message from MQTT to event format.""" message = orjson.loads(msg) - topic = message["topic"].replace("onvif", "tns1").replace("axis", "tnsaxis") source = source_idx = "" if message["message"]["source"]: @@ -39,7 +38,7 @@ def mqtt_json_to_event(msg: bytes | str) -> dict[str, Any]: data_type, data_value = next(iter(message["message"]["data"].items())) return { - "topic": topic, + "topic": message["topic"], "source": source, "source_idx": source_idx, "type": data_type, diff --git a/axis/models/event.py b/axis/models/event.py index d7719a38..e2fb5b6f 100644 --- a/axis/models/event.py +++ b/axis/models/event.py @@ -29,22 +29,22 @@ def _missing_(cls, value: object) -> "EventOperation": 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_3 = "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1" - 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" + DAY_NIGHT_VISION = "onvif:VideoSource/axis:DayNightVision" + FENCE_GUARD = "axis:CameraApplicationPlatform/FenceGuard" + LIGHT_STATUS = "onvif:Device/axis:Light/Status" + LOITERING_GUARD = "axis:CameraApplicationPlatform/LoiteringGuard" + MOTION_DETECTION = "onvif:VideoAnalytics/axis:MotionDetection" + MOTION_DETECTION_3 = "onvif:RuleEngine/axis:VMD3/vmd3_video_1" + MOTION_DETECTION_4 = "axis:CameraApplicationPlatform/VMD" + MOTION_GUARD = "axis:CameraApplicationPlatform/MotionGuard" + OBJECT_ANALYTICS = "axis:CameraApplicationPlatform/ObjectAnalytics" + PIR = "onvif:Device/axis:Sensor/PIR" + PORT_INPUT = "onvif:Device/axis:IO/Port" + PORT_SUPERVISED_INPUT = "onvif:Device/axis:IO/SupervisedPort" + PTZ_IS_MOVING = "onvif:PTZController/axis:Move" + PTZ_ON_PRESET = "onvif:PTZController/axis:PTZPresets" + RELAY = "onvif:Device/Trigger/Relay" + SOUND_TRIGGER_LEVEL = "onvif:AudioSource/axis:TriggerLevel" UNKNOWN = "unknown" @classmethod @@ -71,8 +71,8 @@ def _missing_(cls, value: object) -> "EventTopic": NOTIFICATION_MESSAGE = ("MetadataStream", "Event", "NotificationMessage") MESSAGE = (*NOTIFICATION_MESSAGE, "Message", "Message") TOPIC = (*NOTIFICATION_MESSAGE, "Topic", "#text") -TIMESTAMP = (*MESSAGE, "@UtcTime") -OPERATION = (*MESSAGE, "@PropertyOperation") +TIMESTAMP = (*MESSAGE, "UtcTime") +OPERATION = (*MESSAGE, "PropertyOperation") SOURCE = (*MESSAGE, "Source") DATA = (*MESSAGE, "Data") @@ -97,8 +97,7 @@ def extract_name_value( item = data.get("SimpleItem", {}) if isinstance(item, list): item = item[0] - return item.get("@Name", ""), item.get("@Value", "") - # return item.get("Name", ""), item.get("Value", "") + return item.get("Name", ""), item.get("Value", "") @dataclass @@ -137,7 +136,7 @@ def _decode_from_dict(cls, data: dict[str, Any]) -> Self: source_idx = _source_idx if source_idx == "-1": - source_idx = "ANY" if source != "port" else "" + source_idx = "ANY" return cls( id=source_idx, @@ -154,16 +153,17 @@ def _decode_from_dict(cls, data: dict[str, Any]) -> Self: def _decode_from_bytes(cls, data: bytes) -> Self: """Parse metadata xml.""" raw = xmltodict.parse( - data, - # attr_prefix="", - process_namespaces=True, - namespaces=XML_NAMESPACES, + data, attr_prefix="", process_namespaces=True, namespaces=XML_NAMESPACES ) if raw.get("MetadataStream") is None: return cls._decode_from_dict({}) - topic = traverse(raw, TOPIC) + topic = ( + str(traverse(raw, TOPIC)) + .replace("tns1", "onvif") + .replace("tnsaxis", "axis") + ) # timestamp = traverse(raw, TIMESTAMP) operation = traverse(raw, OPERATION) diff --git a/tests/test_event.py b/tests/test_event.py index b1cfea83..ab159ee7 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -51,7 +51,7 @@ ( AUDIO_INIT, { - "topic": "tns1:AudioSource/tnsaxis:TriggerLevel", + "topic": "onvif:AudioSource/axis:TriggerLevel", "source": "channel", "source_idx": "1", "type": "Sound", @@ -62,7 +62,7 @@ ( DAYNIGHT_INIT, { - "topic": "tns1:VideoSource/tnsaxis:DayNightVision", + "topic": "onvif:VideoSource/axis:DayNightVision", "source": "VideoSourceConfigurationToken", "source_idx": "1", "type": "DayNight", @@ -73,7 +73,7 @@ ( FENCE_GUARD_INIT, { - "topic": "tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1Profile1", + "topic": "axis:CameraApplicationPlatform/FenceGuard/Camera1Profile1", "source": "", "source_idx": "Camera1Profile1", "type": "Fence Guard", @@ -84,7 +84,7 @@ ( LIGHT_STATUS_INIT, { - "topic": "tns1:Device/tnsaxis:Light/Status", + "topic": "onvif:Device/axis:Light/Status", "source": "id", "source_idx": "0", "type": "Light", @@ -95,7 +95,7 @@ ( LOITERING_GUARD_INIT, { - "topic": "tnsaxis:CameraApplicationPlatform/LoiteringGuard/Camera1Profile1", + "topic": "axis:CameraApplicationPlatform/LoiteringGuard/Camera1Profile1", "source": "", "source_idx": "Camera1Profile1", "type": "Loitering Guard", @@ -106,7 +106,7 @@ ( MOTION_GUARD_INIT, { - "topic": "tnsaxis:CameraApplicationPlatform/MotionGuard/Camera1ProfileANY", + "topic": "axis:CameraApplicationPlatform/MotionGuard/Camera1ProfileANY", "source": "", "source_idx": "Camera1ProfileANY", "type": "Motion Guard", @@ -117,7 +117,7 @@ ( OBJECT_ANALYTICS_INIT, { - "topic": "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", + "topic": "axis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", "source": "", "source_idx": "Device1Scenario1", "type": "Object Analytics", @@ -128,7 +128,7 @@ ( PIR_INIT, { - "topic": "tns1:Device/tnsaxis:Sensor/PIR", + "topic": "onvif:Device/axis:Sensor/PIR", "source": "sensor", "source_idx": "0", "type": "PIR", @@ -139,7 +139,7 @@ ( PORT_0_INIT, { - "topic": "tns1:Device/tnsaxis:IO/Port", + "topic": "onvif:Device/axis:IO/Port", "source": "port", "source_idx": "1", "type": "Input", @@ -150,9 +150,9 @@ ( PORT_ANY_INIT, { - "topic": "tns1:Device/tnsaxis:IO/Port", + "topic": "onvif:Device/axis:IO/Port", "source": "port", - "source_idx": "", + "source_idx": "ANY", "type": "Input", "state": "0", "tripped": False, @@ -161,7 +161,7 @@ ( PTZ_MOVE_INIT, { - "topic": "tns1:PTZController/tnsaxis:Move/Channel_1", + "topic": "onvif:PTZController/axis:Move/Channel_1", "source": "PTZConfigurationToken", "source_idx": "1", "type": "is_moving", @@ -172,7 +172,7 @@ ( PTZ_PRESET_INIT_1, { - "topic": "tns1:PTZController/tnsaxis:PTZPresets/Channel_1", + "topic": "onvif:PTZController/axis:PTZPresets/Channel_1", "source": "PresetToken", "source_idx": "1", "type": "on_preset", @@ -183,7 +183,7 @@ ( RELAY_INIT, { - "topic": "tns1:Device/Trigger/Relay", + "topic": "onvif:Device/Trigger/Relay", "source": "RelayToken", "source_idx": "3", "type": "Relay", @@ -194,7 +194,7 @@ ( VMD3_INIT, { - "topic": "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1", + "topic": "onvif:RuleEngine/axis:VMD3/vmd3_video_1", "source": "areaid", "source_idx": "0", "type": "VMD3", @@ -205,7 +205,7 @@ ( VMD4_ANY_INIT, { - "topic": "tnsaxis:CameraApplicationPlatform/VMD/Camera1ProfileANY", + "topic": "axis:CameraApplicationPlatform/VMD/Camera1ProfileANY", "source": "", "source_idx": "Camera1ProfileANY", "type": "VMD4", @@ -217,7 +217,7 @@ ( GLOBAL_SCENE_CHANGE, { - "topic": "tns1:VideoSource/GlobalSceneChange/ImagingService", + "topic": "onvif:VideoSource/GlobalSceneChange/ImagingService", "source": "Source", "source_idx": "0", "type": "VMD4", @@ -249,7 +249,7 @@ def test_create_event(input: bytes, expected: tuple) -> None: PIR_INIT, { "operation": "Initialized", - "topic": "tns1:Device/tnsaxis:Sensor/PIR", + "topic": "onvif:Device/axis:Sensor/PIR", "source": "sensor", "source_idx": "0", "type": "state", @@ -260,7 +260,7 @@ def test_create_event(input: bytes, expected: tuple) -> None: PIR_CHANGE, { "operation": "Changed", - "topic": "tns1:Device/tnsaxis:Sensor/PIR", + "topic": "onvif:Device/axis:Sensor/PIR", "source": "sensor", "source_idx": "0", "type": "state", @@ -273,7 +273,7 @@ def test_create_event(input: bytes, expected: tuple) -> None: "operation": "Initialized", "source": "VideoSource", "source_idx": "0", - "topic": "tns1:RuleEngine/MotionRegionDetector/Motion", + "topic": "onvif:RuleEngine/MotionRegionDetector/Motion", "type": "State", "value": "0", }, @@ -284,7 +284,7 @@ def test_create_event(input: bytes, expected: tuple) -> None: "operation": "Initialized", "source": "disk_id", "source_idx": "NetworkShare", - "topic": "tnsaxis:Storage/Alert", + "topic": "axis:Storage/Alert", "type": "overall_health", "value": "-3", }, @@ -293,7 +293,7 @@ def test_create_event(input: bytes, expected: tuple) -> None: VMD4_ANY_INIT, { "operation": "Initialized", - "topic": "tnsaxis:CameraApplicationPlatform/VMD/Camera1ProfileANY", + "topic": "axis:CameraApplicationPlatform/VMD/Camera1ProfileANY", "source": "", "source_idx": "", "type": "active", @@ -304,7 +304,7 @@ def test_create_event(input: bytes, expected: tuple) -> None: VMD4_ANY_CHANGE, { "operation": "Changed", - "topic": "tnsaxis:CameraApplicationPlatform/VMD/Camera1ProfileANY", + "topic": "axis:CameraApplicationPlatform/VMD/Camera1ProfileANY", "source": "", "source_idx": "", "type": "active", diff --git a/tests/test_event_stream.py b/tests/test_event_stream.py index 6df39d75..74bc1d85 100644 --- a/tests/test_event_stream.py +++ b/tests/test_event_stream.py @@ -63,7 +63,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( AUDIO_INIT, { - "topic": "tns1:AudioSource/tnsaxis:TriggerLevel", + "topic": "onvif:AudioSource/axis:TriggerLevel", "source": "channel", "source_idx": "1", "type": "Sound", @@ -74,7 +74,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( DAYNIGHT_INIT, { - "topic": "tns1:VideoSource/tnsaxis:DayNightVision", + "topic": "onvif:VideoSource/axis:DayNightVision", "source": "VideoSourceConfigurationToken", "source_idx": "1", "type": "DayNight", @@ -85,7 +85,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( FENCE_GUARD_INIT, { - "topic": "tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1Profile1", + "topic": "axis:CameraApplicationPlatform/FenceGuard/Camera1Profile1", "source": "", "source_idx": "Camera1Profile1", "type": "Fence Guard", @@ -96,7 +96,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( LIGHT_STATUS_INIT, { - "topic": "tns1:Device/tnsaxis:Light/Status", + "topic": "onvif:Device/axis:Light/Status", "source": "id", "source_idx": "0", "type": "Light", @@ -107,7 +107,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( LOITERING_GUARD_INIT, { - "topic": "tnsaxis:CameraApplicationPlatform/LoiteringGuard/Camera1Profile1", + "topic": "axis:CameraApplicationPlatform/LoiteringGuard/Camera1Profile1", "source": "", "source_idx": "Camera1Profile1", "type": "Loitering Guard", @@ -118,7 +118,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( MOTION_GUARD_INIT, { - "topic": "tnsaxis:CameraApplicationPlatform/MotionGuard/Camera1ProfileANY", + "topic": "axis:CameraApplicationPlatform/MotionGuard/Camera1ProfileANY", "source": "", "source_idx": "Camera1ProfileANY", "type": "Motion Guard", @@ -129,7 +129,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( OBJECT_ANALYTICS_INIT, { - "topic": "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", + "topic": "axis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", "source": "", "source_idx": "Device1Scenario1", "type": "Object Analytics", @@ -140,7 +140,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( PIR_INIT, { - "topic": "tns1:Device/tnsaxis:Sensor/PIR", + "topic": "onvif:Device/axis:Sensor/PIR", "source": "sensor", "source_idx": "0", "type": "PIR", @@ -151,7 +151,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( PORT_0_INIT, { - "topic": "tns1:Device/tnsaxis:IO/Port", + "topic": "onvif:Device/axis:IO/Port", "source": "port", "source_idx": "1", "type": "Input", @@ -162,9 +162,9 @@ def subscriber(event_manager: EventManager) -> Mock: ( PORT_ANY_INIT, { - "topic": "tns1:Device/tnsaxis:IO/Port", + "topic": "onvif:Device/axis:IO/Port", "source": "port", - "source_idx": "", + "source_idx": "ANY", "type": "Input", "state": "0", "tripped": False, @@ -173,7 +173,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( PTZ_MOVE_INIT, { - "topic": "tns1:PTZController/tnsaxis:Move/Channel_1", + "topic": "onvif:PTZController/axis:Move/Channel_1", "source": "PTZConfigurationToken", "source_idx": "1", "type": "is_moving", @@ -184,7 +184,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( PTZ_PRESET_INIT_1, { - "topic": "tns1:PTZController/tnsaxis:PTZPresets/Channel_1", + "topic": "onvif:PTZController/axis:PTZPresets/Channel_1", "source": "PresetToken", "source_idx": "1", "type": "on_preset", @@ -195,7 +195,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( RELAY_INIT, { - "topic": "tns1:Device/Trigger/Relay", + "topic": "onvif:Device/Trigger/Relay", "source": "RelayToken", "source_idx": "3", "type": "Relay", @@ -206,7 +206,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( VMD3_INIT, { - "topic": "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1", + "topic": "onvif:RuleEngine/axis:VMD3/vmd3_video_1", "source": "areaid", "source_idx": "0", "type": "VMD3", @@ -217,7 +217,7 @@ def subscriber(event_manager: EventManager) -> Mock: ( VMD4_ANY_INIT, { - "topic": "tnsaxis:CameraApplicationPlatform/VMD/Camera1ProfileANY", + "topic": "axis:CameraApplicationPlatform/VMD/Camera1ProfileANY", "source": "", "source_idx": "Camera1ProfileANY", "type": "VMD4", @@ -263,7 +263,7 @@ def test_ptz_preset(event_manager: EventManager, subscriber: Mock) -> None: assert subscriber.call_count == 1 event: Event = subscriber.call_args[0][0] - assert event.topic == "tns1:PTZController/tnsaxis:PTZPresets/Channel_1" + assert event.topic == "onvif:PTZController/axis:PTZPresets/Channel_1" assert event.id == "1" assert event.state == "1" @@ -271,7 +271,7 @@ def test_ptz_preset(event_manager: EventManager, subscriber: Mock) -> None: assert subscriber.call_count == 2 event: Event = subscriber.call_args[0][0] - assert event.topic == "tns1:PTZController/tnsaxis:PTZPresets/Channel_1" + assert event.topic == "onvif:PTZController/axis:PTZPresets/Channel_1" assert event.id == "2" assert event.state == "0" @@ -279,7 +279,7 @@ def test_ptz_preset(event_manager: EventManager, subscriber: Mock) -> None: assert subscriber.call_count == 3 event: Event = subscriber.call_args[0][0] - assert event.topic == "tns1:PTZController/tnsaxis:PTZPresets/Channel_1" + assert event.topic == "onvif:PTZController/axis:PTZPresets/Channel_1" assert event.id == "3" assert event.state == "0" @@ -304,7 +304,7 @@ def test_ptz_move(event_manager: EventManager, subscriber: Mock) -> None: assert subscriber.call_count == 1 event: Event = subscriber.call_args[0][0] - assert event.topic == "tns1:PTZController/tnsaxis:Move/Channel_1" + assert event.topic == "onvif:PTZController/axis:Move/Channel_1" assert event.source == "PTZConfigurationToken" assert event.id == "1" assert event.state == "0" @@ -313,7 +313,7 @@ def test_ptz_move(event_manager: EventManager, subscriber: Mock) -> None: assert subscriber.call_count == 2 event: Event = subscriber.call_args[0][0] - assert event.topic == "tns1:PTZController/tnsaxis:Move/Channel_1" + assert event.topic == "onvif:PTZController/axis:Move/Channel_1" assert event.id == "1" assert event.state == "1" assert event.is_tripped @@ -322,7 +322,7 @@ def test_ptz_move(event_manager: EventManager, subscriber: Mock) -> None: assert subscriber.call_count == 3 event: Event = subscriber.call_args[0][0] - assert event.topic == "tns1:PTZController/tnsaxis:Move/Channel_1" + assert event.topic == "onvif:PTZController/axis:Move/Channel_1" assert event.id == "1" assert event.state == "0" assert not event.is_tripped @@ -331,7 +331,7 @@ def test_ptz_move(event_manager: EventManager, subscriber: Mock) -> None: def test_mqtt_event(event_manager: EventManager, subscriber: Mock) -> None: """Verify that unsupported events aren't signalled to subscribers.""" mqtt_event = { - "topic": "tns1:Device/tnsaxis:Sensor/PIR", + "topic": "onvif:Device/axis:Sensor/PIR", "source": "sensor", "source_idx": "0", "type": "state", @@ -342,7 +342,7 @@ def test_mqtt_event(event_manager: EventManager, subscriber: Mock) -> None: event: Event = subscriber.call_args[0][0] assert event.operation == EventOperation.INITIALIZED - assert event.topic == "tns1:Device/tnsaxis:Sensor/PIR" + assert event.topic == "onvif:Device/axis:Sensor/PIR" assert event.id == "0" assert event.state == "0" assert not event.is_tripped diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index 4d175b0b..7ce14e25 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -309,7 +309,7 @@ async def test_convert_json_to_event(): ) assert event == { - "topic": "tns1:Device/tnsaxis:Sensor/PIR", + "topic": "onvif:Device/axis:Sensor/PIR", "source": "sensor", "source_idx": "0", "type": "state", From 786a4bbc0d85bdce5631b605801bdf9a6f0a710f Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 23 Apr 2024 21:25:00 +0200 Subject: [PATCH 4/4] Move mqtt_json_to_event from interfaces.mqtt to models.mqtt --- axis/interfaces/mqtt.py | 23 ----------------------- axis/models/event.py | 7 +++++-- axis/models/mqtt.py | 30 +++++++++++++++++++++++++++++- tests/test_mqtt.py | 11 +++++++++-- 4 files changed, 43 insertions(+), 28 deletions(-) diff --git a/axis/interfaces/mqtt.py b/axis/interfaces/mqtt.py index fda10311..b1d5d5c2 100644 --- a/axis/interfaces/mqtt.py +++ b/axis/interfaces/mqtt.py @@ -2,8 +2,6 @@ from typing import Any -import orjson - from ..models.api_discovery import ApiId from ..models.mqtt import ( API_VERSION, @@ -25,27 +23,6 @@ DEFAULT_TOPICS = ["//."] -def mqtt_json_to_event(msg: bytes | str) -> dict[str, Any]: - """Convert JSON message from MQTT to event format.""" - message = orjson.loads(msg) - - source = source_idx = "" - if message["message"]["source"]: - source, source_idx = next(iter(message["message"]["source"].items())) - - data_type = data_value = "" - if message["message"]["data"]: - data_type, data_value = next(iter(message["message"]["data"].items())) - - return { - "topic": message["topic"], - "source": source, - "source_idx": source_idx, - "type": data_type, - "value": data_value, - } - - class MqttClientHandler(ApiHandler[Any]): """MQTT Client for Axis devices.""" diff --git a/axis/models/event.py b/axis/models/event.py index e2fb5b6f..4598f8c5 100644 --- a/axis/models/event.py +++ b/axis/models/event.py @@ -129,7 +129,7 @@ def _decode_from_dict(cls, data: dict[str, Any]) -> Self: source_idx = data.get(EVENT_SOURCE_IDX, "") value = data.get(EVENT_VALUE, "") - if (topic_base := EventTopic(topic)) == EventTopic.UNKNOWN: + if (topic_base := EventTopic(topic)) is EventTopic.UNKNOWN: _topic_base, _, _source_idx = topic.rpartition("/") topic_base = EventTopic(_topic_base) if source_idx == "": @@ -153,7 +153,10 @@ def _decode_from_dict(cls, data: dict[str, Any]) -> Self: def _decode_from_bytes(cls, data: bytes) -> Self: """Parse metadata xml.""" raw = xmltodict.parse( - data, attr_prefix="", process_namespaces=True, namespaces=XML_NAMESPACES + data, + attr_prefix="", + process_namespaces=True, + namespaces=XML_NAMESPACES, ) if raw.get("MetadataStream") is None: diff --git a/axis/models/mqtt.py b/axis/models/mqtt.py index 5e765b16..b27d21b2 100644 --- a/axis/models/mqtt.py +++ b/axis/models/mqtt.py @@ -4,16 +4,44 @@ from dataclasses import dataclass import enum -from typing import Literal, NotRequired, Self +from typing import Any, Literal, NotRequired, Self import orjson from typing_extensions import TypedDict from .api import CONTEXT, ApiRequest, ApiResponse +from .event import ( + EVENT_SOURCE, + EVENT_SOURCE_IDX, + EVENT_TOPIC, + EVENT_TYPE, + EVENT_VALUE, +) API_VERSION = "1.0" +def mqtt_json_to_event(msg: bytes | str) -> dict[str, Any]: + """Convert JSON message from MQTT to event format.""" + message = orjson.loads(msg) + + source = source_idx = "" + if message["message"]["source"]: + source, source_idx = next(iter(message["message"]["source"].items())) + + data_type = data_value = "" + if message["message"]["data"]: + data_type, data_value = next(iter(message["message"]["data"].items())) + + return { + EVENT_TOPIC: message["topic"], + EVENT_SOURCE: source, + EVENT_SOURCE_IDX: source_idx, + EVENT_TYPE: data_type, + EVENT_VALUE: data_value, + } + + class ErrorDataT(TypedDict): """Error data in response.""" diff --git a/tests/test_mqtt.py b/tests/test_mqtt.py index 7ce14e25..17b418b6 100644 --- a/tests/test_mqtt.py +++ b/tests/test_mqtt.py @@ -9,8 +9,15 @@ import pytest from axis.device import AxisDevice -from axis.interfaces.mqtt import MqttClientHandler, mqtt_json_to_event -from axis.models.mqtt import ClientConfig, Message, Server, ServerProtocol, Ssl +from axis.interfaces.mqtt import MqttClientHandler +from axis.models.mqtt import ( + ClientConfig, + Message, + Server, + ServerProtocol, + Ssl, + mqtt_json_to_event, +) @pytest.fixture