diff --git a/CODEOWNERS b/CODEOWNERS index 64199f1dfb41c2..0d0ddfd76d5c25 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1739,6 +1739,7 @@ build.json @home-assistant/supervisor /homeassistant/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW /homeassistant/components/vivotek/ @HarlemSquirrel +/tests/components/vivotek/ @HarlemSquirrel /homeassistant/components/vizio/ @raman325 /tests/components/vizio/ @raman325 /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare diff --git a/homeassistant/components/vivotek/__init__.py b/homeassistant/components/vivotek/__init__.py index b5220b12a9b68a..b062d214996f25 100644 --- a/homeassistant/components/vivotek/__init__.py +++ b/homeassistant/components/vivotek/__init__.py @@ -1 +1,73 @@ """The Vivotek camera component.""" + +import logging +from types import MappingProxyType +from typing import Any + +from libpyvivotek.vivotek import VivotekCamera, VivotekCameraError + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_DIGEST_AUTHENTICATION, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError + +from .const import CONF_SECURITY_LEVEL + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.CAMERA] + +type VivotekConfigEntry = config_entries.ConfigEntry[VivotekCamera] + + +def build_cam_client( + data: dict[str, Any] | MappingProxyType[str, Any], +) -> VivotekCamera: + """Build the Vivotek camera client from the provided configuration data.""" + return VivotekCamera( + host=data[CONF_IP_ADDRESS], + port=data[CONF_PORT], + verify_ssl=data[CONF_VERIFY_SSL], + usr=data[CONF_USERNAME], + pwd=data[CONF_PASSWORD], + digest_auth=(data[CONF_AUTHENTICATION] == HTTP_DIGEST_AUTHENTICATION), + sec_lvl=data[CONF_SECURITY_LEVEL], + ) + + +async def async_build_and_test_cam_client( + hass: HomeAssistant, data: dict[str, Any] | MappingProxyType[str, Any] +) -> VivotekCamera: + """Build the client and test if the provided configuration is valid.""" + cam_client = build_cam_client(data) + await hass.async_add_executor_job(cam_client.get_mac) + + return cam_client + + +async def async_setup_entry(hass: HomeAssistant, entry: VivotekConfigEntry) -> bool: + """Set up the Vivotek component from a config entry.""" + + try: + cam_client = await async_build_and_test_cam_client(hass, entry.data) + except VivotekCameraError as err: + raise ConfigEntryError("Failed to connect to camera") from err + + entry.runtime_data = cam_client + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: VivotekConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/vivotek/camera.py b/homeassistant/components/vivotek/camera.py index c044e99a82e24a..2ee5ab21c9af3c 100644 --- a/homeassistant/components/vivotek/camera.py +++ b/homeassistant/components/vivotek/camera.py @@ -2,7 +2,11 @@ from __future__ import annotations -from libpyvivotek import VivotekCamera +import logging +from types import MappingProxyType +from typing import Any + +from libpyvivotek.vivotek import VivotekCamera import voluptuous as vol from homeassistant.components.camera import ( @@ -10,6 +14,7 @@ Camera, CameraEntityFeature, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_AUTHENTICATION, CONF_IP_ADDRESS, @@ -21,18 +26,31 @@ HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -CONF_FRAMERATE = "framerate" -CONF_SECURITY_LEVEL = "security_level" -CONF_STREAM_PATH = "stream_path" +from . import VivotekConfigEntry +from .const import ( + CONF_FRAMERATE, + CONF_SECURITY_LEVEL, + CONF_STREAM_PATH, + DOMAIN, + INTEGRATION_TITLE, +) + +_LOGGER = logging.getLogger(__name__) DEFAULT_CAMERA_BRAND = "VIVOTEK" DEFAULT_NAME = "VIVOTEK Camera" DEFAULT_EVENT_0_KEY = "event_i0_enable" +DEFAULT_FRAMERATE = 2 DEFAULT_SECURITY_LEVEL = "admin" DEFAULT_STREAM_SOURCE = "live.sdp" @@ -47,34 +65,85 @@ ), vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_FRAMERATE, default=2): cv.positive_int, + vol.Optional(CONF_FRAMERATE, default=DEFAULT_FRAMERATE): cv.positive_int, vol.Optional(CONF_SECURITY_LEVEL, default=DEFAULT_SECURITY_LEVEL): cv.string, vol.Optional(CONF_STREAM_PATH, default=DEFAULT_STREAM_SOURCE): cv.string, } ) -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: VivotekConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up a Vivotek IP Camera.""" + """Set up the component from a config entry.""" + config = entry.data creds = f"{config[CONF_USERNAME]}:{config[CONF_PASSWORD]}" - cam = VivotekCamera( - host=config[CONF_IP_ADDRESS], - port=(443 if config[CONF_SSL] else 80), - verify_ssl=config[CONF_VERIFY_SSL], - usr=config[CONF_USERNAME], - pwd=config[CONF_PASSWORD], - digest_auth=config[CONF_AUTHENTICATION] == HTTP_DIGEST_AUTHENTICATION, - sec_lvl=config[CONF_SECURITY_LEVEL], - ) stream_source = ( f"rtsp://{creds}@{config[CONF_IP_ADDRESS]}:554/{config[CONF_STREAM_PATH]}" ) - add_entities([VivotekCam(config, cam, stream_source)], True) + cam_client = entry.runtime_data + mac_address = await hass.async_add_executor_job(cam_client.get_mac) + unique_id = format_mac(mac_address) + + if not entry.unique_id: + hass.config_entries.async_update_entry( + entry, + unique_id=unique_id, + ) + merged_config = entry.data | entry.options + async_add_entities( + [VivotekCam(merged_config, cam_client, stream_source, unique_id)] + ) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Vivotek camera platform.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result.get('reason')}", + breaks_in_ha_version="2026.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + return + + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2026.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) class VivotekCam(Camera): @@ -83,22 +152,29 @@ class VivotekCam(Camera): _attr_brand = DEFAULT_CAMERA_BRAND _attr_supported_features = CameraEntityFeature.STREAM + _attr_configuration_url: str | None = None + _attr_serial: str | None = None + def __init__( - self, config: ConfigType, cam: VivotekCamera, stream_source: str + self, + config: ConfigType | MappingProxyType[str, Any], + cam_client: VivotekCamera, + stream_source: str, + unique_id: str, ) -> None: """Initialize a Vivotek camera.""" super().__init__() - - self._cam = cam - self._attr_frame_interval = 1 / config[CONF_FRAMERATE] - self._attr_name = config[CONF_NAME] + self._cam_client = cam_client + self._attr_configuration_url = f"http://{config[CONF_IP_ADDRESS]}" + self._attr_frame_interval = 1 / config.get(CONF_FRAMERATE, DEFAULT_FRAMERATE) + self._attr_unique_id = unique_id self._stream_source = stream_source def camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return bytes of camera image.""" - return self._cam.snapshot() + return self._cam_client.snapshot() async def stream_source(self) -> str: """Return the source of the stream.""" @@ -106,14 +182,16 @@ async def stream_source(self) -> str: def disable_motion_detection(self) -> None: """Disable motion detection in camera.""" - response = self._cam.set_param(DEFAULT_EVENT_0_KEY, 0) + response = self._cam_client.set_param(DEFAULT_EVENT_0_KEY, 0) self._attr_motion_detection_enabled = int(response) == 1 def enable_motion_detection(self) -> None: """Enable motion detection in camera.""" - response = self._cam.set_param(DEFAULT_EVENT_0_KEY, 1) + response = self._cam_client.set_param(DEFAULT_EVENT_0_KEY, 1) self._attr_motion_detection_enabled = int(response) == 1 def update(self) -> None: """Update entity status.""" - self._attr_model = self._cam.model_name + self._attr_model = self._cam_client.model_name + self._attr_available = self._attr_model is not None + self._attr_serial = self._cam_client.get_serial() diff --git a/homeassistant/components/vivotek/config_flow.py b/homeassistant/components/vivotek/config_flow.py new file mode 100644 index 00000000000000..c84afdef8c3c38 --- /dev/null +++ b/homeassistant/components/vivotek/config_flow.py @@ -0,0 +1,199 @@ +"""Config flow for Vivotek IP cameras integration.""" + +import logging +from typing import Any + +from libpyvivotek.vivotek import SECURITY_LEVELS, VivotekCameraError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from . import VivotekConfigEntry, async_build_and_test_cam_client, build_cam_client +from .camera import ( + DEFAULT_FRAMERATE, + DEFAULT_NAME, + DEFAULT_STREAM_SOURCE, + INTEGRATION_TITLE, +) +from .const import ( + CONF_FRAMERATE, + CONF_SECURITY_LEVEL, + CONF_STREAM_PATH, + DOMAIN, + ISSUE_DEPRECATED_YAML, +) + +_LOGGER = logging.getLogger(__name__) + +DESCRIPTION_PLACEHOLDERS = { + "doc_url": "https://www.home-assistant.io/integrations/vivotek/" +} + +CONF_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PORT, default=80): cv.port, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): vol.In( + [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] + ), + vol.Required(CONF_SSL, default=False): cv.boolean, + vol.Required(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Required(CONF_SECURITY_LEVEL): SelectSelector( + SelectSelectorConfig( + options=list(SECURITY_LEVELS.keys()), + mode=SelectSelectorMode.DROPDOWN, + translation_key="security_level", + sort=True, + ), + ), + vol.Required( + CONF_STREAM_PATH, + default=DEFAULT_STREAM_SOURCE, + ): cv.string, + } +) + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_FRAMERATE, default=DEFAULT_FRAMERATE): cv.positive_int, + } +) + + +class OptionsFlowHandler(OptionsFlow): + """Options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry( + data=user_input, + ) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, self.config_entry.options + ), + ) + + +class VivotekConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Vivotek IP cameras.""" + + @staticmethod + @callback + def async_get_options_flow( + config_entry: VivotekConfigEntry, + ) -> OptionsFlowHandler: + """Create the options flow.""" + return OptionsFlowHandler() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + cam_client = build_cam_client(user_input) + mac_address = await self.hass.async_add_executor_job(cam_client.get_mac) + except VivotekCameraError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during camera connection test") + errors["base"] = "unknown" + else: + # prevent duplicates + await self.async_set_unique_id(format_mac(mac_address)) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=DEFAULT_NAME, + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + CONF_SCHEMA, user_input or {} + ), + errors=errors, + description_placeholders=DESCRIPTION_PLACEHOLDERS, + ) + + async def async_step_import( + self, import_data: (dict[str, Any]) + ) -> ConfigFlowResult: + """Import a Yaml config.""" + self._async_abort_entries_match({CONF_IP_ADDRESS: import_data[CONF_IP_ADDRESS]}) + + port = (import_data.get(CONF_SSL) and 443) or 80 + import_data |= {CONF_PORT: port} + try: + config_flow_result: ConfigFlowResult = await self.async_step_user( + import_data + ) + except AbortFlow: + # this happens if the config entry is already imported + async_create_issue( + self.hass, + DOMAIN, + ISSUE_DEPRECATED_YAML, + breaks_in_ha_version="2026.1.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=ISSUE_DEPRECATED_YAML, + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + raise + else: + async_create_issue( + self.hass, + DOMAIN, + ISSUE_DEPRECATED_YAML, + breaks_in_ha_version="2026.1.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=ISSUE_DEPRECATED_YAML, + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + return config_flow_result + + async def _async_test_config(self, user_input: dict[str, Any]) -> None: + """Test if the provided configuration is valid.""" + await async_build_and_test_cam_client(self.hass, user_input) diff --git a/homeassistant/components/vivotek/const.py b/homeassistant/components/vivotek/const.py new file mode 100644 index 00000000000000..c274e5102f1152 --- /dev/null +++ b/homeassistant/components/vivotek/const.py @@ -0,0 +1,13 @@ +"""Constants for the Vivotek integration.""" + +CONF_FRAMERATE = "framerate" +CONF_SECURITY_LEVEL = "security_level" +CONF_STREAM_PATH = "stream_path" + +DOMAIN = "vivotek" + +MANUFACTURER = "Vivotek" + +INTEGRATION_TITLE = "Vivotek" + +ISSUE_DEPRECATED_YAML = "deprecated_yaml" diff --git a/homeassistant/components/vivotek/manifest.json b/homeassistant/components/vivotek/manifest.json index 74a8bf9b75047f..bcb8a597f9b089 100644 --- a/homeassistant/components/vivotek/manifest.json +++ b/homeassistant/components/vivotek/manifest.json @@ -2,6 +2,7 @@ "domain": "vivotek", "name": "VIVOTEK", "codeowners": ["@HarlemSquirrel"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vivotek", "iot_class": "local_polling", "loggers": ["libpyvivotek"], diff --git a/homeassistant/components/vivotek/strings.json b/homeassistant/components/vivotek/strings.json new file mode 100644 index 00000000000000..acb88ceed71311 --- /dev/null +++ b/homeassistant/components/vivotek/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reconfigure": { + "data": { + "authentication": "[%key:component::vivotek::config::step::user::data::authentication%]", + "framerate": "[%key:component::vivotek::config::step::user::data::framerate%]", + "ip_address": "[%key:common::config_flow::data::ip%]", + "name": "[%key:common::config_flow::data::name%]", + "password": "[%key:common::config_flow::data::password%]", + "security_level": "[%key:component::vivotek::config::step::user::data::security_level%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "stream_path": "[%key:component::vivotek::config::step::user::data::stream_path%]", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "description": "Reconfigure" + }, + "user": { + "data": { + "authentication": "Authentication", + "framerate": "Framerate (Hz)", + "ip_address": "[%key:common::config_flow::data::ip%]", + "name": "[%key:common::config_flow::data::name%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "security_level": "Security level", + "ssl": "[%key:common::config_flow::data::ssl%]", + "stream_path": "Stream path", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "description": "Set required parameters to connect to your camera. For more information, please refer to the [integration documentation]({doc_url})" + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "title": "The {integration_title} YAML configuration is being removed" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1474c4ca3fbd50..01b4a9d7d76d70 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -722,6 +722,7 @@ "vicare", "victron_remote_monitoring", "vilfo", + "vivotek", "vizio", "vlc_telnet", "vodafone_station", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index da2f541163da54..8c7ac56a268633 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7298,7 +7298,7 @@ "vivotek": { "name": "VIVOTEK", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "vizio": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e0352807ab631..c3a668c3883fd7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1177,6 +1177,9 @@ letpot==0.6.3 # homeassistant.components.foscam libpyfoscamcgi==0.0.8 +# homeassistant.components.vivotek +libpyvivotek==0.6.1 + # homeassistant.components.libre_hardware_monitor librehardwaremonitor-api==1.4.0 diff --git a/tests/components/vivotek/__init__.py b/tests/components/vivotek/__init__.py new file mode 100644 index 00000000000000..575e76a24ba545 --- /dev/null +++ b/tests/components/vivotek/__init__.py @@ -0,0 +1 @@ +"""Tests for Vivotek camera component.""" diff --git a/tests/components/vivotek/conftest.py b/tests/components/vivotek/conftest.py new file mode 100644 index 00000000000000..677de9d8a7de82 --- /dev/null +++ b/tests/components/vivotek/conftest.py @@ -0,0 +1,52 @@ +"""Fixtures for Vivotek component tests.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.vivotek.const import DOMAIN +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_IP_ADDRESS, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, +) + +from tests.common import MockConfigEntry + +TEST_DATA = { + CONF_NAME: "Test Camera", + CONF_IP_ADDRESS: "1.2.3.4", + CONF_PORT: "80", + CONF_USERNAME: "admin", + CONF_PASSWORD: "pass1234", + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_SSL: False, + CONF_VERIFY_SSL: True, + "framerate": 2, + "security_level": "admin", + "stream_path": "/live.sdp", +} + + +@pytest.fixture +async def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch("homeassistant.components.vivotek.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock existing config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=TEST_DATA, + title="Vivotek Camera", + unique_id="test_unique_id", + ) diff --git a/tests/components/vivotek/test_config_flow.py b/tests/components/vivotek/test_config_flow.py new file mode 100644 index 00000000000000..2fcb0caf4f7eef --- /dev/null +++ b/tests/components/vivotek/test_config_flow.py @@ -0,0 +1,82 @@ +"""Tests for the Vivotek config flow.""" + +from unittest.mock import patch + +from libpyvivotek.vivotek import VivotekCameraError +import pytest + +from homeassistant import config_entries +from homeassistant.components.vivotek.camera import DEFAULT_NAME +from homeassistant.components.vivotek.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_DATA + + +@pytest.fixture(autouse=True) +def mock_test_config(): + """Mock testing the config.""" + with patch( + "libpyvivotek.VivotekCamera.get_mac", + return_value="11:22:33:44:55:66", + ) as mock_test: + yield mock_test + + +async def test_show_form(hass: HomeAssistant) -> None: + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {} + + +async def test_step_user_connection_error(hass: HomeAssistant) -> None: + """Test we handle connection error.""" + with patch( + "libpyvivotek.VivotekCamera.get_mac", + side_effect=VivotekCameraError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=TEST_DATA, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + +async def test_step_user_unexpected_error(hass: HomeAssistant) -> None: + """Test we handle unexpected error.""" + with patch( + "libpyvivotek.VivotekCamera.get_mac", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=TEST_DATA, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "unknown"} + + +async def test_step_user_success(hass: HomeAssistant) -> None: + """Test we handle success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=TEST_DATA, + ) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == DEFAULT_NAME + assert result.get("data") == TEST_DATA