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 stringpull/36357/head
parent
47706dac1a
commit
cf6043fc2d
|
@ -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}"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue