Add scale support to lamarzocco (#133335)

This commit is contained in:
Josef Zweck 2024-12-20 12:24:15 +01:00 committed by GitHub
parent 3df992790d
commit bddd8624bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1059 additions and 31 deletions

View File

@ -3,6 +3,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from pylamarzocco.const import MachineModel
from pylamarzocco.models import LaMarzoccoMachineConfig
from homeassistant.components.binary_sensor import (
@ -15,7 +16,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@ -28,7 +29,7 @@ class LaMarzoccoBinarySensorEntityDescription(
):
"""Description of a La Marzocco binary sensor."""
is_on_fn: Callable[[LaMarzoccoMachineConfig], bool]
is_on_fn: Callable[[LaMarzoccoMachineConfig], bool | None]
ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
@ -57,6 +58,15 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
),
)
SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
LaMarzoccoBinarySensorEntityDescription(
key="connected",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
is_on_fn=lambda config: config.scale.connected if config.scale else None,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@ -66,11 +76,30 @@ async def async_setup_entry(
"""Set up binary sensor entities."""
coordinator = entry.runtime_data.config_coordinator
async_add_entities(
entities = [
LaMarzoccoBinarySensorEntity(coordinator, description)
for description in ENTITIES
if description.supported_fn(coordinator)
)
]
if (
coordinator.device.model == MachineModel.LINEA_MINI
and coordinator.device.config.scale
):
entities.extend(
LaMarzoccoScaleBinarySensorEntity(coordinator, description)
for description in SCALE_ENTITIES
)
def _async_add_new_scale() -> None:
async_add_entities(
LaMarzoccoScaleBinarySensorEntity(coordinator, description)
for description in SCALE_ENTITIES
)
coordinator.new_device_callback.append(_async_add_new_scale)
async_add_entities(entities)
class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
@ -79,6 +108,14 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
entity_description: LaMarzoccoBinarySensorEntityDescription
@property
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.is_on_fn(self.coordinator.device.config)
class LaMarzoccoScaleBinarySensorEntity(
LaMarzoccoBinarySensorEntity, LaMarzoccScaleEntity
):
"""Binary sensor for La Marzocco scales."""
entity_description: LaMarzoccoBinarySensorEntityDescription

View File

@ -3,6 +3,7 @@
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
@ -14,8 +15,9 @@ from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@ -62,6 +64,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
self.device = device
self.local_connection_configured = local_client is not None
self._local_client = local_client
self.new_device_callback: list[Callable] = []
async def _async_update_data(self) -> None:
"""Do the data update."""
@ -86,6 +89,8 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Class to handle fetching data from the La Marzocco API centrally."""
_scale_address: str | None = None
async def _async_setup(self) -> None:
"""Set up the coordinator."""
if self._local_client is not None:
@ -118,6 +123,25 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Fetch data from API endpoint."""
await self.device.get_config()
_LOGGER.debug("Current status: %s", str(self.device.config))
self._async_add_remove_scale()
@callback
def _async_add_remove_scale(self) -> None:
"""Add or remove a scale when added or removed."""
if self.device.config.scale and not self._scale_address:
self._scale_address = self.device.config.scale.address
for scale_callback in self.new_device_callback:
scale_callback()
elif not self.device.config.scale and self._scale_address:
device_registry = dr.async_get(self.hass)
if device := device_registry.async_get_device(
identifiers={(DOMAIN, self._scale_address)}
):
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self._scale_address = None
class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator):

View File

@ -2,6 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pylamarzocco.const import FirmwareType
from pylamarzocco.devices.machine import LaMarzoccoMachine
@ -85,3 +86,26 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity):
"""Initialize the entity."""
super().__init__(coordinator, entity_description.key)
self.entity_description = entity_description
class LaMarzoccScaleEntity(LaMarzoccoEntity):
"""Common class for scale."""
def __init__(
self,
coordinator: LaMarzoccoUpdateCoordinator,
entity_description: LaMarzoccoEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, entity_description)
scale = coordinator.device.config.scale
if TYPE_CHECKING:
assert scale
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, scale.address)},
name=scale.name,
manufacturer="Acaia",
model="Lunar",
model_id="Y.301",
via_device=(DOMAIN, coordinator.device.serial_number),
)

View File

@ -43,6 +43,9 @@
"preinfusion_off": {
"default": "mdi:water"
},
"scale_target": {
"default": "mdi:scale-balance"
},
"smart_standby_time": {
"default": "mdi:timer"
},
@ -54,6 +57,13 @@
}
},
"select": {
"active_bbw": {
"default": "mdi:alpha-u",
"state": {
"a": "mdi:alpha-a",
"b": "mdi:alpha-b"
}
},
"smart_standby_mode": {
"default": "mdi:power",
"state": {

View File

@ -33,7 +33,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
PARALLEL_UPDATES = 1
@ -56,7 +56,9 @@ class LaMarzoccoKeyNumberEntityDescription(
):
"""Description of an La Marzocco number entity with keys."""
native_value_fn: Callable[[LaMarzoccoMachineConfig, PhysicalKey], float | int]
native_value_fn: Callable[
[LaMarzoccoMachineConfig, PhysicalKey], float | int | None
]
set_value_fn: Callable[
[LaMarzoccoMachine, float | int, PhysicalKey], Coroutine[Any, Any, bool]
]
@ -203,6 +205,27 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
),
)
SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
LaMarzoccoKeyNumberEntityDescription(
key="scale_target",
translation_key="scale_target",
native_step=PRECISION_WHOLE,
native_min_value=1,
native_max_value=100,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda machine, weight, key: machine.set_bbw_recipe_target(
key, int(weight)
),
native_value_fn=lambda config, key: (
config.bbw_settings.doses[key] if config.bbw_settings else None
),
supported_fn=(
lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI
and coordinator.device.config.scale is not None
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
@ -224,6 +247,27 @@ async def async_setup_entry(
LaMarzoccoKeyNumberEntity(coordinator, description, key)
for key in range(min(num_keys, 1), num_keys + 1)
)
for description in SCALE_KEY_ENTITIES:
if description.supported_fn(coordinator):
if bbw_settings := coordinator.device.config.bbw_settings:
entities.extend(
LaMarzoccoScaleTargetNumberEntity(
coordinator, description, int(key)
)
for key in bbw_settings.doses
)
def _async_add_new_scale() -> None:
if bbw_settings := coordinator.device.config.bbw_settings:
async_add_entities(
LaMarzoccoScaleTargetNumberEntity(coordinator, description, int(key))
for description in SCALE_KEY_ENTITIES
for key in bbw_settings.doses
)
coordinator.new_device_callback.append(_async_add_new_scale)
async_add_entities(entities)
@ -281,7 +325,7 @@ class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity):
self.pyhsical_key = pyhsical_key
@property
def native_value(self) -> float:
def native_value(self) -> float | None:
"""Return the current value."""
return self.entity_description.native_value_fn(
self.coordinator.device.config, PhysicalKey(self.pyhsical_key)
@ -305,3 +349,11 @@ class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity):
},
) from exc
self.async_write_ha_state()
class LaMarzoccoScaleTargetNumberEntity(
LaMarzoccoKeyNumberEntity, LaMarzoccScaleEntity
):
"""Entity representing a key number on the scale."""
entity_description: LaMarzoccoKeyNumberEntityDescription

View File

@ -62,9 +62,9 @@ rules:
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
status: done
comment: |
Device type integration.
Device type integration, only possible for addon scale
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@ -74,9 +74,9 @@ rules:
reconfiguration-flow: done
repair-issues: done
stale-devices:
status: exempt
status: done
comment: |
Device type integration.
Device type integration, only possible for addon scale
# Platinum
async-dependency: done

View File

@ -4,7 +4,13 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from pylamarzocco.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel
from pylamarzocco.const import (
MachineModel,
PhysicalKey,
PrebrewMode,
SmartStandbyMode,
SteamLevel,
)
from pylamarzocco.devices.machine import LaMarzoccoMachine
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoMachineConfig
@ -17,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
PARALLEL_UPDATES = 1
@ -52,7 +58,7 @@ class LaMarzoccoSelectEntityDescription(
):
"""Description of a La Marzocco select entity."""
current_option_fn: Callable[[LaMarzoccoMachineConfig], str]
current_option_fn: Callable[[LaMarzoccoMachineConfig], str | None]
select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]]
@ -100,6 +106,22 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
),
)
SCALE_ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
LaMarzoccoSelectEntityDescription(
key="active_bbw",
translation_key="active_bbw",
options=["a", "b"],
select_option_fn=lambda machine, option: machine.set_active_bbw_recipe(
PhysicalKey[option.upper()]
),
current_option_fn=lambda config: (
config.bbw_settings.active_dose.name.lower()
if config.bbw_settings
else None
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
@ -109,11 +131,30 @@ async def async_setup_entry(
"""Set up select entities."""
coordinator = entry.runtime_data.config_coordinator
async_add_entities(
entities = [
LaMarzoccoSelectEntity(coordinator, description)
for description in ENTITIES
if description.supported_fn(coordinator)
)
]
if (
coordinator.device.model == MachineModel.LINEA_MINI
and coordinator.device.config.scale
):
entities.extend(
LaMarzoccoScaleSelectEntity(coordinator, description)
for description in SCALE_ENTITIES
)
def _async_add_new_scale() -> None:
async_add_entities(
LaMarzoccoScaleSelectEntity(coordinator, description)
for description in SCALE_ENTITIES
)
coordinator.new_device_callback.append(_async_add_new_scale)
async_add_entities(entities)
class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
@ -122,7 +163,7 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
entity_description: LaMarzoccoSelectEntityDescription
@property
def current_option(self) -> str:
def current_option(self) -> str | None:
"""Return the current selected option."""
return str(
self.entity_description.current_option_fn(self.coordinator.device.config)
@ -145,3 +186,9 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
},
) from exc
self.async_write_ha_state()
class LaMarzoccoScaleSelectEntity(LaMarzoccoSelectEntity, LaMarzoccScaleEntity):
"""Select entity for La Marzocco scales."""
entity_description: LaMarzoccoSelectEntityDescription

View File

@ -12,12 +12,17 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@ -91,6 +96,21 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
),
)
SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription(
key="scale_battery",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
value_fn=lambda device: (
device.config.scale.battery if device.config.scale else 0
),
supported_fn=(
lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
@ -106,6 +126,15 @@ async def async_setup_entry(
if description.supported_fn(config_coordinator)
]
if (
config_coordinator.device.model == MachineModel.LINEA_MINI
and config_coordinator.device.config.scale
):
entities.extend(
LaMarzoccoScaleSensorEntity(config_coordinator, description)
for description in SCALE_ENTITIES
)
statistics_coordinator = entry.runtime_data.statistics_coordinator
entities.extend(
LaMarzoccoSensorEntity(statistics_coordinator, description)
@ -113,6 +142,14 @@ async def async_setup_entry(
if description.supported_fn(statistics_coordinator)
)
def _async_add_new_scale() -> None:
async_add_entities(
LaMarzoccoScaleSensorEntity(config_coordinator, description)
for description in SCALE_ENTITIES
)
config_coordinator.new_device_callback.append(_async_add_new_scale)
async_add_entities(entities)
@ -125,3 +162,9 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
def native_value(self) -> int | float:
"""State of the sensor."""
return self.entity_description.value_fn(self.coordinator.device)
class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity):
"""Sensor for a La Marzocco scale."""
entity_description: LaMarzoccoSensorEntityDescription

View File

@ -122,6 +122,9 @@
"preinfusion_off_key": {
"name": "Preinfusion time Key {key}"
},
"scale_target_key": {
"name": "Brew by weight target {key}"
},
"smart_standby_time": {
"name": "Smart standby time"
},
@ -133,6 +136,13 @@
}
},
"select": {
"active_bbw": {
"name": "Active brew by weight recipe",
"state": {
"a": "Recipe A",
"b": "Recipe B"
}
},
"prebrew_infusion_select": {
"name": "Prebrew/-infusion mode",
"state": {

View File

@ -135,7 +135,10 @@ def mock_lamarzocco(device_fixture: MachineModel) -> Generator[MagicMock]:
serial_number=serial_number,
name=serial_number,
)
config = load_json_object_fixture("config.json", DOMAIN)
if device_fixture == MachineModel.LINEA_MINI:
config = load_json_object_fixture("config_mini.json", DOMAIN)
else:
config = load_json_object_fixture("config.json", DOMAIN)
statistics = json.loads(load_fixture("statistics.json", DOMAIN))
dummy_machine.parse_config(config)

View File

@ -0,0 +1,116 @@
{
"version": "v1",
"preinfusionModesAvailable": ["ByDoseType"],
"machineCapabilities": [
{
"family": "LINEA",
"groupsNumber": 1,
"coffeeBoilersNumber": 1,
"hasCupWarmer": false,
"steamBoilersNumber": 1,
"teaDosesNumber": 1,
"machineModes": ["BrewingMode", "StandBy"],
"schedulingType": "smartWakeUpSleep"
}
],
"machine_sn": "Sn01239157",
"machine_hw": "0",
"isPlumbedIn": false,
"isBackFlushEnabled": false,
"standByTime": 0,
"tankStatus": true,
"settings": [],
"recipes": [
{
"id": "Recipe1",
"dose_mode": "Mass",
"recipe_doses": [
{ "id": "A", "target": 32 },
{ "id": "B", "target": 45 }
]
}
],
"recipeAssignment": [
{
"dose_index": "DoseA",
"recipe_id": "Recipe1",
"recipe_dose": "A",
"group": "Group1"
}
],
"groupCapabilities": [
{
"capabilities": {
"groupType": "AV_Group",
"groupNumber": "Group1",
"boilerId": "CoffeeBoiler1",
"hasScale": false,
"hasFlowmeter": false,
"numberOfDoses": 1
},
"doses": [
{
"groupNumber": "Group1",
"doseIndex": "DoseA",
"doseType": "MassType",
"stopTarget": 32
}
],
"doseMode": { "groupNumber": "Group1", "brewingType": "ManualType" }
}
],
"machineMode": "StandBy",
"teaDoses": { "DoseA": { "doseIndex": "DoseA", "stopTarget": 0 } },
"scale": {
"connected": true,
"address": "44:b7:d0:74:5f:90",
"name": "LMZ-123A45",
"battery": 64
},
"boilers": [
{ "id": "SteamBoiler", "isEnabled": false, "target": 0, "current": 0 },
{ "id": "CoffeeBoiler1", "isEnabled": true, "target": 89, "current": 42 }
],
"boilerTargetTemperature": { "SteamBoiler": 0, "CoffeeBoiler1": 89 },
"preinfusionMode": {
"Group1": {
"groupNumber": "Group1",
"preinfusionStyle": "PreinfusionByDoseType"
}
},
"preinfusionSettings": {
"mode": "TypeB",
"Group1": [
{
"groupNumber": "Group1",
"doseType": "DoseA",
"preWetTime": 2,
"preWetHoldTime": 3
}
]
},
"wakeUpSleepEntries": [
{
"id": "T6aLl42",
"days": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
],
"steam": false,
"enabled": false,
"timeOn": "24:0",
"timeOff": "24:0"
}
],
"smartStandBy": { "mode": "LastBrewing", "minutes": 10, "enabled": true },
"clock": "2024-08-31T14:47:45",
"firmwareVersions": [
{ "name": "machine_firmware", "fw_version": "2.12" },
{ "name": "gateway_firmware", "fw_version": "v3.6-rc4" }
]
}

View File

@ -140,3 +140,50 @@
'unit_of_measurement': None,
})
# ---
# name: test_scale_connectivity[Linea Mini]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'LMZ-123A45 Connectivity',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.lmz_123a45_connectivity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_scale_connectivity[Linea Mini].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.lmz_123a45_connectivity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'Connectivity',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'LM012345_connected',
'unit_of_measurement': None,
})
# ---

View File

@ -39,3 +39,35 @@
'via_device_id': None,
})
# ---
# name: test_scale_device[Linea Mini]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'lamarzocco',
'44:b7:d0:74:5f:90',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Acaia',
'model': 'Lunar',
'model_id': 'Y.301',
'name': 'LMZ-123A45',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': <ANY>,
})
# ---

View File

@ -657,7 +657,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
'state': '3',
})
# ---
# name: test_pre_brew_infusion_numbers[prebrew_off_time-set_prebrew_time-Enabled-6-kwargs0-Linea Mini].1
@ -771,7 +771,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
'state': '3',
})
# ---
# name: test_pre_brew_infusion_numbers[prebrew_on_time-set_prebrew_time-Enabled-6-kwargs1-Linea Mini].1
@ -885,7 +885,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
'state': '3',
})
# ---
# name: test_pre_brew_infusion_numbers[preinfusion_time-set_preinfusion_time-TypeB-7-kwargs2-Linea Mini].1
@ -983,3 +983,113 @@
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_set_target[Linea Mini-1]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'LMZ-123A45 Brew by weight target 1',
'max': 100,
'min': 1,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.lmz_123a45_brew_by_weight_target_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '32',
})
# ---
# name: test_set_target[Linea Mini-1].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 100,
'min': 1,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.lmz_123a45_brew_by_weight_target_1',
'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': 'Brew by weight target 1',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'scale_target_key',
'unique_id': 'LM012345_scale_target_key1',
'unit_of_measurement': None,
})
# ---
# name: test_set_target[Linea Mini-2]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'LMZ-123A45 Brew by weight target 2',
'max': 100,
'min': 1,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.lmz_123a45_brew_by_weight_target_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '45',
})
# ---
# name: test_set_target[Linea Mini-2].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 100,
'min': 1,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.lmz_123a45_brew_by_weight_target_2',
'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': 'Brew by weight target 2',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'scale_target_key',
'unique_id': 'LM012345_scale_target_key2',
'unit_of_measurement': None,
})
# ---

View File

@ -1,4 +1,59 @@
# serializer version: 1
# name: test_active_bbw_recipe[Linea Mini]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'LMZ-123A45 Active brew by weight recipe',
'options': list([
'a',
'b',
]),
}),
'context': <ANY>,
'entity_id': 'select.lmz_123a45_active_brew_by_weight_recipe',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'a',
})
# ---
# name: test_active_bbw_recipe[Linea Mini].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'a',
'b',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.lmz_123a45_active_brew_by_weight_recipe',
'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': 'Active brew by weight recipe',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'active_bbw',
'unique_id': 'LM012345_active_bbw',
'unit_of_measurement': None,
})
# ---
# name: test_pre_brew_infusion_select[GS3 AV]
StateSnapshot({
'attributes': ReadOnlyDict({

View File

@ -1,4 +1,55 @@
# serializer version: 1
# name: test_scale_battery[Linea Mini]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'LMZ-123A45 Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.lmz_123a45_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '64',
})
# ---
# name: test_scale_battery[Linea Mini].1
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.lmz_123a45_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'Battery',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'LM012345_scale_battery',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[GS012345_current_coffee_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@ -4,7 +4,10 @@ from datetime import timedelta
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import MachineModel
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoScale
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE
@ -98,3 +101,68 @@ async def test_sensor_going_unavailable(
state = hass.states.get(brewing_active_sensor)
assert state
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_scale_connectivity(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the scale binary sensors."""
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("binary_sensor.lmz_123a45_connectivity")
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry.device_id
assert entry == snapshot
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA],
)
async def test_other_models_no_scale_connectivity(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Ensure the other models don't have a connectivity sensor."""
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("binary_sensor.lmz_123a45_connectivity")
assert state is None
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_connectivity_on_new_scale_added(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure the connectivity binary sensor for a new scale is added automatically."""
mock_lamarzocco.config.scale = None
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("binary_sensor.scale_123a45_connectivity")
assert state is None
mock_lamarzocco.config.scale = LaMarzoccoScale(
connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50
)
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("binary_sensor.scale_123a45_connectivity")
assert state

View File

@ -1,8 +1,10 @@
"""Test initialization of lamarzocco."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from pylamarzocco.const import FirmwareType
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import FirmwareType, MachineModel
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
import pytest
from syrupy import SnapshotAssertion
@ -27,7 +29,7 @@ from homeassistant.helpers import (
from . import USER_INPUT, async_init_integration, get_bluetooth_service_info
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_load_unload_config_entry(
@ -251,3 +253,49 @@ async def test_device(
device = device_registry.async_get(entry.device_id)
assert device
assert device == snapshot
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_scale_device(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device."""
await async_init_integration(hass, mock_config_entry)
device = device_registry.async_get_device(
identifiers={(DOMAIN, mock_lamarzocco.config.scale.address)}
)
assert device
assert device == snapshot
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_remove_stale_scale(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure stale scale is cleaned up."""
await async_init_integration(hass, mock_config_entry)
scale_address = mock_lamarzocco.config.scale.address
device = device_registry.async_get_device(identifiers={(DOMAIN, scale_address)})
assert device
mock_lamarzocco.config.scale = None
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, scale_address)})
assert device is None

View File

@ -1,8 +1,10 @@
"""Tests for the La Marzocco number entities."""
from datetime import timedelta
from typing import Any
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import (
KEYS_PER_MODEL,
BoilerType,
@ -11,6 +13,7 @@ from pylamarzocco.const import (
PrebrewMode,
)
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoScale
import pytest
from syrupy import SnapshotAssertion
@ -26,7 +29,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import async_init_integration
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.parametrize(
@ -444,3 +447,91 @@ async def test_number_error(
blocking=True,
)
assert exc_info.value.translation_key == "number_exception_key"
@pytest.mark.parametrize("physical_key", [PhysicalKey.A, PhysicalKey.B])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_set_target(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
physical_key: PhysicalKey,
) -> None:
"""Test the La Marzocco set target sensors."""
await async_init_integration(hass, mock_config_entry)
entity_name = f"number.lmz_123a45_brew_by_weight_target_{int(physical_key)}"
state = hass.states.get(entity_name)
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry == snapshot
# service call
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: entity_name,
ATTR_VALUE: 42,
},
blocking=True,
)
mock_lamarzocco.set_bbw_recipe_target.assert_called_once_with(physical_key, 42)
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA],
)
async def test_other_models_no_scale_set_target(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Ensure the other models don't have a set target numbers."""
await async_init_integration(hass, mock_config_entry)
for i in range(1, 3):
state = hass.states.get(f"number.lmz_123a45_brew_by_weight_target_{i}")
assert state is None
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_set_target_on_new_scale_added(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure the set target numbers for a new scale are added automatically."""
mock_lamarzocco.config.scale = None
await async_init_integration(hass, mock_config_entry)
for i in range(1, 3):
state = hass.states.get(f"number.scale_123a45_brew_by_weight_target_{i}")
assert state is None
mock_lamarzocco.config.scale = LaMarzoccoScale(
connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50
)
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
for i in range(1, 3):
state = hass.states.get(f"number.scale_123a45_brew_by_weight_target_{i}")
assert state

View File

@ -1,9 +1,18 @@
"""Tests for the La Marzocco select entities."""
from datetime import timedelta
from unittest.mock import MagicMock
from pylamarzocco.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import (
MachineModel,
PhysicalKey,
PrebrewMode,
SmartStandbyMode,
SteamLevel,
)
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoScale
import pytest
from syrupy import SnapshotAssertion
@ -17,9 +26,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
pytestmark = pytest.mark.usefixtures("init_integration")
from . import async_init_integration
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MICRA])
async def test_steam_boiler_level(
hass: HomeAssistant,
@ -54,6 +66,9 @@ async def test_steam_boiler_level(
mock_lamarzocco.set_steam_level.assert_called_once_with(level=SteamLevel.LEVEL_2)
pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MINI],
@ -69,6 +84,7 @@ async def test_steam_boiler_level_none(
assert state is None
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.LINEA_MICRA, MachineModel.GS3_AV, MachineModel.LINEA_MINI],
@ -106,6 +122,7 @@ async def test_pre_brew_infusion_select(
mock_lamarzocco.set_prebrew_mode.assert_called_once_with(mode=PrebrewMode.PREBREW)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_MP],
@ -121,6 +138,7 @@ async def test_pre_brew_infusion_select_none(
assert state is None
@pytest.mark.usefixtures("init_integration")
async def test_smart_standby_mode(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
@ -155,6 +173,7 @@ async def test_smart_standby_mode(
)
@pytest.mark.usefixtures("init_integration")
async def test_select_errors(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
@ -179,3 +198,77 @@ async def test_select_errors(
blocking=True,
)
assert exc_info.value.translation_key == "select_option_error"
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_active_bbw_recipe(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_lamarzocco: MagicMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test the La Marzocco active bbw recipe select."""
state = hass.states.get("select.lmz_123a45_active_brew_by_weight_recipe")
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry == snapshot
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.lmz_123a45_active_brew_by_weight_recipe",
ATTR_OPTION: "b",
},
blocking=True,
)
mock_lamarzocco.set_active_bbw_recipe.assert_called_once_with(PhysicalKey.B)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA],
)
async def test_other_models_no_active_bbw_select(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
) -> None:
"""Ensure the other models don't have a battery sensor."""
state = hass.states.get("select.lmz_123a45_active_brew_by_weight_recipe")
assert state is None
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_active_bbw_select_on_new_scale_added(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure the active bbw select for a new scale is added automatically."""
mock_lamarzocco.config.scale = None
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("select.scale_123a45_active_brew_by_weight_recipe")
assert state is None
mock_lamarzocco.config.scale = LaMarzoccoScale(
connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50
)
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("select.scale_123a45_active_brew_by_weight_recipe")
assert state

View File

@ -1,8 +1,11 @@
"""Tests for La Marzocco sensors."""
from datetime import timedelta
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import MachineModel
from pylamarzocco.models import LaMarzoccoScale
import pytest
from syrupy import SnapshotAssertion
@ -12,7 +15,7 @@ from homeassistant.helpers import entity_registry as er
from . import async_init_integration
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
SENSORS = (
"total_coffees_made",
@ -85,3 +88,67 @@ async def test_no_steam_linea_mini(
serial_number = mock_lamarzocco.serial_number
state = hass.states.get(f"sensor.{serial_number}_current_temp_steam")
assert state is None
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_scale_battery(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the scale battery sensor."""
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("sensor.lmz_123a45_battery")
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry.device_id
assert entry == snapshot
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA],
)
async def test_other_models_no_scale_battery(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Ensure the other models don't have a battery sensor."""
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("sensor.lmz_123a45_battery")
assert state is None
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_battery_on_new_scale_added(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure the battery sensor for a new scale is added automatically."""
mock_lamarzocco.config.scale = None
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("sensor.lmz_123a45_battery")
assert state is None
mock_lamarzocco.config.scale = LaMarzoccoScale(
connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50
)
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.scale_123a45_battery")
assert state