Update switchbot to be local push (#75645)

* Update switchbot to be local push

* fixes

* fixes

* fixes

* fixes

* adjust

* cover is not assumed anymore

* cleanups

* adjust

* adjust

* add missing cover

* import compat

* fixes

* uses lower

* uses lower

* bleak users upper case addresses

* fixes

* bump

* keep conf_mac and deprecated options for rollback

* reuse coordinator

* adjust

* move around

* move around

* move around

* move around

* refactor fixes

* compat with DataUpdateCoordinator

* fix available

* Update homeassistant/components/bluetooth/passive_update_processor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/bluetooth/passive_update_coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/bluetooth/update_coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Split bluetooth coordinator into PassiveBluetoothDataUpdateCoordinator and PassiveBluetoothProcessorCoordinator

The PassiveBluetoothDataUpdateCoordinator is now used to replace instances
of DataUpdateCoordinator where the data is coming from bluetooth
advertisements, and the integration may also mix in active updates

The PassiveBluetoothProcessorCoordinator is used for integrations that
want to process each bluetooth advertisement with multiple processors
which can be dispatched to individual platforms or areas or the integration
as it chooes

* change connections

* reduce code churn to reduce review overhead

* reduce code churn to reduce review overhead

* Update homeassistant/components/bluetooth/passive_update_coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* add basic test

* add basic test

* complete coverage

* Update homeassistant/components/switchbot/coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/switchbot/coordinator.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/switchbot/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/switchbot/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* lint

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Nick Koston 2022-07-24 11:38:45 -05:00 committed by GitHub
parent 79be87f9ce
commit 198167a2c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 542 additions and 462 deletions

View file

@ -1,7 +1,11 @@
"""Tests for the switchbot integration."""
from unittest.mock import patch
from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@ -11,37 +15,37 @@ DOMAIN = "switchbot"
ENTRY_CONFIG = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_MAC: "e7:89:43:99:99:99",
CONF_ADDRESS: "e7:89:43:99:99:99",
}
USER_INPUT = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_MAC: "e7:89:43:99:99:99",
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
}
USER_INPUT_CURTAIN = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_MAC: "e7:89:43:90:90:90",
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
}
USER_INPUT_SENSOR = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_MAC: "c0:ce:b0:d4:26:be",
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
}
USER_INPUT_UNSUPPORTED_DEVICE = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_MAC: "test",
CONF_ADDRESS: "test",
}
USER_INPUT_INVALID = {
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_MAC: "invalid-mac",
CONF_ADDRESS: "invalid-mac",
}
@ -68,3 +72,67 @@ async def init_integration(
await hass.async_block_till_done()
return entry
WOHAND_SERVICE_INFO = BluetoothServiceInfoBleak(
name="WoHand",
manufacturer_data={89: b"\xfd`0U\x92W"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="aa:bb:cc:dd:ee:ff",
rssi=-60,
source="local",
advertisement=AdvertisementData(
local_name="WoHand",
manufacturer_data={89: b"\xfd`0U\x92W"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x90\xd9"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoHand"),
)
WOCURTAIN_SERVICE_INFO = BluetoothServiceInfoBleak(
name="WoCurtain",
address="aa:bb:cc:dd:ee:ff",
manufacturer_data={89: b"\xc1\xc7'}U\xab"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Y\x00\x11\x04"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
rssi=-60,
source="local",
advertisement=AdvertisementData(
local_name="WoCurtain",
manufacturer_data={89: b"\xc1\xc7'}U\xab"},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"c\xd0Y\x00\x11\x04"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoCurtain"),
)
WOSENSORTH_SERVICE_INFO = BluetoothServiceInfoBleak(
name="WoSensorTH",
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="aa:bb:cc:dd:ee:ff",
manufacturer_data={2409: b"\xda,\x1e\xb1\x86Au\x03\x00\x96\xac"},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"},
rssi=-60,
source="local",
advertisement=AdvertisementData(
manufacturer_data={2409: b"\xda,\x1e\xb1\x86Au\x03\x00\x96\xac"},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"T\x00d\x00\x96\xac"},
),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "WoSensorTH"),
)
NOT_SWITCHBOT_INFO = BluetoothServiceInfoBleak(
name="unknown",
service_uuids=[],
address="aa:bb:cc:dd:ee:ff",
manufacturer_data={},
service_data={},
rssi=-60,
source="local",
advertisement=AdvertisementData(
manufacturer_data={},
service_data={},
),
device=BLEDevice("aa:bb:cc:dd:ee:ff", "unknown"),
)

View file

@ -1,139 +1,8 @@
"""Define fixtures available for all tests."""
import sys
from unittest.mock import MagicMock, patch
from pytest import fixture
import pytest
class MocGetSwitchbotDevices:
"""Scan for all Switchbot devices and return by type."""
def __init__(self, interface=None) -> None:
"""Get switchbot devices class constructor."""
self._interface = interface
self._all_services_data = {
"e78943999999": {
"mac_address": "e7:89:43:99:99:99",
"isEncrypted": False,
"model": "H",
"data": {
"switchMode": "true",
"isOn": "true",
"battery": 91,
"rssi": -71,
},
"modelName": "WoHand",
},
"e78943909090": {
"mac_address": "e7:89:43:90:90:90",
"isEncrypted": False,
"model": "c",
"data": {
"calibration": True,
"battery": 74,
"inMotion": False,
"position": 100,
"lightLevel": 2,
"deviceChain": 1,
"rssi": -73,
},
"modelName": "WoCurtain",
},
"ffffff19ffff": {
"mac_address": "ff:ff:ff:19:ff:ff",
"isEncrypted": False,
"model": "m",
"rawAdvData": "000d6d00",
},
"c0ceb0d426be": {
"mac_address": "c0:ce:b0:d4:26:be",
"isEncrypted": False,
"data": {
"temp": {"c": 21.6, "f": 70.88},
"fahrenheit": False,
"humidity": 73,
"battery": 100,
"rssi": -58,
},
"model": "T",
"modelName": "WoSensorTH",
},
}
self._curtain_all_services_data = {
"mac_address": "e7:89:43:90:90:90",
"isEncrypted": False,
"model": "c",
"data": {
"calibration": True,
"battery": 74,
"position": 100,
"lightLevel": 2,
"rssi": -73,
},
"modelName": "WoCurtain",
}
self._sensor_data = {
"mac_address": "c0:ce:b0:d4:26:be",
"isEncrypted": False,
"data": {
"temp": {"c": 21.6, "f": 70.88},
"fahrenheit": False,
"humidity": 73,
"battery": 100,
"rssi": -58,
},
"model": "T",
"modelName": "WoSensorTH",
}
self._unsupported_device = {
"mac_address": "test",
"isEncrypted": False,
"model": "HoN",
"data": {
"switchMode": "true",
"isOn": "true",
"battery": 91,
"rssi": -71,
},
"modelName": "WoOther",
}
async def discover(self, retry=0, scan_timeout=0):
"""Mock discover."""
return self._all_services_data
async def get_device_data(self, mac=None):
"""Return data for specific device."""
if mac == "e7:89:43:99:99:99":
return self._all_services_data
if mac == "test":
return self._unsupported_device
if mac == "e7:89:43:90:90:90":
return self._curtain_all_services_data
if mac == "c0:ce:b0:d4:26:be":
return self._sensor_data
return None
class MocNotConnectedError(Exception):
"""Mock exception."""
module = type(sys)("switchbot")
module.GetSwitchbotDevices = MocGetSwitchbotDevices
module.NotConnectedError = MocNotConnectedError
sys.modules["switchbot"] = module
@fixture
def switchbot_config_flow(hass):
"""Mock the bluepy api for easier config flow testing."""
with patch.object(MocGetSwitchbotDevices, "discover", return_value=True), patch(
"homeassistant.components.switchbot.config_flow.GetSwitchbotDevices"
) as mock_switchbot:
instance = mock_switchbot.return_value
instance.discover = MagicMock(return_value=True)
yield mock_switchbot
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""

View file

@ -1,33 +1,104 @@
"""Test the switchbot config flow."""
from homeassistant.components.switchbot.config_flow import NotConnectedError
from unittest.mock import patch
from homeassistant.components.switchbot.const import (
CONF_RETRY_COUNT,
CONF_RETRY_TIMEOUT,
CONF_SCAN_TIMEOUT,
CONF_TIME_BETWEEN_UPDATE_COMMAND,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PASSWORD, CONF_SENSOR_TYPE
from homeassistant.data_entry_flow import FlowResultType
from . import (
NOT_SWITCHBOT_INFO,
USER_INPUT,
USER_INPUT_CURTAIN,
USER_INPUT_SENSOR,
WOCURTAIN_SERVICE_INFO,
WOHAND_SERVICE_INFO,
WOSENSORTH_SERVICE_INFO,
init_integration,
patch_async_setup_entry,
)
from tests.common import MockConfigEntry
DOMAIN = "switchbot"
async def test_user_form_valid_mac(hass):
async def test_bluetooth_discovery(hass):
"""Test discovery via bluetooth with a valid device."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=WOHAND_SERVICE_INFO,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
with patch_async_setup_entry() as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-name"
assert result["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_bluetooth_discovery_already_setup(hass):
"""Test discovery via bluetooth with a valid device when already setup."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot",
},
unique_id="aabbccddeeff",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=WOHAND_SERVICE_INFO,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_async_step_bluetooth_not_switchbot(hass):
"""Test discovery via bluetooth not switchbot."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=NOT_SWITCHBOT_INFO,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "not_supported"
async def test_user_setup_wohand(hass):
"""Test the user initiated form with password and valid mac."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_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}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
@ -42,7 +113,7 @@ async def test_user_form_valid_mac(hass):
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-name"
assert result["data"] == {
CONF_MAC: "e7:89:43:99:99:99",
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot",
@ -50,11 +121,41 @@ async def test_user_form_valid_mac(hass):
assert len(mock_setup_entry.mock_calls) == 1
# test curtain device creation.
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
async def test_user_setup_wohand_already_configured(hass):
"""Test the user initiated form with password and valid mac."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot",
},
unique_id="aabbccddeeff",
)
entry.add_to_hass(hass)
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}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_unconfigured_devices"
async def test_user_setup_wocurtain(hass):
"""Test the user initiated form with password and valid mac."""
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}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
@ -69,7 +170,7 @@ async def test_user_form_valid_mac(hass):
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-name"
assert result["data"] == {
CONF_MAC: "e7:89:43:90:90:90",
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "curtain",
@ -77,11 +178,16 @@ async def test_user_form_valid_mac(hass):
assert len(mock_setup_entry.mock_calls) == 1
# test sensor device creation.
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
async def test_user_setup_wosensor(hass):
"""Test the user initiated form with password and valid mac."""
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}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
@ -96,7 +202,7 @@ async def test_user_form_valid_mac(hass):
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "test-name"
assert result["data"] == {
CONF_MAC: "c0:ce:b0:d4:26:be",
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "hygrometer",
@ -104,39 +210,78 @@ async def test_user_form_valid_mac(hass):
assert len(mock_setup_entry.mock_calls) == 1
# tests abort if no unconfigured devices are found.
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
async def test_user_no_devices(hass):
"""Test the user initiated form with password and valid mac."""
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}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_unconfigured_devices"
async def test_user_form_exception(hass, switchbot_config_flow):
"""Test we handle exception on user form."""
switchbot_config_flow.side_effect = NotConnectedError
async def test_async_step_user_takes_precedence_over_discovery(hass):
"""Test manual setup takes precedence over discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=WOCURTAIN_SERVICE_INFO,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
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},
)
assert result["type"] == FlowResultType.FORM
switchbot_config_flow.side_effect = Exception
with patch_async_setup_entry() as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=USER_INPUT,
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "test-name"
assert result2["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "curtain",
}
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "unknown"
assert len(mock_setup_entry.mock_calls) == 1
# Verify the original one was aborted
assert not hass.config_entries.flow.async_progress(DOMAIN)
async def test_options_flow(hass):
"""Test updating options."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_PASSWORD: "test-password",
CONF_SENSOR_TYPE: "bot",
},
options={
CONF_RETRY_COUNT: 10,
CONF_RETRY_TIMEOUT: 10,
},
unique_id="aabbccddeeff",
)
entry.add_to_hass(hass)
with patch_async_setup_entry() as mock_setup_entry:
entry = await init_integration(hass)
@ -148,21 +293,17 @@ async def test_options_flow(hass):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_TIME_BETWEEN_UPDATE_COMMAND: 60,
CONF_RETRY_COUNT: 3,
CONF_RETRY_TIMEOUT: 5,
CONF_SCAN_TIMEOUT: 5,
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TIME_BETWEEN_UPDATE_COMMAND] == 60
assert result["data"][CONF_RETRY_COUNT] == 3
assert result["data"][CONF_RETRY_TIMEOUT] == 5
assert result["data"][CONF_SCAN_TIMEOUT] == 5
assert len(mock_setup_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 2
# Test changing of entry options.
@ -177,18 +318,17 @@ async def test_options_flow(hass):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_TIME_BETWEEN_UPDATE_COMMAND: 66,
CONF_RETRY_COUNT: 6,
CONF_RETRY_TIMEOUT: 6,
CONF_SCAN_TIMEOUT: 6,
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TIME_BETWEEN_UPDATE_COMMAND] == 66
assert result["data"][CONF_RETRY_COUNT] == 6
assert result["data"][CONF_RETRY_TIMEOUT] == 6
assert result["data"][CONF_SCAN_TIMEOUT] == 6
assert len(mock_setup_entry.mock_calls) == 1
assert entry.options[CONF_RETRY_COUNT] == 6
assert entry.options[CONF_RETRY_TIMEOUT] == 6