Add sensor platform to La Marzocco integration (#108157)

* add sensor

* remove switch

* requested changes

* property instead of function

* add missing snapshot

* rename var, fixture
This commit is contained in:
Josef Zweck 2024-01-17 09:12:49 +01:00 committed by GitHub
parent 3a26bc3ee0
commit a8b67d5a0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 483 additions and 1 deletions

View File

@ -8,6 +8,7 @@ from .const import DOMAIN
from .coordinator import LaMarzoccoUpdateCoordinator from .coordinator import LaMarzoccoUpdateCoordinator
PLATFORMS = [ PLATFORMS = [
Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
] ]

View File

@ -32,6 +32,9 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
self.lm = LaMarzoccoClient( self.lm = LaMarzoccoClient(
callback_websocket_notify=self.async_update_listeners, 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: async def _async_update_data(self) -> None:
"""Fetch data from API endpoint.""" """Fetch data from API endpoint."""

View File

@ -1,7 +1,9 @@
"""Base class for the La Marzocco entities.""" """Base class for the La Marzocco entities."""
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from lmcloud import LMCloud as LaMarzoccoClient
from lmcloud.const import LaMarzoccoModel from lmcloud.const import LaMarzoccoModel
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@ -16,6 +18,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator
class LaMarzoccoEntityDescription(EntityDescription): class LaMarzoccoEntityDescription(EntityDescription):
"""Description for all LM entities.""" """Description for all LM entities."""
available_fn: Callable[[LaMarzoccoClient], bool] = lambda _: True
supported_models: tuple[LaMarzoccoModel, ...] = ( supported_models: tuple[LaMarzoccoModel, ...] = (
LaMarzoccoModel.GS3_AV, LaMarzoccoModel.GS3_AV,
LaMarzoccoModel.GS3_MP, LaMarzoccoModel.GS3_MP,
@ -30,6 +33,13 @@ class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]):
entity_description: LaMarzoccoEntityDescription entity_description: LaMarzoccoEntityDescription
_attr_has_entity_name = True _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__( def __init__(
self, self,
coordinator: LaMarzoccoUpdateCoordinator, coordinator: LaMarzoccoUpdateCoordinator,

View File

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

View File

@ -43,6 +43,23 @@
} }
}, },
"entity": { "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": { "switch": {
"auto_on_off": { "auto_on_off": {
"name": "Auto on/off" "name": "Auto on/off"

View File

@ -7,6 +7,7 @@ from lmcloud.const import LaMarzoccoModel
import pytest import pytest
from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import USER_INPUT, async_init_integration from . import USER_INPUT, async_init_integration
@ -24,7 +25,8 @@ def mock_config_entry(mock_lamarzocco: MagicMock) -> MockConfigEntry:
return MockConfigEntry( return MockConfigEntry(
title="My LaMarzocco", title="My LaMarzocco",
domain=DOMAIN, 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, unique_id=mock_lamarzocco.serial_number,
) )
@ -100,5 +102,25 @@ def mock_lamarzocco(
] ]
lamarzocco.check_local_connection.return_value = True lamarzocco.check_local_connection.return_value = True
lamarzocco.initialized = False 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 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

View File

@ -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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# 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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gs01234_current_coffee_temperature',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '93',
})
# ---
# name: test_sensors[GS01234_current_steam_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# 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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gs01234_current_steam_temperature',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '113',
})
# ---
# name: test_sensors[GS01234_shot_timer-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs01234_shot_timer',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'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': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_sensors[GS01234_shot_timer-sensor]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'GS01234 Shot timer',
'icon': 'mdi:timer',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gs01234_shot_timer',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[GS01234_total_coffees_made-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs01234_total_coffees_made',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': 'drinks',
}),
'context': <ANY>,
'entity_id': 'sensor.gs01234_total_coffees_made',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '13',
})
# ---
# name: test_sensors[GS01234_total_flushes_made-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs01234_total_flushes_made',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': 'drinks',
}),
'context': <ANY>,
'entity_id': 'sensor.gs01234_total_flushes_made',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': '69',
})
# ---

View File

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