From c478b1416c40aadfbae0ab59ce8b3edc5e84d6ad Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 28 Feb 2024 14:36:32 +0100 Subject: [PATCH] Adapt Axis integration to library refactoring (#110898) * Adapt Axis integration to library refactoring * Bump axis to v49 --- .../components/axis/binary_sensor.py | 46 +++++-- homeassistant/components/axis/camera.py | 5 +- homeassistant/components/axis/config_flow.py | 22 ++-- homeassistant/components/axis/device.py | 9 +- homeassistant/components/axis/diagnostics.py | 4 +- homeassistant/components/axis/light.py | 6 +- homeassistant/components/axis/manifest.json | 2 +- homeassistant/components/axis/switch.py | 5 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/axis/conftest.py | 84 ++++++++---- tests/components/axis/const.py | 118 ++++++++++++++++- .../axis/snapshots/test_diagnostics.ambr | 43 +----- tests/components/axis/test_camera.py | 19 ++- tests/components/axis/test_device.py | 2 +- tests/components/axis/test_light.py | 111 +++++++++------- tests/components/axis/test_switch.py | 122 +++++++++++------- 17 files changed, 403 insertions(+), 199 deletions(-) diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index d68de7742dc..8e7cda335e6 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -5,6 +5,10 @@ from collections.abc import Callable from datetime import datetime, timedelta from axis.models.event import Event, EventGroup, EventOperation, EventTopic +from axis.vapix.interfaces.applications.fence_guard import FenceGuardHandler +from axis.vapix.interfaces.applications.loitering_guard import LoiteringGuardHandler +from axis.vapix.interfaces.applications.motion_guard import MotionGuardHandler +from axis.vapix.interfaces.applications.vmd4 import Vmd4Handler from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -111,17 +115,33 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity): self._attr_name = self.device.api.vapix.ports[event.id].name elif event.group == EventGroup.MOTION: - for event_topic, event_data in ( - (EventTopic.FENCE_GUARD, self.device.api.vapix.fence_guard), - (EventTopic.LOITERING_GUARD, self.device.api.vapix.loitering_guard), - (EventTopic.MOTION_GUARD, self.device.api.vapix.motion_guard), - (EventTopic.OBJECT_ANALYTICS, self.device.api.vapix.object_analytics), - (EventTopic.MOTION_DETECTION_4, self.device.api.vapix.vmd4), + event_data: FenceGuardHandler | LoiteringGuardHandler | MotionGuardHandler | Vmd4Handler | None = None + if event.topic_base == EventTopic.FENCE_GUARD: + event_data = self.device.api.vapix.fence_guard + elif event.topic_base == EventTopic.LOITERING_GUARD: + event_data = self.device.api.vapix.loitering_guard + elif event.topic_base == EventTopic.MOTION_GUARD: + event_data = self.device.api.vapix.motion_guard + elif event.topic_base == EventTopic.MOTION_DETECTION_4: + event_data = self.device.api.vapix.vmd4 + if ( + event_data + and event_data.initialized + and (profiles := event_data["0"].profiles) ): - if ( - event.topic_base == event_topic - and event_data - and event.id in event_data - ): - self._attr_name = f"{self._event_type} {event_data[event.id].name}" - break + for profile_id, profile in profiles.items(): + camera_id = profile.camera + if event.id == f"Camera{camera_id}Profile{profile_id}": + self._attr_name = f"{self._event_type} {profile.name}" + return + + if ( + event.topic_base == EventTopic.OBJECT_ANALYTICS + and self.device.api.vapix.object_analytics.initialized + and (scenarios := self.device.api.vapix.object_analytics["0"].scenarios) + ): + for scenario_id, scenario in scenarios.items(): + device_id = scenario.devices[0]["id"] + if event.id == f"Device{device_id}Scenario{scenario_id}": + self._attr_name = f"{self._event_type} {scenario.name}" + break diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 0b3a93f24fc..a0c71f101ca 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -24,7 +24,10 @@ async def async_setup_entry( device: AxisNetworkDevice = hass.data[AXIS_DOMAIN][config_entry.entry_id] - if not device.api.vapix.params.image_format: + if ( + not (prop := device.api.vapix.params.property_handler.get("0")) + or not prop.image_format + ): return async_add_entities([AxisCamera(device)]) diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 75354bb9884..cbba23b8b51 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -249,7 +249,10 @@ class AxisOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): # Stream profiles - if vapix.stream_profiles or vapix.params.stream_profiles_max_groups > 0: + if vapix.stream_profiles or ( + (profiles := vapix.params.stream_profile_handler.get("0")) + and profiles.max_groups > 0 + ): stream_profiles = [DEFAULT_STREAM_PROFILE] for profile in vapix.streaming_profiles: stream_profiles.append(profile.name) @@ -262,14 +265,17 @@ class AxisOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): # Video sources - if vapix.params.image_nbrofviews > 0: - await vapix.params.update_image() - - video_sources = {DEFAULT_VIDEO_SOURCE: DEFAULT_VIDEO_SOURCE} - for idx, video_source in vapix.params.image_sources.items(): - if not video_source["Enabled"]: + if ( + properties := vapix.params.property_handler.get("0") + ) and properties.image_number_of_views > 0: + await vapix.params.image_handler.update() + video_sources: dict[int | str, str] = { + DEFAULT_VIDEO_SOURCE: DEFAULT_VIDEO_SOURCE + } + for idx, video_source in vapix.params.image_handler.items(): + if not video_source.enabled: continue - video_sources[idx + 1] = video_source["Name"] + video_sources[int(idx) + 1] = video_source.name schema[ vol.Optional(CONF_VIDEO_SOURCE, default=self.device.option_video_source) diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 4a54843edfc..845487b79d7 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -9,6 +9,7 @@ from axis.configuration import Configuration from axis.errors import Unauthorized from axis.stream_manager import Signal, State from axis.vapix.interfaces.mqtt import mqtt_json_to_event +from axis.vapix.models.mqtt import ClientState from homeassistant.components import mqtt from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN @@ -188,9 +189,8 @@ class AxisNetworkDevice: status = await self.api.vapix.mqtt.get_client_status() except Unauthorized: # This means the user has too low privileges - status = {} - - if status.get("data", {}).get("status", {}).get("state") == "active": + return + if status.status.state == ClientState.ACTIVE: self.config_entry.async_on_unload( await mqtt.async_subscribe( hass, f"{self.api.vapix.serial_number}/#", self.mqtt_message @@ -209,7 +209,6 @@ class AxisNetworkDevice: def async_setup_events(self) -> None: """Set up the device events.""" - if self.option_events: self.api.stream.connection_status_callback.append( self.async_connection_status_callback @@ -217,7 +216,7 @@ class AxisNetworkDevice: self.api.enable_events() self.api.stream.start() - if self.api.vapix.mqtt: + if self.api.vapix.mqtt.supported: async_when_setup(self.hass, MQTT_DOMAIN, self.async_use_mqtt) @callback diff --git a/homeassistant/components/axis/diagnostics.py b/homeassistant/components/axis/diagnostics.py index 20dfedd717b..948a36a78a0 100644 --- a/homeassistant/components/axis/diagnostics.py +++ b/homeassistant/components/axis/diagnostics.py @@ -33,13 +33,13 @@ async def async_get_config_entry_diagnostics( if device.api.vapix.basic_device_info: diag["basic_device_info"] = async_redact_data( - {attr.id: attr.raw for attr in device.api.vapix.basic_device_info.values()}, + device.api.vapix.basic_device_info["0"], REDACT_BASIC_DEVICE_INFO, ) if device.api.vapix.params: diag["params"] = async_redact_data( - {param.id: param.raw for param in device.api.vapix.params.values()}, + device.api.vapix.params.items(), REDACT_VAPIX_PARAMS, ) diff --git a/homeassistant/components/axis/light.py b/homeassistant/components/axis/light.py index 10dc8258d7e..cebd2f1206b 100644 --- a/homeassistant/components/axis/light.py +++ b/homeassistant/components/axis/light.py @@ -69,12 +69,12 @@ class AxisLight(AxisEventEntity, LightEntity): self._light_id ) ) - self.current_intensity = current_intensity["data"]["intensity"] + self.current_intensity = current_intensity max_intensity = await self.device.api.vapix.light_control.get_valid_intensity( self._light_id ) - self.max_intensity = max_intensity["data"]["ranges"][0]["high"] + self.max_intensity = max_intensity.high @callback def async_event_callback(self, event: Event) -> None: @@ -110,4 +110,4 @@ class AxisLight(AxisEventEntity, LightEntity): self._light_id ) ) - self.current_intensity = current_intensity["data"]["intensity"] + self.current_intensity = current_intensity diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 296a3da8b66..bd6faf8b149 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -26,7 +26,7 @@ "iot_class": "local_push", "loggers": ["axis"], "quality_scale": "platinum", - "requirements": ["axis==48"], + "requirements": ["axis==49"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py index adcd1ba5525..c495dfbdc43 100644 --- a/homeassistant/components/axis/switch.py +++ b/homeassistant/components/axis/switch.py @@ -39,7 +39,6 @@ class AxisSwitch(AxisEventEntity, SwitchEntity): def __init__(self, event: Event, device: AxisNetworkDevice) -> None: """Initialize the Axis switch.""" super().__init__(event, device) - if event.id and device.api.vapix.ports[event.id].name: self._attr_name = device.api.vapix.ports[event.id].name self._attr_is_on = event.is_tripped @@ -52,8 +51,8 @@ class AxisSwitch(AxisEventEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - await self.device.api.vapix.ports[self._event_id].close() + await self.device.api.vapix.ports.close(self._event_id) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - await self.device.api.vapix.ports[self._event_id].open() + await self.device.api.vapix.ports.open(self._event_id) diff --git a/requirements_all.txt b/requirements_all.txt index 4bf1eb21d0f..07d550ae901 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -514,7 +514,7 @@ aurorapy==0.2.7 # avion==0.10 # homeassistant.components.axis -axis==48 +axis==49 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60fd4b0baab..ed439fdc80d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -454,7 +454,7 @@ auroranoaa==0.0.3 aurorapy==0.2.7 # homeassistant.components.axis -axis==48 +axis==49 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py index 3c476705258..8de16ee7990 100644 --- a/tests/components/axis/conftest.py +++ b/tests/components/axis/conftest.py @@ -99,7 +99,9 @@ def options_fixture(request): @pytest.fixture(name="mock_vapix_requests") -def default_request_fixture(respx_mock): +def default_request_fixture( + respx_mock, port_management_payload, param_properties_payload, param_ports_payload +): """Mock default Vapix requests responses.""" def __mock_default_requests(host): @@ -113,7 +115,7 @@ def default_request_fixture(respx_mock): json=BASIC_DEVICE_INFO_RESPONSE, ) respx.post(f"{path}/axis-cgi/io/portmanagement.cgi").respond( - json=PORT_MANAGEMENT_RESPONSE, + json=port_management_payload, ) respx.post(f"{path}/axis-cgi/mqtt/client.cgi").respond( json=MQTT_CLIENT_RESPONSE, @@ -124,38 +126,58 @@ def default_request_fixture(respx_mock): respx.post(f"{path}/axis-cgi/viewarea/info.cgi").respond( json=VIEW_AREAS_RESPONSE ) - respx.get(f"{path}/axis-cgi/param.cgi?action=list&group=root.Brand").respond( + respx.post( + f"{path}/axis-cgi/param.cgi", + data={"action": "list", "group": "root.Brand"}, + ).respond( text=BRAND_RESPONSE, headers={"Content-Type": "text/plain"}, ) - respx.get(f"{path}/axis-cgi/param.cgi?action=list&group=root.Image").respond( + respx.post( + f"{path}/axis-cgi/param.cgi", + data={"action": "list", "group": "root.Image"}, + ).respond( text=IMAGE_RESPONSE, headers={"Content-Type": "text/plain"}, ) - respx.get(f"{path}/axis-cgi/param.cgi?action=list&group=root.Input").respond( - text=PORTS_RESPONSE, - headers={"Content-Type": "text/plain"}, - ) - respx.get(f"{path}/axis-cgi/param.cgi?action=list&group=root.IOPort").respond( - text=PORTS_RESPONSE, - headers={"Content-Type": "text/plain"}, - ) - respx.get(f"{path}/axis-cgi/param.cgi?action=list&group=root.Output").respond( - text=PORTS_RESPONSE, - headers={"Content-Type": "text/plain"}, - ) - respx.get( - f"{path}/axis-cgi/param.cgi?action=list&group=root.Properties" + respx.post( + f"{path}/axis-cgi/param.cgi", + data={"action": "list", "group": "root.Input"}, ).respond( - text=PROPERTIES_RESPONSE, + text=PORTS_RESPONSE, headers={"Content-Type": "text/plain"}, ) - respx.get(f"{path}/axis-cgi/param.cgi?action=list&group=root.PTZ").respond( + respx.post( + f"{path}/axis-cgi/param.cgi", + data={"action": "list", "group": "root.IOPort"}, + ).respond( + text=param_ports_payload, + headers={"Content-Type": "text/plain"}, + ) + respx.post( + f"{path}/axis-cgi/param.cgi", + data={"action": "list", "group": "root.Output"}, + ).respond( + text=PORTS_RESPONSE, + headers={"Content-Type": "text/plain"}, + ) + respx.post( + f"{path}/axis-cgi/param.cgi", + data={"action": "list", "group": "root.Properties"}, + ).respond( + text=param_properties_payload, + headers={"Content-Type": "text/plain"}, + ) + respx.post( + f"{path}/axis-cgi/param.cgi", + data={"action": "list", "group": "root.PTZ"}, + ).respond( text=PTZ_RESPONSE, headers={"Content-Type": "text/plain"}, ) - respx.get( - f"{path}/axis-cgi/param.cgi?action=list&group=root.StreamProfile" + respx.post( + f"{path}/axis-cgi/param.cgi", + data={"action": "list", "group": "root.StreamProfile"}, ).respond( text=STREAM_PROFILES_RESPONSE, headers={"Content-Type": "text/plain"}, @@ -184,6 +206,24 @@ def api_discovery_fixture(api_discovery_items): respx.post(f"http://{DEFAULT_HOST}:80/axis-cgi/apidiscovery.cgi").respond(json=data) +@pytest.fixture(name="port_management_payload") +def io_port_management_data_fixture(): + """Property parameter data.""" + return PORT_MANAGEMENT_RESPONSE + + +@pytest.fixture(name="param_properties_payload") +def param_properties_data_fixture(): + """Property parameter data.""" + return PROPERTIES_RESPONSE + + +@pytest.fixture(name="param_ports_payload") +def param_ports_data_fixture(): + """Property parameter data.""" + return PORTS_RESPONSE + + @pytest.fixture(name="setup_default_vapix_requests") def default_vapix_requests_fixture(mock_vapix_requests): """Mock default Vapix requests responses.""" diff --git a/tests/components/axis/const.py b/tests/components/axis/const.py index d90a788ae75..df1d0aa4529 100644 --- a/tests/components/axis/const.py +++ b/tests/components/axis/const.py @@ -1,5 +1,6 @@ """Constants for Axis integration tests.""" +from axis.vapix.models.api import CONTEXT MAC = "00408C123456" FORMATTED_MAC = "00:40:8c:12:34:56" @@ -12,6 +13,7 @@ DEFAULT_HOST = "1.2.3.4" API_DISCOVERY_RESPONSE = { "method": "getApiList", "apiVersion": "1.0", + "context": CONTEXT, "data": { "apiList": [ {"id": "api-discovery", "version": "1.0", "name": "API Discovery Service"}, @@ -38,27 +40,45 @@ APPLICATIONS_LIST_RESPONSE = """ BASIC_DEVICE_INFO_RESPONSE = { "apiVersion": "1.1", + "context": CONTEXT, "data": { "propertyList": { "ProdNbr": "M1065-LW", "ProdType": "Network Camera", "SerialNumber": MAC, "Version": "9.80.1", + "Architecture": "str", + "Brand": "str", + "BuildDate": "str", + "HardwareID": "str", + "ProdFullName": "str", + "ProdShortName": "str", + "ProdVariant": "str", + "Soc": "str", + "SocSerialNumber": "str", + "WebURL": "str", } }, } MQTT_CLIENT_RESPONSE = { - "apiVersion": "1.0", - "context": "some context", "method": "getClientStatus", - "data": {"status": {"state": "active", "connectionStatus": "Connected"}}, + "apiVersion": "1.0", + "context": CONTEXT, + "data": { + "status": {"state": "active", "connectionStatus": "Connected"}, + "config": { + "server": {"protocol": "tcp", "host": "192.168.0.90", "port": 1883}, + }, + }, } + PORT_MANAGEMENT_RESPONSE = { "apiVersion": "1.0", "method": "getPorts", + "context": CONTEXT, "data": { "numberOfPorts": 1, "items": [ @@ -78,12 +98,13 @@ PORT_MANAGEMENT_RESPONSE = { VMD4_RESPONSE = { "apiVersion": "1.4", "method": "getConfiguration", - "context": "Axis library", + "context": CONTEXT, "data": { "cameras": [{"id": 1, "rotation": 0, "active": True}], "profiles": [ {"filters": [], "camera": 1, "triggers": [], "name": "Profile 1", "uid": 1} ], + "configurationStatus": 2, }, } @@ -102,6 +123,95 @@ root.Image.I0.Source=0 root.Image.I1.Enabled=no root.Image.I1.Name=View Area 2 root.Image.I1.Source=0 +root.Image.I0.Appearance.ColorEnabled=yes +root.Image.I0.Appearance.Compression=30 +root.Image.I0.Appearance.MirrorEnabled=no +root.Image.I0.Appearance.Resolution=1920x1080 +root.Image.I0.Appearance.Rotation=0 +root.Image.I0.MPEG.Complexity=50 +root.Image.I0.MPEG.ConfigHeaderInterval=1 +root.Image.I0.MPEG.FrameSkipMode=drop +root.Image.I0.MPEG.ICount=1 +root.Image.I0.MPEG.PCount=31 +root.Image.I0.MPEG.UserDataEnabled=no +root.Image.I0.MPEG.UserDataInterval=1 +root.Image.I0.MPEG.ZChromaQPMode=off +root.Image.I0.MPEG.ZFpsMode=fixed +root.Image.I0.MPEG.ZGopMode=fixed +root.Image.I0.MPEG.ZMaxGopLength=300 +root.Image.I0.MPEG.ZMinFps=0 +root.Image.I0.MPEG.ZStrength=10 +root.Image.I0.MPEG.H264.Profile=high +root.Image.I0.MPEG.H264.PSEnabled=no +root.Image.I0.Overlay.Enabled=no +root.Image.I0.Overlay.XPos=0 +root.Image.I0.Overlay.YPos=0 +root.Image.I0.Overlay.MaskWindows.Color=black +root.Image.I0.RateControl.MaxBitrate=0 +root.Image.I0.RateControl.Mode=vbr +root.Image.I0.RateControl.Priority=framerate +root.Image.I0.RateControl.TargetBitrate=0 +root.Image.I0.SizeControl.MaxFrameSize=0 +root.Image.I0.Stream.Duration=0 +root.Image.I0.Stream.FPS=0 +root.Image.I0.Stream.NbrOfFrames=0 +root.Image.I0.Text.BGColor=black +root.Image.I0.Text.ClockEnabled=no +root.Image.I0.Text.Color=white +root.Image.I0.Text.DateEnabled=no +root.Image.I0.Text.Position=top +root.Image.I0.Text.String= +root.Image.I0.Text.TextEnabled=no +root.Image.I0.Text.TextSize=medium +root.Image.I0.TriggerData.AudioEnabled=yes +root.Image.I0.TriggerData.MotionDetectionEnabled=yes +root.Image.I0.TriggerData.MotionLevelEnabled=no +root.Image.I0.TriggerData.TamperingEnabled=yes +root.Image.I0.TriggerData.UserTriggers= +root.Image.I1.Appearance.ColorEnabled=yes +root.Image.I1.Appearance.Compression=30 +root.Image.I1.Appearance.MirrorEnabled=no +root.Image.I1.Appearance.Resolution=1920x1080 +root.Image.I1.Appearance.Rotation=0 +root.Image.I1.MPEG.Complexity=50 +root.Image.I1.MPEG.ConfigHeaderInterval=1 +root.Image.I1.MPEG.FrameSkipMode=drop +root.Image.I1.MPEG.ICount=1 +root.Image.I1.MPEG.PCount=31 +root.Image.I1.MPEG.UserDataEnabled=no +root.Image.I1.MPEG.UserDataInterval=1 +root.Image.I1.MPEG.ZChromaQPMode=off +root.Image.I1.MPEG.ZFpsMode=fixed +root.Image.I1.MPEG.ZGopMode=fixed +root.Image.I1.MPEG.ZMaxGopLength=300 +root.Image.I1.MPEG.ZMinFps=0 +root.Image.I1.MPEG.ZStrength=10 +root.Image.I1.MPEG.H264.Profile=high +root.Image.I1.MPEG.H264.PSEnabled=no +root.Image.I1.Overlay.Enabled=no +root.Image.I1.Overlay.XPos=0 +root.Image.I1.Overlay.YPos=0 +root.Image.I1.RateControl.MaxBitrate=0 +root.Image.I1.RateControl.Mode=vbr +root.Image.I1.RateControl.Priority=framerate +root.Image.I1.RateControl.TargetBitrate=0 +root.Image.I1.SizeControl.MaxFrameSize=0 +root.Image.I1.Stream.Duration=0 +root.Image.I1.Stream.FPS=0 +root.Image.I1.Stream.NbrOfFrames=0 +root.Image.I1.Text.BGColor=black +root.Image.I1.Text.ClockEnabled=no +root.Image.I1.Text.Color=white +root.Image.I1.Text.DateEnabled=no +root.Image.I1.Text.Position=top +root.Image.I1.Text.String= +root.Image.I1.Text.TextEnabled=no +root.Image.I1.Text.TextSize=medium +root.Image.I1.TriggerData.AudioEnabled=yes +root.Image.I1.TriggerData.MotionDetectionEnabled=yes +root.Image.I1.TriggerData.MotionLevelEnabled=no +root.Image.I1.TriggerData.TamperingEnabled=yes +root.Image.I1.TriggerData.UserTriggers= """ PORTS_RESPONSE = """root.Input.NbrOfInputs=1 diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr index 9960fc9bfd2..b5647a08543 100644 --- a/tests/components/axis/snapshots/test_diagnostics.ambr +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -19,10 +19,8 @@ }), ]), 'basic_device_info': dict({ - 'ProdNbr': 'M1065-LW', - 'ProdType': 'Network Camera', - 'SerialNumber': '**REDACTED**', - 'Version': '9.80.1', + '__type': "", + 'repr': "DeviceInformation(id='0', architecture='str', brand='str', build_date='str', firmware_version='9.80.1', hardware_id='str', product_full_name='str', product_number='M1065-LW', product_short_name='str', product_type='Network Camera', product_variant='str', serial_number='00408C123456', soc='str', soc_serial_number='str', web_url='str')", }), 'camera_sources': dict({ 'Image': 'http://1.2.3.4:80/axis-cgi/jpg/image.cgi', @@ -53,41 +51,8 @@ 'version': 3, }), 'params': dict({ - 'root.IOPort': dict({ - 'I0.Configurable': 'no', - 'I0.Direction': 'input', - 'I0.Input.Name': 'PIR sensor', - 'I0.Input.Trig': 'closed', - }), - 'root.Input': dict({ - 'NbrOfInputs': '1', - }), - 'root.Output': dict({ - 'NbrOfOutputs': '0', - }), - 'root.Properties': dict({ - 'API.HTTP.Version': '3', - 'API.Metadata.Metadata': 'yes', - 'API.Metadata.Version': '1.0', - 'EmbeddedDevelopment.Version': '2.16', - 'Firmware.BuildDate': 'Feb 15 2019 09:42', - 'Firmware.BuildNumber': '26', - 'Firmware.Version': '9.10.1', - 'Image.Format': 'jpeg,mjpeg,h264', - 'Image.NbrOfViews': '2', - 'Image.Resolution': '1920x1080,1280x960,1280x720,1024x768,1024x576,800x600,640x480,640x360,352x240,320x240', - 'Image.Rotation': '0,180', - 'System.SerialNumber': '**REDACTED**', - }), - 'root.StreamProfile': dict({ - 'MaxGroups': '26', - 'S0.Description': 'profile_1_description', - 'S0.Name': 'profile_1', - 'S0.Parameters': 'videocodec=h264', - 'S1.Description': 'profile_2_description', - 'S1.Name': 'profile_2', - 'S1.Parameters': 'videocodec=h265', - }), + '__type': "", + 'repr': "dict_items([('Properties', {'API': {'HTTP': {'Version': 3}, 'Metadata': {'Metadata': True, 'Version': '1.0'}}, 'EmbeddedDevelopment': {'Version': '2.16'}, 'Firmware': {'BuildDate': 'Feb 15 2019 09:42', 'BuildNumber': 26, 'Version': '9.10.1'}, 'Image': {'Format': 'jpeg,mjpeg,h264', 'NbrOfViews': 2, 'Resolution': '1920x1080,1280x960,1280x720,1024x768,1024x576,800x600,640x480,640x360,352x240,320x240', 'Rotation': '0,180'}, 'System': {'SerialNumber': '00408C123456'}}), ('Input', {'NbrOfInputs': 1}), ('IOPort', {'I0': {'Configurable': False, 'Direction': 'input', 'Input': {'Name': 'PIR sensor', 'Trig': 'closed'}}}), ('Output', {'NbrOfOutputs': 0}), ('StreamProfile', {'MaxGroups': 26, 'S0': {'Description': 'profile_1_description', 'Name': 'profile_1', 'Parameters': 'videocodec=h264'}, 'S1': {'Description': 'profile_2_description', 'Name': 'profile_2', 'Parameters': 'videocodec=h265'}})])", }), }) # --- diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index cbbd2d15e79..440bb17b08e 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -1,5 +1,4 @@ """Axis camera platform tests.""" -from unittest.mock import patch import pytest @@ -13,7 +12,7 @@ from homeassistant.const import STATE_IDLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import NAME +from .const import MAC, NAME async def test_platform_manually_configured(hass: HomeAssistant) -> None: @@ -72,9 +71,19 @@ async def test_camera_with_stream_profile( ) +property_data = f"""root.Properties.API.HTTP.Version=3 +root.Properties.API.Metadata.Metadata=yes +root.Properties.API.Metadata.Version=1.0 +root.Properties.EmbeddedDevelopment.Version=2.16 +root.Properties.Firmware.BuildDate=Feb 15 2019 09:42 +root.Properties.Firmware.BuildNumber=26 +root.Properties.Firmware.Version=9.10.1 +root.Properties.System.SerialNumber={MAC} +""" + + +@pytest.mark.parametrize("param_properties_payload", [property_data]) async def test_camera_disabled(hass: HomeAssistant, prepare_config_entry) -> None: """Test that Axis camera platform is loaded properly but does not create camera entity.""" - with patch("axis.vapix.vapix.Params.image_format", new=None): - await prepare_config_entry() - + await prepare_config_entry() assert len(hass.states.async_entity_ids(CAMERA_DOMAIN)) == 0 diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index bc5bd13c284..0672abbb46b 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -227,7 +227,7 @@ async def test_shutdown(config) -> None: async def test_get_device_fails(hass: HomeAssistant, config) -> None: """Device unauthorized yields authentication required error.""" with patch( - "axis.vapix.vapix.Vapix.request", side_effect=axislib.Unauthorized + "axis.vapix.vapix.Vapix.initialize", side_effect=axislib.Unauthorized ), pytest.raises(axis.errors.AuthenticationRequired): await axis.device.get_axis_device(hass, config) diff --git a/tests/components/axis/test_light.py b/tests/components/axis/test_light.py index 75e869ce90e..b5503e1486a 100644 --- a/tests/components/axis/test_light.py +++ b/tests/components/axis/test_light.py @@ -1,6 +1,7 @@ """Axis light platform tests.""" from unittest.mock import patch +from axis.vapix.models.api import CONTEXT import pytest import respx @@ -49,10 +50,18 @@ def light_control_fixture(light_control_items): """Light control mock response.""" data = { "apiVersion": "1.1", + "context": CONTEXT, "method": "getLightInformation", "data": {"items": light_control_items}, } - respx.post(f"http://{DEFAULT_HOST}:80/axis-cgi/lightcontrol.cgi").respond( + respx.post( + f"http://{DEFAULT_HOST}:80/axis-cgi/lightcontrol.cgi", + json={ + "apiVersion": "1.1", + "context": CONTEXT, + "method": "getLightInformation", + }, + ).respond( json=data, ) @@ -90,24 +99,56 @@ async def test_no_light_entity_without_light_control_representation( @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_LIGHT_CONTROL]) -async def test_lights(hass: HomeAssistant, setup_config_entry, mock_rtsp_event) -> None: +async def test_lights( + hass: HomeAssistant, + respx_mock, + setup_config_entry, + mock_rtsp_event, + api_discovery_items, +) -> None: """Test that lights are loaded properly.""" # Add light - with patch( - "axis.vapix.interfaces.light_control.LightControl.get_current_intensity", - return_value={"data": {"intensity": 100}}, - ), patch( - "axis.vapix.interfaces.light_control.LightControl.get_valid_intensity", - return_value={"data": {"ranges": [{"high": 150}]}}, - ): - mock_rtsp_event( - topic="tns1:Device/tnsaxis:Light/Status", - data_type="state", - data_value="ON", - source_name="id", - source_idx="0", - ) - await hass.async_block_till_done() + respx.post( + f"http://{DEFAULT_HOST}:80/axis-cgi/lightcontrol.cgi", + json={ + "apiVersion": "1.1", + "context": CONTEXT, + "method": "getCurrentIntensity", + "params": {"lightID": "led0"}, + }, + ).respond( + json={ + "apiVersion": "1.1", + "context": "Axis library", + "method": "getCurrentIntensity", + "data": {"intensity": 100}, + }, + ) + respx.post( + f"http://{DEFAULT_HOST}:80/axis-cgi/lightcontrol.cgi", + json={ + "apiVersion": "1.1", + "context": CONTEXT, + "method": "getValidIntensity", + "params": {"lightID": "led0"}, + }, + ).respond( + json={ + "apiVersion": "1.1", + "context": "Axis library", + "method": "getValidIntensity", + "data": {"ranges": [{"low": 0, "high": 150}]}, + }, + ) + + mock_rtsp_event( + topic="tns1:Device/tnsaxis:Light/Status", + data_type="state", + data_value="ON", + source_name="id", + source_idx="0", + ) + await hass.async_block_till_done() assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 @@ -118,14 +159,9 @@ async def test_lights(hass: HomeAssistant, setup_config_entry, mock_rtsp_event) assert light_0.name == f"{NAME} IR Light 0" # Turn on, set brightness, light already on - with patch( - "axis.vapix.interfaces.light_control.LightControl.activate_light" - ) as mock_activate, patch( - "axis.vapix.interfaces.light_control.LightControl.set_manual_intensity" - ) as mock_set_intensity, patch( - "axis.vapix.interfaces.light_control.LightControl.get_current_intensity", - return_value={"data": {"intensity": 100}}, - ): + with patch("axis.vapix.vapix.LightHandler.activate_light") as mock_activate, patch( + "axis.vapix.vapix.LightHandler.set_manual_intensity" + ) as mock_set_intensity: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -136,12 +172,7 @@ async def test_lights(hass: HomeAssistant, setup_config_entry, mock_rtsp_event) mock_set_intensity.assert_called_once_with("led0", 29) # Turn off - with patch( - "axis.vapix.interfaces.light_control.LightControl.deactivate_light" - ) as mock_deactivate, patch( - "axis.vapix.interfaces.light_control.LightControl.get_current_intensity", - return_value={"data": {"intensity": 100}}, - ): + with patch("axis.vapix.vapix.LightHandler.deactivate_light") as mock_deactivate: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -164,14 +195,9 @@ async def test_lights(hass: HomeAssistant, setup_config_entry, mock_rtsp_event) assert light_0.state == STATE_OFF # Turn on, set brightness - with patch( - "axis.vapix.interfaces.light_control.LightControl.activate_light" - ) as mock_activate, patch( - "axis.vapix.interfaces.light_control.LightControl.set_manual_intensity" - ) as mock_set_intensity, patch( - "axis.vapix.interfaces.light_control.LightControl.get_current_intensity", - return_value={"data": {"intensity": 100}}, - ): + with patch("axis.vapix.vapix.LightHandler.activate_light") as mock_activate, patch( + "axis.vapix.vapix.LightHandler.set_manual_intensity" + ) as mock_set_intensity: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -182,12 +208,7 @@ async def test_lights(hass: HomeAssistant, setup_config_entry, mock_rtsp_event) mock_set_intensity.assert_not_called() # Turn off, light already off - with patch( - "axis.vapix.interfaces.light_control.LightControl.deactivate_light" - ) as mock_deactivate, patch( - "axis.vapix.interfaces.light_control.LightControl.get_current_intensity", - return_value={"data": {"intensity": 100}}, - ): + with patch("axis.vapix.vapix.LightHandler.deactivate_light") as mock_deactivate: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, diff --git a/tests/components/axis/test_switch.py b/tests/components/axis/test_switch.py index 269c4eb5a69..14e27c3437a 100644 --- a/tests/components/axis/test_switch.py +++ b/tests/components/axis/test_switch.py @@ -1,6 +1,7 @@ """Axis switch platform tests.""" -from unittest.mock import AsyncMock +from unittest.mock import patch +from axis.vapix.models.api import CONTEXT import pytest from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN @@ -32,18 +33,22 @@ async def test_no_switches(hass: HomeAssistant, setup_config_entry) -> None: assert not hass.states.async_entity_ids(SWITCH_DOMAIN) +PORT_DATA = """root.IOPort.I0.Configurable=yes +root.IOPort.I0.Direction=output +root.IOPort.I0.Output.Name=Doorbell +root.IOPort.I0.Output.Active=closed +root.IOPort.I1.Configurable=yes +root.IOPort.I1.Direction=output +root.IOPort.I1.Output.Name= +root.IOPort.I1.Output.Active=open +""" + + +@pytest.mark.parametrize("param_ports_payload", [PORT_DATA]) async def test_switches_with_port_cgi( hass: HomeAssistant, setup_config_entry, mock_rtsp_event ) -> None: """Test that switches are loaded properly using port.cgi.""" - device = hass.data[AXIS_DOMAIN][setup_config_entry.entry_id] - - device.api.vapix.ports = {"0": AsyncMock(), "1": AsyncMock()} - device.api.vapix.ports["0"].name = "Doorbell" - device.api.vapix.ports["0"].open = AsyncMock() - device.api.vapix.ports["0"].close = AsyncMock() - device.api.vapix.ports["1"].name = "" - mock_rtsp_event( topic="tns1:Device/Trigger/Relay", data_type="LogicalState", @@ -72,36 +77,61 @@ async def test_switches_with_port_cgi( assert relay_0.state == STATE_OFF assert relay_0.name == f"{NAME} Doorbell" - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - device.api.vapix.ports["0"].close.assert_called_once() + with patch("axis.vapix.vapix.Ports.close") as mock_turn_on: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_turn_on.assert_called_once_with("0") - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - device.api.vapix.ports["0"].open.assert_called_once() + with patch("axis.vapix.vapix.Ports.open") as mock_turn_off: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_turn_off.assert_called_once_with("0") + + +PORT_MANAGEMENT_RESPONSE = { + "apiVersion": "1.0", + "method": "getPorts", + "context": CONTEXT, + "data": { + "numberOfPorts": 2, + "items": [ + { + "port": "0", + "configurable": True, + "usage": "", + "name": "Doorbell", + "direction": "output", + "state": "open", + "normalState": "open", + }, + { + "port": "1", + "configurable": True, + "usage": "", + "name": "", + "direction": "output", + "state": "open", + "normalState": "open", + }, + ], + }, +} @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_PORT_MANAGEMENT]) +@pytest.mark.parametrize("port_management_payload", [PORT_MANAGEMENT_RESPONSE]) async def test_switches_with_port_management( hass: HomeAssistant, setup_config_entry, mock_rtsp_event ) -> None: """Test that switches are loaded properly using port management.""" - device = hass.data[AXIS_DOMAIN][setup_config_entry.entry_id] - - device.api.vapix.ports = {"0": AsyncMock(), "1": AsyncMock()} - device.api.vapix.ports["0"].name = "Doorbell" - device.api.vapix.ports["0"].open = AsyncMock() - device.api.vapix.ports["0"].close = AsyncMock() - device.api.vapix.ports["1"].name = "" - mock_rtsp_event( topic="tns1:Device/Trigger/Relay", data_type="LogicalState", @@ -143,18 +173,20 @@ async def test_switches_with_port_management( assert hass.states.get(f"{SWITCH_DOMAIN}.{NAME}_relay_1").state == STATE_ON - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - device.api.vapix.ports["0"].close.assert_called_once() + with patch("axis.vapix.vapix.IoPortManagement.close") as mock_turn_on: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_turn_on.assert_called_once_with("0") - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - device.api.vapix.ports["0"].open.assert_called_once() + with patch("axis.vapix.vapix.IoPortManagement.open") as mock_turn_off: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_turn_off.assert_called_once_with("0")