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
PLATFORMS = [
Platform.SENSOR,
Platform.SWITCH,
]

View File

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

View File

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

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": {
"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"

View File

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

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