From 8d94c8f74aea9a6a75dbc5ffbb8fb6b8ad4442d7 Mon Sep 17 00:00:00 2001 From: Justin Vanderhooft Date: Tue, 30 Aug 2022 17:06:44 -0400 Subject: [PATCH] Add Melnor Bluetooth valve watering Integration (#70457) --- .coveragerc | 4 + CODEOWNERS | 2 + homeassistant/components/melnor/__init__.py | 74 +++++++++ .../components/melnor/config_flow.py | 116 ++++++++++++++ homeassistant/components/melnor/const.py | 8 + homeassistant/components/melnor/manifest.json | 16 ++ homeassistant/components/melnor/models.py | 74 +++++++++ homeassistant/components/melnor/strings.json | 13 ++ homeassistant/components/melnor/switch.py | 75 +++++++++ .../components/melnor/translations/en.json | 13 ++ homeassistant/generated/bluetooth.py | 7 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/melnor/__init__.py | 64 ++++++++ tests/components/melnor/test_config_flow.py | 147 ++++++++++++++++++ 16 files changed, 620 insertions(+) create mode 100644 homeassistant/components/melnor/__init__.py create mode 100644 homeassistant/components/melnor/config_flow.py create mode 100644 homeassistant/components/melnor/const.py create mode 100644 homeassistant/components/melnor/manifest.json create mode 100644 homeassistant/components/melnor/models.py create mode 100644 homeassistant/components/melnor/strings.json create mode 100644 homeassistant/components/melnor/switch.py create mode 100644 homeassistant/components/melnor/translations/en.json create mode 100644 tests/components/melnor/__init__.py create mode 100644 tests/components/melnor/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 770cfb978d9..d6a27259871 100644 --- a/.coveragerc +++ b/.coveragerc @@ -720,6 +720,10 @@ omit = homeassistant/components/melcloud/const.py homeassistant/components/melcloud/sensor.py homeassistant/components/melcloud/water_heater.py + homeassistant/components/melnor/__init__.py + homeassistant/components/melnor/const.py + homeassistant/components/melnor/models.py + homeassistant/components/melnor/switch.py homeassistant/components/message_bird/notify.py homeassistant/components/met/weather.py homeassistant/components/met_eireann/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 9a6578d8fd5..7fdfc5a73c2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -653,6 +653,8 @@ build.json @home-assistant/supervisor /tests/components/melcloud/ @vilppuvuorinen /homeassistant/components/melissa/ @kennedyshead /tests/components/melissa/ @kennedyshead +/homeassistant/components/melnor/ @vanstinator +/tests/components/melnor/ @vanstinator /homeassistant/components/met/ @danielhiversen @thimic /tests/components/met/ @danielhiversen @thimic /homeassistant/components/met_eireann/ @DylanGore diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py new file mode 100644 index 00000000000..5fd697b2088 --- /dev/null +++ b/homeassistant/components/melnor/__init__.py @@ -0,0 +1,74 @@ +"""The melnor integration.""" + +from __future__ import annotations + +from melnor_bluetooth.device import Device + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .models import MelnorDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up melnor from a config entry.""" + + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) + + ble_device = bluetooth.async_ble_device_from_address(hass, entry.data[CONF_ADDRESS]) + + if not ble_device: + raise ConfigEntryNotReady( + f"Couldn't find a nearby device for address: {entry.data[CONF_ADDRESS]}" + ) + + # Create the device and connect immediately so we can pull down + # required attributes before building out our entities + device = Device(ble_device) + await device.connect(retry_attempts=4) + + if not device.is_connected: + raise ConfigEntryNotReady(f"Failed to connect to: {device.mac}") + + @callback + def _async_update_ble( + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update from a ble callback.""" + device.update_ble_device(service_info.device) + + bluetooth.async_register_callback( + hass, + _async_update_ble, + BluetoothCallbackMatcher(address=device.mac), + bluetooth.BluetoothScanningMode.PASSIVE, + ) + + coordinator = MelnorDataUpdateCoordinator(hass, device) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + device: Device = hass.data[DOMAIN][entry.entry_id].data + + await device.disconnect() + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/melnor/config_flow.py b/homeassistant/components/melnor/config_flow.py new file mode 100644 index 00000000000..7e9aed24e8a --- /dev/null +++ b/homeassistant/components/melnor/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for melnor.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import async_discovered_service_info +from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, MANUFACTURER_DATA_START, MANUFACTURER_ID + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for melnor.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_address: str + self._discovered_addresses: list[str] = [] + + def _create_entry(self, address: str) -> FlowResult: + """Create an entry for a discovered device.""" + + return self.async_create_entry( + title=address, + data={ + CONF_ADDRESS: address, + }, + ) + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user-confirmation of discovered device.""" + + if user_input is not None: + return self._create_entry(self._discovered_address) + + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={"name": self._discovered_address}, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle a flow initialized by Bluetooth discovery.""" + + address = discovery_info.address + + await self.async_set_unique_id(address) + self._abort_if_unique_id_configured(updates={CONF_ADDRESS: address}) + + self._discovered_address = address + + self.context["title_placeholders"] = {"name": address} + return await self.async_step_bluetooth_confirm() + + async def async_step_pick_device( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the step to pick discovered device.""" + + if user_input is not None: + address = user_input[CONF_ADDRESS] + + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return self._create_entry(address) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info( + self.hass, connectable=True + ): + + if discovery_info.manufacturer_id == MANUFACTURER_ID and any( + manufacturer_data.startswith(MANUFACTURER_DATA_START) + for manufacturer_data in discovery_info.manufacturer_data.values() + ): + + address = discovery_info.address + if ( + address not in current_addresses + and address not in self._discovered_addresses + ): + self._discovered_addresses.append(address) + + addresses = { + address + for address in self._discovered_addresses + if address not in current_addresses + } + + # Check if there is at least one device + if not addresses: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="pick_device", + data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(addresses)}), + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + + return await self.async_step_pick_device() diff --git a/homeassistant/components/melnor/const.py b/homeassistant/components/melnor/const.py new file mode 100644 index 00000000000..cadf9c0a618 --- /dev/null +++ b/homeassistant/components/melnor/const.py @@ -0,0 +1,8 @@ +"""Constants for the melnor integration.""" + + +DOMAIN = "melnor" +DEFAULT_NAME = "Melnor Bluetooth" + +MANUFACTURER_ID = 13 +MANUFACTURER_DATA_START = bytearray([89]) diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json new file mode 100644 index 00000000000..37ac40cb3aa --- /dev/null +++ b/homeassistant/components/melnor/manifest.json @@ -0,0 +1,16 @@ +{ + "after_dependencies": ["bluetooth"], + "bluetooth": [ + { + "manufacturer_data_start": [89], + "manufacturer_id": 13 + } + ], + "codeowners": ["@vanstinator"], + "config_flow": true, + "domain": "melnor", + "documentation": "https://www.home-assistant.io/integrations/melnor", + "iot_class": "local_polling", + "name": "Melnor Bluetooth", + "requirements": ["melnor-bluetooth==0.0.13"] +} diff --git a/homeassistant/components/melnor/models.py b/homeassistant/components/melnor/models.py new file mode 100644 index 00000000000..4796bf601ff --- /dev/null +++ b/homeassistant/components/melnor/models.py @@ -0,0 +1,74 @@ +"""Melnor integration models.""" + +from datetime import timedelta +import logging + +from melnor_bluetooth.device import Device + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): + """Melnor data update coordinator.""" + + _device: Device + + def __init__(self, hass: HomeAssistant, device: Device) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Melnor Bluetooth", + update_interval=timedelta(seconds=5), + ) + self._device = device + + async def _async_update_data(self): + """Update the device state.""" + + await self._device.fetch_state() + return self._device + + +class MelnorBluetoothBaseEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]): + """Base class for melnor entities.""" + + _device: Device + + def __init__( + self, + coordinator: MelnorDataUpdateCoordinator, + ) -> None: + """Initialize a melnor base entity.""" + super().__init__(coordinator) + + self._device = coordinator.data + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device.mac)}, + manufacturer="Melnor", + model=self._device.model, + name=self._device.name, + ) + self._attr_name = self._device.name + self._attr_unique_id = self._device.mac + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._device = self.coordinator.data + self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._device.is_connected diff --git a/homeassistant/components/melnor/strings.json b/homeassistant/components/melnor/strings.json new file mode 100644 index 00000000000..42309c3bf72 --- /dev/null +++ b/homeassistant/components/melnor/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "There aren't any Melnor Bluetooth devices nearby." + }, + "step": { + "bluetooth_confirm": { + "description": "Do you want to add the Melnor Bluetooth valve `{name}` to Home Assistant?", + "title": "Discovered Melnor Bluetooth valve" + } + } + } +} diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py new file mode 100644 index 00000000000..c2d32c428d3 --- /dev/null +++ b/homeassistant/components/melnor/switch.py @@ -0,0 +1,75 @@ +"""Support for Melnor RainCloud sprinkler water timer.""" + +from __future__ import annotations + +from typing import Any, cast + +from melnor_bluetooth.device import Valve + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .models import MelnorBluetoothBaseEntity, MelnorDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up the switch platform.""" + switches = [] + + coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # This device may not have 4 valves total, but the library will only expose the right number of valves + for i in range(1, 5): + if coordinator.data[f"zone{i}"] is not None: + switches.append(MelnorSwitch(coordinator, i)) + + async_add_devices(switches, True) + + +class MelnorSwitch(MelnorBluetoothBaseEntity, SwitchEntity): + """A switch implementation for a melnor device.""" + + _valve_index: int + _attr_icon = "mdi:sprinkler" + + def __init__( + self, + coordinator: MelnorDataUpdateCoordinator, + valve_index: int, + ) -> None: + """Initialize a switch for a melnor device.""" + super().__init__(coordinator) + self._valve_index = valve_index + + self._attr_unique_id = ( + f"switch-{self._attr_unique_id}-zone{self._valve().id}-manual" + ) + + self._attr_name = f"{self._device.name} Zone {self._valve().id+1}" + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._valve().is_watering + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + self._valve().is_watering = True + await self._device.push_state() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self._valve().is_watering = False + await self._device.push_state() + self.async_write_ha_state() + + def _valve(self) -> Valve: + return cast(Valve, self._device[f"zone{self._valve_index}"]) diff --git a/homeassistant/components/melnor/translations/en.json b/homeassistant/components/melnor/translations/en.json new file mode 100644 index 00000000000..c179e46a070 --- /dev/null +++ b/homeassistant/components/melnor/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "There aren't any Melnor Bluetooth devices nearby." + }, + "step": { + "bluetooth_confirm": { + "description": "Do you want to add the Melnor Bluetooth valve `{name}` to Home Assistant?", + "title": "Discovered Melnor Bluetooth valve" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 320c4c296da..5de90d731bb 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -138,6 +138,13 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "led_ble", "local_name": "LEDBlue*" }, + { + "domain": "melnor", + "manufacturer_data_start": [ + 89 + ], + "manufacturer_id": 13 + }, { "domain": "moat", "local_name": "Moat_S*", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 133c02fd210..ec09c9a7756 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -218,6 +218,7 @@ FLOWS = { "mazda", "meater", "melcloud", + "melnor", "met", "met_eireann", "meteo_france", diff --git a/requirements_all.txt b/requirements_all.txt index b2fb4f2825c..bd940716ebc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1033,6 +1033,9 @@ mcstatus==6.0.0 # homeassistant.components.meater meater-python==0.0.8 +# homeassistant.components.melnor +melnor-bluetooth==0.0.13 + # homeassistant.components.message_bird messagebird==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60c5bbead9a..8c43519e0a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -738,6 +738,9 @@ mcstatus==6.0.0 # homeassistant.components.meater meater-python==0.0.8 +# homeassistant.components.melnor +melnor-bluetooth==0.0.13 + # homeassistant.components.meteo_france meteofrance-api==1.0.2 diff --git a/tests/components/melnor/__init__.py b/tests/components/melnor/__init__.py new file mode 100644 index 00000000000..7af59d55a11 --- /dev/null +++ b/tests/components/melnor/__init__.py @@ -0,0 +1,64 @@ +"""Tests for the melnor integration.""" + +from __future__ import annotations + +from unittest.mock import patch + +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData + +from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak + +FAKE_ADDRESS_1 = "FAKE-ADDRESS-1" +FAKE_ADDRESS_2 = "FAKE-ADDRESS-2" + + +FAKE_SERVICE_INFO_1 = BluetoothServiceInfoBleak( + name="YM_TIMER%", + address=FAKE_ADDRESS_1, + rssi=-63, + manufacturer_data={ + 13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc" + }, + service_uuids=[], + service_data={}, + source="local", + device=BLEDevice(FAKE_ADDRESS_1, None), + advertisement=AdvertisementData(local_name=""), + time=0, + connectable=True, +) + +FAKE_SERVICE_INFO_2 = BluetoothServiceInfoBleak( + name="YM_TIMER%", + address=FAKE_ADDRESS_2, + rssi=-63, + manufacturer_data={ + 13: b"Y\x08\x02\x8f\x00\x00\x00\x00\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0\x00\x00\xf0*\x9b\xcf\xbc" + }, + service_uuids=[], + service_data={}, + source="local", + device=BLEDevice(FAKE_ADDRESS_2, None), + advertisement=AdvertisementData(local_name=""), + time=0, + connectable=True, +) + + +def patch_async_setup_entry(return_value=True): + """Patch async setup entry to return True.""" + return patch( + "homeassistant.components.melnor.async_setup_entry", + return_value=return_value, + ) + + +def patch_async_discovered_service_info( + return_value: list[BluetoothServiceInfoBleak] = [FAKE_SERVICE_INFO_1], +): + """Patch async_discovered_service_info a mocked device info.""" + return patch( + "homeassistant.components.melnor.config_flow.async_discovered_service_info", + return_value=return_value, + ) diff --git a/tests/components/melnor/test_config_flow.py b/tests/components/melnor/test_config_flow.py new file mode 100644 index 00000000000..3b550fba3f7 --- /dev/null +++ b/tests/components/melnor/test_config_flow.py @@ -0,0 +1,147 @@ +"""Test the melnor config flow.""" + +import pytest +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.melnor.const import DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_MAC +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + FAKE_ADDRESS_1, + FAKE_SERVICE_INFO_1, + FAKE_SERVICE_INFO_2, + patch_async_discovered_service_info, + patch_async_setup_entry, +) + + +async def test_user_step_no_devices(hass): + """Test we handle no devices found.""" + with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info( + [] + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_user_step_discovered_devices(hass): + """Test we properly handle device picking.""" + + with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info(): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pick_device" + + with pytest.raises(vol.MultipleInvalid): + await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "wrong_address"} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: FAKE_ADDRESS_1} + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == {CONF_ADDRESS: FAKE_ADDRESS_1} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_step_with_existing_device(hass): + """Test we properly handle device picking.""" + + with patch_async_setup_entry() as mock_setup_entry, patch_async_discovered_service_info( + [FAKE_SERVICE_INFO_1, FAKE_SERVICE_INFO_2] + ): + + # Create the config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_BLUETOOTH, + "step_id": "bluetooth_confirm", + "user_input": {CONF_MAC: FAKE_ADDRESS_1}, + }, + data=FAKE_SERVICE_INFO_1, + ) + + # And create an entry + await hass.config_entries.flow.async_configure(result["flow_id"], user_input={}) + + mock_setup_entry.reset_mock() + + # Now open the picker and validate the current address isn't valid + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] == FlowResultType.FORM + + with pytest.raises(vol.MultipleInvalid): + await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: FAKE_ADDRESS_1} + ) + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_bluetooth_discovered(hass): + """Test we short circuit to config entry creation.""" + + with patch_async_setup_entry() as mock_setup_entry: + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=FAKE_SERVICE_INFO_1, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["description_placeholders"] == {"name": FAKE_ADDRESS_1} + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_bluetooth_confirm(hass): + """Test we short circuit to config entry creation.""" + + with patch_async_setup_entry() as mock_setup_entry: + + # Create the config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_BLUETOOTH, + "step_id": "bluetooth_confirm", + "user_input": {CONF_MAC: FAKE_ADDRESS_1}, + }, + data=FAKE_SERVICE_INFO_1, + ) + + # Interact with it like a user would + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == FAKE_ADDRESS_1 + assert result2["data"] == {CONF_ADDRESS: FAKE_ADDRESS_1} + + assert len(mock_setup_entry.mock_calls) == 1