Add Zeroconf support for bsblan integration (#146137)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Willem-Jan van Rootselaar 2025-07-14 21:26:03 +02:00 committed by GitHub
parent 5ec9c4e6e3
commit 37ae476c67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 658 additions and 57 deletions

View File

@ -12,6 +12,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNA
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN
@ -21,12 +22,15 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
host: str def __init__(self) -> None:
port: int """Initialize BSBLan flow."""
mac: str self.host: str | None = None
passkey: str | None = None self.port: int = DEFAULT_PORT
username: str | None = None self.mac: str | None = None
password: str | None = None self.passkey: str | None = None
self.username: str | None = None
self.password: str | None = None
self._auth_required = True
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -41,9 +45,111 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self.username = user_input.get(CONF_USERNAME) self.username = user_input.get(CONF_USERNAME)
self.password = user_input.get(CONF_PASSWORD) self.password = user_input.get(CONF_PASSWORD)
return await self._validate_and_create()
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle Zeroconf discovery."""
self.host = str(discovery_info.ip_address)
self.port = discovery_info.port or DEFAULT_PORT
# Get MAC from properties
self.mac = discovery_info.properties.get("mac")
# If MAC was found in zeroconf, use it immediately
if self.mac:
await self.async_set_unique_id(format_mac(self.mac))
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
}
)
else:
# MAC not available from zeroconf - check for existing host/port first
self._async_abort_entries_match(
{CONF_HOST: self.host, CONF_PORT: self.port}
)
# Try to get device info without authentication to minimize discovery popup
config = BSBLANConfig(host=self.host, port=self.port)
session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config, session)
try:
device = await bsblan.device()
except BSBLANError:
# Device requires authentication - proceed to discovery confirm
self.mac = None
else:
self.mac = device.MAC
# Got MAC without auth - set unique ID and check for existing device
await self.async_set_unique_id(format_mac(self.mac))
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
}
)
# No auth needed, so we can proceed to a confirmation step without fields
self._auth_required = False
# Proceed to get credentials
self.context["title_placeholders"] = {"name": f"BSBLAN {self.host}"}
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle getting credentials for discovered device."""
if user_input is None:
data_schema = vol.Schema(
{
vol.Optional(CONF_PASSKEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
}
)
if not self._auth_required:
data_schema = vol.Schema({})
return self.async_show_form(
step_id="discovery_confirm",
data_schema=data_schema,
description_placeholders={"host": str(self.host)},
)
if not self._auth_required:
return self._async_create_entry()
self.passkey = user_input.get(CONF_PASSKEY)
self.username = user_input.get(CONF_USERNAME)
self.password = user_input.get(CONF_PASSWORD)
return await self._validate_and_create(is_discovery=True)
async def _validate_and_create(
self, is_discovery: bool = False
) -> ConfigFlowResult:
"""Validate device connection and create entry."""
try: try:
await self._get_bsblan_info() await self._get_bsblan_info(is_discovery=is_discovery)
except BSBLANError: except BSBLANError:
if is_discovery:
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema(
{
vol.Optional(CONF_PASSKEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
}
),
errors={"base": "cannot_connect"},
description_placeholders={"host": str(self.host)},
)
return self._show_setup_form({"base": "cannot_connect"}) return self._show_setup_form({"base": "cannot_connect"})
return self._async_create_entry() return self._async_create_entry()
@ -67,6 +173,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
@callback @callback
def _async_create_entry(self) -> ConfigFlowResult: def _async_create_entry(self) -> ConfigFlowResult:
"""Create the config entry."""
return self.async_create_entry( return self.async_create_entry(
title=format_mac(self.mac), title=format_mac(self.mac),
data={ data={
@ -78,8 +185,10 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
}, },
) )
async def _get_bsblan_info(self, raise_on_progress: bool = True) -> None: async def _get_bsblan_info(
"""Get device information from an BSBLAN device.""" self, raise_on_progress: bool = True, is_discovery: bool = False
) -> None:
"""Get device information from a BSBLAN device."""
config = BSBLANConfig( config = BSBLANConfig(
host=self.host, host=self.host,
passkey=self.passkey, passkey=self.passkey,
@ -90,11 +199,18 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
session = async_get_clientsession(self.hass) session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config, session) bsblan = BSBLAN(config, session)
device = await bsblan.device() device = await bsblan.device()
self.mac = device.MAC retrieved_mac = device.MAC
await self.async_set_unique_id( # Handle unique ID assignment based on whether MAC was available from zeroconf
format_mac(self.mac), raise_on_progress=raise_on_progress if not self.mac:
) # MAC wasn't available from zeroconf, now we have it from API
self.mac = retrieved_mac
await self.async_set_unique_id(
format_mac(self.mac), raise_on_progress=raise_on_progress
)
# Always allow updating host/port for both user and discovery flows
# This ensures connectivity is maintained when devices change IP addresses
self._abort_if_unique_id_configured( self._abort_if_unique_id_configured(
updates={ updates={
CONF_HOST: self.host, CONF_HOST: self.host,

View File

@ -7,5 +7,11 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["bsblan"], "loggers": ["bsblan"],
"requirements": ["python-bsblan==2.1.0"] "requirements": ["python-bsblan==2.1.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
"name": "bsb-lan*"
}
]
} }

View File

@ -20,6 +20,8 @@ from . import BSBLanConfigEntry, BSBLanData
from .coordinator import BSBLanCoordinatorData from .coordinator import BSBLanCoordinatorData
from .entity import BSBLanEntity from .entity import BSBLanEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class BSBLanSensorEntityDescription(SensorEntityDescription): class BSBLanSensorEntityDescription(SensorEntityDescription):

View File

@ -13,7 +13,25 @@
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of your BSB-Lan device." "host": "The hostname or IP address of your BSB-Lan device.",
"port": "The port number of your BSB-Lan device.",
"passkey": "The passkey for your BSB-Lan device.",
"username": "The username for your BSB-Lan device.",
"password": "The password for your BSB-Lan device."
}
},
"discovery_confirm": {
"title": "BSB-Lan device discovered",
"description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.",
"data": {
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]",
"username": "[%key:component::bsblan::config::step::user::data_description::username%]",
"password": "[%key:component::bsblan::config::step::user::data_description::password%]"
} }
} }
}, },

View File

@ -568,6 +568,10 @@ ZEROCONF = {
"domain": "bosch_shc", "domain": "bosch_shc",
"name": "bosch shc*", "name": "bosch shc*",
}, },
{
"domain": "bsblan",
"name": "bsb-lan*",
},
{ {
"domain": "eheimdigital", "domain": "eheimdigital",
"name": "eheimdigital._http._tcp.local.", "name": "eheimdigital._http._tcp.local.",

View File

@ -1,19 +1,124 @@
"""Tests for the BSBLan device config flow.""" """Tests for the BSBLan device config flow."""
from ipaddress import ip_address
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from bsblan import BSBLANConnectionError from bsblan import BSBLANConnectionError
import pytest
from homeassistant.components.bsblan import config_flow
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
# ZeroconfServiceInfo fixtures for different discovery scenarios
@pytest.fixture
def zeroconf_discovery_info() -> ZeroconfServiceInfo:
"""Return zeroconf discovery info for a BSBLAN device with MAC address."""
return ZeroconfServiceInfo(
ip_address=ip_address("10.0.2.60"),
ip_addresses=[ip_address("10.0.2.60")],
name="BSB-LAN web service._http._tcp.local.",
type="_http._tcp.local.",
properties={"mac": "00:80:41:19:69:90"},
port=80,
hostname="BSB-LAN.local.",
)
@pytest.fixture
def zeroconf_discovery_info_no_mac() -> ZeroconfServiceInfo:
"""Return zeroconf discovery info for a BSBLAN device without MAC address."""
return ZeroconfServiceInfo(
ip_address=ip_address("10.0.2.60"),
ip_addresses=[ip_address("10.0.2.60")],
name="BSB-LAN web service._http._tcp.local.",
type="_http._tcp.local.",
properties={}, # No MAC in properties
port=80,
hostname="BSB-LAN.local.",
)
@pytest.fixture
def zeroconf_discovery_info_different_mac() -> ZeroconfServiceInfo:
"""Return zeroconf discovery info with a different MAC than the device API returns."""
return ZeroconfServiceInfo(
ip_address=ip_address("10.0.2.60"),
ip_addresses=[ip_address("10.0.2.60")],
name="BSB-LAN web service._http._tcp.local.",
type="_http._tcp.local.",
properties={"mac": "aa:bb:cc:dd:ee:ff"}, # Different MAC than in device.json
port=80,
hostname="BSB-LAN.local.",
)
# Helper functions to reduce repetition
async def _init_user_flow(hass: HomeAssistant, user_input: dict | None = None):
"""Initialize a user config flow."""
return await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=user_input,
)
async def _init_zeroconf_flow(hass: HomeAssistant, discovery_info):
"""Initialize a zeroconf config flow."""
return await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
async def _configure_flow(hass: HomeAssistant, flow_id: str, user_input: dict):
"""Configure a flow with user input."""
return await hass.config_entries.flow.async_configure(
flow_id,
user_input=user_input,
)
def _assert_create_entry_result(
result, expected_title: str, expected_data: dict, expected_unique_id: str
):
"""Assert that result is a successful CREATE_ENTRY."""
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == expected_title
assert result.get("data") == expected_data
assert "result" in result
assert result["result"].unique_id == expected_unique_id
def _assert_form_result(
result, expected_step_id: str, expected_errors: dict | None = None
):
"""Assert that result is a FORM with correct step and optional errors."""
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == expected_step_id
if expected_errors is None:
# Handle both None and {} as valid "no errors" states (like other integrations)
assert result.get("errors") in ({}, None)
else:
assert result.get("errors") == expected_errors
def _assert_abort_result(result, expected_reason: str):
"""Assert that result is an ABORT with correct reason."""
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == expected_reason
async def test_full_user_flow_implementation( async def test_full_user_flow_implementation(
hass: HomeAssistant, hass: HomeAssistant,
@ -21,17 +126,13 @@ async def test_full_user_flow_implementation(
mock_setup_entry: AsyncMock, mock_setup_entry: AsyncMock,
) -> None: ) -> None:
"""Test the full manual user flow from start to finish.""" """Test the full manual user flow from start to finish."""
result = await hass.config_entries.flow.async_init( result = await _init_user_flow(hass)
DOMAIN, _assert_form_result(result, "user")
context={"source": SOURCE_USER},
)
assert result.get("type") is FlowResultType.FORM result2 = await _configure_flow(
assert result.get("step_id") == "user" hass,
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={ {
CONF_HOST: "127.0.0.1", CONF_HOST: "127.0.0.1",
CONF_PORT: 80, CONF_PORT: 80,
CONF_PASSKEY: "1234", CONF_PASSKEY: "1234",
@ -40,17 +141,18 @@ async def test_full_user_flow_implementation(
}, },
) )
assert result2.get("type") is FlowResultType.CREATE_ENTRY _assert_create_entry_result(
assert result2.get("title") == format_mac("00:80:41:19:69:90") result2,
assert result2.get("data") == { format_mac("00:80:41:19:69:90"),
CONF_HOST: "127.0.0.1", {
CONF_PORT: 80, CONF_HOST: "127.0.0.1",
CONF_PASSKEY: "1234", CONF_PORT: 80,
CONF_USERNAME: "admin", CONF_PASSKEY: "1234",
CONF_PASSWORD: "admin1234", CONF_USERNAME: "admin",
} CONF_PASSWORD: "admin1234",
assert "result" in result2 },
assert result2["result"].unique_id == format_mac("00:80:41:19:69:90") format_mac("00:80:41:19:69:90"),
)
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_bsblan.device.mock_calls) == 1 assert len(mock_bsblan.device.mock_calls) == 1
@ -58,13 +160,8 @@ async def test_full_user_flow_implementation(
async def test_show_user_form(hass: HomeAssistant) -> None: async def test_show_user_form(hass: HomeAssistant) -> None:
"""Test that the user set up form is served.""" """Test that the user set up form is served."""
result = await hass.config_entries.flow.async_init( result = await _init_user_flow(hass)
config_flow.DOMAIN, _assert_form_result(result, "user")
context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
assert result["type"] is FlowResultType.FORM
async def test_connection_error( async def test_connection_error(
@ -74,10 +171,9 @@ async def test_connection_error(
"""Test we show user form on BSBLan connection error.""" """Test we show user form on BSBLan connection error."""
mock_bsblan.device.side_effect = BSBLANConnectionError mock_bsblan.device.side_effect = BSBLANConnectionError
result = await hass.config_entries.flow.async_init( result = await _init_user_flow(
DOMAIN, hass,
context={"source": SOURCE_USER}, {
data={
CONF_HOST: "127.0.0.1", CONF_HOST: "127.0.0.1",
CONF_PORT: 80, CONF_PORT: 80,
CONF_PASSKEY: "1234", CONF_PASSKEY: "1234",
@ -86,9 +182,7 @@ async def test_connection_error(
}, },
) )
assert result.get("type") is FlowResultType.FORM _assert_form_result(result, "user", {"base": "cannot_connect"})
assert result.get("errors") == {"base": "cannot_connect"}
assert result.get("step_id") == "user"
async def test_user_device_exists_abort( async def test_user_device_exists_abort(
@ -98,10 +192,10 @@ async def test_user_device_exists_abort(
) -> None: ) -> None:
"""Test we abort flow if BSBLAN device already configured.""" """Test we abort flow if BSBLAN device already configured."""
mock_config_entry.add_to_hass(hass) mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, result = await _init_user_flow(
context={"source": SOURCE_USER}, hass,
data={ {
CONF_HOST: "127.0.0.1", CONF_HOST: "127.0.0.1",
CONF_PORT: 80, CONF_PORT: 80,
CONF_PASSKEY: "1234", CONF_PASSKEY: "1234",
@ -110,5 +204,366 @@ async def test_user_device_exists_abort(
}, },
) )
assert result.get("type") is FlowResultType.ABORT _assert_abort_result(result, "already_configured")
assert result.get("reason") == "already_configured"
async def test_zeroconf_discovery(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_setup_entry: AsyncMock,
zeroconf_discovery_info: ZeroconfServiceInfo,
) -> None:
"""Test the Zeroconf discovery flow."""
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_form_result(result, "discovery_confirm")
result2 = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
)
_assert_create_entry_result(
result2,
format_mac("00:80:41:19:69:90"),
{
CONF_HOST: "10.0.2.60",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
format_mac("00:80:41:19:69:90"),
)
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_bsblan.device.mock_calls) == 1
async def test_abort_if_existing_entry_for_zeroconf(
hass: HomeAssistant,
mock_bsblan: MagicMock,
zeroconf_discovery_info: ZeroconfServiceInfo,
) -> None:
"""Test we abort if the same host/port already exists during zeroconf discovery."""
# Create an existing entry
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
unique_id="00:80:41:19:69:90",
)
entry.add_to_hass(hass)
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_abort_result(result, "already_configured")
async def test_zeroconf_discovery_no_mac_requires_auth(
hass: HomeAssistant,
mock_bsblan: MagicMock,
zeroconf_discovery_info_no_mac: ZeroconfServiceInfo,
) -> None:
"""Test Zeroconf discovery when no MAC in announcement and device requires auth."""
# Make the first API call (without auth) fail, second call (with auth) succeed
mock_bsblan.device.side_effect = [
BSBLANConnectionError,
mock_bsblan.device.return_value,
]
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac)
_assert_form_result(result, "discovery_confirm")
# Reset side_effect for the second call to succeed
mock_bsblan.device.side_effect = None
result2 = await _configure_flow(
hass,
result["flow_id"],
{
CONF_USERNAME: "admin",
CONF_PASSWORD: "secret",
},
)
_assert_create_entry_result(
result2,
"00:80:41:19:69:90", # MAC from fixture file
{
CONF_HOST: "10.0.2.60",
CONF_PORT: 80,
CONF_PASSKEY: None,
CONF_USERNAME: "admin",
CONF_PASSWORD: "secret",
},
"00:80:41:19:69:90",
)
# Should be called 3 times: once without auth (fails), twice with auth (in _validate_and_create)
assert len(mock_bsblan.device.mock_calls) == 3
async def test_zeroconf_discovery_no_mac_no_auth_required(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_setup_entry: AsyncMock,
zeroconf_discovery_info_no_mac: ZeroconfServiceInfo,
) -> None:
"""Test Zeroconf discovery when no MAC in announcement but device accessible without auth."""
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac)
# Should now show the discovery_confirm form to the user
_assert_form_result(result, "discovery_confirm")
# User confirms the discovery
result2 = await _configure_flow(hass, result["flow_id"], {})
_assert_create_entry_result(
result2,
"00:80:41:19:69:90", # MAC from fixture file
{
CONF_HOST: "10.0.2.60",
CONF_PORT: 80,
CONF_PASSKEY: None,
CONF_USERNAME: None,
CONF_PASSWORD: None,
},
"00:80:41:19:69:90",
)
assert len(mock_setup_entry.mock_calls) == 1
# Should be called once in zeroconf step, as _validate_and_create is skipped
assert len(mock_bsblan.device.mock_calls) == 1
async def test_zeroconf_discovery_connection_error(
hass: HomeAssistant,
mock_bsblan: MagicMock,
zeroconf_discovery_info: ZeroconfServiceInfo,
) -> None:
"""Test connection error during zeroconf discovery shows the correct form."""
mock_bsblan.device.side_effect = BSBLANConnectionError
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_form_result(result, "discovery_confirm")
result2 = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
)
_assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"})
async def test_zeroconf_discovery_updates_host_port_on_existing_entry(
hass: HomeAssistant,
mock_bsblan: MagicMock,
zeroconf_discovery_info: ZeroconfServiceInfo,
) -> None:
"""Test that discovered devices update host/port of existing entries."""
# Create an existing entry with different host/port
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "192.168.1.100", # Different IP
CONF_PORT: 8080, # Different port
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
unique_id="00:80:41:19:69:90",
)
entry.add_to_hass(hass)
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_abort_result(result, "already_configured")
# Verify the existing entry WAS updated with new host/port from discovery
assert entry.data[CONF_HOST] == "10.0.2.60" # Updated host from discovery
assert entry.data[CONF_PORT] == 80 # Updated port from discovery
async def test_user_flow_can_update_existing_host_port(
hass: HomeAssistant,
mock_bsblan: MagicMock,
) -> None:
"""Test that manual user configuration can update host/port of existing entries."""
# Create an existing entry
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "192.168.1.100",
CONF_PORT: 8080,
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
unique_id="00:80:41:19:69:90",
)
entry.add_to_hass(hass)
# Try to configure the same device with different host/port via user flow
result = await _init_user_flow(
hass,
{
CONF_HOST: "10.0.2.60", # Different IP
CONF_PORT: 80, # Different port
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
)
_assert_abort_result(result, "already_configured")
# Verify the existing entry WAS updated with new host/port (user flow behavior)
assert entry.data[CONF_HOST] == "10.0.2.60" # Updated host
assert entry.data[CONF_PORT] == 80 # Updated port
async def test_zeroconf_discovery_connection_error_recovery(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_setup_entry: AsyncMock,
zeroconf_discovery_info: ZeroconfServiceInfo,
) -> None:
"""Test connection error during zeroconf discovery can be recovered from."""
# First attempt fails with connection error
mock_bsblan.device.side_effect = BSBLANConnectionError
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info)
_assert_form_result(result, "discovery_confirm")
result2 = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
)
_assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"})
# Second attempt succeeds (connection is fixed)
mock_bsblan.device.side_effect = None
result3 = await _configure_flow(
hass,
result["flow_id"],
{
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
)
_assert_create_entry_result(
result3,
format_mac("00:80:41:19:69:90"),
{
CONF_HOST: "10.0.2.60",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
format_mac("00:80:41:19:69:90"),
)
assert len(mock_setup_entry.mock_calls) == 1
# Should have been called twice: first failed, second succeeded
assert len(mock_bsblan.device.mock_calls) == 2
async def test_connection_error_recovery(
hass: HomeAssistant,
mock_bsblan: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test we can recover from BSBLan connection error in user flow."""
# First attempt fails with connection error
mock_bsblan.device.side_effect = BSBLANConnectionError
result = await _init_user_flow(
hass,
{
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
)
_assert_form_result(result, "user", {"base": "cannot_connect"})
# Second attempt succeeds (connection is fixed)
mock_bsblan.device.side_effect = None
result2 = await _configure_flow(
hass,
result["flow_id"],
{
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
)
_assert_create_entry_result(
result2,
format_mac("00:80:41:19:69:90"),
{
CONF_HOST: "127.0.0.1",
CONF_PORT: 80,
CONF_PASSKEY: "1234",
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
format_mac("00:80:41:19:69:90"),
)
assert len(mock_setup_entry.mock_calls) == 1
# Should have been called twice: first failed, second succeeded
assert len(mock_bsblan.device.mock_calls) == 2
async def test_zeroconf_discovery_no_mac_duplicate_host_port(
hass: HomeAssistant,
mock_bsblan: MagicMock,
zeroconf_discovery_info_no_mac: ZeroconfServiceInfo,
) -> None:
"""Test Zeroconf discovery aborts when no MAC and same host/port already configured."""
# Create an existing entry with same host/port but no unique_id
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "10.0.2.60", # Same IP as discovery
CONF_PORT: 80, # Same port as discovery
CONF_USERNAME: "admin",
CONF_PASSWORD: "admin1234",
},
unique_id=None, # Old entry without unique_id
)
entry.add_to_hass(hass)
result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac)
_assert_abort_result(result, "already_configured")
# Should not call device API since we abort early
assert len(mock_bsblan.device.mock_calls) == 0