From 2cd828b2d01390086ab097f3bb654828d3e94bc8 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Wed, 17 Jan 2024 14:15:48 +0100 Subject: [PATCH] Add number platform to La Marzocco (#108229) * add number * remove key entities * remove key numbers * rename entities * rename sensors --- .../components/lamarzocco/__init__.py | 1 + homeassistant/components/lamarzocco/number.py | 123 ++++++++ .../components/lamarzocco/strings.json | 11 + .../lamarzocco/snapshots/test_number.ambr | 276 ++++++++++++++++++ tests/components/lamarzocco/test_number.py | 128 ++++++++ 5 files changed, 539 insertions(+) create mode 100644 homeassistant/components/lamarzocco/number.py create mode 100644 tests/components/lamarzocco/snapshots/test_number.ambr create mode 100644 tests/components/lamarzocco/test_number.py diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 6d2802fb218..0ef40a231cc 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -9,6 +9,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py new file mode 100644 index 00000000000..c14f04f05d8 --- /dev/null +++ b/homeassistant/components/lamarzocco/number.py @@ -0,0 +1,123 @@ +"""Number platform for La Marzocco espresso machines.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from lmcloud import LMCloud as LaMarzoccoClient +from lmcloud.const import LaMarzoccoModel + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PRECISION_TENTHS, + PRECISION_WHOLE, + UnitOfTemperature, + UnitOfTime, +) +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 LaMarzoccoNumberEntityDescription( + LaMarzoccoEntityDescription, + NumberEntityDescription, +): + """Description of a La Marzocco number entity.""" + + native_value_fn: Callable[[LaMarzoccoClient], float | int] + set_value_fn: Callable[ + [LaMarzoccoUpdateCoordinator, float | int], Coroutine[Any, Any, bool] + ] + + +ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( + LaMarzoccoNumberEntityDescription( + key="coffee_temp", + translation_key="coffee_temp", + icon="mdi:coffee-maker", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_step=PRECISION_TENTHS, + native_min_value=85, + native_max_value=104, + set_value_fn=lambda coordinator, temp: coordinator.lm.set_coffee_temp(temp), + native_value_fn=lambda lm: lm.current_status["coffee_set_temp"], + ), + LaMarzoccoNumberEntityDescription( + key="steam_temp", + translation_key="steam_temp", + icon="mdi:kettle-steam", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + 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)), + native_value_fn=lambda lm: lm.current_status["steam_set_temp"], + supported_fn=lambda coordinator: coordinator.lm.model_name + in ( + LaMarzoccoModel.GS3_AV, + LaMarzoccoModel.GS3_MP, + ), + ), + LaMarzoccoNumberEntityDescription( + key="tea_water_duration", + translation_key="tea_water_duration", + icon="mdi:water-percent", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=30, + set_value_fn=lambda coordinator, value: coordinator.lm.set_dose_hot_water( + value=int(value) + ), + native_value_fn=lambda lm: lm.current_status["dose_k5"], + supported_fn=lambda coordinator: coordinator.lm.model_name + in ( + LaMarzoccoModel.GS3_AV, + LaMarzoccoModel.GS3_MP, + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up number entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + LaMarzoccoNumberEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): + """La Marzocco number entity.""" + + entity_description: LaMarzoccoNumberEntityDescription + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.entity_description.native_value_fn(self.coordinator.lm) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.entity_description.set_value_fn(self.coordinator, value) + self.async_write_ha_state() diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 57f14030a6d..150356d600f 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -51,6 +51,17 @@ "name": "Water tank empty" } }, + "number": { + "coffee_temp": { + "name": "Coffee target temperature" + }, + "steam_temp": { + "name": "Steam target temperature" + }, + "tea_water_duration": { + "name": "Tea water duration" + } + }, "select": { "prebrew_infusion_select": { "name": "Prebrew/-infusion mode", diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr new file mode 100644 index 00000000000..d20801aed90 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -0,0 +1,276 @@ +# serializer version: 1 +# name: test_coffee_boiler + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Coffee target temperature', + 'icon': 'mdi:coffee-maker', + 'max': 104, + 'min': 85, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_coffee_target_temperature', + 'last_changed': , + 'last_updated': , + 'state': '95', + }) +# --- +# name: test_coffee_boiler.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 104, + 'min': 85, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_coffee_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:coffee-maker', + 'original_name': 'Coffee target temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'coffee_temp', + 'unique_id': 'GS01234_coffee_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 AV] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Steam target temperature', + 'icon': 'mdi:kettle-steam', + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_steam_target_temperature', + 'last_changed': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 AV].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_steam_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:kettle-steam', + 'original_name': 'Steam target temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_temp', + 'unique_id': 'GS01234_steam_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 MP] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Steam target temperature', + 'icon': 'mdi:kettle-steam', + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_steam_target_temperature', + 'last_changed': , + 'last_updated': , + 'state': '128', + }) +# --- +# name: test_gs3_exclusive[steam_target_temperature-131-set_steam_temp-kwargs0-GS3 MP].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 131, + 'min': 126, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_steam_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:kettle-steam', + 'original_name': 'Steam target temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_temp', + 'unique_id': 'GS01234_steam_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 AV] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'GS01234 Tea water duration', + 'icon': 'mdi:water-percent', + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_tea_water_duration', + 'last_changed': , + 'last_updated': , + 'state': '1023', + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 AV].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_tea_water_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:water-percent', + 'original_name': 'Tea water duration', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tea_water_duration', + 'unique_id': 'GS01234_tea_water_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 MP] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'GS01234 Tea water duration', + 'icon': 'mdi:water-percent', + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.gs01234_tea_water_duration', + 'last_changed': , + 'last_updated': , + 'state': '1023', + }) +# --- +# name: test_gs3_exclusive[tea_water_duration-15-set_dose_hot_water-kwargs1-GS3 MP].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 30, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.gs01234_tea_water_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:water-percent', + 'original_name': 'Tea water duration', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tea_water_duration', + 'unique_id': 'GS01234_tea_water_duration', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py new file mode 100644 index 00000000000..7a9eb334637 --- /dev/null +++ b/tests/components/lamarzocco/test_number.py @@ -0,0 +1,128 @@ +"""Tests for the La Marzocco number entities.""" + + +from unittest.mock import MagicMock + +from lmcloud.const import LaMarzoccoModel +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +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") + + +async def test_coffee_boiler( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco coffee temperature Number.""" + serial_number = mock_lamarzocco.serial_number + + state = hass.states.get(f"number.{serial_number}_coffee_target_temperature") + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + device = device_registry.async_get(entry.device_id) + assert device + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.{serial_number}_coffee_target_temperature", + ATTR_VALUE: 95, + }, + blocking=True, + ) + + assert len(mock_lamarzocco.set_coffee_temp.mock_calls) == 1 + mock_lamarzocco.set_coffee_temp.assert_called_once_with(temperature=95) + + +@pytest.mark.parametrize( + "device_fixture", [LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP] +) +@pytest.mark.parametrize( + ("entity_name", "value", "func_name", "kwargs"), + [ + ("steam_target_temperature", 131, "set_steam_temp", {"temperature": 131}), + ("tea_water_duration", 15, "set_dose_hot_water", {"value": 15}), + ], +) +async def test_gs3_exclusive( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + entity_name: str, + value: float, + func_name: str, + kwargs: dict[str, float], +) -> None: + """Test exclusive entities for GS3 AV/MP.""" + + serial_number = mock_lamarzocco.serial_number + + func = getattr(mock_lamarzocco, func_name) + + state = hass.states.get(f"number.{serial_number}_{entity_name}") + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + device = device_registry.async_get(entry.device_id) + assert device + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}", + ATTR_VALUE: value, + }, + blocking=True, + ) + + assert len(func.mock_calls) == 1 + func.assert_called_once_with(**kwargs) + + +@pytest.mark.parametrize( + "device_fixture", [LaMarzoccoModel.LINEA_MICRA, LaMarzoccoModel.LINEA_MINI] +) +async def test_gs3_exclusive_none( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, +) -> None: + """Ensure GS3 exclusive is None for unsupported models.""" + + ENTITIES = ("steam_target_temperature", "tea_water_duration") + + serial_number = mock_lamarzocco.serial_number + for entity in ENTITIES: + state = hass.states.get(f"number.{serial_number}_{entity}") + assert state is None