diff --git a/.coveragerc b/.coveragerc index b2290556519..5e389b0e58f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1002,6 +1002,11 @@ omit = homeassistant/components/qrcode/image_processing.py homeassistant/components/quantum_gateway/device_tracker.py homeassistant/components/qvr_pro/* + homeassistant/components/rabbitair/__init__.py + homeassistant/components/rabbitair/const.py + homeassistant/components/rabbitair/coordinator.py + homeassistant/components/rabbitair/entity.py + homeassistant/components/rabbitair/fan.py homeassistant/components/rachio/__init__.py homeassistant/components/rachio/binary_sensor.py homeassistant/components/rachio/device.py diff --git a/.strict-typing b/.strict-typing index be595c52a23..67d7866ba62 100644 --- a/.strict-typing +++ b/.strict-typing @@ -307,6 +307,7 @@ homeassistant.components.purpleair.* homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* +homeassistant.components.rabbitair.* homeassistant.components.radarr.* homeassistant.components.rainforest_raven.* homeassistant.components.rainmachine.* diff --git a/CODEOWNERS b/CODEOWNERS index f52d810958b..26096d2247a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1036,6 +1036,8 @@ build.json @home-assistant/supervisor /homeassistant/components/qvr_pro/ @oblogic7 /homeassistant/components/qwikswitch/ @kellerza /tests/components/qwikswitch/ @kellerza +/homeassistant/components/rabbitair/ @rabbit-air +/tests/components/rabbitair/ @rabbit-air /homeassistant/components/rachio/ @bdraco @rfverbruggen /tests/components/rachio/ @bdraco @rfverbruggen /homeassistant/components/radarr/ @tkdrob diff --git a/homeassistant/components/rabbitair/__init__.py b/homeassistant/components/rabbitair/__init__.py new file mode 100644 index 00000000000..97b37f6c03f --- /dev/null +++ b/homeassistant/components/rabbitair/__init__.py @@ -0,0 +1,51 @@ +"""The Rabbit Air integration.""" +from __future__ import annotations + +from rabbitair import Client, UdpClient + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RabbitAirDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.FAN] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Rabbit Air from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + host: str = entry.data[CONF_HOST] + token: str = entry.data[CONF_ACCESS_TOKEN] + + zeroconf_instance = await zeroconf.async_get_async_instance(hass) + device: Client = UdpClient(host, token, zeroconf=zeroconf_instance) + + coordinator = RabbitAirDataUpdateCoordinator(hass, device) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + 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): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py new file mode 100644 index 00000000000..70cd07f4d91 --- /dev/null +++ b/homeassistant/components/rabbitair/config_flow.py @@ -0,0 +1,126 @@ +"""Config flow for Rabbit Air integration.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from rabbitair import UdpClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + try: + try: + zeroconf_instance = await zeroconf.async_get_async_instance(hass) + with UdpClient( + data[CONF_HOST], data[CONF_ACCESS_TOKEN], zeroconf=zeroconf_instance + ) as client: + info = await client.get_info() + except Exception as err: + _LOGGER.debug("Connection attempt failed: %s", err) + raise + except ValueError as err: + # Most likely caused by the invalid access token. + raise InvalidAccessToken from err + except asyncio.TimeoutError as err: + # Either the host doesn't respond or the auth failed. + raise TimeoutConnect from err + except OSError as err: + # Most likely caused by the invalid host. + raise InvalidHost from err + except Exception as err: + # Other possible errors. + raise CannotConnect from err + + # Return info to store in the config entry. + return {"mac": info.mac} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rabbit Air.""" + + VERSION = 1 + + _discovered_host: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAccessToken: + errors["base"] = "invalid_access_token" + except InvalidHost: + errors["base"] = "invalid_host" + except TimeoutConnect: + errors["base"] = "timeout_connect" + except Exception as err: # pylint: disable=broad-except + _LOGGER.debug("Unexpected exception: %s", err) + errors["base"] = "unknown" + else: + user_input[CONF_MAC] = info["mac"] + await self.async_set_unique_id(dr.format_mac(info["mac"])) + self._abort_if_unique_id_configured(updates=user_input) + return self.async_create_entry(title="Rabbit Air", data=user_input) + + user_input = user_input or {} + host = user_input.get(CONF_HOST, self._discovered_host) + token = user_input.get(CONF_ACCESS_TOKEN) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=host): str, + vol.Required(CONF_ACCESS_TOKEN, default=token): vol.All( + str, vol.Length(min=32, max=32) + ), + } + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + mac = dr.format_mac(discovery_info.properties["id"]) + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + self._discovered_host = discovery_info.hostname.rstrip(".") + return await self.async_step_user() + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAccessToken(HomeAssistantError): + """Error to indicate the access token is not valid.""" + + +class InvalidHost(HomeAssistantError): + """Error to indicate the host is not valid.""" + + +class TimeoutConnect(HomeAssistantError): + """Error to indicate the connection attempt is timed out.""" diff --git a/homeassistant/components/rabbitair/const.py b/homeassistant/components/rabbitair/const.py new file mode 100644 index 00000000000..8428570faaa --- /dev/null +++ b/homeassistant/components/rabbitair/const.py @@ -0,0 +1,3 @@ +"""Constants for the Rabbit Air integration.""" + +DOMAIN = "rabbitair" diff --git a/homeassistant/components/rabbitair/coordinator.py b/homeassistant/components/rabbitair/coordinator.py new file mode 100644 index 00000000000..36c58f8700c --- /dev/null +++ b/homeassistant/components/rabbitair/coordinator.py @@ -0,0 +1,74 @@ +"""Rabbit Air Update Coordinator.""" +from collections.abc import Coroutine +from datetime import timedelta +import logging +from typing import Any, cast + +from rabbitair import Client, State + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class RabbitAirDebouncer(Debouncer[Coroutine[Any, Any, None]]): + """Class to rate limit calls to a specific command.""" + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize debounce.""" + # We don't want an immediate refresh since the device needs some time + # to apply the changes and reflect the updated state. Two seconds + # should be sufficient, since the internal cycle of the device runs at + # one-second intervals. + super().__init__(hass, _LOGGER, cooldown=2.0, immediate=False) + + async def async_call(self) -> None: + """Call the function.""" + # Restart the timer. + self.async_cancel() + await super().async_call() + + def has_pending_call(self) -> bool: + """Indicate that the debouncer has a call waiting for cooldown.""" + return self._execute_at_end_of_timer + + +class RabbitAirDataUpdateCoordinator(DataUpdateCoordinator[State]): + """Class to manage fetching data from single endpoint.""" + + def __init__(self, hass: HomeAssistant, device: Client) -> None: + """Initialize global data updater.""" + self.device = device + super().__init__( + hass, + _LOGGER, + name="rabbitair", + update_interval=timedelta(seconds=10), + request_refresh_debouncer=RabbitAirDebouncer(hass), + ) + + async def _async_update_data(self) -> State: + return await self.device.get_state() + + async def _async_refresh( + self, + log_failures: bool = True, + raise_on_auth_failed: bool = False, + scheduled: bool = False, + raise_on_entry_error: bool = False, + ) -> None: + """Refresh data.""" + + # Skip a scheduled refresh if there is a pending requested refresh. + debouncer = cast(RabbitAirDebouncer, self._debounced_refresh) + if scheduled and debouncer.has_pending_call(): + return + + await super()._async_refresh( + log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error + ) diff --git a/homeassistant/components/rabbitair/entity.py b/homeassistant/components/rabbitair/entity.py new file mode 100644 index 00000000000..07e49aae7cb --- /dev/null +++ b/homeassistant/components/rabbitair/entity.py @@ -0,0 +1,62 @@ +"""A base class for Rabbit Air entities.""" +from __future__ import annotations + +import logging +from typing import Any + +from rabbitair import Model + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import RabbitAirDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +MODELS = { + Model.A3: "A3", + Model.BioGS: "BioGS 2.0", + Model.MinusA2: "MinusA2", + None: None, +} + + +class RabbitAirBaseEntity(CoordinatorEntity[RabbitAirDataUpdateCoordinator]): + """Base class for Rabbit Air entity.""" + + def __init__( + self, + coordinator: RabbitAirDataUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_name = entry.title + self._attr_unique_id = entry.unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.data[CONF_MAC])}, + manufacturer="Rabbit Air", + model=MODELS.get(coordinator.data.model), + name=entry.title, + sw_version=coordinator.data.wifi_firmware, + hw_version=coordinator.data.main_firmware, + ) + + def _is_model(self, model: Model | list[Model]) -> bool: + """Check the model of the device.""" + if isinstance(model, list): + return self.coordinator.data.model in model + return self.coordinator.data.model is model + + async def _set_state(self, **kwargs: Any) -> None: + """Change the state of the device.""" + _LOGGER.debug("Set state %s", kwargs) + await self.coordinator.device.set_state(**kwargs) + # Force polling of the device, because changing one parameter often + # causes other parameters to change as well. By getting updated status + # we provide a better user experience, especially if the default + # polling interval is set too long. + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py new file mode 100644 index 00000000000..46465163839 --- /dev/null +++ b/homeassistant/components/rabbitair/fan.py @@ -0,0 +1,147 @@ +"""Support for Rabbit Air fan entity.""" +from __future__ import annotations + +from typing import Any + +from rabbitair import Mode, Model, Speed + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, +) + +from .const import DOMAIN +from .coordinator import RabbitAirDataUpdateCoordinator +from .entity import RabbitAirBaseEntity + +SPEED_LIST = [ + Speed.Silent, + Speed.Low, + Speed.Medium, + Speed.High, + Speed.Turbo, +] + +PRESET_MODE_AUTO = "Auto" +PRESET_MODE_MANUAL = "Manual" +PRESET_MODE_POLLEN = "Pollen" + +PRESET_MODES = { + PRESET_MODE_AUTO: Mode.Auto, + PRESET_MODE_MANUAL: Mode.Manual, + PRESET_MODE_POLLEN: Mode.Pollen, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a config entry.""" + coordinator: RabbitAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([RabbitAirFanEntity(coordinator, entry)]) + + +class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): + """Fan control functions of the Rabbit Air air purifier.""" + + _attr_supported_features = FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED + + def __init__( + self, + coordinator: RabbitAirDataUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, entry) + + if self._is_model(Model.MinusA2): + self._attr_preset_modes = list(PRESET_MODES) + elif self._is_model(Model.A3): + # A3 does not support Pollen mode + self._attr_preset_modes = [ + k for k in PRESET_MODES if k != PRESET_MODE_POLLEN + ] + + self._attr_speed_count = len(SPEED_LIST) + + self._get_state_from_coordinator_data() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._get_state_from_coordinator_data() + super()._handle_coordinator_update() + + def _get_state_from_coordinator_data(self) -> None: + """Populate the entity fields with values from the coordinator data.""" + data = self.coordinator.data + + # Speed as a percentage + if not data.power: + self._attr_percentage = 0 + elif data.speed is None: + self._attr_percentage = None + elif data.speed is Speed.SuperSilent: + self._attr_percentage = 1 + else: + self._attr_percentage = ordered_list_item_to_percentage( + SPEED_LIST, data.speed + ) + + # Preset mode + if not data.power or data.mode is None: + self._attr_preset_mode = None + else: + # Get key by value in dictionary + self._attr_preset_mode = next( + k for k, v in PRESET_MODES.items() if v == data.mode + ) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self._set_state(power=True, mode=PRESET_MODES[preset_mode]) + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if percentage > 0: + value = percentage_to_ordered_list_item(SPEED_LIST, percentage) + await self._set_state(power=True, speed=value) + self._attr_percentage = percentage + else: + await self._set_state(power=False) + self._attr_percentage = 0 + self._attr_preset_mode = None + self.async_write_ha_state() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + mode_value: Mode | None = None + if preset_mode is not None: + mode_value = PRESET_MODES[preset_mode] + speed_value: Speed | None = None + if percentage is not None: + speed_value = percentage_to_ordered_list_item(SPEED_LIST, percentage) + await self._set_state(power=True, mode=mode_value, speed=speed_value) + if percentage is not None: + self._attr_percentage = percentage + if preset_mode is not None: + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self._set_state(power=False) + self._attr_percentage = 0 + self._attr_preset_mode = None + self.async_write_ha_state() diff --git a/homeassistant/components/rabbitair/manifest.json b/homeassistant/components/rabbitair/manifest.json new file mode 100644 index 00000000000..8f4df8afb7b --- /dev/null +++ b/homeassistant/components/rabbitair/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "rabbitair", + "name": "Rabbit Air", + "after_dependencies": ["zeroconf"], + "codeowners": ["@rabbit-air"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/rabbitair", + "iot_class": "local_polling", + "requirements": ["python-rabbitair==0.0.8"], + "zeroconf": ["_rabbitair._udp.local."] +} diff --git a/homeassistant/components/rabbitair/strings.json b/homeassistant/components/rabbitair/strings.json new file mode 100644 index 00000000000..dd44a51d48f --- /dev/null +++ b/homeassistant/components/rabbitair/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "access_token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8a71d51acf2..6a387da0d42 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -394,6 +394,7 @@ FLOWS = { "qingping", "qnap", "qnap_qsw", + "rabbitair", "rachio", "radarr", "radio_browser", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4738de291fa..a7cfea03be7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4664,6 +4664,12 @@ "config_flow": false, "iot_class": "local_push" }, + "rabbitair": { + "name": "Rabbit Air", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "rachio": { "name": "Rachio", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index fea1d4ec889..21d44317161 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -621,6 +621,11 @@ ZEROCONF = { "name": "brother*", }, ], + "_rabbitair._udp.local.": [ + { + "domain": "rabbitair", + }, + ], "_raop._tcp.local.": [ { "domain": "apple_tv", diff --git a/mypy.ini b/mypy.ini index 6e2630813e6..8d621949810 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2831,6 +2831,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rabbitair.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.radarr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 8d68009e180..522c99dd572 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,6 +2237,9 @@ python-picnic-api==1.1.0 # homeassistant.components.qbittorrent python-qbittorrent==0.4.3 +# homeassistant.components.rabbitair +python-rabbitair==0.0.8 + # homeassistant.components.ripple python-ripple-api==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b7f526ad15f..bd030a294bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1695,6 +1695,9 @@ python-picnic-api==1.1.0 # homeassistant.components.qbittorrent python-qbittorrent==0.4.3 +# homeassistant.components.rabbitair +python-rabbitair==0.0.8 + # homeassistant.components.roborock python-roborock==0.38.0 diff --git a/tests/components/rabbitair/__init__.py b/tests/components/rabbitair/__init__.py new file mode 100644 index 00000000000..04fae763f56 --- /dev/null +++ b/tests/components/rabbitair/__init__.py @@ -0,0 +1 @@ +"""Tests for the RabbitAir integration.""" diff --git a/tests/components/rabbitair/test_config_flow.py b/tests/components/rabbitair/test_config_flow.py new file mode 100644 index 00000000000..75b97d01065 --- /dev/null +++ b/tests/components/rabbitair/test_config_flow.py @@ -0,0 +1,210 @@ +"""Test the RabbitAir config flow.""" +from __future__ import annotations + +import asyncio +from collections.abc import Generator +from ipaddress import ip_address +from unittest.mock import Mock, patch + +import pytest +from rabbitair import Mode, Model, Speed + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.rabbitair.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac + +TEST_HOST = "1.1.1.1" +TEST_NAME = "abcdef1234_123456789012345678" +TEST_TOKEN = "0123456789abcdef0123456789abcdef" +TEST_MAC = "01:23:45:67:89:AB" +TEST_FIRMWARE = "2.3.17" +TEST_HARDWARE = "1.0.0.4" +TEST_UNIQUE_ID = format_mac(TEST_MAC) +TEST_TITLE = "Rabbit Air" + +ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], + port=9009, + hostname=f"{TEST_NAME}.local.", + type="_rabbitair._udp.local.", + name=f"{TEST_NAME}._rabbitair._udp.local.", + properties={"id": TEST_MAC.replace(":", "")}, +) + + +@pytest.fixture(autouse=True) +def use_mocked_zeroconf(mock_async_zeroconf): + """Mock zeroconf in all tests.""" + + +@pytest.fixture +def rabbitair_connect() -> Generator[None, None, None]: + """Mock connection.""" + with patch("rabbitair.UdpClient.get_info", return_value=get_mock_info()), patch( + "rabbitair.UdpClient.get_state", return_value=get_mock_state() + ): + yield + + +def get_mock_info(mac: str = TEST_MAC) -> Mock: + """Return a mock device info instance.""" + mock_info = Mock() + mock_info.mac = mac + return mock_info + + +def get_mock_state( + model: Model | None = Model.A3, + main_firmware: str | None = TEST_HARDWARE, + power: bool | None = True, + mode: Mode | None = Mode.Auto, + speed: Speed | None = Speed.Low, + wifi_firmware: str | None = TEST_FIRMWARE, +) -> Mock: + """Return a mock device state instance.""" + mock_state = Mock() + mock_state.model = model + mock_state.main_firmware = main_firmware + mock_state.power = power + mock_state.mode = mode + mock_state.speed = speed + mock_state.wifi_firmware = wifi_firmware + return mock_state + + +@pytest.mark.usefixtures("rabbitair_connect") +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.rabbitair.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_TITLE + assert result2["data"] == { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + CONF_MAC: TEST_MAC, + } + assert result2["result"].unique_id == TEST_UNIQUE_ID + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error_type", "base_value"), + [ + (ValueError, "invalid_access_token"), + (OSError, "invalid_host"), + (asyncio.TimeoutError, "timeout_connect"), + (Exception, "cannot_connect"), + ], +) +async def test_form_cannot_connect( + hass: HomeAssistant, error_type: type[Exception], base_value: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "rabbitair.UdpClient.get_info", + side_effect=error_type, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": base_value} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.rabbitair.config_flow.validate_input", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST, + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + +@pytest.mark.usefixtures("rabbitair_connect") +async def test_zeroconf_discovery(hass: HomeAssistant) -> None: + """Test zeroconf discovery setup flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=ZEROCONF_DATA + ) + + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.rabbitair.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_NAME + ".local", + CONF_ACCESS_TOKEN: TEST_TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_TITLE + assert result2["data"] == { + CONF_HOST: TEST_NAME + ".local", + CONF_ACCESS_TOKEN: TEST_TOKEN, + CONF_MAC: TEST_MAC, + } + assert result2["result"].unique_id == TEST_UNIQUE_ID + assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=ZEROCONF_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured"