From e5fa6f01763448dffc0ba1048db46617092ede32 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 21 Mar 2024 13:08:11 +0100 Subject: [PATCH] Add Bluetooth support to La Marzocco integration (#108287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init * init tests * linting * checks * tests, linting * pylint * add tests * switch tests * add water heater tests * change icons * extra args cleanup * moar tests * services tests * remove extra platforms * test for unique id * back to single instance * add diagnostics * remove extra platforms * test for unique id * back to single instance * Add better connection management for Idasen Desk (#102135) * Return 'None' for light attributes when off instead of removing them (#101946) * Bump home-assistant-bluetooth to 1.10.4 (#102268) * Bump orjson to 3.9.9 (#102267) * Bump opower to 0.0.37 (#102265) * Bump Python-Roborock to 0.35.0 (#102275) * Add CodeQL CI Job (#102273) * Remove unused dsmr sensors (#102223) * rebase messed up conftest * more tests for init * add client to coveragerc * add client to coveragerc * next lmcloud version * strict typing * more typing * allow multiple machines * remove unneeded var * Update homeassistant/components/lamarzocco/coordinator.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/diagnostics.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/__init__.py Co-authored-by: Robert Resch * PR suggestions * remove base exception * Update manifest.json * update lmcloud * update lmcloud * remove ignore * selection bugfix for machines with space in name * bugfix temps * add options flow * send out full user input * remove options flow * split the tests to avoid timeouts * use selectoptionsdict for selection * removing rccoleman * improve test coverage to 100% * Update config_flow.py Co-authored-by: Robert Resch * Update config_flow.py Co-authored-by: Robert Resch * Update config_flow.py Co-authored-by: Robert Resch * autoselect cloud machine for discovered machine * move default values to 3rd party lib * bring property changes from lmcloud * moving things to lmcloud * move validation to method * move more things to lmcloud * remove unused const * Update homeassistant/components/lamarzocco/coordinator.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/coordinator.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/__init__.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/__init__.py Co-authored-by: Robert Resch * remove callback from coordinator * remove waterheater, add switch * improvement to background task * next lmcloud * adapt to lib changes * Update homeassistant/components/lamarzocco/strings.json Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/entity.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/entity.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/switch.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/switch.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/entity.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/strings.json Co-authored-by: Robert Resch * requested changes * Update homeassistant/components/lamarzocco/switch.py Co-authored-by: Robert Resch * Update homeassistant/components/lamarzocco/entity.py Co-authored-by: Robert Resch * Update tests/components/lamarzocco/test_config_flow.py Co-authored-by: Robert Resch * Update tests/components/lamarzocco/test_config_flow.py Co-authored-by: Robert Resch * some requested changes * changes * requested changes * move steam boiler to controls * fix: remove entities from GS3MP model + tests * remove dataclass decorator * next lmcloud version * improvements * move reauth to user step * improve config flow * remove asserts in favor of runtimeerrors * undo conftest comment * make duc return none * Update homeassistant/components/lamarzocco/switch.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lamarzocco/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lamarzocco/config_flow.py Co-authored-by: Joost Lekkerkerker * remove diagnostics, changes * refine config flow * remove runtimeerrors in favor of asserts * move initialization of lm_client to coordinator * remove things from lmclient * remove lm_client * remove lm_client * bump lm version * correctly set initialized for tests * move exception handling inside init + tests * add test for switch without bluetooth on * bump lmcloud * pass httpx client to LMLocalAPI * add call function to reduce code * switch to snapshot testing * remove bluetooth * bump version * cleanup import * remove unused const * set correct integration_type * correct default selection in CF * reduce unnecessary tests by fixture change * use other json loads helpers * move prebrew/infusion to select entity * bump lmcloud * Update coordinator.py Co-authored-by: Joost Lekkerkerker * Update coordinator.py Co-authored-by: Joost Lekkerkerker * Update coordinator.py Co-authored-by: Joost Lekkerkerker * Update entity.py Co-authored-by: Joost Lekkerkerker * Update entity.py Co-authored-by: Joost Lekkerkerker * requested feedback * step description, bump lmcloud * create init integration functino * revert * ruff * remove leftover BT test * make main switch main entity * bump lmcloud * re-add bluetooth * improve * bump firmware (again) * correct test * Update homeassistant/components/lamarzocco/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lamarzocco/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lamarzocco/strings.json Co-authored-by: Joost Lekkerkerker * separate device test * add BT to entites * fix import * docstring * minor * fix rebase * get device from discovered devices * tweak * change tests * switch to dict * switch to options * fix * fix --------- Co-authored-by: AbĂ­lio Costa Co-authored-by: Paul Bottein Co-authored-by: J. Nick Koston Co-authored-by: tronikos Co-authored-by: Luke Lashley Co-authored-by: Franck Nijhof Co-authored-by: dupondje Co-authored-by: Robert Resch Co-authored-by: Joost Lekkerkerker --- .../components/lamarzocco/__init__.py | 5 + .../components/lamarzocco/config_flow.py | 100 +++++++++++- homeassistant/components/lamarzocco/const.py | 2 + .../components/lamarzocco/coordinator.py | 66 +++++++- .../components/lamarzocco/manifest.json | 15 ++ homeassistant/components/lamarzocco/number.py | 8 +- homeassistant/components/lamarzocco/select.py | 2 +- .../components/lamarzocco/strings.json | 10 ++ homeassistant/components/lamarzocco/switch.py | 8 +- homeassistant/generated/bluetooth.py | 16 ++ tests/components/lamarzocco/__init__.py | 31 ++++ tests/components/lamarzocco/conftest.py | 47 +++--- .../lamarzocco/snapshots/test_switch.ambr | 146 +++++++++++++++++- .../components/lamarzocco/test_config_flow.py | 138 ++++++++++++++++- tests/components/lamarzocco/test_init.py | 40 ++++- tests/components/lamarzocco/test_number.py | 11 +- tests/components/lamarzocco/test_select.py | 2 +- tests/components/lamarzocco/test_switch.py | 45 +++++- 18 files changed, 633 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 0cdacc8d2e4..d2a7bbb6216 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -29,6 +29,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + await hass.config_entries.async_reload(entry.entry_id) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index de960f364ce..3cacdae1749 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -8,8 +8,22 @@ from lmcloud import LMCloud as LaMarzoccoClient from lmcloud.exceptions import AuthFail, RequestNotSuccessful import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.bluetooth import BluetoothServiceInfo +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( SelectOptionDict, @@ -18,7 +32,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_MACHINE, DOMAIN +from .const import CONF_MACHINE, CONF_USE_BLUETOOTH, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -32,6 +46,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): self.reauth_entry: ConfigEntry | None = None self._config: dict[str, Any] = {} self._machines: list[tuple[str, str]] = [] + self._discovered: dict[str, str] = {} async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -47,6 +62,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): data = { **data, **user_input, + **self._discovered, } lm = LaMarzoccoClient() @@ -71,6 +87,18 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): self.reauth_entry.entry_id ) return self.async_abort(reason="reauth_successful") + if self._discovered: + serials = [machine[0] for machine in self._machines] + if self._discovered[CONF_MACHINE] not in serials: + errors["base"] = "machine_not_found" + else: + self._config = data + return self.async_show_form( + step_id="machine_selection", + data_schema=vol.Schema( + {vol.Optional(CONF_HOST): cv.string} + ), + ) if not errors: self._config = data @@ -93,9 +121,12 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): """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() + if not self._discovered: + serial_number = user_input[CONF_MACHINE] + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + else: + serial_number = self._discovered[CONF_MACHINE] # validate local connection if host is provided if user_input.get(CONF_HOST): @@ -141,6 +172,30 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by discovery over Bluetooth.""" + address = discovery_info.address + name = discovery_info.name + + _LOGGER.debug( + "Discovered La Marzocco machine %s through Bluetooth at address %s", + name, + address, + ) + + self._discovered[CONF_NAME] = name + self._discovered[CONF_MAC] = address + + serial = name.split("_")[1] + self._discovered[CONF_MACHINE] = serial + + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + + return await self.async_step_user() + async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -165,3 +220,36 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): ) return await self.async_step_user(user_input) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: + """Create the options flow.""" + return LmOptionsFlowHandler(config_entry) + + +class LmOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Handles options flow for the component.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options for the custom component.""" + if user_input: + return self.async_create_entry(title="", data=user_input) + + options_schema = vol.Schema( + { + vol.Optional( + CONF_USE_BLUETOOTH, + default=self.options.get(CONF_USE_BLUETOOTH, True), + ): cv.boolean, + } + ) + + return self.async_show_form( + step_id="init", + data_schema=options_schema, + ) diff --git a/homeassistant/components/lamarzocco/const.py b/homeassistant/components/lamarzocco/const.py index 2afd1c4cf48..87878ea5089 100644 --- a/homeassistant/components/lamarzocco/const.py +++ b/homeassistant/components/lamarzocco/const.py @@ -5,3 +5,5 @@ from typing import Final DOMAIN: Final = "lamarzocco" CONF_MACHINE: Final = "machine" + +CONF_USE_BLUETOOTH = "use_bluetooth" diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 85fb8bb8854..7901b0bb3fa 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -5,23 +5,32 @@ from datetime import timedelta import logging from typing import Any +from bleak.backends.device import BLEDevice from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.const import BT_MODEL_NAMES from lmcloud.exceptions import AuthFail, RequestNotSuccessful +from homeassistant.components.bluetooth import ( + async_ble_device_from_address, + async_discovered_service_info, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_USERNAME 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 +from .const import CONF_MACHINE, CONF_USE_BLUETOOTH, DOMAIN SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) +NAME_PREFIXES = tuple(BT_MODEL_NAMES) + + class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): """Class to handle fetching data from the La Marzocco API centrally.""" @@ -36,6 +45,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): self.local_connection_configured = ( self.config_entry.data.get(CONF_HOST) is not None ) + self._use_bluetooth = False async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" @@ -80,6 +90,46 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): name="lm_websocket_task", ) + # initialize Bluetooth + if self.config_entry.options.get(CONF_USE_BLUETOOTH, True): + + def bluetooth_configured() -> bool: + return self.config_entry.data.get( + CONF_MAC, "" + ) and self.config_entry.data.get(CONF_NAME, "") + + if not bluetooth_configured(): + machine = self.config_entry.data[CONF_MACHINE] + for discovery_info in async_discovered_service_info(self.hass): + if ( + (name := discovery_info.name) + and name.startswith(NAME_PREFIXES) + and name.split("_")[1] == machine + ): + _LOGGER.debug( + "Found Bluetooth device, configuring with Bluetooth" + ) + # found a device, add MAC address to config entry + self.hass.config_entries.async_update_entry( + self.config_entry, + data={ + **self.config_entry.data, + CONF_MAC: discovery_info.address, + CONF_NAME: discovery_info.name, + }, + ) + break + + if bluetooth_configured(): + # config entry contains BT config + _LOGGER.debug("Initializing with known Bluetooth device") + await self.lm.init_bluetooth_with_known_device( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data.get(CONF_MAC, ""), + self.config_entry.data.get(CONF_NAME, ""), + ) + self._use_bluetooth = True + self.lm.initialized = True async def _async_handle_request( @@ -98,3 +148,15 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): except RequestNotSuccessful as ex: _LOGGER.debug(ex, exc_info=True) raise UpdateFailed("Querying API failed. Error: %s" % ex) from ex + + def async_get_ble_device(self) -> BLEDevice | None: + """Get a Bleak Client for the machine.""" + # according to HA best practices, we should not reuse the same client + # get a new BLE device from hass and init a new Bleak Client with it + if not self._use_bluetooth: + return None + + return async_ble_device_from_address( + self.hass, + self.lm.lm_bluetooth.address, + ) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 8dd8e1294b0..ec6068e1988 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -1,8 +1,23 @@ { "domain": "lamarzocco", "name": "La Marzocco", + "bluetooth": [ + { + "local_name": "MICRA_*" + }, + { + "local_name": "MINI_*" + }, + { + "local_name": "GS3_*" + }, + { + "local_name": "GS3AV_*" + } + ], "codeowners": ["@zweckj"], "config_flow": true, + "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/lamarzocco", "integration_type": "device", "iot_class": "cloud_polling", diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 88a06a0c9d0..af5256bc77b 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -63,7 +63,9 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( native_step=PRECISION_TENTHS, native_min_value=85, native_max_value=104, - set_value_fn=lambda coordinator, temp: coordinator.lm.set_coffee_temp(temp), + set_value_fn=lambda coordinator, temp: coordinator.lm.set_coffee_temp( + temp, coordinator.async_get_ble_device() + ), native_value_fn=lambda lm: lm.current_status["coffee_set_temp"], ), LaMarzoccoNumberEntityDescription( @@ -74,7 +76,9 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( native_step=PRECISION_WHOLE, native_min_value=126, native_max_value=131, - set_value_fn=lambda coordinator, temp: coordinator.lm.set_steam_temp(int(temp)), + set_value_fn=lambda coordinator, temp: coordinator.lm.set_steam_temp( + int(temp), coordinator.async_get_ble_device() + ), native_value_fn=lambda lm: lm.current_status["steam_set_temp"], supported_fn=lambda coordinator: coordinator.lm.model_name in ( diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index 1e70000a479..f063f8e6336 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -36,7 +36,7 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( translation_key="steam_temp_select", options=["1", "2", "3"], select_option_fn=lambda coordinator, option: coordinator.lm.set_steam_level( - int(option) + int(option), coordinator.async_get_ble_device() ), current_option_fn=lambda lm: lm.current_status["steam_level_set"], supported_fn=lambda coordinator: coordinator.lm.model_name diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 57421dfee83..03ce2eb93e8 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -42,6 +42,16 @@ } } }, + "options": { + "step": { + "init": { + "data": { + "title": "Update Configuration", + "use_bluetooth": "Use Bluetooth" + } + } + } + }, "entity": { "binary_sensor": { "brew_active": { diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index d8f5edec6b9..dd647bf4582 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -31,7 +31,9 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( key="main", translation_key="main", name=None, - control_fn=lambda coordinator, state: coordinator.lm.set_power(state), + control_fn=lambda coordinator, state: coordinator.lm.set_power( + state, coordinator.async_get_ble_device() + ), is_on_fn=lambda coordinator: coordinator.lm.current_status["power"], ), LaMarzoccoSwitchEntityDescription( @@ -47,7 +49,9 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( LaMarzoccoSwitchEntityDescription( key="steam_boiler_enable", translation_key="steam_boiler", - control_fn=lambda coordinator, state: coordinator.lm.set_steam(state), + control_fn=lambda coordinator, state: coordinator.lm.set_steam( + state, coordinator.async_get_ble_device() + ), is_on_fn=lambda coordinator: coordinator.lm.current_status[ "steam_boiler_enable" ], diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index c0b21c0a81d..d1972e703b4 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -266,6 +266,22 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "keymitt_ble", "local_name": "mib*", }, + { + "domain": "lamarzocco", + "local_name": "MICRA_*", + }, + { + "domain": "lamarzocco", + "local_name": "MINI_*", + }, + { + "domain": "lamarzocco", + "local_name": "GS3_*", + }, + { + "domain": "lamarzocco", + "local_name": "GS3AV_*", + }, { "domain": "ld2410_ble", "local_name": "HLK-LD2410B_*", diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py index 1e7d5ed0148..ed4d2e0990e 100644 --- a/tests/components/lamarzocco/__init__.py +++ b/tests/components/lamarzocco/__init__.py @@ -1,7 +1,10 @@ """Mock inputs for tests.""" +from lmcloud.const import LaMarzoccoModel + from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from tests.common import MockConfigEntry @@ -15,6 +18,13 @@ PASSWORD_SELECTION = { USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"} +MODEL_DICT = { + LaMarzoccoModel.GS3_AV: ("GS01234", "GS3 AV"), + LaMarzoccoModel.GS3_MP: ("GS01234", "GS3 MP"), + LaMarzoccoModel.LINEA_MICRA: ("MR01234", "Linea Micra"), + LaMarzoccoModel.LINEA_MINI: ("LM01234", "Linea Mini"), +} + async def async_init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry @@ -22,3 +32,24 @@ async def async_init_integration( """Set up the La Marzocco integration for testing.""" await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + + +def get_bluetooth_service_info( + model: LaMarzoccoModel, serial: str +) -> BluetoothServiceInfo: + """Return a mocked BluetoothServiceInfo.""" + if model in (LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP): + name = f"GS3_{serial}" + elif model == LaMarzoccoModel.LINEA_MINI: + name = f"MINI_{serial}" + elif model == LaMarzoccoModel.LINEA_MICRA: + name = f"MICRA_{serial}" + return BluetoothServiceInfo( + name=name, + address="aa:bb:cc:dd:ee:ff", + rssi=-63, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", + ) diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index 17d605a0dde..d76e44d60af 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -7,10 +7,10 @@ from lmcloud.const import LaMarzoccoModel import pytest from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant -from . import USER_INPUT, async_init_integration +from . import MODEL_DICT, USER_INPUT, async_init_integration from tests.common import ( MockConfigEntry, @@ -28,7 +28,12 @@ def mock_config_entry( title="My LaMarzocco", domain=DOMAIN, data=USER_INPUT - | {CONF_MACHINE: mock_lamarzocco.serial_number, CONF_HOST: "host"}, + | { + CONF_MACHINE: mock_lamarzocco.serial_number, + CONF_HOST: "host", + CONF_NAME: "name", + CONF_MAC: "mac", + }, unique_id=mock_lamarzocco.serial_number, ) entry.add_to_hass(hass) @@ -58,25 +63,17 @@ def mock_lamarzocco( """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" + (serial_number, true_model_name) = MODEL_DICT[model_name] - with patch( - "homeassistant.components.lamarzocco.coordinator.LaMarzoccoClient", - autospec=True, - ) as lamarzocco_mock, patch( - "homeassistant.components.lamarzocco.config_flow.LaMarzoccoClient", - new=lamarzocco_mock, + 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 @@ -118,6 +115,9 @@ def mock_lamarzocco( lamarzocco.lm_local_api.websocket_connect = websocket_connect_mock + lamarzocco.lm_bluetooth = MagicMock() + lamarzocco.lm_bluetooth.address = "AA:BB:CC:DD:EE:FF" + yield lamarzocco @@ -130,3 +130,8 @@ def remove_local_connection( del data[CONF_HOST] hass.config_entries.async_update_entry(mock_config_entry, data=data) return mock_config_entry + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/lamarzocco/snapshots/test_switch.ambr b/tests/components/lamarzocco/snapshots/test_switch.ambr index 1639e8fce94..59053c5c478 100644 --- a/tests/components/lamarzocco/snapshots/test_switch.ambr +++ b/tests/components/lamarzocco/snapshots/test_switch.ambr @@ -29,7 +29,7 @@ 'via_device_id': None, }) # --- -# name: test_switches[-set_power] +# name: test_switches[-set_power-args_on0-args_off0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234', @@ -42,7 +42,7 @@ 'state': 'on', }) # --- -# name: test_switches[-set_power].1 +# name: test_switches[-set_power-args_on0-args_off0].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -75,7 +75,51 @@ 'unit_of_measurement': None, }) # --- -# name: test_switches[_auto_on_off-set_auto_on_off_global] +# name: test_switches[-set_power-kwargs_on0-kwargs_off0] + 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-kwargs_on0-kwargs_off0].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-args_on1-args_off1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Auto on/off', @@ -88,7 +132,7 @@ 'state': 'on', }) # --- -# name: test_switches[_auto_on_off-set_auto_on_off_global].1 +# name: test_switches[_auto_on_off-set_auto_on_off_global-args_on1-args_off1].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -121,7 +165,51 @@ 'unit_of_measurement': None, }) # --- -# name: test_switches[_steam_boiler-set_steam] +# name: test_switches[_auto_on_off-set_auto_on_off_global-kwargs_on1-kwargs_off1] + 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-kwargs_on1-kwargs_off1].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-args_on2-args_off2] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'GS01234 Steam boiler', @@ -134,7 +222,53 @@ 'state': 'on', }) # --- -# name: test_switches[_steam_boiler-set_steam].1 +# name: test_switches[_steam_boiler-set_steam-args_on2-args_off2].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': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + '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, + }) +# --- +# name: test_switches[_steam_boiler-set_steam-kwargs_on2-kwargs_off2] + 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-kwargs_on2-kwargs_off2].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index ffdf43df3ae..37d9c9a3e95 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -5,13 +5,17 @@ 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.components.lamarzocco.const import ( + CONF_MACHINE, + CONF_USE_BLUETOOTH, + DOMAIN, +) from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType -from . import USER_INPUT +from . import USER_INPUT, async_init_integration, get_bluetooth_service_info from tests.common import MockConfigEntry @@ -233,3 +237,131 @@ async def test_reauth_flow( assert result2["reason"] == "reauth_successful" assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 assert mock_config_entry.data[CONF_PASSWORD] == "new_password" + + +async def test_bluetooth_discovery( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test bluetooth discovery.""" + service_info = get_bluetooth_service_info( + mock_lamarzocco.model_name, mock_lamarzocco.serial_number + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + }, + ) + 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, + CONF_NAME: service_info.name, + CONF_MAC: "aa:bb:cc:dd:ee:ff", + } + + assert len(mock_lamarzocco.check_local_connection.mock_calls) == 1 + + +async def test_bluetooth_discovery_errors( + hass: HomeAssistant, mock_lamarzocco: MagicMock +) -> None: + """Test bluetooth discovery errors.""" + service_info = get_bluetooth_service_info( + mock_lamarzocco.model_name, mock_lamarzocco.serial_number + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=service_info, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + mock_lamarzocco.get_all_machines.return_value = [("GS98765", "GS3 MP")] + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "machine_not_found"} + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1 + + mock_lamarzocco.get_all_machines.return_value = [ + (mock_lamarzocco.serial_number, mock_lamarzocco.model_name) + ] + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "machine_selection" + assert len(mock_lamarzocco.get_all_machines.mock_calls) == 2 + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + }, + ) + 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, + CONF_NAME: service_info.name, + CONF_MAC: "aa:bb:cc:dd:ee:ff", + } + + +async def test_options_flow( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test options flow.""" + await async_init_integration(hass, mock_config_entry) + assert mock_config_entry.state is config_entries.ConfigEntryState.LOADED + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_USE_BLUETOOTH: False, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + CONF_USE_BLUETOOTH: False, + } diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 5647129b5a5..a4bc25f64af 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -1,13 +1,16 @@ """Test initialization of lamarzocco.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from lmcloud.exceptions import AuthFail, RequestNotSuccessful from homeassistant.components.lamarzocco.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant +from . import async_init_integration, get_bluetooth_service_info + from tests.common import MockConfigEntry @@ -17,8 +20,7 @@ async def test_load_unload_config_entry( mock_lamarzocco: MagicMock, ) -> None: """Test loading and unloading the integration.""" - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await async_init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -36,8 +38,7 @@ async def test_config_entry_not_ready( """Test the La Marzocco configuration entry not ready.""" mock_lamarzocco.update_local_machine_status.side_effect = RequestNotSuccessful("") - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await async_init_integration(hass, mock_config_entry) assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY @@ -50,8 +51,7 @@ async def test_invalid_auth( ) -> None: """Test auth error during setup.""" mock_lamarzocco.update_local_machine_status.side_effect = AuthFail("") - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + await async_init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1 @@ -66,3 +66,29 @@ async def test_invalid_auth( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == mock_config_entry.entry_id + + +async def test_bluetooth_is_set_from_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lamarzocco: MagicMock, +) -> None: + """Assert we're not searching for a new BT device when we already found one previously.""" + + # remove the bluetooth configuration from entry + data = mock_config_entry.data.copy() + del data[CONF_NAME] + del data[CONF_MAC] + hass.config_entries.async_update_entry(mock_config_entry, data=data) + + service_info = get_bluetooth_service_info( + mock_lamarzocco.model_name, mock_lamarzocco.serial_number + ) + with patch( + "homeassistant.components.lamarzocco.coordinator.async_discovered_service_info", + return_value=[service_info], + ): + await async_init_integration(hass, mock_config_entry) + mock_lamarzocco.init_bluetooth_with_known_device.assert_called_once() + assert mock_config_entry.data[CONF_NAME] == service_info.name + assert mock_config_entry.data[CONF_MAC] == service_info.address diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py index 86ae1b90126..8cba3d2387d 100644 --- a/tests/components/lamarzocco/test_number.py +++ b/tests/components/lamarzocco/test_number.py @@ -53,7 +53,9 @@ async def test_coffee_boiler( ) assert len(mock_lamarzocco.set_coffee_temp.mock_calls) == 1 - mock_lamarzocco.set_coffee_temp.assert_called_once_with(temperature=95) + mock_lamarzocco.set_coffee_temp.assert_called_once_with( + temperature=95, ble_device=None + ) @pytest.mark.parametrize( @@ -62,7 +64,12 @@ async def test_coffee_boiler( @pytest.mark.parametrize( ("entity_name", "value", "func_name", "kwargs"), [ - ("steam_target_temperature", 131, "set_steam_temp", {"temperature": 131}), + ( + "steam_target_temperature", + 131, + "set_steam_temp", + {"temperature": 131, "ble_device": None}, + ), ("tea_water_duration", 15, "set_dose_hot_water", {"value": 15}), ], ) diff --git a/tests/components/lamarzocco/test_select.py b/tests/components/lamarzocco/test_select.py index 3c96f16de9c..497a95f6d0d 100644 --- a/tests/components/lamarzocco/test_select.py +++ b/tests/components/lamarzocco/test_select.py @@ -50,7 +50,7 @@ async def test_steam_boiler_level( ) assert len(mock_lamarzocco.set_steam_level.mock_calls) == 1 - mock_lamarzocco.set_steam_level.assert_called_once_with(level=1) + mock_lamarzocco.set_steam_level.assert_called_once_with(1, None) @pytest.mark.parametrize( diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index db4bc5541b9..e1924f0a8ca 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock import pytest from syrupy import SnapshotAssertion +from homeassistant.components.lamarzocco.const import DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -14,15 +15,22 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from tests.common import MockConfigEntry + pytestmark = pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( - ("entity_name", "method_name"), + ("entity_name", "method_name", "args_on", "args_off"), [ - ("", "set_power"), - ("_auto_on_off", "set_auto_on_off_global"), - ("_steam_boiler", "set_steam"), + ("", "set_power", (True, None), (False, None)), + ( + "_auto_on_off", + "set_auto_on_off_global", + (True,), + (False,), + ), + ("_steam_boiler", "set_steam", (True, None), (False, None)), ], ) async def test_switches( @@ -32,6 +40,8 @@ async def test_switches( snapshot: SnapshotAssertion, entity_name: str, method_name: str, + args_on: tuple, + args_off: tuple, ) -> None: """Test the La Marzocco switches.""" serial_number = mock_lamarzocco.serial_number @@ -56,7 +66,7 @@ async def test_switches( ) assert len(control_fn.mock_calls) == 1 - control_fn.assert_called_once_with(False) + control_fn.assert_called_once_with(*args_off) await hass.services.async_call( SWITCH_DOMAIN, @@ -68,7 +78,7 @@ async def test_switches( ) assert len(control_fn.mock_calls) == 2 - control_fn.assert_called_with(True) + control_fn.assert_called_with(*args_on) async def test_device( @@ -90,3 +100,26 @@ async def test_device( device = device_registry.async_get(entry.device_id) assert device assert device == snapshot + + +async def test_call_without_bluetooth_works( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if not using bluetooth, the switch still works.""" + serial_number = mock_lamarzocco.serial_number + coordinator = hass.data[DOMAIN][mock_config_entry.entry_id] + coordinator._use_bluetooth = False + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: f"switch.{serial_number}_steam_boiler", + }, + blocking=True, + ) + + assert len(mock_lamarzocco.set_steam.mock_calls) == 1 + mock_lamarzocco.set_steam.assert_called_once_with(False, None)