diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index c4cc5df68a0..8e7e4592cb6 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -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}" diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 508bee6aff5..5b300fe323b 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -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) + } + ), + ) diff --git a/homeassistant/components/axis/const.py b/homeassistant/components/axis/const.py index 05a1211f89d..203bbdf94c7 100644 --- a/homeassistant/components/axis/const.py +++ b/homeassistant/components/axis/const.py @@ -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] diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 54e534068a8..69cab856516 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -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.""" diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json index 88b117a9802..672bfe141b9 100644 --- a/homeassistant/components/axis/strings.json +++ b/homeassistant/components/axis/strings.json @@ -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" + } + } } -} \ No newline at end of file +} diff --git a/homeassistant/components/axis/translations/en.json b/homeassistant/components/axis/translations/en.json index 2bc9650c010..29461ee0612 100644 --- a/homeassistant/components/axis/translations/en.json +++ b/homeassistant/components/axis/translations/en.json @@ -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" + } + } } } \ No newline at end of file diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index 6281c87740c..6db8de0a0a8 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -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 diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index ab3516873fa..941961f623a 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -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" diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index c96d13659d1..e4b0a960979 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -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):