diff --git a/.coveragerc b/.coveragerc index d57ffe392175dad875798aadb703cf38b75b72d9..4599ddfbd7b4811406b9955eb4eab7d5831a20bd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -300,8 +300,8 @@ omit = homeassistant/components/folder_watcher/* homeassistant/components/foobot/sensor.py homeassistant/components/fortios/device_tracker.py + homeassistant/components/foscam/__init__.py homeassistant/components/foscam/camera.py - homeassistant/components/foscam/const.py homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py homeassistant/components/freebox/__init__.py diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 5c63f7b2a154e3c533fcd308712647f9eaddfde6..0b9620ceab3b416f832936d6e54f44433cf18719 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -1 +1,51 @@ """The foscam component.""" +import asyncio + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, SERVICE_PTZ + +PLATFORMS = ["camera"] + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the foscam component.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up foscam from a config entry.""" + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + hass.data[DOMAIN][entry.unique_id] = entry.data + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + hass.data[DOMAIN].pop(entry.unique_id) + + if not hass.data[DOMAIN]: + hass.services.async_remove(domain=DOMAIN, service=SERVICE_PTZ) + + return unload_ok diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index bc28e160b258d7172685f427b588503a9e2d1fcf..edbd10e04b988a4a4c3414df8d6a5afabc0e4e5e 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -1,13 +1,14 @@ """This component provides basic support for Foscam IP cameras.""" import asyncio -import logging from libpyfoscam import FoscamCamera import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, @@ -15,21 +16,18 @@ from homeassistant.const import ( ) from homeassistant.helpers import config_validation as cv, entity_platform -from .const import DATA as FOSCAM_DATA, ENTITIES as FOSCAM_ENTITIES +from .const import CONF_STREAM, DOMAIN, LOGGER, SERVICE_PTZ -_LOGGER = logging.getLogger(__name__) - -CONF_IP = "ip" -CONF_RTSP_PORT = "rtsp_port" - -DEFAULT_NAME = "Foscam Camera" -DEFAULT_PORT = 88 - -SERVICE_PTZ = "ptz" -ATTR_MOVEMENT = "movement" -ATTR_TRAVELTIME = "travel_time" - -DEFAULT_TRAVELTIME = 0.125 +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required("ip"): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_NAME, default="Foscam Camera"): cv.string, + vol.Optional(CONF_PORT, default=88): cv.port, + vol.Optional("rtsp_port"): cv.port, + } +) DIR_UP = "up" DIR_DOWN = "down" @@ -52,16 +50,11 @@ MOVEMENT_ATTRS = { DIR_BOTTOMRIGHT: "ptz_move_bottom_right", } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_IP): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_RTSP_PORT): cv.port, - } -) +DEFAULT_TRAVELTIME = 0.125 + +ATTR_MOVEMENT = "movement" +ATTR_TRAVELTIME = "travel_time" + SERVICE_PTZ_SCHEMA = vol.Schema( { @@ -85,83 +78,90 @@ SERVICE_PTZ_SCHEMA = vol.Schema( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up a Foscam IP Camera.""" + LOGGER.warning( + "Loading foscam via platform config is deprecated, it will be automatically imported. Please remove it afterwards." + ) + + config_new = { + CONF_NAME: config[CONF_NAME], + CONF_HOST: config["ip"], + CONF_PORT: config[CONF_PORT], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_STREAM: "Main", + } + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config_new + ) + ) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a Foscam IP camera from a config entry.""" platform = entity_platform.current_platform.get() - assert platform is not None platform.async_register_entity_service( - "ptz", - { - vol.Required(ATTR_MOVEMENT): vol.In( - [ - DIR_UP, - DIR_DOWN, - DIR_LEFT, - DIR_RIGHT, - DIR_TOPLEFT, - DIR_TOPRIGHT, - DIR_BOTTOMLEFT, - DIR_BOTTOMRIGHT, - ] - ), - vol.Optional(ATTR_TRAVELTIME, default=DEFAULT_TRAVELTIME): cv.small_float, - }, - "async_perform_ptz", + SERVICE_PTZ, SERVICE_PTZ_SCHEMA, "async_perform_ptz" ) camera = FoscamCamera( - config[CONF_IP], - config[CONF_PORT], - config[CONF_USERNAME], - config[CONF_PASSWORD], + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], verbose=False, ) - rtsp_port = config.get(CONF_RTSP_PORT) - if not rtsp_port: - ret, response = await hass.async_add_executor_job(camera.get_port_info) - - if ret == 0: - rtsp_port = response.get("rtspPort") or response.get("mediaPort") - - ret, response = await hass.async_add_executor_job(camera.get_motion_detect_config) - - motion_status = False - if ret != 0 and response == 1: - motion_status = True - - async_add_entities( - [ - HassFoscamCamera( - camera, - config[CONF_NAME], - config[CONF_USERNAME], - config[CONF_PASSWORD], - rtsp_port, - motion_status, - ) - ] - ) + async_add_entities([HassFoscamCamera(camera, config_entry)]) class HassFoscamCamera(Camera): """An implementation of a Foscam IP camera.""" - def __init__(self, camera, name, username, password, rtsp_port, motion_status): + def __init__(self, camera, config_entry): """Initialize a Foscam camera.""" super().__init__() self._foscam_session = camera - self._name = name - self._username = username - self._password = password - self._rtsp_port = rtsp_port - self._motion_status = motion_status + self._name = config_entry.title + self._username = config_entry.data[CONF_USERNAME] + self._password = config_entry.data[CONF_PASSWORD] + self._stream = config_entry.data[CONF_STREAM] + self._unique_id = config_entry.unique_id + self._rtsp_port = None + self._motion_status = False async def async_added_to_hass(self): """Handle entity addition to hass.""" - entities = self.hass.data.setdefault(FOSCAM_DATA, {}).setdefault( - FOSCAM_ENTITIES, [] + # Get motion detection status + ret, response = await self.hass.async_add_executor_job( + self._foscam_session.get_motion_detect_config ) - entities.append(self) + + if ret != 0: + LOGGER.error( + "Error getting motion detection status of %s: %s", self._name, ret + ) + + else: + self._motion_status = response == 1 + + # Get RTSP port + ret, response = await self.hass.async_add_executor_job( + self._foscam_session.get_port_info + ) + + if ret != 0: + LOGGER.error("Error getting RTSP port of %s: %s", self._name, ret) + + else: + self._rtsp_port = response.get("rtspPort") or response.get("mediaPort") + + @property + def unique_id(self): + """Return the entity unique ID.""" + return self._unique_id def camera_image(self): """Return a still image response from the camera.""" @@ -178,12 +178,14 @@ class HassFoscamCamera(Camera): """Return supported features.""" if self._rtsp_port: return SUPPORT_STREAM - return 0 + + return None async def stream_source(self): """Return the stream source.""" if self._rtsp_port: - return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/videoMain" + return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}" + return None @property @@ -201,7 +203,10 @@ class HassFoscamCamera(Camera): self._motion_status = True except TypeError: - _LOGGER.debug("Communication problem") + LOGGER.debug( + "Failed enabling motion detection on '%s'. Is it supported by the device?", + self._name, + ) def disable_motion_detection(self): """Disable motion detection.""" @@ -213,18 +218,21 @@ class HassFoscamCamera(Camera): self._motion_status = False except TypeError: - _LOGGER.debug("Communication problem") + LOGGER.debug( + "Failed disabling motion detection on '%s'. Is it supported by the device?", + self._name, + ) async def async_perform_ptz(self, movement, travel_time): """Perform a PTZ action on the camera.""" - _LOGGER.debug("PTZ action '%s' on %s", movement, self._name) + LOGGER.debug("PTZ action '%s' on %s", movement, self._name) movement_function = getattr(self._foscam_session, MOVEMENT_ATTRS[movement]) ret, _ = await self.hass.async_add_executor_job(movement_function) if ret != 0: - _LOGGER.error("Error moving %s '%s': %s", movement, self._name, ret) + LOGGER.error("Error moving %s '%s': %s", movement, self._name, ret) return await asyncio.sleep(travel_time) @@ -234,7 +242,7 @@ class HassFoscamCamera(Camera): ) if ret != 0: - _LOGGER.error("Error stopping movement on '%s': %s", self._name, ret) + LOGGER.error("Error stopping movement on '%s': %s", self._name, ret) return @property diff --git a/homeassistant/components/foscam/config_flow.py b/homeassistant/components/foscam/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..7bb8cb50a5109a3dbab4a4860192deb8237f6c79 --- /dev/null +++ b/homeassistant/components/foscam/config_flow.py @@ -0,0 +1,123 @@ +"""Config flow for foscam integration.""" +from libpyfoscam import FoscamCamera +from libpyfoscam.foscam import ERROR_FOSCAM_AUTH, ERROR_FOSCAM_UNAVAILABLE +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.data_entry_flow import AbortFlow + +from .const import CONF_STREAM, LOGGER +from .const import DOMAIN # pylint:disable=unused-import + +STREAMS = ["Main", "Sub"] + +DEFAULT_PORT = 88 + + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_STREAM, default=STREAMS[0]): vol.In(STREAMS), + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for foscam.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def _validate_and_create(self, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + camera = FoscamCamera( + data[CONF_HOST], + data[CONF_PORT], + data[CONF_USERNAME], + data[CONF_PASSWORD], + verbose=False, + ) + + # Validate data by sending a request to the camera + ret, response = await self.hass.async_add_executor_job(camera.get_dev_info) + + if ret == ERROR_FOSCAM_UNAVAILABLE: + raise CannotConnect + + if ret == ERROR_FOSCAM_AUTH: + raise InvalidAuth + + await self.async_set_unique_id(response["mac"]) + self._abort_if_unique_id_configured() + + name = data.pop(CONF_NAME, response["devName"]) + + return self.async_create_entry(title=name, data=data) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + try: + return await self._validate_and_create(user_input) + + except CannotConnect: + errors["base"] = "cannot_connect" + + except InvalidAuth: + errors["base"] = "invalid_auth" + + except AbortFlow: + raise + + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_config): + """Handle config import from yaml.""" + try: + return await self._validate_and_create(import_config) + + except CannotConnect: + LOGGER.error("Error importing foscam platform config: cannot connect.") + return self.async_abort(reason="cannot_connect") + + except InvalidAuth: + LOGGER.error("Error importing foscam platform config: invalid auth.") + return self.async_abort(reason="invalid_auth") + + except AbortFlow: + raise + + except Exception: # pylint: disable=broad-except + LOGGER.exception( + "Error importing foscam platform config: unexpected exception." + ) + return self.async_abort(reason="unknown") + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/foscam/const.py b/homeassistant/components/foscam/const.py index 63b4b74a763304a93b1da93c243009e697e2b760..c0cb8c25e9fd4a54f0261edb9e7e6f0ba0191a76 100644 --- a/homeassistant/components/foscam/const.py +++ b/homeassistant/components/foscam/const.py @@ -1,5 +1,10 @@ """Constants for Foscam component.""" +import logging + +LOGGER = logging.getLogger(__package__) DOMAIN = "foscam" -DATA = "foscam" -ENTITIES = "entities" + +CONF_STREAM = "stream" + +SERVICE_PTZ = "ptz" diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index 8c7e8e7d77a6619ebdf21486a0cb4758e91cba7c..fdd050d513345cb19fda47b7613d0f410dbef907 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -1,6 +1,7 @@ { "domain": "foscam", "name": "Foscam", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/foscam", "requirements": ["libpyfoscam==1.0"], "codeowners": ["@skgsergio"] diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..6033fa099cd99a0206961eb0a12368433574d4d1 --- /dev/null +++ b/homeassistant/components/foscam/strings.json @@ -0,0 +1,24 @@ +{ + "title": "Foscam", + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "stream": "Stream" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/foscam/translations/en.json b/homeassistant/components/foscam/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..521a22076dd3e59e1af2dd0a1bbff61a7c977304 --- /dev/null +++ b/homeassistant/components/foscam/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "stream": "Stream", + "username": "Username" + } + } + } + }, + "title": "Foscam" +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3218ab4baa556628db0bf6830fc5cd211cf83b02..a1941d08f1fbac69d80c58a277968765ce8bfccd 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -66,6 +66,7 @@ FLOWS = [ "flume", "flunearyou", "forked_daapd", + "foscam", "freebox", "fritzbox", "garmin_connect", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d0e316d48fb3dcf1c79f609e50bf41af89afdc2..792b0342b21f5e710e271ba59414603d03f8394e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -443,6 +443,9 @@ konnected==1.2.0 # homeassistant.components.dyson libpurecool==0.6.4 +# homeassistant.components.foscam +libpyfoscam==1.0 + # homeassistant.components.mikrotik librouteros==3.0.0 diff --git a/tests/components/foscam/__init__.py b/tests/components/foscam/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..391907b8a8ce6154a77b1ca36547335e11987206 --- /dev/null +++ b/tests/components/foscam/__init__.py @@ -0,0 +1 @@ +"""Tests for the Foscam integration.""" diff --git a/tests/components/foscam/test_config_flow.py b/tests/components/foscam/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..8087ac1894f1a2cf459327ac9e46f68988c3bead --- /dev/null +++ b/tests/components/foscam/test_config_flow.py @@ -0,0 +1,358 @@ +"""Test the Foscam config flow.""" +from unittest.mock import patch + +from libpyfoscam.foscam import ERROR_FOSCAM_AUTH, ERROR_FOSCAM_UNAVAILABLE + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.foscam import config_flow + +from tests.common import MockConfigEntry + +VALID_CONFIG = { + config_flow.CONF_HOST: "10.0.0.2", + config_flow.CONF_PORT: 88, + config_flow.CONF_USERNAME: "admin", + config_flow.CONF_PASSWORD: "1234", + config_flow.CONF_STREAM: "Main", +} +CAMERA_NAME = "Mocked Foscam Camera" +CAMERA_MAC = "C0:C1:D0:F4:B4:D4" + + +def setup_mock_foscam_camera(mock_foscam_camera): + """Mock FoscamCamera simulating behaviour using a base valid config.""" + + def configure_mock_on_init(host, port, user, passwd, verbose=False): + return_code = 0 + data = {} + + if ( + host != VALID_CONFIG[config_flow.CONF_HOST] + or port != VALID_CONFIG[config_flow.CONF_PORT] + ): + return_code = ERROR_FOSCAM_UNAVAILABLE + + elif ( + user != VALID_CONFIG[config_flow.CONF_USERNAME] + or passwd != VALID_CONFIG[config_flow.CONF_PASSWORD] + ): + return_code = ERROR_FOSCAM_AUTH + + else: + data["devName"] = CAMERA_NAME + data["mac"] = CAMERA_MAC + + mock_foscam_camera.get_dev_info.return_value = (return_code, data) + + return mock_foscam_camera + + mock_foscam_camera.side_effect = configure_mock_on_init + + +async def test_user_valid(hass): + """Test valid config from user input.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera, patch( + "homeassistant.components.foscam.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.foscam.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + setup_mock_foscam_camera(mock_foscam_camera) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CAMERA_NAME + assert result["data"] == VALID_CONFIG + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_invalid_auth(hass): + """Test we handle invalid auth from user input.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + + invalid_user = VALID_CONFIG.copy() + invalid_user[config_flow.CONF_USERNAME] = "invalid" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + invalid_user, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_user_cannot_connect(hass): + """Test we handle cannot connect error from user input.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + + invalid_host = VALID_CONFIG.copy() + invalid_host[config_flow.CONF_HOST] = "127.0.0.1" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + invalid_host, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_already_configured(hass): + """Test we handle already configured from user input.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, data=VALID_CONFIG, unique_id=CAMERA_MAC + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_unknown_exception(hass): + """Test we handle unknown exceptions from user input.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera: + mock_foscam_camera.side_effect = Exception("test") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_import_user_valid(hass): + """Test valid config from import.""" + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera, patch( + "homeassistant.components.foscam.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.foscam.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + setup_mock_foscam_camera(mock_foscam_camera) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == CAMERA_NAME + assert result["data"] == VALID_CONFIG + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_user_valid_with_name(hass): + """Test valid config with extra name from import.""" + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera, patch( + "homeassistant.components.foscam.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.foscam.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + setup_mock_foscam_camera(mock_foscam_camera) + + name = CAMERA_NAME + " 1234" + with_name = VALID_CONFIG.copy() + with_name[config_flow.CONF_NAME] = name + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=with_name, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == name + assert result["data"] == VALID_CONFIG + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_invalid_auth(hass): + """Test we handle invalid auth from import.""" + entry = MockConfigEntry( + domain=config_flow.DOMAIN, data=VALID_CONFIG, unique_id=CAMERA_MAC + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + + invalid_user = VALID_CONFIG.copy() + invalid_user[config_flow.CONF_USERNAME] = "invalid" + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=invalid_user, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "invalid_auth" + + +async def test_import_cannot_connect(hass): + """Test we handle invalid auth from import.""" + entry = MockConfigEntry( + domain=config_flow.DOMAIN, data=VALID_CONFIG, unique_id=CAMERA_MAC + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + + invalid_host = VALID_CONFIG.copy() + invalid_host[config_flow.CONF_HOST] = "127.0.0.1" + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=invalid_host, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_already_configured(hass): + """Test we handle already configured from import.""" + entry = MockConfigEntry( + domain=config_flow.DOMAIN, data=VALID_CONFIG, unique_id=CAMERA_MAC + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera: + setup_mock_foscam_camera(mock_foscam_camera) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_import_unknown_exception(hass): + """Test we handle unknown exceptions from import.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.foscam.config_flow.FoscamCamera", + ) as mock_foscam_camera: + mock_foscam_camera.side_effect = Exception("test") + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=VALID_CONFIG, + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "unknown"