diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index b0883d42a5f..8838d3f20fa 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -42,7 +42,7 @@ async def async_setup_entry(hass, entry): try: await api.get_state() except exceptions.ConnectError as err: - raise UpdateFailed(f"Failed to communicating with API: {err}") from err + raise UpdateFailed(f"Failed to communicating with device {err}") from err coordinator = DataUpdateCoordinator( hass, @@ -84,21 +84,16 @@ class YetiEntity(CoordinatorEntity): @property def device_info(self): """Return the device information of the entity.""" - if self.api.data: - sw_version = self.api.data["firmwareVersion"] - else: - sw_version = None - if self.api.sysdata: - model = self.api.sysdata["model"] - else: - model = model or None - return { + info = { "identifiers": {(DOMAIN, self._server_unique_id)}, "manufacturer": "Goal Zero", - "model": model, "name": self._name, - "sw_version": sw_version, } + if self.api.sysdata: + info["model"] = self.api.sysdata["model"] + if self.api.data: + info["sw_version"] = self.api.data["firmwareVersion"] + return info @property def device_class(self): diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index 269885e5c3a..575ff2ba350 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -1,12 +1,18 @@ """Config flow for Goal Zero Yeti integration.""" +from __future__ import annotations + import logging +from typing import Any from goalzero import Yeti, exceptions import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.dhcp import IP_ADDRESS, MAC_ADDRESS from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from .const import DEFAULT_NAME, DOMAIN @@ -20,32 +26,63 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self): + """Initialize a Goal Zero Yeti flow.""" + self.ip_address = None + + async def async_step_dhcp(self, discovery_info): + """Handle dhcp discovery.""" + self.ip_address = discovery_info[IP_ADDRESS] + + await self.async_set_unique_id(discovery_info[MAC_ADDRESS]) + self._abort_if_unique_id_configured(updates={CONF_HOST: self.ip_address}) + self._async_abort_entries_match({CONF_HOST: self.ip_address}) + + _, error = await self._async_try_connect(self.ip_address) + if error is None: + return await self.async_step_confirm_discovery() + return self.async_abort(reason=error) + + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Allow the user to confirm adding the device.""" + if user_input is not None: + return self.async_create_entry( + title="Goal Zero", + data={ + CONF_HOST: self.ip_address, + CONF_NAME: DEFAULT_NAME, + }, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="confirm_discovery", + description_placeholders={ + CONF_HOST: self.ip_address, + CONF_NAME: DEFAULT_NAME, + }, + ) + async def async_step_user(self, user_input=None): """Handle a flow initiated by the user.""" errors = {} - if user_input is not None: host = user_input[CONF_HOST] name = user_input[CONF_NAME] self._async_abort_entries_match({CONF_HOST: host}) - try: - await self._async_try_connect(host) - except exceptions.ConnectError: - errors["base"] = "cannot_connect" - _LOGGER.error("Error connecting to device at %s", host) - except exceptions.InvalidHost: - errors["base"] = "invalid_host" - _LOGGER.error("Invalid host at %s", host) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + mac_address, error = await self._async_try_connect(host) + if error is None: + await self.async_set_unique_id(format_mac(mac_address)) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) return self.async_create_entry( title=name, data={CONF_HOST: host, CONF_NAME: name}, ) + errors["base"] = error user_input = user_input or {} return self.async_show_form( @@ -64,6 +101,18 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) async def _async_try_connect(self, host): - session = async_get_clientsession(self.hass) - api = Yeti(host, self.hass.loop, session) - await api.get_state() + """Try connecting to Goal Zero Yeti.""" + try: + session = async_get_clientsession(self.hass) + api = Yeti(host, self.hass.loop, session) + await api.sysinfo() + except exceptions.ConnectError: + _LOGGER.error("Error connecting to device at %s", host) + return None, "cannot_connect" + except exceptions.InvalidHost: + _LOGGER.error("Invalid host at %s", host) + return None, "invalid_host" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return None, "unknown" + return str(api.sysdata["macAddress"]), None diff --git a/homeassistant/components/goalzero/manifest.json b/homeassistant/components/goalzero/manifest.json index 0a1bc4df70d..52d3a024955 100644 --- a/homeassistant/components/goalzero/manifest.json +++ b/homeassistant/components/goalzero/manifest.json @@ -4,6 +4,9 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/goalzero", "requirements": ["goalzero==0.1.7"], + "dhcp": [ + {"hostname": "yeti*"} + ], "codeowners": ["@tkdrob"], "iot_class": "local_polling" } diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index 92813337e77..5147299b564 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -3,11 +3,15 @@ "step": { "user": { "title": "Goal Zero Yeti", - "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wifi network. DHCP reservation must be set up in your router settings for the device to ensure the host IP does not change. Refer to your router's user manual.", + "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wi-fi network. DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual.", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" } + }, + "confirm_discovery": { + "title": "Goal Zero Yeti", + "description": "DHCP reservation on your router is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Refer to your router's user manual." } }, "error": { @@ -16,7 +20,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/homeassistant/components/goalzero/translations/en.json b/homeassistant/components/goalzero/translations/en.json index e6c6e4a7298..2f2e1ac0d2b 100644 --- a/homeassistant/components/goalzero/translations/en.json +++ b/homeassistant/components/goalzero/translations/en.json @@ -14,7 +14,7 @@ "host": "Host", "name": "Name" }, - "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wifi network. DHCP reservation must be set up in your router settings for the device to ensure the host IP does not change. Refer to your router's user manual.", + "description": "First, you need to download the Goal Zero app: https://www.goalzero.com/product-features/yeti-app/\n\nFollow the instructions to connect your Yeti to your Wi-fi network. DHCP reservation is recommended. If not set up, the device may become unavailable until Home Assistant detects the new ip address. Set it up in your router settings for the device. Refer to your router's user manual.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 9da371090f5..2a592953123 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -66,6 +66,10 @@ DHCP = [ "domain": "flume", "hostname": "flume-gw-*" }, + { + "domain": "goalzero", + "hostname": "yeti*" + }, { "domain": "gogogate2", "hostname": "ismartgate*" diff --git a/tests/components/goalzero/__init__.py b/tests/components/goalzero/__init__.py index fb531dfca4b..1b5302dbc1b 100644 --- a/tests/components/goalzero/__init__.py +++ b/tests/components/goalzero/__init__.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS from homeassistant.const import CONF_HOST, CONF_NAME HOST = "1.2.3.4" @@ -17,6 +18,12 @@ CONF_CONFIG_FLOW = { CONF_NAME: NAME, } +CONF_DHCP_FLOW = { + IP_ADDRESS: "1.1.1.1", + MAC_ADDRESS: "AA:BB:CC:DD:EE:FF", + HOSTNAME: "any", +} + async def _create_mocked_yeti(raise_exception=False): mocked_yeti = AsyncMock() diff --git a/tests/components/goalzero/test_config_flow.py b/tests/components/goalzero/test_config_flow.py index 10ef02bfcff..6df5465eff9 100644 --- a/tests/components/goalzero/test_config_flow.py +++ b/tests/components/goalzero/test_config_flow.py @@ -4,16 +4,18 @@ from unittest.mock import patch from goalzero import exceptions from homeassistant.components.goalzero.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM, ) +from homeassistant.setup import async_setup_component from . import ( CONF_CONFIG_FLOW, CONF_DATA, + CONF_DHCP_FLOW, CONF_HOST, CONF_NAME, NAME, @@ -114,3 +116,69 @@ async def test_flow_user_unknown_error(hass): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "unknown"} + + +async def test_dhcp_discovery(hass): + """Test we can process the discovery from dhcp.""" + await async_setup_component(hass, "persistent_notification", {}) + mocked_yeti = await _create_mocked_yeti() + with _patch_config_flow_yeti(mocked_yeti), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=CONF_DHCP_FLOW, + ) + assert result["type"] == "form" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_NAME: "Yeti", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=CONF_DHCP_FLOW, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_dhcp_discovery_failed(hass): + """Test failed setup from dhcp.""" + mocked_yeti = await _create_mocked_yeti(True) + with _patch_config_flow_yeti(mocked_yeti) as yetimock: + yetimock.side_effect = exceptions.ConnectError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=CONF_DHCP_FLOW, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + with _patch_config_flow_yeti(mocked_yeti) as yetimock: + yetimock.side_effect = exceptions.InvalidHost + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=CONF_DHCP_FLOW, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_host" + + with _patch_config_flow_yeti(mocked_yeti) as yetimock: + yetimock.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=CONF_DHCP_FLOW, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown"