Add Bluetooth support to La Marzocco integration (#108287)

* 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 <robert@resch.dev>

* Update homeassistant/components/lamarzocco/diagnostics.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/lamarzocco/__init__.py

Co-authored-by: Robert Resch <robert@resch.dev>

* 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 <robert@resch.dev>

* Update config_flow.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update config_flow.py

Co-authored-by: Robert Resch <robert@resch.dev>

* 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 <robert@resch.dev>

* Update homeassistant/components/lamarzocco/coordinator.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/lamarzocco/__init__.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/lamarzocco/__init__.py

Co-authored-by: Robert Resch <robert@resch.dev>

* 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 <robert@resch.dev>

* Update homeassistant/components/lamarzocco/entity.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/lamarzocco/entity.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/lamarzocco/switch.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/lamarzocco/switch.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/lamarzocco/entity.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/lamarzocco/strings.json

Co-authored-by: Robert Resch <robert@resch.dev>

* requested changes

* Update homeassistant/components/lamarzocco/switch.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update homeassistant/components/lamarzocco/entity.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update tests/components/lamarzocco/test_config_flow.py

Co-authored-by: Robert Resch <robert@resch.dev>

* Update tests/components/lamarzocco/test_config_flow.py

Co-authored-by: Robert Resch <robert@resch.dev>

* 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 <joostlek@outlook.com>

* Update homeassistant/components/lamarzocco/entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/lamarzocco/config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* 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 <joostlek@outlook.com>

* Update coordinator.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update coordinator.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* 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 <joostlek@outlook.com>

* Update homeassistant/components/lamarzocco/entity.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/lamarzocco/strings.json

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* 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 <abmantis@users.noreply.github.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: tronikos <tronikos@users.noreply.github.com>
Co-authored-by: Luke Lashley <conway220@gmail.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: dupondje <jean-louis@dupond.be>
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Josef Zweck 2024-03-21 13:08:11 +01:00 committed by GitHub
parent e23943debf
commit e5fa6f0176
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 633 additions and 59 deletions

View File

@ -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

View File

@ -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,
)

View File

@ -5,3 +5,5 @@ from typing import Final
DOMAIN: Final = "lamarzocco"
CONF_MACHINE: Final = "machine"
CONF_USE_BLUETOOTH = "use_bluetooth"

View File

@ -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,
)

View File

@ -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",

View File

@ -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 (

View File

@ -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

View File

@ -42,6 +42,16 @@
}
}
},
"options": {
"step": {
"init": {
"data": {
"title": "Update Configuration",
"use_bluetooth": "Use Bluetooth"
}
}
}
},
"entity": {
"binary_sensor": {
"brew_active": {

View File

@ -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"
],

View File

@ -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_*",

View File

@ -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",
)

View File

@ -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."""

View File

@ -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': <ANY>,
'entity_id': 'switch.gs01234',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[-set_power-kwargs_on0-kwargs_off0].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.gs01234',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.gs01234_auto_on_off',
'last_changed': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.gs01234_auto_on_off',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.gs01234_steam_boiler',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.gs01234_steam_boiler',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[_steam_boiler-set_steam-kwargs_on2-kwargs_off2].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),

View File

@ -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,
}

View File

@ -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

View File

@ -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}),
],
)

View File

@ -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(

View File

@ -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)