From 6325bc8bfebae40f17ae00cefb3e467065c09e5d Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 13 Jan 2021 14:03:54 +0100 Subject: [PATCH] Follow Axis library changes and improve tests (#44126) --- .../components/axis/binary_sensor.py | 7 +- homeassistant/components/axis/device.py | 17 +- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/axis/test_binary_sensor.py | 11 +- tests/components/axis/test_config_flow.py | 28 +++- tests/components/axis/test_device.py | 148 ++++++++++++------ tests/components/axis/test_light.py | 4 +- tests/components/axis/test_switch.py | 6 +- 10 files changed, 149 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index 1881fe887f9..32d4afa328d 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -7,10 +7,12 @@ from axis.event_stream import ( CLASS_LIGHT, CLASS_MOTION, CLASS_OUTPUT, + CLASS_PTZ, CLASS_SOUND, FenceGuard, LoiteringGuard, MotionGuard, + ObjectAnalytics, Vmd4, ) @@ -46,7 +48,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Add binary sensor from Axis device.""" event = device.api.event[event_id] - if event.CLASS != CLASS_OUTPUT and not ( + if event.CLASS not in (CLASS_OUTPUT, CLASS_PTZ) and not ( event.CLASS == CLASS_LIGHT and event.TYPE == "Light" ): async_add_entities([AxisBinarySensor(event, device)]) @@ -101,7 +103,7 @@ class AxisBinarySensor(AxisEventBase, BinarySensorEntity): """Return the name of the event.""" if ( self.event.CLASS == CLASS_INPUT - and self.event.id + and self.event.id in self.device.api.vapix.ports and self.device.api.vapix.ports[self.event.id].name ): return ( @@ -114,6 +116,7 @@ class AxisBinarySensor(AxisEventBase, BinarySensorEntity): (FenceGuard, self.device.api.vapix.fence_guard), (LoiteringGuard, self.device.api.vapix.loitering_guard), (MotionGuard, self.device.api.vapix.motion_guard), + (ObjectAnalytics, self.device.api.vapix.object_analytics), (Vmd4, self.device.api.vapix.vmd4), ): if ( diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 79204bf3002..d589ebb46bd 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -25,6 +25,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.setup import async_when_setup from .const import ( @@ -177,7 +178,7 @@ class AxisNetworkDevice: self.disconnect_from_stream() event = mqtt_json_to_event(message.payload) - self.api.event.process_event(event) + self.api.event.update([event]) # Setup and teardown methods @@ -195,8 +196,10 @@ class AxisNetworkDevice: except CannotConnect as err: raise ConfigEntryNotReady from err - except Exception: # pylint: disable=broad-except - LOGGER.error("Unknown error connecting with Axis device on %s", self.host) + except Exception as err: # pylint: disable=broad-except + LOGGER.error( + "Unknown error connecting with Axis device (%s): %s", self.host, err + ) return False self.fw_version = self.api.vapix.firmware_version @@ -239,12 +242,10 @@ class AxisNetworkDevice: async def shutdown(self, event): """Stop the event stream.""" self.disconnect_from_stream() - await self.api.vapix.close() async def async_reset(self): """Reset this device to default state.""" self.disconnect_from_stream() - await self.api.vapix.close() unload_ok = all( await asyncio.gather( @@ -267,9 +268,10 @@ class AxisNetworkDevice: async def get_device(hass, host, port, username, password): """Create a Axis device.""" + session = get_async_client(hass, verify_ssl=False) device = axis.AxisDevice( - Configuration(host, port=port, username=username, password=password) + Configuration(session, host, port=port, username=username, password=password) ) try: @@ -280,15 +282,12 @@ async def get_device(hass, host, port, username, password): except axis.Unauthorized as err: LOGGER.warning("Connected to device at %s but not registered.", host) - await device.vapix.close() raise AuthenticationRequired from err except (asyncio.TimeoutError, axis.RequestError) as err: LOGGER.error("Error connecting to the Axis device at %s", host) - await device.vapix.close() raise CannotConnect from err except axis.AxisException as err: LOGGER.exception("Unknown Axis communication error occurred") - await device.vapix.close() raise AuthenticationRequired from err diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 188520241f3..09015dc92d2 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -3,7 +3,7 @@ "name": "Axis", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/axis", - "requirements": ["axis==41"], + "requirements": ["axis==42"], "zeroconf": [ { "type": "_axis-video._tcp.local.", "macaddress": "00408C*" }, { "type": "_axis-video._tcp.local.", "macaddress": "ACCC8E*" }, diff --git a/requirements_all.txt b/requirements_all.txt index d0c2af809a7..460c3010fec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -306,7 +306,7 @@ av==8.0.2 # avion==0.10 # homeassistant.components.axis -axis==41 +axis==42 # homeassistant.components.azure_event_hub azure-eventhub==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 199e3c351a3..034f3d3dd61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -177,7 +177,7 @@ auroranoaa==0.0.2 av==8.0.2 # homeassistant.components.axis -axis==41 +axis==42 # homeassistant.components.azure_event_hub azure-eventhub==5.1.0 diff --git a/tests/components/axis/test_binary_sensor.py b/tests/components/axis/test_binary_sensor.py index 0c6f4535cd0..98ef55282c3 100644 --- a/tests/components/axis/test_binary_sensor.py +++ b/tests/components/axis/test_binary_sensor.py @@ -19,6 +19,14 @@ EVENTS = [ "type": "state", "value": "0", }, + { + "operation": "Initialized", + "topic": "tns1:PTZController/tnsaxis:PTZPresets/Channel_1", + "source": "PresetToken", + "source_idx": "0", + "type": "on_preset", + "value": "1", + }, { "operation": "Initialized", "topic": "tnsaxis:CameraApplicationPlatform/VMD/Camera1Profile1", @@ -54,8 +62,7 @@ async def test_binary_sensors(hass): config_entry = await setup_axis_integration(hass) device = hass.data[AXIS_DOMAIN][config_entry.unique_id] - for event in EVENTS: - device.api.event.process_event(event) + device.api.event.update(EVENTS) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 2 diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index a557c1144e5..91674883378 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -1,6 +1,8 @@ """Test Axis config flow.""" from unittest.mock import patch +import respx + from homeassistant import data_entry_flow from homeassistant.components.axis import config_flow from homeassistant.components.axis.const import ( @@ -25,7 +27,13 @@ from homeassistant.data_entry_flow import ( RESULT_TYPE_FORM, ) -from .test_device import MAC, MODEL, NAME, setup_axis_integration, vapix_request +from .test_device import ( + MAC, + MODEL, + NAME, + mock_default_vapix_requests, + setup_axis_integration, +) from tests.common import MockConfigEntry @@ -41,7 +49,8 @@ async def test_flow_manual_configuration(hass): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == SOURCE_USER - with patch("axis.vapix.Vapix.request", new=vapix_request): + with respx.mock: + mock_default_vapix_requests(respx) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -80,7 +89,8 @@ async def test_manual_configuration_update_configuration(hass): with patch( "homeassistant.components.axis.async_setup_entry", return_value=True, - ) as mock_setup_entry, patch("axis.vapix.Vapix.request", new=vapix_request): + ) as mock_setup_entry, respx.mock: + mock_default_vapix_requests(respx, "2.3.4.5") result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -109,7 +119,8 @@ async def test_flow_fails_already_configured(hass): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == SOURCE_USER - with patch("axis.vapix.Vapix.request", new=vapix_request): + with respx.mock: + mock_default_vapix_requests(respx) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -196,7 +207,8 @@ async def test_flow_create_entry_multiple_existing_entries_of_same_model(hass): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == SOURCE_USER - with patch("axis.vapix.Vapix.request", new=vapix_request): + with respx.mock: + mock_default_vapix_requests(respx) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -238,7 +250,8 @@ async def test_zeroconf_flow(hass): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == SOURCE_USER - with patch("axis.vapix.Vapix.request", new=vapix_request): + with respx.mock: + mock_default_vapix_requests(respx) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -304,7 +317,8 @@ async def test_zeroconf_flow_updated_configuration(hass): with patch( "homeassistant.components.axis.async_setup_entry", return_value=True, - ) as mock_setup_entry, patch("axis.vapix.Vapix.request", new=vapix_request): + ) as mock_setup_entry, respx.mock: + mock_default_vapix_requests(respx, "2.3.4.5") result = await hass.config_entries.flow.async_init( AXIS_DOMAIN, data={ diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index ec9313e3cd5..6c3b35125be 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -1,26 +1,12 @@ """Test Axis device.""" from copy import deepcopy from unittest import mock -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch import axis as axislib -from axis.api_discovery import URL as API_DISCOVERY_URL -from axis.applications import URL_LIST as APPLICATIONS_URL -from axis.applications.vmd4 import URL as VMD4_URL -from axis.basic_device_info import URL as BASIC_DEVICE_INFO_URL from axis.event_stream import OPERATION_INITIALIZED -from axis.light_control import URL as LIGHT_CONTROL_URL -from axis.mqtt import URL_CLIENT as MQTT_CLIENT_URL -from axis.param_cgi import ( - BRAND as BRAND_URL, - INPUT as INPUT_URL, - IOPORT as IOPORT_URL, - OUTPUT as OUTPUT_URL, - PROPERTIES as PROPERTIES_URL, - STREAM_PROFILES as STREAM_PROFILES_URL, -) -from axis.port_management import URL as PORT_MANAGEMENT_URL import pytest +import respx from homeassistant import config_entries from homeassistant.components import axis @@ -47,10 +33,12 @@ MAC = "00408C12345" MODEL = "model" NAME = "name" +DEFAULT_HOST = "1.2.3.4" + ENTRY_OPTIONS = {CONF_EVENTS: True} ENTRY_CONFIG = { - CONF_HOST: "1.2.3.4", + CONF_HOST: DEFAULT_HOST, CONF_USERNAME: "root", CONF_PASSWORD: "pass", CONF_PORT: 80, @@ -166,6 +154,14 @@ root.Brand.ProdVariant= root.Brand.WebURL=http://www.axis.com """ +IMAGE_RESPONSE = """root.Image.I0.Enabled=yes +root.Image.I0.Name=View Area 1 +root.Image.I0.Source=0 +root.Image.I1.Enabled=no +root.Image.I1.Name=View Area 2 +root.Image.I1.Source=0 +""" + PORTS_RESPONSE = """root.Input.NbrOfInputs=1 root.IOPort.I0.Configurable=no root.IOPort.I0.Direction=input @@ -188,6 +184,9 @@ root.Properties.Image.Rotation=0,180 root.Properties.System.SerialNumber=00408C12345 """ +PTZ_RESPONSE = "" + + STREAM_PROFILES_RESPONSE = """root.StreamProfile.MaxGroups=26 root.StreamProfile.S0.Description=profile_1_description root.StreamProfile.S0.Name=profile_1 @@ -197,31 +196,85 @@ root.StreamProfile.S1.Name=profile_2 root.StreamProfile.S1.Parameters=videocodec=h265 """ +VIEW_AREAS_RESPONSE = {"apiVersion": "1.0", "method": "list", "data": {"viewAreas": []}} -async def vapix_request(self, session, url, **kwargs): - """Return data based on url.""" - if API_DISCOVERY_URL in url: - return API_DISCOVERY_RESPONSE - if APPLICATIONS_URL in url: - return APPLICATIONS_LIST_RESPONSE - if BASIC_DEVICE_INFO_URL in url: - return BASIC_DEVICE_INFO_RESPONSE - if LIGHT_CONTROL_URL in url: - return LIGHT_CONTROL_RESPONSE - if MQTT_CLIENT_URL in url: - return MQTT_CLIENT_RESPONSE - if PORT_MANAGEMENT_URL in url: - return PORT_MANAGEMENT_RESPONSE - if VMD4_URL in url: - return VMD4_RESPONSE - if BRAND_URL in url: - return BRAND_RESPONSE - if IOPORT_URL in url or INPUT_URL in url or OUTPUT_URL in url: - return PORTS_RESPONSE - if PROPERTIES_URL in url: - return PROPERTIES_RESPONSE - if STREAM_PROFILES_URL in url: - return STREAM_PROFILES_RESPONSE + +def mock_default_vapix_requests(respx: respx, host: str = DEFAULT_HOST) -> None: + """Mock default Vapix requests responses.""" + respx.post(f"http://{host}:80/axis-cgi/apidiscovery.cgi").respond( + json=API_DISCOVERY_RESPONSE, + ) + respx.post(f"http://{host}:80/axis-cgi/basicdeviceinfo.cgi").respond( + json=BASIC_DEVICE_INFO_RESPONSE, + ) + respx.post(f"http://{host}:80/axis-cgi/io/portmanagement.cgi").respond( + json=PORT_MANAGEMENT_RESPONSE, + ) + respx.post(f"http://{host}:80/axis-cgi/lightcontrol.cgi").respond( + json=LIGHT_CONTROL_RESPONSE, + ) + respx.post(f"http://{host}:80/axis-cgi/mqtt/client.cgi").respond( + json=MQTT_CLIENT_RESPONSE, + ) + respx.post(f"http://{host}:80/axis-cgi/streamprofile.cgi").respond( + json=STREAM_PROFILES_RESPONSE, + ) + respx.post(f"http://{host}:80/axis-cgi/viewarea/info.cgi").respond( + json=VIEW_AREAS_RESPONSE + ) + respx.get( + f"http://{host}:80/axis-cgi/param.cgi?action=list&group=root.Brand" + ).respond( + text=BRAND_RESPONSE, + headers={"Content-Type": "text/plain"}, + ) + respx.get( + f"http://{host}:80/axis-cgi/param.cgi?action=list&group=root.Image" + ).respond( + text=IMAGE_RESPONSE, + headers={"Content-Type": "text/plain"}, + ) + respx.get( + f"http://{host}:80/axis-cgi/param.cgi?action=list&group=root.Input" + ).respond( + text=PORTS_RESPONSE, + headers={"Content-Type": "text/plain"}, + ) + respx.get( + f"http://{host}:80/axis-cgi/param.cgi?action=list&group=root.IOPort" + ).respond( + text=PORTS_RESPONSE, + headers={"Content-Type": "text/plain"}, + ) + respx.get( + f"http://{host}:80/axis-cgi/param.cgi?action=list&group=root.Output" + ).respond( + text=PORTS_RESPONSE, + headers={"Content-Type": "text/plain"}, + ) + respx.get( + f"http://{host}:80/axis-cgi/param.cgi?action=list&group=root.Properties" + ).respond( + text=PROPERTIES_RESPONSE, + headers={"Content-Type": "text/plain"}, + ) + respx.get( + f"http://{host}:80/axis-cgi/param.cgi?action=list&group=root.PTZ" + ).respond( + text=PTZ_RESPONSE, + headers={"Content-Type": "text/plain"}, + ) + respx.get( + f"http://{host}:80/axis-cgi/param.cgi?action=list&group=root.StreamProfile" + ).respond( + text=STREAM_PROFILES_RESPONSE, + headers={"Content-Type": "text/plain"}, + ) + respx.post(f"http://{host}:80/axis-cgi/applications/list.cgi").respond( + text=APPLICATIONS_LIST_RESPONSE, + headers={"Content-Type": "text/xml"}, + ) + respx.post(f"http://{host}:80/local/vmd/control.cgi").respond(json=VMD4_RESPONSE) async def setup_axis_integration(hass, config=ENTRY_CONFIG, options=ENTRY_OPTIONS): @@ -235,10 +288,8 @@ async def setup_axis_integration(hass, config=ENTRY_CONFIG, options=ENTRY_OPTION ) config_entry.add_to_hass(hass) - with patch("axis.vapix.Vapix.request", new=vapix_request), patch( - "axis.rtsp.RTSPClient.start", - return_value=True, - ): + with patch("axis.rtsp.RTSPClient.start", return_value=True), respx.mock: + mock_default_vapix_requests(respx) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -317,10 +368,11 @@ async def test_update_address(hass): device = hass.data[AXIS_DOMAIN][config_entry.unique_id] assert device.api.config.host == "1.2.3.4" - with patch("axis.vapix.Vapix.request", new=vapix_request), patch( + with patch( "homeassistant.components.axis.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ) as mock_setup_entry, respx.mock: + mock_default_vapix_requests(respx, "2.3.4.5") await hass.config_entries.flow.async_init( AXIS_DOMAIN, data={ @@ -390,12 +442,10 @@ async def test_shutdown(): axis_device = axis.device.AxisNetworkDevice(hass, entry) axis_device.api = Mock() - axis_device.api.vapix.close = AsyncMock() await axis_device.shutdown(None) assert len(axis_device.api.stream.stop.mock_calls) == 1 - assert len(axis_device.api.vapix.close.mock_calls) == 1 async def test_get_device_fails(hass): diff --git a/tests/components/axis/test_light.py b/tests/components/axis/test_light.py index 05b58c04565..37b251d8ede 100644 --- a/tests/components/axis/test_light.py +++ b/tests/components/axis/test_light.py @@ -74,7 +74,7 @@ async def test_lights(hass): "axis.light_control.LightControl.get_valid_intensity", return_value={"data": {"ranges": [{"high": 150}]}}, ): - device.api.event.process_event(EVENT_ON) + device.api.event.update([EVENT_ON]) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(LIGHT_DOMAIN)) == 1 @@ -119,7 +119,7 @@ async def test_lights(hass): mock_deactivate.assert_called_once() # Event turn off light - device.api.event.process_event(EVENT_OFF) + device.api.event.update([EVENT_OFF]) await hass.async_block_till_done() light_0 = hass.states.get(entity_id) diff --git a/tests/components/axis/test_switch.py b/tests/components/axis/test_switch.py index 2f8cde777b5..dcbe285cb54 100644 --- a/tests/components/axis/test_switch.py +++ b/tests/components/axis/test_switch.py @@ -68,8 +68,7 @@ async def test_switches_with_port_cgi(hass): device.api.vapix.ports["0"].close = AsyncMock() device.api.vapix.ports["1"].name = "" - for event in EVENTS: - device.api.event.process_event(event) + device.api.event.update(EVENTS) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2 @@ -116,8 +115,7 @@ async def test_switches_with_port_management(hass): device.api.vapix.ports["0"].close = AsyncMock() device.api.vapix.ports["1"].name = "" - for event in EVENTS: - device.api.event.process_event(event) + device.api.event.update(EVENTS) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 2