From 1692d830630ee8d4914ac0fc7319ab4cdd3e0782 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 28 Aug 2023 15:10:23 +0200 Subject: [PATCH] Vodafone Station device tracker (#94032) * New integration for Vodafone Station * coveragerc * Add ConfigFlow,ScannerEntity,DataUpdateCoordinator * Introduce aiovodafone lib * heavy cleanup * bump aiovodafone to v0.0.5 * add config_flow tests (100% coverage) * run pre-comimit scripts again * Remove redundant parameter SSL * rename and cleanup * cleanup and bug fix * cleanup exceptions * constructor comment review * improve test patching * move VodafoneStationDeviceInfo to dataclass * intriduce home field * dispacher cleanup * remove extra attributes (reduces state writes) * attempt to complete test flow * complete flow for test_exception_connection * add comment about unique id --- .coveragerc | 4 + CODEOWNERS | 2 + .../components/vodafone_station/__init__.py | 40 ++++ .../vodafone_station/config_flow.py | 127 +++++++++++ .../components/vodafone_station/const.py | 11 + .../vodafone_station/coordinator.py | 124 ++++++++++ .../vodafone_station/device_tracker.py | 114 ++++++++++ .../components/vodafone_station/manifest.json | 10 + .../components/vodafone_station/strings.json | 33 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/vodafone_station/__init__.py | 1 + tests/components/vodafone_station/const.py | 17 ++ .../vodafone_station/test_config_flow.py | 215 ++++++++++++++++++ 16 files changed, 711 insertions(+) create mode 100644 homeassistant/components/vodafone_station/__init__.py create mode 100644 homeassistant/components/vodafone_station/config_flow.py create mode 100644 homeassistant/components/vodafone_station/const.py create mode 100644 homeassistant/components/vodafone_station/coordinator.py create mode 100644 homeassistant/components/vodafone_station/device_tracker.py create mode 100644 homeassistant/components/vodafone_station/manifest.json create mode 100644 homeassistant/components/vodafone_station/strings.json create mode 100644 tests/components/vodafone_station/__init__.py create mode 100644 tests/components/vodafone_station/const.py create mode 100644 tests/components/vodafone_station/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 46c7c568124..6f26795d1b5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1440,6 +1440,10 @@ omit = homeassistant/components/vlc/media_player.py homeassistant/components/vlc_telnet/__init__.py homeassistant/components/vlc_telnet/media_player.py + homeassistant/components/vodafone_station/__init__.py + homeassistant/components/vodafone_station/const.py + homeassistant/components/vodafone_station/coordinator.py + homeassistant/components/vodafone_station/device_tracker.py homeassistant/components/volkszaehler/sensor.py homeassistant/components/volumio/__init__.py homeassistant/components/volumio/browse_media.py diff --git a/CODEOWNERS b/CODEOWNERS index b6241669796..9e8d297d37f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1368,6 +1368,8 @@ build.json @home-assistant/supervisor /tests/components/vizio/ @raman325 /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare /tests/components/vlc_telnet/ @rodripf @MartinHjelmare +/homeassistant/components/vodafone_station/ @paoloantinori @chemelli74 +/tests/components/vodafone_station/ @paoloantinori @chemelli74 /homeassistant/components/voip/ @balloob @synesthesiam /tests/components/voip/ @balloob @synesthesiam /homeassistant/components/volumio/ @OnFreund diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py new file mode 100644 index 00000000000..c1cf23d974f --- /dev/null +++ b/homeassistant/components/vodafone_station/__init__.py @@ -0,0 +1,40 @@ +"""Vodafone Station integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import VodafoneStationRouter + +PLATFORMS = [Platform.DEVICE_TRACKER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Vodafone Station platform.""" + coordinator = VodafoneStationRouter( + hass, + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.unique_id, + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + await coordinator.api.logout() + await coordinator.api.close() + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py new file mode 100644 index 00000000000..e4a087f6903 --- /dev/null +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -0,0 +1,127 @@ +"""Config flow for Vodafone Station integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiovodafone import VodafoneStationApi, exceptions as aiovodafone_exceptions +import voluptuous as vol + +from homeassistant import core +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN + + +def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: + """Return user form schema.""" + user_input = user_input or {} + return vol.Schema( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) + + +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: + """Validate the user input allows us to connect.""" + + api = VodafoneStationApi(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) + + try: + await api.login() + finally: + await api.logout() + await api.close() + + return {"title": data[CONF_HOST]} + + +class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Vodafone Station.""" + + VERSION = 1 + entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=user_form_schema(user_input) + ) + + # Use host because no serial number or mac is available to use for a unique id + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except aiovodafone_exceptions.CannotConnect: + errors["base"] = "cannot_connect" + except aiovodafone_exceptions.CannotAuthenticate: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=user_form_schema(user_input), errors=errors + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauth flow.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert self.entry + self.context["title_placeholders"] = {"host": self.entry.data[CONF_HOST]} + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle reauth confirm.""" + assert self.entry + errors = {} + + if user_input is not None: + try: + await validate_input(self.hass, {**self.entry.data, **user_input}) + except aiovodafone_exceptions.CannotConnect: + errors["base"] = "cannot_connect" + except aiovodafone_exceptions.CannotAuthenticate: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_HOST: self.entry.data[CONF_HOST]}, + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/vodafone_station/const.py b/homeassistant/components/vodafone_station/const.py new file mode 100644 index 00000000000..8d5a60afb60 --- /dev/null +++ b/homeassistant/components/vodafone_station/const.py @@ -0,0 +1,11 @@ +"""Vodafone Station constants.""" +import logging + +_LOGGER = logging.getLogger(__package__) + +DOMAIN = "vodafone_station" + +DEFAULT_DEVICE_NAME = "Unknown device" +DEFAULT_HOST = "192.168.1.1" +DEFAULT_USERNAME = "vodafone" +DEFAULT_SSL = True diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py new file mode 100644 index 00000000000..b79acac9ce9 --- /dev/null +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -0,0 +1,124 @@ +"""Support for Vodafone Station.""" +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any + +from aiovodafone import VodafoneStationApi, VodafoneStationDevice, exceptions + +from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import _LOGGER, DOMAIN + +CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds() + + +@dataclass(slots=True) +class VodafoneStationDeviceInfo: + """Representation of a device connected to the Vodafone Station.""" + + device: VodafoneStationDevice + update_time: datetime | None + home: bool + + +@dataclass(slots=True) +class UpdateCoordinatorDataType: + """Update coordinator data type.""" + + devices: dict[str, VodafoneStationDeviceInfo] + sensors: dict[str, Any] + + +class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): + """Queries router running Vodafone Station firmware.""" + + def __init__( + self, + hass: HomeAssistant, + host: str, + username: str, + password: str, + config_entry_unique_id: str | None, + ) -> None: + """Initialize the scanner.""" + + self._host = host + self.api = VodafoneStationApi(host, username, password) + + # Last resort as no MAC or S/N can be retrieved via API + self._id = config_entry_unique_id + + super().__init__( + hass=hass, + logger=_LOGGER, + name=f"{DOMAIN}-{host}-coordinator", + update_interval=timedelta(seconds=30), + ) + + def _calculate_update_time_and_consider_home( + self, device: VodafoneStationDevice, utc_point_in_time: datetime + ) -> tuple[datetime | None, bool]: + """Return update time and consider home. + + If the device is connected, return the current time and True. + + If the device is not connected, return the last update time and + whether the device was considered home at that time. + + If the device is not connected and there is no last update time, + return None and False. + """ + if device.connected: + return utc_point_in_time, True + + if ( + (data := self.data) + and (stored_device := data.devices.get(device.mac)) + and (update_time := stored_device.update_time) + ): + return ( + update_time, + ( + (utc_point_in_time - update_time).total_seconds() + < CONSIDER_HOME_SECONDS + ), + ) + + return None, False + + async def _async_update_data(self) -> UpdateCoordinatorDataType: + """Update router data.""" + _LOGGER.debug("Polling Vodafone Station host: %s", self._host) + try: + logged = await self.api.login() + except exceptions.CannotConnect as err: + _LOGGER.warning("Connection error for %s", self._host) + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed from err + + if not logged: + raise ConfigEntryAuthFailed + + utc_point_in_time = dt_util.utcnow() + data_devices = { + dev_info.mac: VodafoneStationDeviceInfo( + dev_info, + *self._calculate_update_time_and_consider_home( + dev_info, utc_point_in_time + ), + ) + for dev_info in (await self.api.get_all_devices()).values() + } + data_sensors = await self.api.get_user_data() + await self.api.logout() + return UpdateCoordinatorDataType(data_devices, data_sensors) + + @property + def signal_device_new(self) -> str: + """Event specific per Vodafone Station entry to signal new device.""" + return f"{DOMAIN}-device-new-{self._id}" diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py new file mode 100644 index 00000000000..9f98da88d22 --- /dev/null +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -0,0 +1,114 @@ +"""Support for Vodafone Station routers.""" +from __future__ import annotations + +from aiovodafone import VodafoneStationDevice + +from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import _LOGGER, DOMAIN +from .coordinator import VodafoneStationDeviceInfo, VodafoneStationRouter + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up device tracker for Vodafone Station component.""" + + _LOGGER.debug("Start device trackers setup") + coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + + tracked: set = set() + + @callback + def async_update_router() -> None: + """Update the values of the router.""" + async_add_new_tracked_entities(coordinator, async_add_entities, tracked) + + entry.async_on_unload( + async_dispatcher_connect( + hass, coordinator.signal_device_new, async_update_router + ) + ) + + async_update_router() + + +@callback +def async_add_new_tracked_entities( + coordinator: VodafoneStationRouter, + async_add_entities: AddEntitiesCallback, + tracked: set[str], +) -> None: + """Add new tracker entities from the router.""" + new_tracked = [] + + _LOGGER.debug("Adding device trackers entities") + for mac, device_info in coordinator.data.devices.items(): + if mac in tracked: + continue + _LOGGER.debug("New device tracker: %s", device_info.device.name) + new_tracked.append(VodafoneStationTracker(coordinator, device_info)) + tracked.add(mac) + + async_add_entities(new_tracked) + + +class VodafoneStationTracker(CoordinatorEntity[VodafoneStationRouter], ScannerEntity): + """Representation of a Vodafone Station device.""" + + def __init__( + self, coordinator: VodafoneStationRouter, device_info: VodafoneStationDeviceInfo + ) -> None: + """Initialize a Vodafone Station device.""" + super().__init__(coordinator) + self._coordinator = coordinator + device = device_info.device + mac = device.mac + self._device_mac = mac + self._attr_unique_id = mac + self._attr_name = device.name or mac.replace(":", "_") + + @property + def _device_info(self) -> VodafoneStationDeviceInfo: + """Return fresh data for the device.""" + return self.coordinator.data.devices[self._device_mac] + + @property + def _device(self) -> VodafoneStationDevice: + """Return fresh data for the device.""" + return self.coordinator.data.devices[self._device_mac].device + + @property + def is_connected(self) -> bool: + """Return true if the device is connected to the network.""" + return self._device_info.home + + @property + def source_type(self) -> SourceType: + """Return the source type.""" + return SourceType.ROUTER + + @property + def hostname(self) -> str | None: + """Return the hostname of device.""" + return self._attr_name + + @property + def icon(self) -> str: + """Return device icon.""" + return "mdi:lan-connect" if self._device.connected else "mdi:lan-disconnect" + + @property + def ip_address(self) -> str | None: + """Return the primary ip address of the device.""" + return self._device.ip_address + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self._device_mac diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json new file mode 100644 index 00000000000..7069629ca2e --- /dev/null +++ b/homeassistant/components/vodafone_station/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "vodafone_station", + "name": "Vodafone Station", + "codeowners": ["@paoloantinori", "@chemelli74"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/vodafone_station", + "iot_class": "local_polling", + "loggers": ["aiovodafone"], + "requirements": ["aiovodafone==0.0.6"] +} diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json new file mode 100644 index 00000000000..3c452133c28 --- /dev/null +++ b/homeassistant/components/vodafone_station/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "flow_title": "{host}", + "step": { + "reauth_confirm": { + "description": "Please enter the correct password for host: {host}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "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%]" + }, + "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%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 93d7ec1fbdc..fe23ae9697f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -508,6 +508,7 @@ FLOWS = { "vilfo", "vizio", "vlc_telnet", + "vodafone_station", "voip", "volumio", "volvooncall", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 07960a97fe5..81afb1cecd8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6202,6 +6202,12 @@ } } }, + "vodafone_station": { + "name": "Vodafone Station", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "voicerss": { "name": "VoiceRSS", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 6be8bbd1c31..56455ef39c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -365,6 +365,9 @@ aiounifi==58 # homeassistant.components.vlc_telnet aiovlc==0.1.0 +# homeassistant.components.vodafone_station +aiovodafone==0.0.6 + # homeassistant.components.waqi aiowaqi==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bdc29d3690..5ad1dd12361 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -340,6 +340,9 @@ aiounifi==58 # homeassistant.components.vlc_telnet aiovlc==0.1.0 +# homeassistant.components.vodafone_station +aiovodafone==0.0.6 + # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/tests/components/vodafone_station/__init__.py b/tests/components/vodafone_station/__init__.py new file mode 100644 index 00000000000..68f11a27b95 --- /dev/null +++ b/tests/components/vodafone_station/__init__.py @@ -0,0 +1 @@ +"""Tests for the Vodafone Station integration.""" diff --git a/tests/components/vodafone_station/const.py b/tests/components/vodafone_station/const.py new file mode 100644 index 00000000000..40dc305630e --- /dev/null +++ b/tests/components/vodafone_station/const.py @@ -0,0 +1,17 @@ +"""Common stuff for Vodafone Station tests.""" +from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +MOCK_CONFIG = { + DOMAIN: { + CONF_DEVICES: [ + { + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + } + ] + } +} + +MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py new file mode 100644 index 00000000000..03a1198288d --- /dev/null +++ b/tests/components/vodafone_station/test_config_flow.py @@ -0,0 +1,215 @@ +"""Tests for Vodafone Station config flow.""" +from unittest.mock import patch + +from aiovodafone import exceptions as aiovodafone_exceptions +import pytest + +from homeassistant.components.vodafone_station.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_user(hass: HomeAssistant) -> None: + """Test starting a flow by user.""" + with patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + ), patch( + "homeassistant.components.vodafone_station.async_setup_entry" + ) as mock_setup_entry, patch( + "requests.get" + ) as mock_request_get: + mock_request_get.return_value.status_code = 200 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_USERNAME] == "fake_username" + assert result["data"][CONF_PASSWORD] == "fake_password" + assert not result["result"].unique_id + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (aiovodafone_exceptions.CannotConnect, "cannot_connect"), + (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), + (ConnectionResetError, "unknown"), + ], +) +async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> None: + """Test starting a flow by user with a connection error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "aiovodafone.api.VodafoneStationApi.login", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == error + + # Should be recoverable after hits error + with patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_all_devices", + return_value={ + "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", + "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", + }, + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + ), patch( + "homeassistant.components.vodafone_station.async_setup_entry" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "fake_host" + assert result2["data"] == { + "host": "fake_host", + "username": "fake_username", + "password": "fake_password", + } + + +async def test_reauth_successful(hass: HomeAssistant) -> None: + """Test starting a reauthentication flow.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + ), patch( + "homeassistant.components.vodafone_station.async_setup_entry" + ), patch( + "requests.get" + ) as mock_request_get: + mock_request_get.return_value.status_code = 200 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (aiovodafone_exceptions.CannotConnect, "cannot_connect"), + (aiovodafone_exceptions.CannotAuthenticate, "invalid_auth"), + (ConnectionResetError, "unknown"), + ], +) +async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: + """Test starting a reauthentication flow but no connection found.""" + + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + side_effect=side_effect, + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + ), patch( + "homeassistant.components.vodafone_station.async_setup_entry" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"]["base"] == error + + # Should be recoverable after hits error + with patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_all_devices", + return_value={ + "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", + "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", + }, + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + ), patch( + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + ), patch( + "homeassistant.components.vodafone_station.async_setup_entry" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful"