diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index d9cf28f3e8b..8637690a3ac 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -13,6 +13,7 @@ from wsdiscovery.service import Service from zeep.exceptions import Fault from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, @@ -27,6 +28,8 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import device_registry as dr from .const import CONF_DEVICE_ID, DEFAULT_ARGUMENTS, DEFAULT_PORT, DOMAIN, LOGGER from .device import get_device @@ -101,6 +104,30 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required("auto", default=True): bool}), ) + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle dhcp discovery.""" + hass = self.hass + mac = discovery_info.macaddress + registry = dr.async_get(self.hass) + if not ( + device := registry.async_get_device( + identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + ): + return self.async_abort(reason="no_devices_found") + for entry_id in device.config_entries: + if ( + not (entry := hass.config_entries.async_get_entry(entry_id)) + or entry.domain != DOMAIN + or entry.state is config_entries.ConfigEntryState.LOADED + ): + continue + if hass.config_entries.async_update_entry( + entry, data=entry.data | {CONF_HOST: discovery_info.ip} + ): + hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) + return self.async_abort(reason="already_configured") + async def async_step_device(self, user_input=None): """Handle WS-Discovery. diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index aa06d9c028d..10b7a845125 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@hunterjm"], "config_flow": true, "dependencies": ["ffmpeg"], + "dhcp": [{ "registered_devices": true }], "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 210027e96e5..348a50a1e4a 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "onvif_error": "Error setting up ONVIF device. Check logs for more information.", "no_h264": "There were no H264 streams available. Check the profile configuration on your device.", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 4a496b93d84..adcc32fe8d9 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -339,6 +339,10 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "kohlergen*", "macaddress": "00146F*", }, + { + "domain": "onvif", + "registered_devices": True, + }, { "domain": "overkiz", "hostname": "gateway*", diff --git a/tests/components/onvif/__init__.py b/tests/components/onvif/__init__.py index d90ec02159f..ff4d88fb5b3 100644 --- a/tests/components/onvif/__init__.py +++ b/tests/components/onvif/__init__.py @@ -6,7 +6,13 @@ from zeep.exceptions import Fault from homeassistant import config_entries from homeassistant.components.onvif import config_flow from homeassistant.components.onvif.const import CONF_SNAPSHOT_AUTH -from homeassistant.components.onvif.models import Capabilities, DeviceInfo, Profile +from homeassistant.components.onvif.models import ( + Capabilities, + DeviceInfo, + Profile, + Resolution, + Video, +) from homeassistant.const import HTTP_DIGEST_AUTHENTICATION from tests.common import MockConfigEntry @@ -100,7 +106,7 @@ def setup_mock_device(mock_device): index=0, token="dummy", name="profile1", - video=None, + video=Video("any", Resolution(640, 480)), ptz=None, video_source_token=None, ) @@ -120,7 +126,7 @@ async def setup_onvif_integration( unique_id=MAC, entry_id="1", source=config_entries.SOURCE_USER, -): +) -> tuple[MockConfigEntry, MagicMock, MagicMock]: """Create an ONVIF config entry.""" if not config: config = { diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 69631865880..f5e7143af84 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -2,8 +2,13 @@ from unittest.mock import MagicMock, patch from homeassistant import config_entries, data_entry_flow -from homeassistant.components.onvif import config_flow +from homeassistant.components import dhcp +from homeassistant.components.onvif import DOMAIN, config_flow +from homeassistant.config_entries import SOURCE_DHCP +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from . import ( HOST, @@ -34,6 +39,16 @@ DISCOVERY = [ "MAC": "ee:dd:cc:bb:aa", }, ] +DHCP_DISCOVERY = dhcp.DhcpServiceInfo( + hostname="any", + ip="5.6.7.8", + macaddress=MAC, +) +DHCP_DISCOVERY_SAME_IP = dhcp.DhcpServiceInfo( + hostname="any", + ip="1.2.3.4", + macaddress=MAC, +) def setup_mock_discovery( @@ -339,3 +354,88 @@ async def test_option_flow(hass: HomeAssistant) -> None: config_flow.CONF_RTSP_TRANSPORT: list(config_flow.RTSP_TRANSPORTS)[1], config_flow.CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, } + + +async def test_discovered_by_dhcp_updates_host(hass: HomeAssistant) -> None: + """Test dhcp updates existing host.""" + config_entry, _camera, device = await setup_onvif_integration(hass) + device.profiles = device.async_get_profiles() + registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(registry, config_entry.entry_id) + assert len(devices) == 1 + device = devices[0] + assert device.model == "TestModel" + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, MAC)} + assert config_entry.data[CONF_HOST] == "1.2.3.4" + await hass.config_entries.async_unload(config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == DHCP_DISCOVERY.ip + + +async def test_discovered_by_dhcp_does_nothing_if_host_is_the_same( + hass: HomeAssistant, +) -> None: + """Test dhcp update does nothing if host is the same.""" + config_entry, _camera, device = await setup_onvif_integration(hass) + device.profiles = device.async_get_profiles() + registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(registry, config_entry.entry_id) + assert len(devices) == 1 + device = devices[0] + assert device.model == "TestModel" + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, MAC)} + assert config_entry.data[CONF_HOST] == DHCP_DISCOVERY_SAME_IP.ip + await hass.config_entries.async_unload(config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY_SAME_IP + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == DHCP_DISCOVERY_SAME_IP.ip + + +async def test_discovered_by_dhcp_does_not_update_if_already_loaded( + hass: HomeAssistant, +) -> None: + """Test dhcp does not update existing host if its already loaded.""" + config_entry, _camera, device = await setup_onvif_integration(hass) + device.profiles = device.async_get_profiles() + registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(registry, config_entry.entry_id) + assert len(devices) == 1 + device = devices[0] + assert device.model == "TestModel" + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, MAC)} + assert config_entry.data[CONF_HOST] == "1.2.3.4" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] != DHCP_DISCOVERY.ip + + +async def test_discovered_by_dhcp_does_not_update_if_no_matching_entry( + hass: HomeAssistant, +) -> None: + """Test dhcp does not update existing host if there are no matching entries.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" diff --git a/tests/components/onvif/test_diagnostics.py b/tests/components/onvif/test_diagnostics.py index 6a81f14fe5b..66404d60e1b 100644 --- a/tests/components/onvif/test_diagnostics.py +++ b/tests/components/onvif/test_diagnostics.py @@ -63,7 +63,10 @@ async def test_diagnostics( "index": 0, "token": "dummy", "name": "profile1", - "video": None, + "video": { + "encoding": "any", + "resolution": {"width": 640, "height": 480}, + }, "ptz": None, "video_source_token": None, }