diff --git a/.strict-typing b/.strict-typing index d0b47db2d59..a4238292b6b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -248,6 +248,7 @@ homeassistant.components.knx.* homeassistant.components.kraken.* homeassistant.components.lacrosse.* homeassistant.components.lacrosse_view.* +homeassistant.components.lamarzocco.* homeassistant.components.lametric.* homeassistant.components.laundrify.* homeassistant.components.lawn_mower.* diff --git a/CODEOWNERS b/CODEOWNERS index ffad270b09f..1aac954b280 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -688,6 +688,8 @@ build.json @home-assistant/supervisor /tests/components/kulersky/ @emlove /homeassistant/components/lacrosse_view/ @IceBotYT /tests/components/lacrosse_view/ @IceBotYT +/homeassistant/components/lamarzocco/ @zweckj +/tests/components/lamarzocco/ @zweckj /homeassistant/components/lametric/ @robbiet480 @frenck @bachya /tests/components/lametric/ @robbiet480 @frenck @bachya /homeassistant/components/landisgyr_heat_meter/ @vpathuis diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py new file mode 100644 index 00000000000..8bf48c3bf91 --- /dev/null +++ b/homeassistant/components/lamarzocco/__init__.py @@ -0,0 +1,36 @@ +"""The La Marzocco integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator + +PLATFORMS = [ + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up La Marzocco as config entry.""" + + coordinator = LaMarzoccoUpdateCoordinator(hass) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py new file mode 100644 index 00000000000..b2f097b818b --- /dev/null +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -0,0 +1,156 @@ +"""Config flow for La Marzocco integration.""" +from collections.abc import Mapping +import logging +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.exceptions import AuthFail, RequestNotSuccessful +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_MACHINE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LmConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for La Marzocco.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + + self.reauth_entry: ConfigEntry | None = None + self._config: dict[str, Any] = {} + self._machines: list[tuple[str, str]] = [] + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + errors = {} + + if user_input: + data: dict[str, Any] = {} + if self.reauth_entry: + data = dict(self.reauth_entry.data) + data = { + **data, + **user_input, + } + + lm = LaMarzoccoClient() + try: + self._machines = await lm.get_all_machines(data) + except AuthFail: + _LOGGER.debug("Server rejected login credentials") + errors["base"] = "invalid_auth" + except RequestNotSuccessful as exc: + _LOGGER.exception("Error connecting to server: %s", str(exc)) + errors["base"] = "cannot_connect" + else: + if not self._machines: + errors["base"] = "no_machines" + + if not errors: + if self.reauth_entry: + self.hass.config_entries.async_update_entry( + self.reauth_entry, data=user_input + ) + await self.hass.config_entries.async_reload( + self.reauth_entry.entry_id + ) + return self.async_abort(reason="reauth_successful") + + if not errors: + self._config = data + return await self.async_step_machine_selection() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_machine_selection( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Let user select machine to connect to.""" + errors: dict[str, str] = {} + if user_input: + serial_number = user_input[CONF_MACHINE] + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + + # validate local connection if host is provided + if user_input.get(CONF_HOST): + lm = LaMarzoccoClient() + if not await lm.check_local_connection( + credentials=self._config, + host=user_input[CONF_HOST], + serial=serial_number, + ): + errors[CONF_HOST] = "cannot_connect" + + if not errors: + return self.async_create_entry( + title=serial_number, + data=self._config | user_input, + ) + + machine_options = [ + SelectOptionDict( + value=serial_number, + label=f"{model_name} ({serial_number})", + ) + for serial_number, model_name in self._machines + ] + + machine_selection_schema = vol.Schema( + { + vol.Required( + CONF_MACHINE, default=machine_options[0]["value"] + ): SelectSelector( + SelectSelectorConfig( + options=machine_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_HOST): cv.string, + } + ) + + return self.async_show_form( + step_id="machine_selection", + data_schema=machine_selection_schema, + errors=errors, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + ) diff --git a/homeassistant/components/lamarzocco/const.py b/homeassistant/components/lamarzocco/const.py new file mode 100644 index 00000000000..2afd1c4cf48 --- /dev/null +++ b/homeassistant/components/lamarzocco/const.py @@ -0,0 +1,7 @@ +"""Constants for the La Marzocco integration.""" + +from typing import Final + +DOMAIN: Final = "lamarzocco" + +CONF_MACHINE: Final = "machine" diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py new file mode 100644 index 00000000000..9b6341e0858 --- /dev/null +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -0,0 +1,96 @@ +"""Coordinator for La Marzocco API.""" +from collections.abc import Callable, Coroutine +from datetime import timedelta +import logging +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.exceptions import AuthFail, RequestNotSuccessful + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_MACHINE, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + + +class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to handle fetching data from the La Marzocco API centrally.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.lm = LaMarzoccoClient( + callback_websocket_notify=self.async_update_listeners, + ) + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + + if not self.lm.initialized: + await self._async_init_client() + + await self._async_handle_request( + self.lm.update_local_machine_status, force_update=True + ) + + _LOGGER.debug("Current status: %s", str(self.lm.current_status)) + + async def _async_init_client(self) -> None: + """Initialize the La Marzocco Client.""" + + # Initialize cloud API + _LOGGER.debug("Initializing Cloud API") + await self._async_handle_request( + self.lm.init_cloud_api, + credentials=self.config_entry.data, + machine_serial=self.config_entry.data[CONF_MACHINE], + ) + _LOGGER.debug("Model name: %s", self.lm.model_name) + + # initialize local API + if (host := self.config_entry.data.get(CONF_HOST)) is not None: + _LOGGER.debug("Initializing local API") + await self.lm.init_local_api( + host=host, + client=get_async_client(self.hass), + ) + + _LOGGER.debug("Init WebSocket in Background Task") + + self.config_entry.async_create_background_task( + hass=self.hass, + target=self.lm.lm_local_api.websocket_connect( + callback=self.lm.on_websocket_message_received, + use_sigterm_handler=False, + ), + name="lm_websocket_task", + ) + + self.lm.initialized = True + + async def _async_handle_request( + self, + func: Callable[..., Coroutine[None, None, None]], + *args: Any, + **kwargs: Any, + ) -> None: + """Handle a request to the API.""" + try: + await func(*args, **kwargs) + except AuthFail as ex: + msg = "Authentication failed." + _LOGGER.debug(msg, exc_info=True) + raise ConfigEntryAuthFailed(msg) from ex + except RequestNotSuccessful as ex: + _LOGGER.debug(ex, exc_info=True) + raise UpdateFailed("Querying API failed. Error: %s" % ex) from ex diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py new file mode 100644 index 00000000000..b2cb6dc2bff --- /dev/null +++ b/homeassistant/components/lamarzocco/entity.py @@ -0,0 +1,50 @@ +"""Base class for the La Marzocco entities.""" + +from dataclasses import dataclass + +from lmcloud.const import LaMarzoccoModel + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoEntityDescription(EntityDescription): + """Description for all LM entities.""" + + supported_models: tuple[LaMarzoccoModel, ...] = ( + LaMarzoccoModel.GS3_AV, + LaMarzoccoModel.GS3_MP, + LaMarzoccoModel.LINEA_MICRA, + LaMarzoccoModel.LINEA_MINI, + ) + + +class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): + """Common elements for all entities.""" + + entity_description: LaMarzoccoEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + entity_description: LaMarzoccoEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + lm = coordinator.lm + self._attr_unique_id = f"{lm.serial_number}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, lm.serial_number)}, + name=lm.machine_name, + manufacturer="La Marzocco", + model=lm.true_model_name, + serial_number=lm.serial_number, + sw_version=lm.firmware_version, + ) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json new file mode 100644 index 00000000000..422f971186e --- /dev/null +++ b/homeassistant/components/lamarzocco/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "lamarzocco", + "name": "La Marzocco", + "codeowners": ["@zweckj"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lamarzocco", + "integration_type": "device", + "iot_class": "cloud_polling", + "loggers": ["lmcloud"], + "requirements": ["lmcloud==0.4.34"] +} diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json new file mode 100644 index 00000000000..e3490270172 --- /dev/null +++ b/homeassistant/components/lamarzocco/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "flow_title": "La Marzocco Espresso {host}", + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_machines": "No machines found in account", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "machine_not_found": "The configured machine was not found in your account. Did you login to the correct account?", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Your username from the La Marzocco app", + "password": "Your password from the La Marzocco app" + } + }, + "machine_selection": { + "description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.", + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "machine": "Machine" + }, + "data_description": { + "host": "Local IP address of the machine" + } + } + } + }, + "entity": { + "switch": { + "auto_on_off": { + "name": "Auto on/off" + }, + "steam_boiler": { + "name": "Steam boiler" + } + } + } +} diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py new file mode 100644 index 00000000000..4c39bd2c5f0 --- /dev/null +++ b/homeassistant/components/lamarzocco/switch.py @@ -0,0 +1,92 @@ +"""Switch platform for La Marzocco espresso machines.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LaMarzoccoUpdateCoordinator +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoSwitchEntityDescription( + LaMarzoccoEntityDescription, + SwitchEntityDescription, +): + """Description of a La Marzocco Switch.""" + + control_fn: Callable[[LaMarzoccoUpdateCoordinator, bool], Coroutine[Any, Any, bool]] + is_on_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] + + +ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( + LaMarzoccoSwitchEntityDescription( + key="main", + name=None, + icon="mdi:power", + control_fn=lambda coordinator, state: coordinator.lm.set_power(state), + is_on_fn=lambda coordinator: coordinator.lm.current_status["power"], + ), + LaMarzoccoSwitchEntityDescription( + key="auto_on_off", + translation_key="auto_on_off", + icon="mdi:alarm", + control_fn=lambda coordinator, state: coordinator.lm.set_auto_on_off_global( + state + ), + is_on_fn=lambda coordinator: coordinator.lm.current_status["global_auto"] + == "Enabled", + entity_category=EntityCategory.CONFIG, + ), + LaMarzoccoSwitchEntityDescription( + key="steam_boiler_enable", + translation_key="steam_boiler", + icon="mdi:water-boiler", + control_fn=lambda coordinator, state: coordinator.lm.set_steam(state), + is_on_fn=lambda coordinator: coordinator.lm.current_status[ + "steam_boiler_enable" + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switch entities and services.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + LaMarzoccoSwitchEntity(coordinator, description) + for description in ENTITIES + if coordinator.lm.model_name in description.supported_models + ) + + +class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity): + """Switches representing espresso machine power, prebrew, and auto on/off.""" + + entity_description: LaMarzoccoSwitchEntityDescription + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn device on.""" + await self.entity_description.control_fn(self.coordinator, True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn device off.""" + await self.entity_description.control_fn(self.coordinator, False) + self.async_write_ha_state() + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self.entity_description.is_on_fn(self.coordinator) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d14872aa29d..22b0fcf064f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -261,6 +261,7 @@ FLOWS = { "kraken", "kulersky", "lacrosse_view", + "lamarzocco", "lametric", "landisgyr_heat_meter", "lastfm", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 07872e987ae..ff35cf3235f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2999,6 +2999,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "lamarzocco": { + "name": "La Marzocco", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "lametric": { "name": "LaMetric", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 87829af666b..1b352a72f18 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2241,6 +2241,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lamarzocco.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lametric.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 2f4b411ec80..cdf97a57504 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1209,6 +1209,9 @@ linear-garage-door==0.2.7 # homeassistant.components.linode linode-api==4.1.9b1 +# homeassistant.components.lamarzocco +lmcloud==0.4.34 + # homeassistant.components.google_maps locationsharinglib==5.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ce86ae77b4..9f8daba5708 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -954,6 +954,9 @@ libsoundtouch==0.8 # homeassistant.components.linear_garage_door linear-garage-door==0.2.7 +# homeassistant.components.lamarzocco +lmcloud==0.4.34 + # homeassistant.components.logi_circle logi-circle==0.2.3 diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py new file mode 100644 index 00000000000..bac7d4b3c61 --- /dev/null +++ b/tests/components/lamarzocco/__init__.py @@ -0,0 +1,25 @@ +"""Mock inputs for tests.""" + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +HOST_SELECTION = { + CONF_HOST: "192.168.1.1", +} + +PASSWORD_SELECTION = { + CONF_PASSWORD: "password", +} + +USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"} + + +async def async_init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the La Marzocco integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py new file mode 100644 index 00000000000..98baac22d33 --- /dev/null +++ b/tests/components/lamarzocco/conftest.py @@ -0,0 +1,104 @@ +"""Lamarzocco session fixtures.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from lmcloud.const import LaMarzoccoModel +import pytest + +from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN +from homeassistant.core import HomeAssistant + +from . import USER_INPUT, async_init_integration + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture +def mock_config_entry(mock_lamarzocco: MagicMock) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My LaMarzocco", + domain=DOMAIN, + data=USER_INPUT | {CONF_MACHINE: mock_lamarzocco.serial_number}, + unique_id=mock_lamarzocco.serial_number, + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock +) -> MockConfigEntry: + """Set up the LaMetric integration for testing.""" + await async_init_integration(hass, mock_config_entry) + + return mock_config_entry + + +@pytest.fixture +def device_fixture() -> LaMarzoccoModel: + """Return the device fixture for a specific device.""" + return LaMarzoccoModel.GS3_AV + + +@pytest.fixture +def mock_lamarzocco( + request: pytest.FixtureRequest, device_fixture: LaMarzoccoModel +) -> Generator[MagicMock, None, None]: + """Return a mocked LM client.""" + model_name = device_fixture + + if model_name == LaMarzoccoModel.GS3_AV: + serial_number = "GS01234" + true_model_name = "GS3 AV" + elif model_name == LaMarzoccoModel.GS3_MP: + serial_number = "GS01234" + true_model_name = "GS3 MP" + elif model_name == LaMarzoccoModel.LINEA_MICRA: + serial_number = "MR01234" + true_model_name = "Linea Micra" + elif model_name == LaMarzoccoModel.LINEA_MINI: + serial_number = "LM01234" + true_model_name = "Linea Mini" + + with patch( + "homeassistant.components.lamarzocco.coordinator.LaMarzoccoClient", + autospec=True, + ) as lamarzocco_mock, patch( + "homeassistant.components.lamarzocco.config_flow.LaMarzoccoClient", + new=lamarzocco_mock, + ): + lamarzocco = lamarzocco_mock.return_value + + lamarzocco.machine_info = { + "machine_name": serial_number, + "serial_number": serial_number, + } + + lamarzocco.model_name = model_name + lamarzocco.true_model_name = true_model_name + lamarzocco.machine_name = serial_number + lamarzocco.serial_number = serial_number + + lamarzocco.firmware_version = "1.1" + lamarzocco.latest_firmware_version = "1.1" + lamarzocco.gateway_version = "v2.2-rc0" + lamarzocco.latest_gateway_version = "v3.1-rc4" + + lamarzocco.current_status = load_json_object_fixture( + "current_status.json", DOMAIN + ) + lamarzocco.config = load_json_object_fixture("config.json", DOMAIN) + lamarzocco.statistics = load_json_array_fixture("statistics.json", DOMAIN) + + lamarzocco.get_all_machines.return_value = [ + (serial_number, model_name), + ] + lamarzocco.check_local_connection.return_value = True + lamarzocco.initialized = False + + yield lamarzocco diff --git a/tests/components/lamarzocco/fixtures/config.json b/tests/components/lamarzocco/fixtures/config.json new file mode 100644 index 00000000000..60d11b0d470 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/config.json @@ -0,0 +1,187 @@ +{ + "version": "v1", + "preinfusionModesAvailable": ["ByDoseType"], + "machineCapabilities": [ + { + "family": "GS3AV", + "groupsNumber": 1, + "coffeeBoilersNumber": 1, + "hasCupWarmer": false, + "steamBoilersNumber": 1, + "teaDosesNumber": 1, + "machineModes": ["BrewingMode", "StandBy"], + "schedulingType": "weeklyScheduling" + } + ], + "machine_sn": "GS01234", + "machine_hw": "2", + "isPlumbedIn": true, + "isBackFlushEnabled": false, + "standByTime": 0, + "tankStatus": true, + "groupCapabilities": [ + { + "capabilities": { + "groupType": "AV_Group", + "groupNumber": "Group1", + "boilerId": "CoffeeBoiler1", + "hasScale": false, + "hasFlowmeter": true, + "numberOfDoses": 4 + }, + "doses": [ + { + "groupNumber": "Group1", + "doseIndex": "DoseA", + "doseType": "PulsesType", + "stopTarget": 135 + }, + { + "groupNumber": "Group1", + "doseIndex": "DoseB", + "doseType": "PulsesType", + "stopTarget": 97 + }, + { + "groupNumber": "Group1", + "doseIndex": "DoseC", + "doseType": "PulsesType", + "stopTarget": 108 + }, + { + "groupNumber": "Group1", + "doseIndex": "DoseD", + "doseType": "PulsesType", + "stopTarget": 121 + } + ], + "doseMode": { + "groupNumber": "Group1", + "brewingType": "PulsesType" + } + } + ], + "machineMode": "BrewingMode", + "teaDoses": { + "DoseA": { + "doseIndex": "DoseA", + "stopTarget": 8 + } + }, + "boilers": [ + { + "id": "SteamBoiler", + "isEnabled": true, + "target": 123.90000152587891, + "current": 123.80000305175781 + }, + { + "id": "CoffeeBoiler1", + "isEnabled": true, + "target": 95, + "current": 96.5 + } + ], + "boilerTargetTemperature": { + "SteamBoiler": 123.90000152587891, + "CoffeeBoiler1": 95 + }, + "preinfusionMode": { + "Group1": { + "groupNumber": "Group1", + "preinfusionStyle": "PreinfusionByDoseType" + } + }, + "preinfusionSettings": { + "mode": "TypeB", + "Group1": [ + { + "groupNumber": "Group1", + "doseType": "DoseA", + "preWetTime": 0.5, + "preWetHoldTime": 1 + }, + { + "groupNumber": "Group1", + "doseType": "DoseB", + "preWetTime": 0.5, + "preWetHoldTime": 1 + }, + { + "groupNumber": "Group1", + "doseType": "DoseC", + "preWetTime": 3.2999999523162842, + "preWetHoldTime": 3.2999999523162842 + }, + { + "groupNumber": "Group1", + "doseType": "DoseD", + "preWetTime": 2, + "preWetHoldTime": 2 + } + ] + }, + "weeklySchedulingConfig": { + "enabled": true, + "monday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "tuesday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "wednesday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "thursday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "friday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "saturday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + }, + "sunday": { + "enabled": true, + "h_on": 6, + "h_off": 16, + "m_on": 0, + "m_off": 0 + } + }, + "clock": "1901-07-08T10:29:00", + "firmwareVersions": [ + { + "name": "machine_firmware", + "fw_version": "1.40" + }, + { + "name": "gateway_firmware", + "fw_version": "v3.1-rc4" + } + ] +} diff --git a/tests/components/lamarzocco/fixtures/current_status.json b/tests/components/lamarzocco/fixtures/current_status.json new file mode 100644 index 00000000000..4367bd1e38d --- /dev/null +++ b/tests/components/lamarzocco/fixtures/current_status.json @@ -0,0 +1,61 @@ +{ + "power": true, + "global_auto": "Enabled", + "enable_prebrewing": true, + "coffee_boiler_on": true, + "steam_boiler_on": true, + "enable_preinfusion": false, + "steam_boiler_enable": true, + "steam_temp": 113, + "steam_set_temp": 128, + "coffee_temp": 93, + "coffee_set_temp": 95, + "water_reservoir_contact": true, + "brew_active": false, + "drinks_k1": 13, + "drinks_k2": 2, + "drinks_k3": 42, + "drinks_k4": 34, + "total_flushing": 69, + "mon_auto": "Disabled", + "mon_on_time": "00:00", + "mon_off_time": "00:00", + "tue_auto": "Disabled", + "tue_on_time": "00:00", + "tue_off_time": "00:00", + "wed_auto": "Disabled", + "wed_on_time": "00:00", + "wed_off_time": "00:00", + "thu_auto": "Disabled", + "thu_on_time": "00:00", + "thu_off_time": "00:00", + "fri_auto": "Disabled", + "fri_on_time": "00:00", + "fri_off_time": "00:00", + "sat_auto": "Disabled", + "sat_on_time": "00:00", + "sat_off_time": "00:00", + "sun_auto": "Disabled", + "sun_on_time": "00:00", + "sun_off_time": "00:00", + "dose_k1": 1023, + "dose_k2": 1023, + "dose_k3": 1023, + "dose_k4": 1023, + "dose_k5": 1023, + "prebrewing_ton_k1": 3, + "prebrewing_toff_k1": 5, + "prebrewing_ton_k2": 3, + "prebrewing_toff_k2": 5, + "prebrewing_ton_k3": 3, + "prebrewing_toff_k3": 5, + "prebrewing_ton_k4": 3, + "prebrewing_toff_k4": 5, + "prebrewing_ton_k5": 3, + "prebrewing_toff_k5": 5, + "preinfusion_k1": 4, + "preinfusion_k2": 4, + "preinfusion_k3": 4, + "preinfusion_k4": 4, + "preinfusion_k5": 4 +} diff --git a/tests/components/lamarzocco/fixtures/statistics.json b/tests/components/lamarzocco/fixtures/statistics.json new file mode 100644 index 00000000000..c82d02cc7c1 --- /dev/null +++ b/tests/components/lamarzocco/fixtures/statistics.json @@ -0,0 +1,26 @@ +[ + { + "count": 1047, + "coffeeType": 0 + }, + { + "count": 560, + "coffeeType": 1 + }, + { + "count": 468, + "coffeeType": 2 + }, + { + "count": 312, + "coffeeType": 3 + }, + { + "count": 2252, + "coffeeType": 4 + }, + { + "coffeeType": -1, + "count": 1740 + } +] diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr new file mode 100644 index 00000000000..36df947bb70 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -0,0 +1,161 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'lamarzocco', + 'GS01234', + ), + }), + 'is_new': False, + 'manufacturer': 'La Marzocco', + 'model': 'GS3 AV', + 'name': 'GS01234', + 'name_by_user': None, + 'serial_number': 'GS01234', + 'suggested_area': None, + 'sw_version': '1.1', + 'via_device_id': None, + }) +# --- +# name: test_switches[-set_power] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234', + 'icon': 'mdi:power', + }), + 'context': , + 'entity_id': 'switch.gs01234', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[-set_power].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gs01234', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:power', + 'original_name': None, + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'GS01234_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[_auto_on_off-set_auto_on_off_global] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Auto on/off', + 'icon': 'mdi:alarm', + }), + 'context': , + 'entity_id': 'switch.gs01234_auto_on_off', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[_auto_on_off-set_auto_on_off_global].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.gs01234_auto_on_off', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:alarm', + 'original_name': 'Auto on/off', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_on_off', + 'unique_id': 'GS01234_auto_on_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[_steam_boiler-set_steam] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Steam boiler', + 'icon': 'mdi:water-boiler', + }), + 'context': , + 'entity_id': 'switch.gs01234_steam_boiler', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switches[_steam_boiler-set_steam].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.gs01234_steam_boiler', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:water-boiler', + 'original_name': 'Steam boiler', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_boiler', + 'unique_id': 'GS01234_steam_boiler_enable', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py new file mode 100644 index 00000000000..703a1e9d36e --- /dev/null +++ b/tests/components/lamarzocco/test_config_flow.py @@ -0,0 +1,235 @@ +"""Test the La Marzocco config flow.""" +from unittest.mock import MagicMock + +from lmcloud.exceptions import AuthFail, RequestNotSuccessful + +from homeassistant import config_entries +from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from . import PASSWORD_SELECTION, USER_INPUT + +from tests.common import MockConfigEntry + + +async def __do_successful_user_step( + hass: HomeAssistant, result: FlowResult +) -> FlowResult: + """Successfully configure the user step.""" + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + return result2 + + +async def __do_sucessful_machine_selection_step( + hass: HomeAssistant, result2: FlowResult, mock_lamarzocco: MagicMock +) -> None: + """Successfully configure the machine selection step.""" + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + + assert result3["title"] == mock_lamarzocco.serial_number + assert result3["data"] == { + **USER_INPUT, + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + } + + +async def test_form(hass: HomeAssistant, mock_lamarzocco: MagicMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result2 = await __do_successful_user_step(hass, result) + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + assert len(mock_lamarzocco.check_local_connection.mock_calls) == 1 + + +async def test_form_abort_already_configured( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "already_configured" + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test invalid auth error.""" + + mock_lamarzocco.get_all_machines.side_effect = AuthFail("") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + + # test recovery from failure + mock_lamarzocco.get_all_machines.side_effect = None + result2 = await __do_successful_user_step(hass, result) + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + +async def test_form_invalid_host( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test invalid auth error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + mock_lamarzocco.check_local_connection.return_value = False + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_lamarzocco.serial_number, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == {"host": "cannot_connect"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + + # test recovery from failure + mock_lamarzocco.check_local_connection.return_value = True + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test cannot connect error.""" + + mock_lamarzocco.get_all_machines.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "no_machines"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + + mock_lamarzocco.get_all_machines.side_effect = RequestNotSuccessful("") + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 2 + + # test recovery from failure + mock_lamarzocco.get_all_machines.side_effect = None + mock_lamarzocco.get_all_machines.return_value = [ + (mock_lamarzocco.serial_number, mock_lamarzocco.model_name) + ] + result2 = await __do_successful_user_step(hass, result) + await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco) + + +async def test_reauth_flow( + hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that the reauth flow.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + PASSWORD_SELECTION, + ) + + assert result2["type"] == FlowResultType.ABORT + await hass.async_block_till_done() + assert result2["reason"] == "reauth_successful" + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py new file mode 100644 index 00000000000..1302961cfc0 --- /dev/null +++ b/tests/components/lamarzocco/test_init.py @@ -0,0 +1,70 @@ +"""Test initialization of lamarzocco.""" +from unittest.mock import MagicMock + +from lmcloud.exceptions import AuthFail, RequestNotSuccessful + +from homeassistant.components.lamarzocco.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Test loading and unloading the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Test the La Marzocco configuration entry not ready.""" + mock_lamarzocco.update_local_machine_status.side_effect = RequestNotSuccessful("") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Test auth error during setup.""" + mock_lamarzocco.update_local_machine_status.side_effect = AuthFail("") + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1 + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "user" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py new file mode 100644 index 00000000000..70024e3e340 --- /dev/null +++ b/tests/components/lamarzocco/test_switch.py @@ -0,0 +1,91 @@ +"""Tests for La Marzocco switches.""" +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize( + ("entity_name", "method_name"), + [ + ("", "set_power"), + ("_auto_on_off", "set_auto_on_off_global"), + ("_steam_boiler", "set_steam"), + ], +) +async def test_switches( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_name: str, + method_name: str, +) -> None: + """Test the La Marzocco switches.""" + serial_number = mock_lamarzocco.serial_number + + control_fn = getattr(mock_lamarzocco, method_name) + + state = hass.states.get(f"switch.{serial_number}{entity_name}") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: f"switch.{serial_number}{entity_name}", + }, + blocking=True, + ) + + assert len(control_fn.mock_calls) == 1 + control_fn.assert_called_once_with(False) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: f"switch.{serial_number}{entity_name}", + }, + blocking=True, + ) + + assert len(control_fn.mock_calls) == 2 + control_fn.assert_called_with(True) + + +async def test_device( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the device for one switch.""" + + state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}") + assert state + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + + device = device_registry.async_get(entry.device_id) + assert device + assert device == snapshot