Compare commits

...

9 Commits

Author SHA1 Message Date
J. Nick Koston
8dc9fa7bba Add async_current_scanners API to Bluetooth integration 2025-09-11 09:50:38 -05:00
J. Nick Koston
7d2cdf8024 preen 2025-09-10 13:41:46 -05:00
J. Nick Koston
d9091c3d52 Merge remote-tracking branch 'upstream/dev' into switchbot_passive_id 2025-09-10 13:39:58 -05:00
J. Nick Koston
1553933c6c update tests 2025-09-10 13:32:26 -05:00
J. Nick Koston
d21d708efd update more tests 2025-09-10 13:22:21 -05:00
J. Nick Koston
3827ba64a9 update more tests 2025-09-10 13:20:15 -05:00
J. Nick Koston
1336095e95 update more tests 2025-09-10 13:19:10 -05:00
J. Nick Koston
f75bebb532 dont ask again 2025-09-10 12:57:38 -05:00
J. Nick Koston
bd1862538b switchbot passive 2025-09-10 12:47:51 -05:00
6 changed files with 553 additions and 43 deletions

View File

@@ -57,6 +57,7 @@ from .api import (
_get_manager,
async_address_present,
async_ble_device_from_address,
async_current_scanners,
async_discovered_service_info,
async_get_advertisement_callback,
async_get_fallback_availability_interval,
@@ -114,6 +115,7 @@ __all__ = [
"HomeAssistantRemoteScanner",
"async_address_present",
"async_ble_device_from_address",
"async_current_scanners",
"async_discovered_service_info",
"async_get_advertisement_callback",
"async_get_fallback_availability_interval",

View File

@@ -66,6 +66,22 @@ def async_scanner_count(hass: HomeAssistant, connectable: bool = True) -> int:
return _get_manager(hass).async_scanner_count(connectable)
@hass_callback
def async_current_scanners(hass: HomeAssistant) -> list[BaseHaScanner]:
"""Return the list of currently active scanners.
This method returns a list of all active Bluetooth scanners registered
with Home Assistant, including both connectable and non-connectable scanners.
Args:
hass: Home Assistant instance
Returns:
List of all active scanner instances
"""
return _get_manager(hass).async_current_scanners()
@hass_callback
def async_discovered_service_info(
hass: HomeAssistant, connectable: bool = True

View File

@@ -5,18 +5,21 @@ from __future__ import annotations
import logging
from typing import Any
from habluetooth import BluetoothScanningMode
from switchbot import (
SwitchbotAccountConnectionError,
SwitchBotAdvertisement,
SwitchbotApiError,
SwitchbotAuthenticationError,
SwitchbotModel,
fetch_cloud_devices,
parse_advertisement_data,
)
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_current_scanners,
async_discovered_service_info,
)
from homeassistant.config_entries import (
@@ -87,6 +90,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the config flow."""
self._discovered_adv: SwitchBotAdvertisement | None = None
self._discovered_advs: dict[str, SwitchBotAdvertisement] = {}
self._cloud_username: str | None = None
self._cloud_password: str | None = None
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
@@ -176,9 +181,17 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SwitchBot API auth step."""
errors = {}
errors: dict[str, str] = {}
assert self._discovered_adv is not None
description_placeholders = {}
description_placeholders: dict[str, str] = {}
# If we have saved credentials from cloud login, try them first
if user_input is None and self._cloud_username and self._cloud_password:
user_input = {
CONF_USERNAME: self._cloud_username,
CONF_PASSWORD: self._cloud_password,
}
if user_input is not None:
model: SwitchbotModel = self._discovered_adv.data["modelName"]
cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model]
@@ -200,6 +213,9 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Authentication failed: %s", ex, exc_info=True)
errors = {"base": "auth_failed"}
description_placeholders = {"error_detail": str(ex)}
# Clear saved credentials if auth failed
self._cloud_username = None
self._cloud_password = None
else:
return await self.async_step_encrypted_key(key_details)
@@ -239,7 +255,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the encryption key step."""
errors = {}
errors: dict[str, str] = {}
assert self._discovered_adv is not None
if user_input is not None:
model: SwitchbotModel = self._discovered_adv.data["modelName"]
@@ -308,7 +324,73 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick discovered device."""
"""Handle the user step to choose cloud login or direct discovery."""
# Check if all scanners are in active mode
# If so, skip the menu and go directly to device selection
scanners = async_current_scanners(self.hass)
if scanners and all(
scanner.current_mode == BluetoothScanningMode.ACTIVE for scanner in scanners
):
# All scanners are active, skip the menu
return await self.async_step_select_device()
return self.async_show_menu(
step_id="user",
menu_options=["cloud_login", "select_device"],
)
async def async_step_cloud_login(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the cloud login step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
try:
await fetch_cloud_devices(
async_get_clientsession(self.hass),
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
except (SwitchbotApiError, SwitchbotAccountConnectionError) as ex:
_LOGGER.debug(
"Failed to connect to SwitchBot API: %s", ex, exc_info=True
)
raise AbortFlow(
"api_error", description_placeholders={"error_detail": str(ex)}
) from ex
except SwitchbotAuthenticationError as ex:
_LOGGER.debug("Authentication failed: %s", ex, exc_info=True)
errors = {"base": "auth_failed"}
description_placeholders = {"error_detail": str(ex)}
else:
# Save credentials temporarily for the duration of this flow
# to avoid re-prompting if encrypted device auth is needed
# These will be discarded when the flow completes
self._cloud_username = user_input[CONF_USERNAME]
self._cloud_password = user_input[CONF_PASSWORD]
return await self.async_step_select_device()
user_input = user_input or {}
return self.async_show_form(
step_id="cloud_login",
errors=errors,
data_schema=vol.Schema(
{
vol.Required(
CONF_USERNAME, default=user_input.get(CONF_USERNAME)
): str,
vol.Required(CONF_PASSWORD): str,
}
),
description_placeholders=description_placeholders,
)
async def async_step_select_device(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the step to pick discovered device."""
errors: dict[str, str] = {}
device_adv: SwitchBotAdvertisement | None = None
if user_input is not None:
@@ -333,7 +415,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
return self.async_show_form(
step_id="user",
step_id="select_device",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(

View File

@@ -3,6 +3,24 @@
"flow_title": "{name} ({address})",
"step": {
"user": {
"description": "Would you like to sign in to your SwitchBot account to download device model information? This improves device discovery, especially when using passive Bluetooth scanning. If your Bluetooth adapter uses active scanning, you can skip this step.",
"menu_options": {
"cloud_login": "Sign in to SwitchBot account",
"select_device": "Continue without signing in"
}
},
"cloud_login": {
"description": "Please provide your SwitchBot app username and password. This data won't be saved and is only used to retrieve device model information for better discovery. Usernames and passwords are case-sensitive.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::switchbot::config::step::encrypted_auth::data_description::username%]",
"password": "[%key:component::switchbot::config::step::encrypted_auth::data_description::password%]"
}
},
"select_device": {
"data": {
"address": "MAC address"
},

View File

@@ -9,6 +9,7 @@ from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
MONOTONIC_TIME,
BaseHaRemoteScanner,
BluetoothScanningMode,
HaBluetoothConnector,
async_scanner_by_source,
async_scanner_devices_by_address,
@@ -16,6 +17,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.core import HomeAssistant
from . import (
FakeRemoteScanner,
FakeScanner,
MockBleakClient,
_get_manager,
@@ -161,3 +163,68 @@ async def test_async_scanner_devices_by_address_non_connectable(
assert devices[0].ble_device.name == switchbot_device.name
assert devices[0].advertisement.local_name == switchbot_device_adv.local_name
cancel()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_current_scanners(hass: HomeAssistant) -> None:
"""Test getting the list of current scanners."""
# The enable_bluetooth fixture registers one scanner
initial_scanners = bluetooth.async_current_scanners(hass)
assert len(initial_scanners) == 1
initial_scanner_count = len(initial_scanners)
# Verify current_mode is accessible on the initial scanner
for scanner in initial_scanners:
assert hasattr(scanner, "current_mode")
# The mode might be None or a BluetoothScanningMode enum value
# Register additional connectable scanners
hci0_scanner = FakeScanner("hci0", "hci0")
hci1_scanner = FakeScanner("hci1", "hci1")
cancel_hci0 = bluetooth.async_register_scanner(hass, hci0_scanner)
cancel_hci1 = bluetooth.async_register_scanner(hass, hci1_scanner)
# Test that the new scanners are added
scanners = bluetooth.async_current_scanners(hass)
assert len(scanners) == initial_scanner_count + 2
assert hci0_scanner in scanners
assert hci1_scanner in scanners
# Verify current_mode is accessible on all scanners
for scanner in scanners:
assert hasattr(scanner, "current_mode")
# Verify it's None or the correct type (BluetoothScanningMode)
assert scanner.current_mode is None or isinstance(
scanner.current_mode, BluetoothScanningMode
)
# Register non-connectable scanner
connector = HaBluetoothConnector(
MockBleakClient, "mock_bleak_client", lambda: False
)
hci2_scanner = FakeRemoteScanner("hci2", "hci2", connector, False)
cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner)
# Test that all scanners are returned (both connectable and non-connectable)
all_scanners = bluetooth.async_current_scanners(hass)
assert len(all_scanners) == initial_scanner_count + 3
assert hci0_scanner in all_scanners
assert hci1_scanner in all_scanners
assert hci2_scanner in all_scanners
# Verify current_mode is accessible on all scanners including non-connectable
for scanner in all_scanners:
assert hasattr(scanner, "current_mode")
# The mode should be None or a BluetoothScanningMode instance
assert scanner.current_mode is None or isinstance(
scanner.current_mode, BluetoothScanningMode
)
# Clean up our scanners
cancel_hci0()
cancel_hci1()
cancel_hci2()
# Verify we're back to the initial scanner
final_scanners = bluetooth.async_current_scanners(hass)
assert len(final_scanners) == initial_scanner_count

View File

@@ -251,12 +251,19 @@ async def test_async_step_bluetooth_not_connectable(hass: HomeAssistant) -> None
async def test_user_setup_wohand(hass: HomeAssistant) -> None:
"""Test the user initiated form with password and valid mac."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
@@ -292,12 +299,20 @@ async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None
unique_id="aabbccddeeff",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
@@ -309,12 +324,20 @@ async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None:
domain=DOMAIN, data={}, unique_id="aabbccddeeff", source=SOURCE_IGNORE
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
@@ -339,12 +362,19 @@ async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None:
async def test_user_setup_wocurtain(hass: HomeAssistant) -> None:
"""Test the user initiated form with password and valid mac."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOCURTAIN_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
@@ -370,6 +400,12 @@ async def test_user_setup_wocurtain(hass: HomeAssistant) -> None:
async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None:
"""Test the user initiated form with valid address."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[
@@ -379,11 +415,12 @@ async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None:
WOHAND_SERVICE_INFO_NOT_CONNECTABLE,
],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "select_device"
assert result["errors"] == {}
with patch_async_setup_entry() as mock_setup_entry:
@@ -406,6 +443,12 @@ async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None:
async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> None:
"""Test the user initiated form and valid address and a bot with a password."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[
@@ -414,11 +457,12 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) ->
WOHAND_SERVICE_INFO_NOT_CONNECTABLE,
],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "select_device"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
@@ -450,12 +494,19 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) ->
async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None:
"""Test the user initiated form for a bot with a password."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_ENCRYPTED_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "password"
@@ -482,12 +533,19 @@ async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None:
async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None:
"""Test the user initiated form for a lock."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOLOCK_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -548,12 +606,19 @@ async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None:
async def test_user_setup_woencrypted_auth(hass: HomeAssistant) -> None:
"""Test the user initiated form for a lock."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOLOCK_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -623,12 +688,19 @@ async def test_user_setup_woencrypted_auth_switchbot_api_down(
) -> None:
"""Test the user initiated form for a lock when the switchbot api is down."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOLOCK_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -661,6 +733,12 @@ async def test_user_setup_woencrypted_auth_switchbot_api_down(
async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None:
"""Test the user initiated form for a lock."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[
@@ -668,11 +746,12 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None:
WOHAND_SERVICE_ALT_ADDRESS_INFO,
],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "select_device"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
@@ -721,12 +800,19 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None:
async def test_user_setup_wosensor(hass: HomeAssistant) -> None:
"""Test the user initiated form with password and valid mac."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOSENSORTH_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
@@ -749,14 +835,225 @@ async def test_user_setup_wosensor(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_cloud_login(hass: HomeAssistant) -> None:
"""Test the cloud login flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "cloud_login"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud_login"
# Test successful cloud login
with (
patch(
"homeassistant.components.switchbot.config_flow.fetch_cloud_devices",
return_value=None,
),
patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test@example.com",
CONF_PASSWORD: "testpass",
},
)
# Should proceed to device selection with single device, so go to confirm
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
# Confirm device setup
with patch_async_setup_entry():
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Bot EEFF"
assert result["data"] == {
CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
CONF_SENSOR_TYPE: "bot",
}
async def test_user_cloud_login_auth_failed(hass: HomeAssistant) -> None:
"""Test the cloud login flow with authentication failure."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "cloud_login"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud_login"
# Test authentication failure
with patch(
"homeassistant.components.switchbot.config_flow.fetch_cloud_devices",
side_effect=SwitchbotAuthenticationError("Invalid credentials"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test@example.com",
CONF_PASSWORD: "wrongpass",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud_login"
assert result["errors"] == {"base": "auth_failed"}
assert "Invalid credentials" in result["description_placeholders"]["error_detail"]
async def test_user_cloud_login_api_error(hass: HomeAssistant) -> None:
"""Test the cloud login flow with API error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "cloud_login"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud_login"
# Test API connection error
with patch(
"homeassistant.components.switchbot.config_flow.fetch_cloud_devices",
side_effect=SwitchbotAccountConnectionError("API is down"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test@example.com",
CONF_PASSWORD: "testpass",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "api_error"
assert result["description_placeholders"] == {"error_detail": "API is down"}
async def test_user_cloud_login_then_encrypted_device(hass: HomeAssistant) -> None:
"""Test cloud login followed by encrypted device setup using saved credentials."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "cloud_login"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud_login"
with (
patch(
"homeassistant.components.switchbot.config_flow.fetch_cloud_devices",
return_value=None,
),
patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOLOCK_SERVICE_INFO],
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test@example.com",
CONF_PASSWORD: "testpass",
},
)
# Should go to encrypted device choice menu
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
# Choose encrypted auth
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "encrypted_auth"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encrypted_auth"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
None,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encrypted_auth"
with (
patch_async_setup_entry() as mock_setup_entry,
patch(
"switchbot.SwitchbotLock.async_retrieve_encryption_key",
return_value={
CONF_KEY_ID: "ff",
CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff",
},
),
patch("switchbot.SwitchbotLock.verify_encryption_key", return_value=True),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test@example.com",
CONF_PASSWORD: "testpass",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Lock EEFF"
assert result["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_KEY_ID: "ff",
CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff",
CONF_SENSOR_TYPE: "lock",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_no_devices(hass: HomeAssistant) -> None:
"""Test the user initiated form with password and valid mac."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
@@ -774,13 +1071,20 @@ async def test_async_step_user_takes_precedence_over_discovery(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOCURTAIN_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
@@ -931,12 +1235,19 @@ async def test_options_flow_lock_pro(hass: HomeAssistant) -> None:
async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None:
"""Test the user initiated form for a relay switch 1pm."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -979,12 +1290,19 @@ async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None:
async def test_user_setup_worelay_switch_1pm_auth(hass: HomeAssistant) -> None:
"""Test the user initiated form for a relay switch 1pm."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -1053,12 +1371,19 @@ async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down(
) -> None:
"""Test the user initiated form for a relay switch 1pm when the switchbot api is down."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"