diff --git a/.coveragerc b/.coveragerc index 6690ed5ee20..7a1f53347b2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -585,6 +585,10 @@ omit = homeassistant/components/logi_circle/const.py homeassistant/components/logi_circle/sensor.py homeassistant/components/london_underground/sensor.py + homeassistant/components/lookin/__init__.py + homeassistant/components/lookin/entity.py + homeassistant/components/lookin/models.py + homeassistant/components/lookin/sensor.py homeassistant/components/loopenergy/sensor.py homeassistant/components/luci/device_tracker.py homeassistant/components/luftdaten/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index d25abecb8ed..936fa50cb9f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -287,6 +287,7 @@ homeassistant/components/litterrobot/* @natekspencer homeassistant/components/local_ip/* @issacg homeassistant/components/logger/* @home-assistant/core homeassistant/components/logi_circle/* @evanjd +homeassistant/components/lookin/* @ANMalko homeassistant/components/loopenergy/* @pavoni homeassistant/components/lovelace/* @home-assistant/frontend homeassistant/components/luci/* @mzdrale diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py new file mode 100644 index 00000000000..a096c08dfeb --- /dev/null +++ b/homeassistant/components/lookin/__init__.py @@ -0,0 +1,68 @@ +"""The lookin integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import aiohttp +from aiolookin import LookInHttpProtocol, LookinUDPSubscriptions, start_lookin_udp + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, PLATFORMS +from .models import LookinData + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up lookin from a config entry.""" + + host = entry.data[CONF_HOST] + lookin_protocol = LookInHttpProtocol( + api_uri=f"http://{host}", session=async_get_clientsession(hass) + ) + + try: + lookin_device = await lookin_protocol.get_info() + devices = await lookin_protocol.get_devices() + except aiohttp.ClientError as ex: + raise ConfigEntryNotReady from ex + + meteo_coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + hass, + LOGGER, + name=entry.title, + update_method=lookin_protocol.get_meteo_sensor, + update_interval=timedelta( + minutes=5 + ), # Updates are pushed (fallback is polling) + ) + await meteo_coordinator.async_config_entry_first_refresh() + + lookin_udp_subs = LookinUDPSubscriptions() + entry.async_on_unload(await start_lookin_udp(lookin_udp_subs)) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = LookinData( + lookin_udp_subs=lookin_udp_subs, + lookin_device=lookin_device, + meteo_coordinator=meteo_coordinator, + devices=devices, + lookin_protocol=lookin_protocol, + ) + + hass.config_entries.async_setup_platforms(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): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/lookin/config_flow.py b/homeassistant/components/lookin/config_flow.py new file mode 100644 index 00000000000..f4fcece1303 --- /dev/null +++ b/homeassistant/components/lookin/config_flow.py @@ -0,0 +1,108 @@ +"""The lookin integration config_flow.""" +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +from aiolookin import Device, LookInHttpProtocol, NoUsableService +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import DiscoveryInfoType + +from .const import DOMAIN + +LOGGER = logging.getLogger(__name__) + + +class LookinFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for lookin.""" + + def __init__(self) -> None: + """Init the lookin flow.""" + self._host: str | None = None + self._name: str | None = None + + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Start a discovery flow from zeroconf.""" + uid: str = discovery_info["hostname"][: -len(".local.")] + host: str = discovery_info["host"] + await self.async_set_unique_id(uid.upper()) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + try: + device: Device = await self._validate_device(host=host) + except (aiohttp.ClientError, NoUsableService): + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + self._name = device.name + + self._host = host + self._set_confirm_only() + self.context["title_placeholders"] = {"name": self._name, "host": host} + return await self.async_step_discovery_confirm() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """User initiated discover flow.""" + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + try: + device = await self._validate_device(host=host) + except (aiohttp.ClientError, NoUsableService): + errors[CONF_HOST] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + device_id = device.id.upper() + await self.async_set_unique_id(device_id, raise_on_progress=False) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + return self.async_create_entry( + title=device.name or host, + data={CONF_HOST: host}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + async def _validate_device(self, host: str) -> Device: + """Validate we can connect to the device.""" + session = async_get_clientsession(self.hass) + lookin_protocol = LookInHttpProtocol(host, session) + return await lookin_protocol.get_info() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm the discover flow.""" + assert self._host is not None + if user_input is None: + self.context["title_placeholders"] = { + "name": self._name, + "host": self._host, + } + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={"name": self._name, "host": self._host}, + ) + + return self.async_create_entry( + title=self._name or self._host, + data={CONF_HOST: self._host}, + ) diff --git a/homeassistant/components/lookin/const.py b/homeassistant/components/lookin/const.py new file mode 100644 index 00000000000..a478b24df3b --- /dev/null +++ b/homeassistant/components/lookin/const.py @@ -0,0 +1,9 @@ +"""The lookin integration constants.""" +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "lookin" +PLATFORMS: Final = [ + "sensor", +] diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py new file mode 100644 index 00000000000..fd2ee5e4a6c --- /dev/null +++ b/homeassistant/components/lookin/entity.py @@ -0,0 +1,84 @@ +"""The lookin integration entity.""" +from __future__ import annotations + +from aiolookin import POWER_CMD, POWER_OFF_CMD, POWER_ON_CMD, Climate, Remote + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN +from .models import LookinData + + +class LookinDeviceEntity(Entity): + """A lookin device entity on the device itself.""" + + _attr_should_poll = False + + def __init__(self, lookin_data: LookinData) -> None: + """Init the lookin device entity.""" + super().__init__() + self._lookin_device = lookin_data.lookin_device + self._lookin_protocol = lookin_data.lookin_protocol + self._lookin_udp_subs = lookin_data.lookin_udp_subs + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._lookin_device.id)}, + name=self._lookin_device.name, + manufacturer="LOOKin", + model="LOOKin Remote2", + sw_version=self._lookin_device.firmware, + ) + + +class LookinEntity(Entity): + """A base class for lookin entities.""" + + _attr_should_poll = False + _attr_assumed_state = True + + def __init__( + self, + uuid: str, + device: Remote | Climate, + lookin_data: LookinData, + ) -> None: + """Init the base entity.""" + self._device = device + self._uuid = uuid + self._lookin_device = lookin_data.lookin_device + self._lookin_protocol = lookin_data.lookin_protocol + self._lookin_udp_subs = lookin_data.lookin_udp_subs + self._meteo_coordinator = lookin_data.meteo_coordinator + self._function_names = {function.name for function in self._device.functions} + self._attr_unique_id = uuid + self._attr_name = self._device.name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._uuid)}, + name=self._device.name, + model=self._device.device_type, + via_device=(DOMAIN, self._lookin_device.id), + ) + + async def _async_send_command(self, command: str) -> None: + """Send command from saved IR device.""" + await self._lookin_protocol.send_command( + uuid=self._uuid, command=command, signal="FF" + ) + + +class LookinPowerEntity(LookinEntity): + """A Lookin entity that has a power on and power off command.""" + + def __init__( + self, + uuid: str, + device: Remote | Climate, + lookin_data: LookinData, + ) -> None: + """Init the power entity.""" + super().__init__(uuid, device, lookin_data) + self._power_on_command: str = POWER_CMD + self._power_off_command: str = POWER_CMD + if POWER_ON_CMD in self._function_names: + self._power_on_command = POWER_ON_CMD + if POWER_OFF_CMD in self._function_names: + self._power_off_command = POWER_OFF_CMD diff --git a/homeassistant/components/lookin/manifest.json b/homeassistant/components/lookin/manifest.json new file mode 100644 index 00000000000..2307c89f3aa --- /dev/null +++ b/homeassistant/components/lookin/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "lookin", + "name": "LOOKin", + "documentation": "https://www.home-assistant.io/integrations/lookin/", + "codeowners": ["@ANMalko"], + "requirements": ["aiolookin==0.0.2"], + "zeroconf": ["_lookin._tcp.local."], + "config_flow": true, + "iot_class": "local_push" +} diff --git a/homeassistant/components/lookin/models.py b/homeassistant/components/lookin/models.py new file mode 100644 index 00000000000..6fd812133c0 --- /dev/null +++ b/homeassistant/components/lookin/models.py @@ -0,0 +1,20 @@ +"""The lookin integration models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from aiolookin import Device, LookInHttpProtocol, LookinUDPSubscriptions + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +@dataclass +class LookinData: + """Data for the lookin integration.""" + + lookin_udp_subs: LookinUDPSubscriptions + lookin_device: Device + meteo_coordinator: DataUpdateCoordinator + devices: list[dict[str, Any]] + lookin_protocol: LookInHttpProtocol diff --git a/homeassistant/components/lookin/sensor.py b/homeassistant/components/lookin/sensor.py new file mode 100644 index 00000000000..34a3859c7ec --- /dev/null +++ b/homeassistant/components/lookin/sensor.py @@ -0,0 +1,98 @@ +"""The lookin integration sensor platform.""" +from __future__ import annotations + +import logging + +from aiolookin import MeteoSensor, SensorID + +from homeassistant.components.sensor import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .entity import LookinDeviceEntity +from .models import LookinData + +LOGGER = logging.getLogger(__name__) + + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up lookin sensors from the config entry.""" + lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + [LookinSensorEntity(description, lookin_data) for description in SENSOR_TYPES] + ) + + +class LookinSensorEntity(CoordinatorEntity, LookinDeviceEntity, SensorEntity, Entity): + """A lookin device sensor entity.""" + + def __init__( + self, description: SensorEntityDescription, lookin_data: LookinData + ) -> None: + """Init the lookin sensor entity.""" + super().__init__(lookin_data.meteo_coordinator) + LookinDeviceEntity.__init__(self, lookin_data) + self.entity_description = description + self._attr_name = f"{self._lookin_device.name} {description.name}" + self._attr_native_value = getattr(self.coordinator.data, description.key) + self._attr_unique_id = f"{self._lookin_device.id}-{description.key}" + + def _handle_coordinator_update(self) -> None: + """Update the state of the entity.""" + self._attr_native_value = getattr( + self.coordinator.data, self.entity_description.key + ) + super()._handle_coordinator_update() + + @callback + def _async_push_update(self, msg: dict[str, str]) -> None: + """Process an update pushed via UDP.""" + if int(msg["event_id"]): + return + LOGGER.debug("Processing push message for meteo sensor: %s", msg) + meteo: MeteoSensor = self.coordinator.data + meteo.update_from_value(msg["value"]) + self.coordinator.async_set_updated_data(meteo) + + async def async_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + self.async_on_remove( + self._lookin_udp_subs.subscribe_sensor( + self._lookin_device.id, SensorID.Meteo, None, self._async_push_update + ) + ) + return await super().async_added_to_hass() diff --git a/homeassistant/components/lookin/strings.json b/homeassistant/components/lookin/strings.json new file mode 100644 index 00000000000..1285be4abf0 --- /dev/null +++ b/homeassistant/components/lookin/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "flow_title": "{name} ({host})", + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + } + }, + "device_name": { + "data": { + "name": "[%key:common::config_flow::data::name%]" + } + }, + "discovery_confirm": { + "description": "Do you want to setup {name} ({host})?" + } + }, + "error": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lookin/translations/en.json b/homeassistant/components/lookin/translations/en.json new file mode 100644 index 00000000000..ddb8c310408 --- /dev/null +++ b/homeassistant/components/lookin/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", + "no_devices_found": "No devices found on the network" + }, + "error": { + "cannot_connect": "Failed to connect", + "no_devices_found": "No devices found on the network", + "unknown": "Unexpected error" + }, + "flow_title": "{name} ({host})", + "step": { + "device_name": { + "data": { + "name": "Name" + } + }, + "discovery_confirm": { + "description": "Do you want to setup {name} ({host})?" + }, + "user": { + "data": { + "ip_address": "IP Address" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 39719402239..042babb6313 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -159,6 +159,7 @@ FLOWS = [ "local_ip", "locative", "logi_circle", + "lookin", "luftdaten", "lutron_caseta", "lyric", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 6a1a7f03be1..041406af50a 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -157,6 +157,11 @@ ZEROCONF = { "domain": "lutron_caseta" } ], + "_lookin._tcp.local.": [ + { + "domain": "lookin" + } + ], "_mediaremotetv._tcp.local.": [ { "domain": "apple_tv" diff --git a/requirements_all.txt b/requirements_all.txt index 15d664ba697..34bf5a9764c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -206,6 +206,9 @@ aiolifx_effects==0.2.2 # homeassistant.components.lutron_caseta aiolip==1.1.6 +# homeassistant.components.lookin +aiolookin==0.0.2 + # homeassistant.components.lyric aiolyric==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f58342b8e5..c78662c7351 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -136,6 +136,9 @@ aiokafka==0.6.0 # homeassistant.components.lutron_caseta aiolip==1.1.6 +# homeassistant.components.lookin +aiolookin==0.0.2 + # homeassistant.components.lyric aiolyric==1.0.7 diff --git a/tests/components/lookin/__init__.py b/tests/components/lookin/__init__.py new file mode 100644 index 00000000000..c2821fafab8 --- /dev/null +++ b/tests/components/lookin/__init__.py @@ -0,0 +1,53 @@ +"""Tests for the lookin integration.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from aiolookin import Climate, Device, Remote + +from homeassistant.components.zeroconf import HaServiceInfo + +DEVICE_ID = "98F33163" +MODULE = "homeassistant.components.lookin" +MODULE_CONFIG_FLOW = "homeassistant.components.lookin.config_flow" +IP_ADDRESS = "127.0.0.1" + +DEVICE_NAME = "Living Room" +DEFAULT_ENTRY_TITLE = DEVICE_NAME + +ZC_NAME = f"LOOKin_{DEVICE_ID}" +ZC_TYPE = "_lookin._tcp." +ZEROCONF_DATA: HaServiceInfo = { + "host": IP_ADDRESS, + "hostname": f"{ZC_NAME.lower()}.local.", + "port": 80, + "type": ZC_TYPE, + "name": ZC_NAME, + "properties": {}, +} + + +def _mocked_climate() -> Climate: + climate = MagicMock(auto_spec=Climate) + return climate + + +def _mocked_remote() -> Remote: + remote = MagicMock(auto_spec=Remote) + return remote + + +def _mocked_device() -> Device: + device = MagicMock(auto_spec=Device) + device.name = DEVICE_NAME + device.id = DEVICE_ID + return device + + +def _patch_get_info(device=None, exception=None): + async def _get_info(*args, **kwargs): + if exception: + raise exception + return device if device else _mocked_device() + + return patch(f"{MODULE_CONFIG_FLOW}.LookInHttpProtocol.get_info", new=_get_info) diff --git a/tests/components/lookin/test_config_flow.py b/tests/components/lookin/test_config_flow.py new file mode 100644 index 00000000000..92f6e500045 --- /dev/null +++ b/tests/components/lookin/test_config_flow.py @@ -0,0 +1,185 @@ +"""Define tests for the lookin config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +from aiolookin import NoUsableService + +from homeassistant import config_entries +from homeassistant.components.lookin.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import ( + DEFAULT_ENTRY_TITLE, + DEVICE_ID, + IP_ADDRESS, + MODULE, + ZEROCONF_DATA, + _patch_get_info, +) + +from tests.common import MockConfigEntry + + +async def test_manual_setup(hass: HomeAssistant): + """Test manually setting up.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_get_info(), patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_HOST: IP_ADDRESS} + assert result["title"] == DEFAULT_ENTRY_TITLE + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_manual_setup_already_exists(hass: HomeAssistant): + """Test manually setting up and the device already exists.""" + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=DEVICE_ID + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_get_info(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_manual_setup_device_offline(hass: HomeAssistant): + """Test manually setting up, device offline.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_get_info(exception=NoUsableService): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["errors"] == {CONF_HOST: "cannot_connect"} + + +async def test_manual_setup_unknown_exception(hass: HomeAssistant): + """Test manually setting up, unknown exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + assert not result["errors"] + + with _patch_get_info(exception=Exception): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() + + assert result["type"] == "form" + assert result["errors"] == {"base": "unknown"} + + +async def test_discovered_zeroconf(hass): + """Test we can setup when discovered from zeroconf.""" + + with _patch_get_info(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with _patch_get_info(), patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == {CONF_HOST: IP_ADDRESS} + assert result2["title"] == DEFAULT_ENTRY_TITLE + assert mock_async_setup_entry.called + + entry = hass.config_entries.async_entries(DOMAIN)[0] + zc_data_new_ip = ZEROCONF_DATA.copy() + zc_data_new_ip["host"] = "127.0.0.2" + + with _patch_get_info(), patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zc_data_new_ip, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == "127.0.0.2" + + +async def test_discovered_zeroconf_cannot_connect(hass): + """Test we abort if we cannot connect when discovered from zeroconf.""" + + with _patch_get_info(exception=NoUsableService): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_discovered_zeroconf_unknown_exception(hass): + """Test we abort if we get an unknown exception when discovered from zeroconf.""" + + with _patch_get_info(exception=Exception): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown"