diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 8bf48c3bf91..599721ced3a 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -8,6 +8,7 @@ from .const import DOMAIN from .coordinator import LaMarzoccoUpdateCoordinator PLATFORMS = [ + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 9b6341e0858..438c4e42634 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -32,6 +32,9 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): self.lm = LaMarzoccoClient( callback_websocket_notify=self.async_update_listeners, ) + self.local_connection_configured = ( + self.config_entry.data.get(CONF_HOST) is not None + ) async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index b2cb6dc2bff..4b8ccc86688 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -1,7 +1,9 @@ """Base class for the La Marzocco entities.""" +from collections.abc import Callable from dataclasses import dataclass +from lmcloud import LMCloud as LaMarzoccoClient from lmcloud.const import LaMarzoccoModel from homeassistant.helpers.device_registry import DeviceInfo @@ -16,6 +18,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator class LaMarzoccoEntityDescription(EntityDescription): """Description for all LM entities.""" + available_fn: Callable[[LaMarzoccoClient], bool] = lambda _: True supported_models: tuple[LaMarzoccoModel, ...] = ( LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_MP, @@ -30,6 +33,13 @@ class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): entity_description: LaMarzoccoEntityDescription _attr_has_entity_name = True + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.entity_description.available_fn( + self.coordinator.lm + ) + def __init__( self, coordinator: LaMarzoccoUpdateCoordinator, diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py new file mode 100644 index 00000000000..bb811eaa890 --- /dev/null +++ b/homeassistant/components/lamarzocco/sensor.py @@ -0,0 +1,113 @@ +"""Sensor platform for La Marzocco espresso machines.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from lmcloud import LMCloud as LaMarzoccoClient + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoSensorEntityDescription( + LaMarzoccoEntityDescription, + SensorEntityDescription, +): + """Description of a La Marzocco sensor.""" + + value_fn: Callable[[LaMarzoccoClient], float | int] + + +ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( + LaMarzoccoSensorEntityDescription( + key="drink_stats_coffee", + translation_key="drink_stats_coffee", + icon="mdi:chart-line", + native_unit_of_measurement="drinks", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda lm: lm.current_status.get("drinks_k1", 0), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="drink_stats_flushing", + translation_key="drink_stats_flushing", + icon="mdi:chart-line", + native_unit_of_measurement="drinks", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda lm: lm.current_status.get("total_flushing", 0), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="shot_timer", + translation_key="shot_timer", + icon="mdi:timer", + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda lm: lm.current_status.get("brew_active_duration", 0), + available_fn=lambda lm: lm.websocket_connected, + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="current_temp_coffee", + translation_key="current_temp_coffee", + icon="mdi:thermometer", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda lm: lm.current_status.get("coffee_temp", 0), + ), + LaMarzoccoSensorEntityDescription( + key="current_temp_steam", + translation_key="current_temp_steam", + icon="mdi:thermometer", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda lm: lm.current_status.get("steam_temp", 0), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor entities.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[LaMarzoccoSensorEntity] = [] + for description in ENTITIES: + if coordinator.lm.model_name in description.supported_models: + if ( + description.key == "shot_timer" + and not coordinator.local_connection_configured + ): + continue + entities.append(LaMarzoccoSensorEntity(coordinator, description)) + + async_add_entities(entities) + + +class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): + """Sensor representing espresso machine temperature data.""" + + entity_description: LaMarzoccoSensorEntityDescription + + @property + def native_value(self) -> int | float: + """State of the sensor.""" + return self.entity_description.value_fn(self.coordinator.lm) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 01bd3860825..759a9e327dc 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -43,6 +43,23 @@ } }, "entity": { + "sensor": { + "current_temp_coffee": { + "name": "Current coffee temperature" + }, + "current_temp_steam": { + "name": "Current steam temperature" + }, + "drink_stats_coffee": { + "name": "Total coffees made" + }, + "drink_stats_flushing": { + "name": "Total flushes made" + }, + "shot_timer": { + "name": "Shot timer" + } + }, "switch": { "auto_on_off": { "name": "Auto on/off" diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index 98baac22d33..cc2d121e632 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -7,6 +7,7 @@ from lmcloud.const import LaMarzoccoModel import pytest from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from . import USER_INPUT, async_init_integration @@ -24,7 +25,8 @@ def mock_config_entry(mock_lamarzocco: MagicMock) -> MockConfigEntry: return MockConfigEntry( title="My LaMarzocco", domain=DOMAIN, - data=USER_INPUT | {CONF_MACHINE: mock_lamarzocco.serial_number}, + data=USER_INPUT + | {CONF_MACHINE: mock_lamarzocco.serial_number, CONF_HOST: "host"}, unique_id=mock_lamarzocco.serial_number, ) @@ -100,5 +102,25 @@ def mock_lamarzocco( ] lamarzocco.check_local_connection.return_value = True lamarzocco.initialized = False + lamarzocco.websocket_connected = True + + async def websocket_connect_mock( + callback: MagicMock, use_sigterm_handler: MagicMock + ) -> None: + """Mock the websocket connect method.""" + return None + + lamarzocco.lm_local_api.websocket_connect = websocket_connect_mock yield lamarzocco + + +@pytest.fixture +def remove_local_connection( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Remove the local connection.""" + data = mock_config_entry.data.copy() + del data[CONF_HOST] + hass.config_entries.async_update_entry(mock_config_entry, data=data) + return mock_config_entry diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..e3719a25a33 --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -0,0 +1,244 @@ +# serializer version: 1 +# name: test_sensors[GS01234_current_coffee_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gs01234_current_coffee_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer', + 'original_name': 'Current coffee temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_temp_coffee', + 'unique_id': 'GS01234_current_temp_coffee', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[GS01234_current_coffee_temperature-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Current coffee temperature', + 'icon': 'mdi:thermometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gs01234_current_coffee_temperature', + 'last_changed': , + 'last_updated': , + 'state': '93', + }) +# --- +# name: test_sensors[GS01234_current_steam_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.gs01234_current_steam_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:thermometer', + 'original_name': 'Current steam temperature', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_temp_steam', + 'unique_id': 'GS01234_current_temp_steam', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[GS01234_current_steam_temperature-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'GS01234 Current steam temperature', + 'icon': 'mdi:thermometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gs01234_current_steam_temperature', + 'last_changed': , + 'last_updated': , + 'state': '113', + }) +# --- +# name: test_sensors[GS01234_shot_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs01234_shot_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:timer', + 'original_name': 'Shot timer', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'shot_timer', + 'unique_id': 'GS01234_shot_timer', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[GS01234_shot_timer-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'GS01234 Shot timer', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.gs01234_shot_timer', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[GS01234_total_coffees_made-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs01234_total_coffees_made', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:chart-line', + 'original_name': 'Total coffees made', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_coffee', + 'unique_id': 'GS01234_drink_stats_coffee', + 'unit_of_measurement': 'drinks', + }) +# --- +# name: test_sensors[GS01234_total_coffees_made-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Total coffees made', + 'icon': 'mdi:chart-line', + 'state_class': , + 'unit_of_measurement': 'drinks', + }), + 'context': , + 'entity_id': 'sensor.gs01234_total_coffees_made', + 'last_changed': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_sensors[GS01234_total_flushes_made-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs01234_total_flushes_made', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:chart-line', + 'original_name': 'Total flushes made', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'drink_stats_flushing', + 'unique_id': 'GS01234_drink_stats_flushing', + 'unit_of_measurement': 'drinks', + }) +# --- +# name: test_sensors[GS01234_total_flushes_made-sensor] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GS01234 Total flushes made', + 'icon': 'mdi:chart-line', + 'state_class': , + 'unit_of_measurement': 'drinks', + }), + 'context': , + 'entity_id': 'sensor.gs01234_total_flushes_made', + 'last_changed': , + 'last_updated': , + 'state': '69', + }) +# --- diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py new file mode 100644 index 00000000000..3333fed1464 --- /dev/null +++ b/tests/components/lamarzocco/test_sensor.py @@ -0,0 +1,72 @@ +"""Tests for La Marzocco sensors.""" +from unittest.mock import MagicMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_init_integration + +from tests.common import MockConfigEntry + +SENSORS = ( + "total_coffees_made", + "total_flushes_made", + "shot_timer", + "current_coffee_temperature", + "current_steam_temperature", +) + + +async def test_sensors( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco sensors.""" + + serial_number = mock_lamarzocco.serial_number + + await async_init_integration(hass, mock_config_entry) + + for sensor in SENSORS: + state = hass.states.get(f"sensor.{serial_number}_{sensor}") + assert state + assert state == snapshot(name=f"{serial_number}_{sensor}-sensor") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot(name=f"{serial_number}_{sensor}-entry") + + +@pytest.mark.usefixtures("remove_local_connection") +async def test_shot_timer_not_exists( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco shot timer doesn't exist if host not set.""" + + await async_init_integration(hass, mock_config_entry) + state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") + assert state is None + + +async def test_shot_timer_unavailable( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco brew_active becomes unavailable.""" + + mock_lamarzocco.websocket_connected = False + await async_init_integration(hass, mock_config_entry) + state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") + assert state + assert state.state == STATE_UNAVAILABLE