Adapt Axis integration to library refactoring (#110898)

* Adapt Axis integration to library refactoring

* Bump axis to v49
This commit is contained in:
Robert Svensson 2024-02-28 14:36:32 +01:00 committed by GitHub
parent 2b3630b054
commit c478b1416c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 403 additions and 199 deletions

View file

@ -5,6 +5,10 @@ from collections.abc import Callable
from datetime import datetime, timedelta from datetime import datetime, timedelta
from axis.models.event import Event, EventGroup, EventOperation, EventTopic 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 ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
@ -111,17 +115,33 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity):
self._attr_name = self.device.api.vapix.ports[event.id].name self._attr_name = self.device.api.vapix.ports[event.id].name
elif event.group == EventGroup.MOTION: elif event.group == EventGroup.MOTION:
for event_topic, event_data in ( event_data: FenceGuardHandler | LoiteringGuardHandler | MotionGuardHandler | Vmd4Handler | None = None
(EventTopic.FENCE_GUARD, self.device.api.vapix.fence_guard), if event.topic_base == EventTopic.FENCE_GUARD:
(EventTopic.LOITERING_GUARD, self.device.api.vapix.loitering_guard), event_data = self.device.api.vapix.fence_guard
(EventTopic.MOTION_GUARD, self.device.api.vapix.motion_guard), elif event.topic_base == EventTopic.LOITERING_GUARD:
(EventTopic.OBJECT_ANALYTICS, self.device.api.vapix.object_analytics), event_data = self.device.api.vapix.loitering_guard
(EventTopic.MOTION_DETECTION_4, self.device.api.vapix.vmd4), 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 ( for profile_id, profile in profiles.items():
event.topic_base == event_topic camera_id = profile.camera
and event_data if event.id == f"Camera{camera_id}Profile{profile_id}":
and event.id in event_data self._attr_name = f"{self._event_type} {profile.name}"
): return
self._attr_name = f"{self._event_type} {event_data[event.id].name}"
break 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

View file

@ -24,7 +24,10 @@ async def async_setup_entry(
device: AxisNetworkDevice = hass.data[AXIS_DOMAIN][config_entry.entry_id] 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 return
async_add_entities([AxisCamera(device)]) async_add_entities([AxisCamera(device)])

View file

@ -249,7 +249,10 @@ class AxisOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
# Stream profiles # 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] stream_profiles = [DEFAULT_STREAM_PROFILE]
for profile in vapix.streaming_profiles: for profile in vapix.streaming_profiles:
stream_profiles.append(profile.name) stream_profiles.append(profile.name)
@ -262,14 +265,17 @@ class AxisOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
# Video sources # Video sources
if vapix.params.image_nbrofviews > 0: if (
await vapix.params.update_image() properties := vapix.params.property_handler.get("0")
) and properties.image_number_of_views > 0:
video_sources = {DEFAULT_VIDEO_SOURCE: DEFAULT_VIDEO_SOURCE} await vapix.params.image_handler.update()
for idx, video_source in vapix.params.image_sources.items(): video_sources: dict[int | str, str] = {
if not video_source["Enabled"]: DEFAULT_VIDEO_SOURCE: DEFAULT_VIDEO_SOURCE
}
for idx, video_source in vapix.params.image_handler.items():
if not video_source.enabled:
continue continue
video_sources[idx + 1] = video_source["Name"] video_sources[int(idx) + 1] = video_source.name
schema[ schema[
vol.Optional(CONF_VIDEO_SOURCE, default=self.device.option_video_source) vol.Optional(CONF_VIDEO_SOURCE, default=self.device.option_video_source)

View file

@ -9,6 +9,7 @@ from axis.configuration import Configuration
from axis.errors import Unauthorized from axis.errors import Unauthorized
from axis.stream_manager import Signal, State from axis.stream_manager import Signal, State
from axis.vapix.interfaces.mqtt import mqtt_json_to_event 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 import mqtt
from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN
@ -188,9 +189,8 @@ class AxisNetworkDevice:
status = await self.api.vapix.mqtt.get_client_status() status = await self.api.vapix.mqtt.get_client_status()
except Unauthorized: except Unauthorized:
# This means the user has too low privileges # This means the user has too low privileges
status = {} return
if status.status.state == ClientState.ACTIVE:
if status.get("data", {}).get("status", {}).get("state") == "active":
self.config_entry.async_on_unload( self.config_entry.async_on_unload(
await mqtt.async_subscribe( await mqtt.async_subscribe(
hass, f"{self.api.vapix.serial_number}/#", self.mqtt_message hass, f"{self.api.vapix.serial_number}/#", self.mqtt_message
@ -209,7 +209,6 @@ class AxisNetworkDevice:
def async_setup_events(self) -> None: def async_setup_events(self) -> None:
"""Set up the device events.""" """Set up the device events."""
if self.option_events: if self.option_events:
self.api.stream.connection_status_callback.append( self.api.stream.connection_status_callback.append(
self.async_connection_status_callback self.async_connection_status_callback
@ -217,7 +216,7 @@ class AxisNetworkDevice:
self.api.enable_events() self.api.enable_events()
self.api.stream.start() 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) async_when_setup(self.hass, MQTT_DOMAIN, self.async_use_mqtt)
@callback @callback

View file

@ -33,13 +33,13 @@ async def async_get_config_entry_diagnostics(
if device.api.vapix.basic_device_info: if device.api.vapix.basic_device_info:
diag["basic_device_info"] = async_redact_data( 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, REDACT_BASIC_DEVICE_INFO,
) )
if device.api.vapix.params: if device.api.vapix.params:
diag["params"] = async_redact_data( 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, REDACT_VAPIX_PARAMS,
) )

View file

@ -69,12 +69,12 @@ class AxisLight(AxisEventEntity, LightEntity):
self._light_id 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( max_intensity = await self.device.api.vapix.light_control.get_valid_intensity(
self._light_id self._light_id
) )
self.max_intensity = max_intensity["data"]["ranges"][0]["high"] self.max_intensity = max_intensity.high
@callback @callback
def async_event_callback(self, event: Event) -> None: def async_event_callback(self, event: Event) -> None:
@ -110,4 +110,4 @@ class AxisLight(AxisEventEntity, LightEntity):
self._light_id self._light_id
) )
) )
self.current_intensity = current_intensity["data"]["intensity"] self.current_intensity = current_intensity

View file

@ -26,7 +26,7 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["axis"], "loggers": ["axis"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["axis==48"], "requirements": ["axis==49"],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "AXIS" "manufacturer": "AXIS"

View file

@ -39,7 +39,6 @@ class AxisSwitch(AxisEventEntity, SwitchEntity):
def __init__(self, event: Event, device: AxisNetworkDevice) -> None: def __init__(self, event: Event, device: AxisNetworkDevice) -> None:
"""Initialize the Axis switch.""" """Initialize the Axis switch."""
super().__init__(event, device) super().__init__(event, device)
if event.id and device.api.vapix.ports[event.id].name: if event.id and device.api.vapix.ports[event.id].name:
self._attr_name = device.api.vapix.ports[event.id].name self._attr_name = device.api.vapix.ports[event.id].name
self._attr_is_on = event.is_tripped self._attr_is_on = event.is_tripped
@ -52,8 +51,8 @@ class AxisSwitch(AxisEventEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on switch.""" """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: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off switch.""" """Turn off switch."""
await self.device.api.vapix.ports[self._event_id].open() await self.device.api.vapix.ports.open(self._event_id)

View file

@ -514,7 +514,7 @@ aurorapy==0.2.7
# avion==0.10 # avion==0.10
# homeassistant.components.axis # homeassistant.components.axis
axis==48 axis==49
# homeassistant.components.azure_event_hub # homeassistant.components.azure_event_hub
azure-eventhub==5.11.1 azure-eventhub==5.11.1

View file

@ -454,7 +454,7 @@ auroranoaa==0.0.3
aurorapy==0.2.7 aurorapy==0.2.7
# homeassistant.components.axis # homeassistant.components.axis
axis==48 axis==49
# homeassistant.components.azure_event_hub # homeassistant.components.azure_event_hub
azure-eventhub==5.11.1 azure-eventhub==5.11.1

View file

@ -99,7 +99,9 @@ def options_fixture(request):
@pytest.fixture(name="mock_vapix_requests") @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.""" """Mock default Vapix requests responses."""
def __mock_default_requests(host): def __mock_default_requests(host):
@ -113,7 +115,7 @@ def default_request_fixture(respx_mock):
json=BASIC_DEVICE_INFO_RESPONSE, json=BASIC_DEVICE_INFO_RESPONSE,
) )
respx.post(f"{path}/axis-cgi/io/portmanagement.cgi").respond( 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( respx.post(f"{path}/axis-cgi/mqtt/client.cgi").respond(
json=MQTT_CLIENT_RESPONSE, json=MQTT_CLIENT_RESPONSE,
@ -124,38 +126,58 @@ def default_request_fixture(respx_mock):
respx.post(f"{path}/axis-cgi/viewarea/info.cgi").respond( respx.post(f"{path}/axis-cgi/viewarea/info.cgi").respond(
json=VIEW_AREAS_RESPONSE 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, text=BRAND_RESPONSE,
headers={"Content-Type": "text/plain"}, 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, text=IMAGE_RESPONSE,
headers={"Content-Type": "text/plain"}, headers={"Content-Type": "text/plain"},
) )
respx.get(f"{path}/axis-cgi/param.cgi?action=list&group=root.Input").respond( respx.post(
text=PORTS_RESPONSE, f"{path}/axis-cgi/param.cgi",
headers={"Content-Type": "text/plain"}, data={"action": "list", "group": "root.Input"},
)
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"
).respond( ).respond(
text=PROPERTIES_RESPONSE, text=PORTS_RESPONSE,
headers={"Content-Type": "text/plain"}, 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, text=PTZ_RESPONSE,
headers={"Content-Type": "text/plain"}, headers={"Content-Type": "text/plain"},
) )
respx.get( respx.post(
f"{path}/axis-cgi/param.cgi?action=list&group=root.StreamProfile" f"{path}/axis-cgi/param.cgi",
data={"action": "list", "group": "root.StreamProfile"},
).respond( ).respond(
text=STREAM_PROFILES_RESPONSE, text=STREAM_PROFILES_RESPONSE,
headers={"Content-Type": "text/plain"}, 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) 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") @pytest.fixture(name="setup_default_vapix_requests")
def default_vapix_requests_fixture(mock_vapix_requests): def default_vapix_requests_fixture(mock_vapix_requests):
"""Mock default Vapix requests responses.""" """Mock default Vapix requests responses."""

View file

@ -1,5 +1,6 @@
"""Constants for Axis integration tests.""" """Constants for Axis integration tests."""
from axis.vapix.models.api import CONTEXT
MAC = "00408C123456" MAC = "00408C123456"
FORMATTED_MAC = "00:40:8c:12:34:56" FORMATTED_MAC = "00:40:8c:12:34:56"
@ -12,6 +13,7 @@ DEFAULT_HOST = "1.2.3.4"
API_DISCOVERY_RESPONSE = { API_DISCOVERY_RESPONSE = {
"method": "getApiList", "method": "getApiList",
"apiVersion": "1.0", "apiVersion": "1.0",
"context": CONTEXT,
"data": { "data": {
"apiList": [ "apiList": [
{"id": "api-discovery", "version": "1.0", "name": "API Discovery Service"}, {"id": "api-discovery", "version": "1.0", "name": "API Discovery Service"},
@ -38,27 +40,45 @@ APPLICATIONS_LIST_RESPONSE = """<reply result="ok">
BASIC_DEVICE_INFO_RESPONSE = { BASIC_DEVICE_INFO_RESPONSE = {
"apiVersion": "1.1", "apiVersion": "1.1",
"context": CONTEXT,
"data": { "data": {
"propertyList": { "propertyList": {
"ProdNbr": "M1065-LW", "ProdNbr": "M1065-LW",
"ProdType": "Network Camera", "ProdType": "Network Camera",
"SerialNumber": MAC, "SerialNumber": MAC,
"Version": "9.80.1", "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 = { MQTT_CLIENT_RESPONSE = {
"apiVersion": "1.0",
"context": "some context",
"method": "getClientStatus", "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 = { PORT_MANAGEMENT_RESPONSE = {
"apiVersion": "1.0", "apiVersion": "1.0",
"method": "getPorts", "method": "getPorts",
"context": CONTEXT,
"data": { "data": {
"numberOfPorts": 1, "numberOfPorts": 1,
"items": [ "items": [
@ -78,12 +98,13 @@ PORT_MANAGEMENT_RESPONSE = {
VMD4_RESPONSE = { VMD4_RESPONSE = {
"apiVersion": "1.4", "apiVersion": "1.4",
"method": "getConfiguration", "method": "getConfiguration",
"context": "Axis library", "context": CONTEXT,
"data": { "data": {
"cameras": [{"id": 1, "rotation": 0, "active": True}], "cameras": [{"id": 1, "rotation": 0, "active": True}],
"profiles": [ "profiles": [
{"filters": [], "camera": 1, "triggers": [], "name": "Profile 1", "uid": 1} {"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.Enabled=no
root.Image.I1.Name=View Area 2 root.Image.I1.Name=View Area 2
root.Image.I1.Source=0 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 PORTS_RESPONSE = """root.Input.NbrOfInputs=1

View file

@ -19,10 +19,8 @@
}), }),
]), ]),
'basic_device_info': dict({ 'basic_device_info': dict({
'ProdNbr': 'M1065-LW', '__type': "<class 'axis.vapix.models.basic_device_info.DeviceInformation'>",
'ProdType': 'Network Camera', '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')",
'SerialNumber': '**REDACTED**',
'Version': '9.80.1',
}), }),
'camera_sources': dict({ 'camera_sources': dict({
'Image': 'http://1.2.3.4:80/axis-cgi/jpg/image.cgi', 'Image': 'http://1.2.3.4:80/axis-cgi/jpg/image.cgi',
@ -53,41 +51,8 @@
'version': 3, 'version': 3,
}), }),
'params': dict({ 'params': dict({
'root.IOPort': dict({ '__type': "<class 'dict_items'>",
'I0.Configurable': 'no', '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'}})])",
'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',
}),
}), }),
}) })
# --- # ---

View file

@ -1,5 +1,4 @@
"""Axis camera platform tests.""" """Axis camera platform tests."""
from unittest.mock import patch
import pytest import pytest
@ -13,7 +12,7 @@ from homeassistant.const import STATE_IDLE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component 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: 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: 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.""" """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 assert len(hass.states.async_entity_ids(CAMERA_DOMAIN)) == 0

View file

@ -227,7 +227,7 @@ async def test_shutdown(config) -> None:
async def test_get_device_fails(hass: HomeAssistant, config) -> None: async def test_get_device_fails(hass: HomeAssistant, config) -> None:
"""Device unauthorized yields authentication required error.""" """Device unauthorized yields authentication required error."""
with patch( 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): ), pytest.raises(axis.errors.AuthenticationRequired):
await axis.device.get_axis_device(hass, config) await axis.device.get_axis_device(hass, config)

View file

@ -1,6 +1,7 @@
"""Axis light platform tests.""" """Axis light platform tests."""
from unittest.mock import patch from unittest.mock import patch
from axis.vapix.models.api import CONTEXT
import pytest import pytest
import respx import respx
@ -49,10 +50,18 @@ def light_control_fixture(light_control_items):
"""Light control mock response.""" """Light control mock response."""
data = { data = {
"apiVersion": "1.1", "apiVersion": "1.1",
"context": CONTEXT,
"method": "getLightInformation", "method": "getLightInformation",
"data": {"items": light_control_items}, "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, 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]) @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.""" """Test that lights are loaded properly."""
# Add light # Add light
with patch( respx.post(
"axis.vapix.interfaces.light_control.LightControl.get_current_intensity", f"http://{DEFAULT_HOST}:80/axis-cgi/lightcontrol.cgi",
return_value={"data": {"intensity": 100}}, json={
), patch( "apiVersion": "1.1",
"axis.vapix.interfaces.light_control.LightControl.get_valid_intensity", "context": CONTEXT,
return_value={"data": {"ranges": [{"high": 150}]}}, "method": "getCurrentIntensity",
): "params": {"lightID": "led0"},
mock_rtsp_event( },
topic="tns1:Device/tnsaxis:Light/Status", ).respond(
data_type="state", json={
data_value="ON", "apiVersion": "1.1",
source_name="id", "context": "Axis library",
source_idx="0", "method": "getCurrentIntensity",
) "data": {"intensity": 100},
await hass.async_block_till_done() },
)
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 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" assert light_0.name == f"{NAME} IR Light 0"
# Turn on, set brightness, light already on # Turn on, set brightness, light already on
with patch( with patch("axis.vapix.vapix.LightHandler.activate_light") as mock_activate, patch(
"axis.vapix.interfaces.light_control.LightControl.activate_light" "axis.vapix.vapix.LightHandler.set_manual_intensity"
) as mock_activate, patch( ) as mock_set_intensity:
"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}},
):
await hass.services.async_call( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
SERVICE_TURN_ON, 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) mock_set_intensity.assert_called_once_with("led0", 29)
# Turn off # Turn off
with patch( with patch("axis.vapix.vapix.LightHandler.deactivate_light") as mock_deactivate:
"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}},
):
await hass.services.async_call( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
SERVICE_TURN_OFF, 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 assert light_0.state == STATE_OFF
# Turn on, set brightness # Turn on, set brightness
with patch( with patch("axis.vapix.vapix.LightHandler.activate_light") as mock_activate, patch(
"axis.vapix.interfaces.light_control.LightControl.activate_light" "axis.vapix.vapix.LightHandler.set_manual_intensity"
) as mock_activate, patch( ) as mock_set_intensity:
"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}},
):
await hass.services.async_call( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
SERVICE_TURN_ON, 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() mock_set_intensity.assert_not_called()
# Turn off, light already off # Turn off, light already off
with patch( with patch("axis.vapix.vapix.LightHandler.deactivate_light") as mock_deactivate:
"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}},
):
await hass.services.async_call( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
SERVICE_TURN_OFF, SERVICE_TURN_OFF,

View file

@ -1,6 +1,7 @@
"""Axis switch platform tests.""" """Axis switch platform tests."""
from unittest.mock import AsyncMock from unittest.mock import patch
from axis.vapix.models.api import CONTEXT
import pytest import pytest
from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN 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) 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( async def test_switches_with_port_cgi(
hass: HomeAssistant, setup_config_entry, mock_rtsp_event hass: HomeAssistant, setup_config_entry, mock_rtsp_event
) -> None: ) -> None:
"""Test that switches are loaded properly using port.cgi.""" """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( mock_rtsp_event(
topic="tns1:Device/Trigger/Relay", topic="tns1:Device/Trigger/Relay",
data_type="LogicalState", data_type="LogicalState",
@ -72,36 +77,61 @@ async def test_switches_with_port_cgi(
assert relay_0.state == STATE_OFF assert relay_0.state == STATE_OFF
assert relay_0.name == f"{NAME} Doorbell" assert relay_0.name == f"{NAME} Doorbell"
await hass.services.async_call( with patch("axis.vapix.vapix.Ports.close") as mock_turn_on:
SWITCH_DOMAIN, await hass.services.async_call(
SERVICE_TURN_ON, SWITCH_DOMAIN,
{ATTR_ENTITY_ID: entity_id}, SERVICE_TURN_ON,
blocking=True, {ATTR_ENTITY_ID: entity_id},
) blocking=True,
device.api.vapix.ports["0"].close.assert_called_once() )
mock_turn_on.assert_called_once_with("0")
await hass.services.async_call( with patch("axis.vapix.vapix.Ports.open") as mock_turn_off:
SWITCH_DOMAIN, await hass.services.async_call(
SERVICE_TURN_OFF, SWITCH_DOMAIN,
{ATTR_ENTITY_ID: entity_id}, SERVICE_TURN_OFF,
blocking=True, {ATTR_ENTITY_ID: entity_id},
) blocking=True,
device.api.vapix.ports["0"].open.assert_called_once() )
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("api_discovery_items", [API_DISCOVERY_PORT_MANAGEMENT])
@pytest.mark.parametrize("port_management_payload", [PORT_MANAGEMENT_RESPONSE])
async def test_switches_with_port_management( async def test_switches_with_port_management(
hass: HomeAssistant, setup_config_entry, mock_rtsp_event hass: HomeAssistant, setup_config_entry, mock_rtsp_event
) -> None: ) -> None:
"""Test that switches are loaded properly using port management.""" """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( mock_rtsp_event(
topic="tns1:Device/Trigger/Relay", topic="tns1:Device/Trigger/Relay",
data_type="LogicalState", 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 assert hass.states.get(f"{SWITCH_DOMAIN}.{NAME}_relay_1").state == STATE_ON
await hass.services.async_call( with patch("axis.vapix.vapix.IoPortManagement.close") as mock_turn_on:
SWITCH_DOMAIN, await hass.services.async_call(
SERVICE_TURN_ON, SWITCH_DOMAIN,
{ATTR_ENTITY_ID: entity_id}, SERVICE_TURN_ON,
blocking=True, {ATTR_ENTITY_ID: entity_id},
) blocking=True,
device.api.vapix.ports["0"].close.assert_called_once() )
mock_turn_on.assert_called_once_with("0")
await hass.services.async_call( with patch("axis.vapix.vapix.IoPortManagement.open") as mock_turn_off:
SWITCH_DOMAIN, await hass.services.async_call(
SERVICE_TURN_OFF, SWITCH_DOMAIN,
{ATTR_ENTITY_ID: entity_id}, SERVICE_TURN_OFF,
blocking=True, {ATTR_ENTITY_ID: entity_id},
) blocking=True,
device.api.vapix.ports["0"].open.assert_called_once() )
mock_turn_off.assert_called_once_with("0")