Support Axis stream profile and configuring it through options flow (#36322)

* Support stream profile and configuring it through options flow

* Options flow test

* Allow configuration of not using a stream profile

* Shorten default stream profile string
pull/36357/head
Robert Svensson 2020-06-01 18:45:38 +02:00 committed by GitHub
parent 47706dac1a
commit cf6043fc2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 220 additions and 45 deletions

View File

@ -9,7 +9,6 @@ from homeassistant.components.mjpeg.camera import (
)
from homeassistant.const import (
CONF_AUTHENTICATION,
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
@ -19,11 +18,7 @@ from homeassistant.const import (
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .axis_base import AxisEntityBase
from .const import DOMAIN as AXIS_DOMAIN
AXIS_IMAGE = "http://{host}:{port}/axis-cgi/jpg/image.cgi"
AXIS_VIDEO = "http://{host}:{port}/axis-cgi/mjpg/video.cgi"
AXIS_STREAM = "rtsp://{user}:{password}@{host}/axis-media/media.amp?videocodec=h264"
from .const import DEFAULT_STREAM_PROFILE, DOMAIN as AXIS_DOMAIN
async def async_setup_entry(hass, config_entry, async_add_entities):
@ -35,27 +30,24 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
if not device.option_camera:
return
config = {
CONF_NAME: config_entry.data[CONF_NAME],
CONF_USERNAME: config_entry.data[CONF_USERNAME],
CONF_PASSWORD: config_entry.data[CONF_PASSWORD],
CONF_MJPEG_URL: AXIS_VIDEO.format(
host=config_entry.data[CONF_HOST], port=config_entry.data[CONF_PORT],
),
CONF_STILL_IMAGE_URL: AXIS_IMAGE.format(
host=config_entry.data[CONF_HOST], port=config_entry.data[CONF_PORT],
),
CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION,
}
async_add_entities([AxisCamera(config, device)])
async_add_entities([AxisCamera(device)])
class AxisCamera(AxisEntityBase, MjpegCamera):
"""Representation of a Axis camera."""
def __init__(self, config, device):
def __init__(self, device):
"""Initialize Axis Communications camera component."""
AxisEntityBase.__init__(self, device)
config = {
CONF_NAME: device.config_entry.data[CONF_NAME],
CONF_USERNAME: device.config_entry.data[CONF_USERNAME],
CONF_PASSWORD: device.config_entry.data[CONF_PASSWORD],
CONF_MJPEG_URL: self.mjpeg_source,
CONF_STILL_IMAGE_URL: self.image_source,
CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION,
}
MjpegCamera.__init__(self, config)
async def async_added_to_hass(self):
@ -73,21 +65,34 @@ class AxisCamera(AxisEntityBase, MjpegCamera):
"""Return supported features."""
return SUPPORT_STREAM
async def stream_source(self):
"""Return the stream source."""
return AXIS_STREAM.format(
user=self.device.config_entry.data[CONF_USERNAME],
password=self.device.config_entry.data[CONF_PASSWORD],
host=self.device.host,
)
def _new_address(self):
"""Set new device address for video stream."""
port = self.device.config_entry.data[CONF_PORT]
self._mjpeg_url = AXIS_VIDEO.format(host=self.device.host, port=port)
self._still_image_url = AXIS_IMAGE.format(host=self.device.host, port=port)
self._mjpeg_url = self.mjpeg_source
self._still_image_url = self.image_source
@property
def unique_id(self):
"""Return a unique identifier for this device."""
return f"{self.device.serial}-camera"
@property
def image_source(self):
"""Return still image URL for device."""
return f"http://{self.device.host}:{self.device.config_entry.data[CONF_PORT]}/axis-cgi/jpg/image.cgi"
@property
def mjpeg_source(self):
"""Return mjpeg URL for device."""
options = ""
if self.device.option_stream_profile != DEFAULT_STREAM_PROFILE:
options = f"?&streamprofile={self.device.option_stream_profile}"
return f"http://{self.device.host}:{self.device.config_entry.data[CONF_PORT]}/axis-cgi/mjpg/video.cgi{options}"
async def stream_source(self):
"""Return the stream source."""
options = ""
if self.device.option_stream_profile != DEFAULT_STREAM_PROFILE:
options = f"&streamprofile={self.device.option_stream_profile}"
return f"rtsp://{self.device.config_entry.data[CONF_USERNAME]}:{self.device.config_entry.data[CONF_PASSWORD]}@{self.device.host}/axis-media/media.amp?videocodec=h264{options}"

View File

@ -13,9 +13,15 @@ from homeassistant.const import (
CONF_PORT,
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.util.network import is_link_local
from .const import CONF_MODEL, DOMAIN
from .const import (
CONF_MODEL,
CONF_STREAM_PROFILE,
DEFAULT_STREAM_PROFILE,
DOMAIN as AXIS_DOMAIN,
)
from .device import get_device
from .errors import AuthenticationRequired, CannotConnect
@ -32,12 +38,18 @@ AXIS_INCLUDE = EVENT_TYPES + PLATFORMS
DEFAULT_PORT = 80
class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN):
"""Handle a Axis config flow."""
VERSION = 2
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return AxisOptionsFlowHandler(config_entry)
def __init__(self):
"""Initialize the Axis config flow."""
self.device_config = {}
@ -109,7 +121,7 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
model = self.device_config[CONF_MODEL]
same_model = [
entry.data[CONF_NAME]
for entry in self.hass.config_entries.async_entries(DOMAIN)
for entry in self.hass.config_entries.async_entries(AXIS_DOMAIN)
if entry.data[CONF_MODEL] == model
]
@ -157,3 +169,39 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
}
return await self.async_step_user()
class AxisOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle Axis device options."""
def __init__(self, config_entry):
"""Initialize Axis device options flow."""
self.config_entry = config_entry
self.options = dict(config_entry.options)
self.device = None
async def async_step_init(self, user_input=None):
"""Manage the Axis device options."""
self.device = self.hass.data[AXIS_DOMAIN][self.config_entry.unique_id]
return await self.async_step_configure_stream()
async def async_step_configure_stream(self, user_input=None):
"""Manage the Axis device options."""
if user_input is not None:
self.options.update(user_input)
return self.async_create_entry(title="", data=self.options)
profiles = [DEFAULT_STREAM_PROFILE]
for profile in self.device.api.vapix.streaming_profiles:
profiles.append(profile.name)
return self.async_show_form(
step_id="configure_stream",
data_schema=vol.Schema(
{
vol.Optional(
CONF_STREAM_PROFILE, default=self.device.option_stream_profile
): vol.In(profiles)
}
),
)

View File

@ -14,8 +14,10 @@ ATTR_MANUFACTURER = "Axis Communications AB"
CONF_CAMERA = "camera"
CONF_EVENTS = "events"
CONF_MODEL = "model"
CONF_STREAM_PROFILE = "stream_profile"
DEFAULT_EVENTS = True
DEFAULT_STREAM_PROFILE = "No stream profile"
DEFAULT_TRIGGER_TIME = 0
PLATFORMS = [BINARY_SENSOR_DOMAIN, CAMERA_DOMAIN, SWITCH_DOMAIN]

View File

@ -31,7 +31,9 @@ from .const import (
CONF_CAMERA,
CONF_EVENTS,
CONF_MODEL,
CONF_STREAM_PROFILE,
DEFAULT_EVENTS,
DEFAULT_STREAM_PROFILE,
DEFAULT_TRIGGER_TIME,
DOMAIN as AXIS_DOMAIN,
LOGGER,
@ -86,6 +88,13 @@ class AxisNetworkDevice:
"""Config entry option defining if platforms based on events should be created."""
return self.config_entry.options.get(CONF_EVENTS, DEFAULT_EVENTS)
@property
def option_stream_profile(self):
"""Config entry option defining what stream profile camera platform should use."""
return self.config_entry.options.get(
CONF_STREAM_PROFILE, DEFAULT_STREAM_PROFILE
)
@property
def option_trigger_time(self):
"""Config entry option defining minimum number of seconds to keep trigger high."""

View File

@ -24,5 +24,15 @@
"link_local_address": "Link local addresses are not supported",
"not_axis_device": "Discovered device not an Axis device"
}
},
"options": {
"step": {
"configure_stream": {
"data": {
"stream_profile": "Select stream profile to use"
},
"title": "Axis device video stream options"
}
}
}
}
}

View File

@ -24,5 +24,15 @@
"title": "Set up Axis device"
}
}
},
"options": {
"step": {
"configure_stream": {
"data": {
"stream_profile": "Select stream profile to use"
},
"title": "Axis device video stream options"
}
}
}
}

View File

@ -1,10 +1,17 @@
"""Axis camera platform tests."""
from homeassistant.components.axis.const import DOMAIN as AXIS_DOMAIN
from homeassistant.components import camera
from homeassistant.components.axis.const import (
CONF_CAMERA,
CONF_STREAM_PROFILE,
DOMAIN as AXIS_DOMAIN,
)
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.setup import async_setup_component
from .test_device import NAME, setup_axis_integration
from .test_device import ENTRY_OPTIONS, NAME, setup_axis_integration
from tests.async_mock import patch
async def test_platform_manually_configured(hass):
@ -28,3 +35,42 @@ async def test_camera(hass):
cam = hass.states.get(f"camera.{NAME}")
assert cam.state == "idle"
assert cam.name == NAME
camera_entity = camera._get_camera_from_entity_id(hass, f"camera.{NAME}")
assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi"
assert camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi"
assert (
await camera_entity.stream_source()
== "rtsp://root:pass@1.2.3.4/axis-media/media.amp?videocodec=h264"
)
async def test_camera_with_stream_profile(hass):
"""Test that Axis camera entity is using the correct path with stream profike."""
with patch.dict(ENTRY_OPTIONS, {CONF_STREAM_PROFILE: "profile_1"}):
await setup_axis_integration(hass)
assert len(hass.states.async_entity_ids(CAMERA_DOMAIN)) == 1
cam = hass.states.get(f"camera.{NAME}")
assert cam.state == "idle"
assert cam.name == NAME
camera_entity = camera._get_camera_from_entity_id(hass, f"camera.{NAME}")
assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi"
assert (
camera_entity.mjpeg_source
== "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi?&streamprofile=profile_1"
)
assert (
await camera_entity.stream_source()
== "rtsp://root:pass@1.2.3.4/axis-media/media.amp?videocodec=h264&streamprofile=profile_1"
)
async def test_camera_disabled(hass):
"""Test that Axis camera platform is loaded properly but does not create camera entity."""
with patch.dict(ENTRY_OPTIONS, {CONF_CAMERA: False}):
await setup_axis_integration(hass)
assert len(hass.states.async_entity_ids(CAMERA_DOMAIN)) == 0

View File

@ -1,6 +1,14 @@
"""Test Axis config flow."""
from homeassistant import data_entry_flow
from homeassistant.components.axis import config_flow
from homeassistant.components.axis.const import CONF_MODEL, DOMAIN as AXIS_DOMAIN
from homeassistant.components.axis.const import (
CONF_CAMERA,
CONF_EVENTS,
CONF_MODEL,
CONF_STREAM_PROFILE,
DEFAULT_STREAM_PROFILE,
DOMAIN as AXIS_DOMAIN,
)
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
@ -268,8 +276,8 @@ async def test_zeroconf_flow_updated_configuration(hass):
assert device.config_entry.data == {
CONF_HOST: "1.2.3.4",
CONF_PORT: 80,
CONF_USERNAME: "username",
CONF_PASSWORD: "password",
CONF_USERNAME: "root",
CONF_PASSWORD: "pass",
CONF_MAC: MAC,
CONF_MODEL: MODEL,
CONF_NAME: NAME,
@ -291,8 +299,8 @@ async def test_zeroconf_flow_updated_configuration(hass):
assert device.config_entry.data == {
CONF_HOST: "2.3.4.5",
CONF_PORT: 8080,
CONF_USERNAME: "username",
CONF_PASSWORD: "password",
CONF_USERNAME: "root",
CONF_PASSWORD: "pass",
CONF_MAC: MAC,
CONF_MODEL: MODEL,
CONF_NAME: NAME,
@ -321,3 +329,31 @@ async def test_zeroconf_flow_ignore_link_local_address(hass):
assert result["type"] == "abort"
assert result["reason"] == "link_local_address"
async def test_option_flow(hass):
"""Test config flow options."""
device = await setup_axis_integration(hass)
assert device.option_stream_profile == DEFAULT_STREAM_PROFILE
result = await hass.config_entries.options.async_init(device.config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "configure_stream"
assert set(result["data_schema"].schema[CONF_STREAM_PROFILE].container) == {
DEFAULT_STREAM_PROFILE,
"profile_1",
"profile_2",
}
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_STREAM_PROFILE: "profile_1"},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"] == {
CONF_CAMERA: True,
CONF_EVENTS: True,
CONF_STREAM_PROFILE: "profile_1",
}
assert device.option_stream_profile == "profile_1"

View File

@ -52,8 +52,8 @@ ENTRY_OPTIONS = {CONF_CAMERA: True, CONF_EVENTS: True}
ENTRY_CONFIG = {
CONF_HOST: "1.2.3.4",
CONF_USERNAME: "username",
CONF_PASSWORD: "password",
CONF_USERNAME: "root",
CONF_PASSWORD: "pass",
CONF_PORT: 80,
CONF_MAC: MAC,
CONF_MODEL: MODEL,
@ -152,6 +152,15 @@ root.Properties.Image.Rotation=0,180
root.Properties.System.SerialNumber=00408C12345
"""
STREAM_PROFILES_RESPONSE = """root.StreamProfile.MaxGroups=26
root.StreamProfile.S0.Description=profile_1_description
root.StreamProfile.S0.Name=profile_1
root.StreamProfile.S0.Parameters=videocodec=h264
root.StreamProfile.S1.Description=profile_2_description
root.StreamProfile.S1.Name=profile_2
root.StreamProfile.S1.Parameters=videocodec=h265
"""
def vapix_session_request(session, url, **kwargs):
"""Return data based on url."""
@ -170,7 +179,7 @@ def vapix_session_request(session, url, **kwargs):
if PROPERTIES_URL in url:
return PROPERTIES_RESPONSE
if STREAM_PROFILES_URL in url:
return ""
return STREAM_PROFILES_RESPONSE
async def setup_axis_integration(hass, config=ENTRY_CONFIG, options=ENTRY_OPTIONS):