Add DHCP discovery to balboa (#136762)

pull/136851/head^2
Nathan Spencer 2025-01-29 09:28:09 -07:00 committed by GitHub
parent fa6df1cc25
commit 35e3952770
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 182 additions and 9 deletions

View File

@ -10,7 +10,7 @@ from pybalboa.exceptions import SpaConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
@ -18,6 +18,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_SYNC_TIME, DOMAIN
@ -55,7 +56,8 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_host: str | None
_host: str
_model: str
@staticmethod
@callback
@ -63,6 +65,43 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
self._async_abort_entries_match({CONF_HOST: discovery_info.ip})
error = None
try:
info = await validate_input({CONF_HOST: discovery_info.ip})
except CannotConnect:
error = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
error = "unknown"
if not error:
self._host = discovery_info.ip
self._model = info["title"]
self.context["title_placeholders"] = {CONF_MODEL: self._model}
return await self.async_step_discovery_confirm()
return self.async_abort(reason=error)
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Allow the user to confirm adding the device."""
if user_input is not None:
data = {CONF_HOST: self._host}
return self.async_create_entry(title=self._model, data=data)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={CONF_HOST: self._host},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -78,7 +117,9 @@ class BalboaSpaClientFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(info["formatted_mac"])
await self.async_set_unique_id(
info["formatted_mac"], raise_on_progress=False
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=info["title"], data=user_input)

View File

@ -3,6 +3,14 @@
"name": "Balboa Spa Client",
"codeowners": ["@garbled1", "@natekspencer"],
"config_flow": true,
"dhcp": [
{
"registered_devices": true
},
{
"macaddress": "001527*"
}
],
"documentation": "https://www.home-assistant.io/integrations/balboa",
"iot_class": "local_push",
"loggers": ["pybalboa"],

View File

@ -1,5 +1,6 @@
{
"config": {
"flow_title": "{model}",
"step": {
"user": {
"description": "Connect to the Balboa Wi-Fi device",
@ -9,6 +10,9 @@
"data_description": {
"host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58."
}
},
"confirm_discovery": {
"description": "Do you want to set up the spa at {host}?"
}
},
"error": {

View File

@ -61,6 +61,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "axis-e82725*",
"macaddress": "E82725*",
},
{
"domain": "balboa",
"registered_devices": True,
},
{
"domain": "balboa",
"macaddress": "001527*",
},
{
"domain": "blink",
"hostname": "blink*",

View File

@ -3,19 +3,23 @@
from unittest.mock import MagicMock, patch
from pybalboa.exceptions import SpaConnectionError
import pytest
from homeassistant import config_entries
from homeassistant.components.balboa.const import CONF_SYNC_TIME, DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from tests.common import MockConfigEntry
TEST_DATA = {
CONF_HOST: "1.1.1.1",
}
TEST_ID = "FakeBalboa"
TEST_HOST = "1.1.1.1"
TEST_DATA = {CONF_HOST: TEST_HOST}
TEST_MAC = "ef:ef:ef:c0:ff:ee"
TEST_DHCP_SERVICE_INFO = DhcpServiceInfo(
ip=TEST_HOST, macaddress=TEST_MAC.replace(":", ""), hostname="fakespa"
)
async def test_form(hass: HomeAssistant, client: MagicMock) -> None:
@ -107,7 +111,7 @@ async def test_unknown_error(hass: HomeAssistant, client: MagicMock) -> None:
async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> None:
"""Test when provided credentials are already configured."""
MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID).add_to_hass(hass)
MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -138,7 +142,7 @@ async def test_already_configured(hass: HomeAssistant, client: MagicMock) -> Non
async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None:
"""Test specifying non default settings using options flow."""
config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_ID)
config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
@ -161,3 +165,111 @@ async def test_options_flow(hass: HomeAssistant, client: MagicMock) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
assert dict(config_entry.options) == {CONF_SYNC_TIME: True}
async def test_dhcp_discovery(hass: HomeAssistant, client: MagicMock) -> None:
"""Test we can process the discovery from dhcp."""
with patch(
"homeassistant.components.balboa.config_flow.SpaClient.__aenter__",
return_value=client,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=TEST_DHCP_SERVICE_INFO,
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "FakeSpa"
assert result["data"] == TEST_DATA
assert result["result"].unique_id == TEST_MAC
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=TEST_DHCP_SERVICE_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_dhcp_discovery_updates_host(
hass: HomeAssistant, client: MagicMock
) -> None:
"""Test dhcp discovery updates host and aborts."""
entry = MockConfigEntry(domain=DOMAIN, data=TEST_DATA, unique_id=TEST_MAC)
entry.add_to_hass(hass)
updated_ip = "1.1.1.2"
TEST_DHCP_SERVICE_INFO.ip = updated_ip
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=TEST_DHCP_SERVICE_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert entry.data[CONF_HOST] == updated_ip
@pytest.mark.parametrize(
("side_effect", "reason"),
[
(SpaConnectionError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_dhcp_discovery_failed(
hass: HomeAssistant, client: MagicMock, side_effect: Exception, reason: str
) -> None:
"""Test failed setup from dhcp."""
with patch(
"homeassistant.components.balboa.config_flow.SpaClient.__aenter__",
return_value=client,
side_effect=side_effect(),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=TEST_DHCP_SERVICE_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
async def test_dhcp_discovery_manual_user_setup(
hass: HomeAssistant, client: MagicMock
) -> None:
"""Test dhcp discovery with manual user setup."""
with patch(
"homeassistant.components.balboa.config_flow.SpaClient.__aenter__",
return_value=client,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=TEST_DHCP_SERVICE_INFO,
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_DATA,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == TEST_DATA