├── custom_components ├── __init__.py └── lamarzocco │ ├── const.py │ ├── manifest.json │ ├── diagnostics.py │ ├── quality_scale.yaml │ ├── button.py │ ├── icons.json │ ├── entity.py │ ├── update.py │ ├── binary_sensor.py │ ├── calendar.py │ ├── switch.py │ ├── coordinator.py │ ├── select.py │ ├── strings.json │ ├── __init__.py │ ├── sensor.py │ ├── translations │ └── en.json │ └── number.py ├── hacs.json ├── images ├── States.png ├── Config_Flow.png ├── Configured_Integration.png └── Discovered_Integration.png ├── requirements_test.txt ├── .gitignore ├── .github └── workflows │ ├── hassfest.yaml │ └── hacs.yaml ├── info.md ├── tests ├── fixtures │ ├── statistics.json │ ├── config_mini.json │ └── config.json ├── test_diagnostics.py ├── snapshots │ ├── test_button.ambr │ ├── test_init.ambr │ ├── test_diagnostics.ambr │ ├── test_update.ambr │ ├── test_binary_sensor.ambr │ ├── test_select.ambr │ └── test_switch.ambr ├── __init__.py ├── test_button.py ├── test_update.py ├── test_sensor.py ├── test_calendar.py ├── test_binary_sensor.py ├── conftest.py ├── test_switch.py ├── test_select.py └── test_init.py ├── .vscode └── settings.json ├── setup.cfg └── README.md /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "La Marzocco", 3 | "homeassistant": "2024.1.0b0" 4 | } 5 | -------------------------------------------------------------------------------- /images/States.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zweckj/lamarzocco/HEAD/images/States.png -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | pytest-homeassistant-custom-component==0.13.57 2 | pytest-asyncio 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .DS_Store 3 | pythonenv* 4 | venv 5 | .venv 6 | .coverage 7 | .idea 8 | -------------------------------------------------------------------------------- /images/Config_Flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zweckj/lamarzocco/HEAD/images/Config_Flow.png -------------------------------------------------------------------------------- /images/Configured_Integration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zweckj/lamarzocco/HEAD/images/Configured_Integration.png -------------------------------------------------------------------------------- /images/Discovered_Integration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zweckj/lamarzocco/HEAD/images/Discovered_Integration.png -------------------------------------------------------------------------------- /custom_components/lamarzocco/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the La Marzocco integration.""" 2 | 3 | from typing import Final 4 | 5 | DOMAIN: Final = "lamarzocco" 6 | 7 | CONF_USE_BLUETOOTH: Final = "use_bluetooth" 8 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | ## Integration for La Marzocco espresso machines that support a Wifi connection 2 | 3 | ### Useful links 4 | - [README](https://github.com/zweckj/lamarzocco/blob/master/README.md) 5 | - [GitHub](https://github.com/zweckj/lamarzocco) 6 | - [Discord](https://discord.gg/SwpW46rR4p) 7 | - [lmcloud package](https://github.com/zweckj/lmcloud) used for the local API 8 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: HACS 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /tests/fixtures/statistics.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "count": 1047, 4 | "coffeeType": 0 5 | }, 6 | { 7 | "count": 560, 8 | "coffeeType": 1 9 | }, 10 | { 11 | "count": 468, 12 | "coffeeType": 2 13 | }, 14 | { 15 | "count": 312, 16 | "coffeeType": 3 17 | }, 18 | { 19 | "count": 2252, 20 | "coffeeType": 4 21 | }, 22 | { 23 | "coffeeType": -1, 24 | "count": 1740 25 | } 26 | ] 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black", 3 | "python.testing.pytestArgs": [ 4 | "tests" 5 | ], 6 | "python.testing.unittestEnabled": false, 7 | "python.testing.nosetestsEnabled": false, 8 | "python.testing.pytestEnabled": true, 9 | "jupyter.debugJustMyCode": false, 10 | "python.linting.flake8Enabled": true, 11 | "python.linting.enabled": true, 12 | "python.testing.unittestArgs": [ 13 | "-v", 14 | "-s", 15 | "./tests", 16 | "-p", 17 | "test_*.py" 18 | ] 19 | } -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.tox,docs,bin,lib,deps,build,.git,*migrations*,venv,README.md,hacs.json,info.md,images,setup.cfg,examples.py,functions.py,map.py 3 | doctests = True 4 | # To work with Black 5 | # E501: line too long 6 | # W503: Line break occurred before a binary operator 7 | # E203: Whitespace before ':' 8 | # D202 No blank lines allowed after function docstring 9 | # W504 line break after binary operator 10 | ignore = 11 | E501, 12 | W503, 13 | E203, 14 | D202, 15 | W504, 16 | F405, 17 | F403 18 | [isort] 19 | multi_line_output = 3 20 | include_trailing_comma = True 21 | -------------------------------------------------------------------------------- /tests/test_diagnostics.py: -------------------------------------------------------------------------------- 1 | """Tests for the diagnostics data provided by the La Marzocco integration.""" 2 | 3 | from syrupy import SnapshotAssertion 4 | 5 | from homeassistant.core import HomeAssistant 6 | 7 | from tests.common import MockConfigEntry 8 | from tests.components.diagnostics import get_diagnostics_for_config_entry 9 | from tests.typing import ClientSessionGenerator 10 | 11 | 12 | async def test_diagnostics( 13 | hass: HomeAssistant, 14 | hass_client: ClientSessionGenerator, 15 | init_integration: MockConfigEntry, 16 | snapshot: SnapshotAssertion, 17 | ) -> None: 18 | """Test diagnostics.""" 19 | assert ( 20 | await get_diagnostics_for_config_entry(hass, hass_client, init_integration) 21 | == snapshot 22 | ) 23 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "lamarzocco", 3 | "name": "La Marzocco", 4 | "bluetooth": [ 5 | { 6 | "local_name": "MICRA_*" 7 | }, 8 | { 9 | "local_name": "MINI_*" 10 | }, 11 | { 12 | "local_name": "GS3_*" 13 | }, 14 | { 15 | "local_name": "GS3AV_*" 16 | } 17 | ], 18 | "codeowners": ["@zweckj"], 19 | "config_flow": true, 20 | "dependencies": ["bluetooth_adapters"], 21 | "dhcp": [ 22 | { 23 | "registered_devices": true 24 | }, 25 | { 26 | "hostname": "gs[0-9][0-9][0-9][0-9][0-9][0-9]" 27 | }, 28 | { 29 | "hostname": "lm[0-9][0-9][0-9][0-9][0-9][0-9]" 30 | }, 31 | { 32 | "hostname": "mr[0-9][0-9][0-9][0-9][0-9][0-9]" 33 | } 34 | ], 35 | "documentation": "https://www.home-assistant.io/integrations/lamarzocco", 36 | "integration_type": "device", 37 | "iot_class": "cloud_polling", 38 | "loggers": ["pylamarzocco"], 39 | "quality_scale": "platinum", 40 | "requirements": ["pylamarzocco==1.4.9"], 41 | "version": "0.14.0" 42 | } 43 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for La Marzocco.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import asdict 6 | from typing import Any, TypedDict 7 | 8 | from pylamarzocco.const import FirmwareType 9 | 10 | from homeassistant.components.diagnostics import async_redact_data 11 | from homeassistant.core import HomeAssistant 12 | 13 | from .coordinator import LaMarzoccoConfigEntry 14 | 15 | TO_REDACT = { 16 | "serial_number", 17 | } 18 | 19 | 20 | class DiagnosticsData(TypedDict): 21 | """Diagnostic data for La Marzocco.""" 22 | 23 | model: str 24 | config: dict[str, Any] 25 | firmware: list[dict[FirmwareType, dict[str, Any]]] 26 | statistics: dict[str, Any] 27 | 28 | 29 | async def async_get_config_entry_diagnostics( 30 | hass: HomeAssistant, 31 | entry: LaMarzoccoConfigEntry, 32 | ) -> dict[str, Any]: 33 | """Return diagnostics for a config entry.""" 34 | coordinator = entry.runtime_data.config_coordinator 35 | device = coordinator.device 36 | # collect all data sources 37 | diagnostics_data = DiagnosticsData( 38 | model=device.model, 39 | config=asdict(device.config), 40 | firmware=[{key: asdict(firmware)} for key, firmware in device.firmware.items()], 41 | statistics=asdict(device.statistics), 42 | ) 43 | 44 | return async_redact_data(diagnostics_data, TO_REDACT) 45 | -------------------------------------------------------------------------------- /tests/snapshots/test_button.ambr: -------------------------------------------------------------------------------- 1 | # serializer version: 1 2 | # name: test_start_backflush 3 | StateSnapshot({ 4 | 'attributes': ReadOnlyDict({ 5 | 'friendly_name': 'GS012345 Start backflush', 6 | }), 7 | 'context': , 8 | 'entity_id': 'button.gs012345_start_backflush', 9 | 'last_changed': , 10 | 'last_reported': , 11 | 'last_updated': , 12 | 'state': 'unknown', 13 | }) 14 | # --- 15 | # name: test_start_backflush.1 16 | EntityRegistryEntrySnapshot({ 17 | 'aliases': set({ 18 | }), 19 | 'area_id': None, 20 | 'capabilities': None, 21 | 'config_entry_id': , 22 | 'config_subentry_id': , 23 | 'device_class': None, 24 | 'device_id': , 25 | 'disabled_by': None, 26 | 'domain': 'button', 27 | 'entity_category': None, 28 | 'entity_id': 'button.gs012345_start_backflush', 29 | 'has_entity_name': True, 30 | 'hidden_by': None, 31 | 'icon': None, 32 | 'id': , 33 | 'labels': set({ 34 | }), 35 | 'name': None, 36 | 'options': dict({ 37 | }), 38 | 'original_device_class': None, 39 | 'original_icon': None, 40 | 'original_name': 'Start backflush', 41 | 'platform': 'lamarzocco', 42 | 'previous_unique_id': None, 43 | 'supported_features': 0, 44 | 'translation_key': 'start_backflush', 45 | 'unique_id': 'GS012345_start_backflush', 46 | 'unit_of_measurement': None, 47 | }) 48 | # --- 49 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Mock inputs for tests.""" 2 | 3 | from pylamarzocco.const import MachineModel 4 | 5 | from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME 6 | from homeassistant.core import HomeAssistant 7 | from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo 8 | 9 | from tests.common import MockConfigEntry 10 | 11 | HOST_SELECTION = { 12 | CONF_HOST: "192.168.1.1", 13 | } 14 | 15 | PASSWORD_SELECTION = { 16 | CONF_PASSWORD: "password", 17 | } 18 | 19 | USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"} 20 | 21 | SERIAL_DICT = { 22 | MachineModel.GS3_AV: "GS012345", 23 | MachineModel.GS3_MP: "GS012345", 24 | MachineModel.LINEA_MICRA: "MR012345", 25 | MachineModel.LINEA_MINI: "LM012345", 26 | } 27 | 28 | WAKE_UP_SLEEP_ENTRY_IDS = ["Os2OswX", "aXFz5bJ"] 29 | 30 | 31 | async def async_init_integration( 32 | hass: HomeAssistant, mock_config_entry: MockConfigEntry 33 | ) -> None: 34 | """Set up the La Marzocco integration for testing.""" 35 | mock_config_entry.add_to_hass(hass) 36 | await hass.config_entries.async_setup(mock_config_entry.entry_id) 37 | await hass.async_block_till_done() 38 | 39 | 40 | def get_bluetooth_service_info( 41 | model: MachineModel, serial: str 42 | ) -> BluetoothServiceInfo: 43 | """Return a mocked BluetoothServiceInfo.""" 44 | if model in (MachineModel.GS3_AV, MachineModel.GS3_MP): 45 | name = f"GS3_{serial}" 46 | elif model == MachineModel.LINEA_MINI: 47 | name = f"MINI_{serial}" 48 | elif model == MachineModel.LINEA_MICRA: 49 | name = f"MICRA_{serial}" 50 | return BluetoothServiceInfo( 51 | name=name, 52 | address="aa:bb:cc:dd:ee:ff", 53 | rssi=-63, 54 | manufacturer_data={}, 55 | service_data={}, 56 | service_uuids=[], 57 | source="local", 58 | ) 59 | -------------------------------------------------------------------------------- /tests/snapshots/test_init.ambr: -------------------------------------------------------------------------------- 1 | # serializer version: 1 2 | # name: test_device 3 | DeviceRegistryEntrySnapshot({ 4 | 'area_id': None, 5 | 'config_entries': , 6 | 'config_entries_subentries': , 7 | 'configuration_url': None, 8 | 'connections': set({ 9 | tuple( 10 | 'bluetooth', 11 | 'aa:bb:cc:dd:ee:ff', 12 | ), 13 | tuple( 14 | 'mac', 15 | '00:00:00:00:00:00', 16 | ), 17 | }), 18 | 'disabled_by': None, 19 | 'entry_type': None, 20 | 'hw_version': None, 21 | 'id': , 22 | 'identifiers': set({ 23 | tuple( 24 | 'lamarzocco', 25 | 'GS012345', 26 | ), 27 | }), 28 | 'is_new': False, 29 | 'labels': set({ 30 | }), 31 | 'manufacturer': 'La Marzocco', 32 | 'model': , 33 | 'model_id': , 34 | 'name': 'GS012345', 35 | 'name_by_user': None, 36 | 'primary_config_entry': , 37 | 'serial_number': 'GS012345', 38 | 'suggested_area': None, 39 | 'sw_version': '1.40', 40 | 'via_device_id': None, 41 | }) 42 | # --- 43 | # name: test_scale_device[Linea Mini] 44 | DeviceRegistryEntrySnapshot({ 45 | 'area_id': None, 46 | 'config_entries': , 47 | 'config_entries_subentries': , 48 | 'configuration_url': None, 49 | 'connections': set({ 50 | }), 51 | 'disabled_by': None, 52 | 'entry_type': None, 53 | 'hw_version': None, 54 | 'id': , 55 | 'identifiers': set({ 56 | tuple( 57 | 'lamarzocco', 58 | '44:b7:d0:74:5f:90', 59 | ), 60 | }), 61 | 'is_new': False, 62 | 'labels': set({ 63 | }), 64 | 'manufacturer': 'Acaia', 65 | 'model': 'Lunar', 66 | 'model_id': 'Y.301', 67 | 'name': 'LMZ-123A45', 68 | 'name_by_user': None, 69 | 'primary_config_entry': , 70 | 'serial_number': None, 71 | 'suggested_area': None, 72 | 'sw_version': None, 73 | 'via_device_id': , 74 | }) 75 | # --- 76 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/quality_scale.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | # Bronze 3 | action-setup: 4 | status: exempt 5 | comment: | 6 | No custom actions are defined. 7 | appropriate-polling: done 8 | brands: done 9 | common-modules: done 10 | config-flow-test-coverage: done 11 | config-flow: done 12 | dependency-transparency: done 13 | docs-actions: 14 | status: exempt 15 | comment: | 16 | No custom actions are defined. 17 | docs-high-level-description: done 18 | docs-installation-instructions: done 19 | docs-removal-instructions: done 20 | entity-event-setup: 21 | status: exempt 22 | comment: | 23 | No explicit event subscriptions. 24 | entity-unique-id: done 25 | has-entity-name: done 26 | runtime-data: done 27 | test-before-configure: done 28 | test-before-setup: done 29 | unique-config-entry: done 30 | 31 | # Silver 32 | action-exceptions: 33 | status: exempt 34 | comment: | 35 | No custom actions are defined. 36 | config-entry-unloading: done 37 | docs-configuration-parameters: done 38 | docs-installation-parameters: done 39 | entity-unavailable: done 40 | integration-owner: done 41 | log-when-unavailable: 42 | status: done 43 | comment: | 44 | Handled by coordinator. 45 | parallel-updates: done 46 | reauthentication-flow: done 47 | test-coverage: done 48 | 49 | # Gold 50 | devices: done 51 | diagnostics: done 52 | discovery-update-info: done 53 | discovery: 54 | status: done 55 | comment: | 56 | DHCP & Bluetooth discovery. 57 | docs-data-update: done 58 | docs-examples: done 59 | docs-known-limitations: done 60 | docs-supported-devices: done 61 | docs-supported-functions: done 62 | docs-troubleshooting: done 63 | docs-use-cases: done 64 | dynamic-devices: 65 | status: done 66 | comment: | 67 | Device type integration, only possible for addon scale 68 | entity-category: done 69 | entity-device-class: done 70 | entity-disabled-by-default: done 71 | entity-translations: done 72 | exception-translations: done 73 | icon-translations: done 74 | reconfiguration-flow: done 75 | repair-issues: done 76 | stale-devices: 77 | status: done 78 | comment: | 79 | Device type integration, only possible for addon scale 80 | 81 | # Platinum 82 | async-dependency: done 83 | inject-websession: 84 | status: done 85 | comment: | 86 | Uses `httpx` session. 87 | strict-typing: done 88 | -------------------------------------------------------------------------------- /tests/test_button.py: -------------------------------------------------------------------------------- 1 | """Tests for the La Marzocco Buttons.""" 2 | 3 | from unittest.mock import AsyncMock, MagicMock, patch 4 | 5 | from pylamarzocco.exceptions import RequestNotSuccessful 6 | import pytest 7 | from syrupy import SnapshotAssertion 8 | 9 | from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS 10 | from homeassistant.const import ATTR_ENTITY_ID 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.exceptions import HomeAssistantError 13 | from homeassistant.helpers import entity_registry as er 14 | 15 | pytestmark = pytest.mark.usefixtures("init_integration") 16 | 17 | 18 | async def test_start_backflush( 19 | hass: HomeAssistant, 20 | mock_lamarzocco: MagicMock, 21 | entity_registry: er.EntityRegistry, 22 | snapshot: SnapshotAssertion, 23 | ) -> None: 24 | """Test the La Marzocco backflush button.""" 25 | 26 | serial_number = mock_lamarzocco.serial_number 27 | 28 | state = hass.states.get(f"button.{serial_number}_start_backflush") 29 | assert state 30 | assert state == snapshot 31 | 32 | entry = entity_registry.async_get(state.entity_id) 33 | assert entry 34 | assert entry == snapshot 35 | 36 | with patch( 37 | "homeassistant.components.lamarzocco.button.asyncio.sleep", 38 | new_callable=AsyncMock, 39 | ): 40 | await hass.services.async_call( 41 | BUTTON_DOMAIN, 42 | SERVICE_PRESS, 43 | { 44 | ATTR_ENTITY_ID: f"button.{serial_number}_start_backflush", 45 | }, 46 | blocking=True, 47 | ) 48 | 49 | assert len(mock_lamarzocco.start_backflush.mock_calls) == 1 50 | mock_lamarzocco.start_backflush.assert_called_once() 51 | 52 | 53 | async def test_button_error( 54 | hass: HomeAssistant, 55 | mock_lamarzocco: MagicMock, 56 | ) -> None: 57 | """Test the La Marzocco button error.""" 58 | serial_number = mock_lamarzocco.serial_number 59 | 60 | state = hass.states.get(f"button.{serial_number}_start_backflush") 61 | assert state 62 | 63 | mock_lamarzocco.start_backflush.side_effect = RequestNotSuccessful("Boom.") 64 | with pytest.raises(HomeAssistantError) as exc_info: 65 | await hass.services.async_call( 66 | BUTTON_DOMAIN, 67 | SERVICE_PRESS, 68 | { 69 | ATTR_ENTITY_ID: f"button.{serial_number}_start_backflush", 70 | }, 71 | blocking=True, 72 | ) 73 | assert exc_info.value.translation_key == "button_error" 74 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/button.py: -------------------------------------------------------------------------------- 1 | """Button platform for La Marzocco espresso machines.""" 2 | 3 | import asyncio 4 | from collections.abc import Callable, Coroutine 5 | from dataclasses import dataclass 6 | from typing import Any 7 | 8 | from pylamarzocco.exceptions import RequestNotSuccessful 9 | 10 | from homeassistant.components.button import ButtonEntity, ButtonEntityDescription 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.exceptions import HomeAssistantError 13 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 14 | 15 | from .const import DOMAIN 16 | from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator 17 | from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription 18 | 19 | PARALLEL_UPDATES = 1 20 | BACKFLUSH_ENABLED_DURATION = 15 21 | 22 | 23 | @dataclass(frozen=True, kw_only=True) 24 | class LaMarzoccoButtonEntityDescription( 25 | LaMarzoccoEntityDescription, 26 | ButtonEntityDescription, 27 | ): 28 | """Description of a La Marzocco button.""" 29 | 30 | press_fn: Callable[[LaMarzoccoUpdateCoordinator], Coroutine[Any, Any, None]] 31 | 32 | 33 | async def async_backflush_and_update(coordinator: LaMarzoccoUpdateCoordinator) -> None: 34 | """Press backflush button.""" 35 | await coordinator.device.start_backflush() 36 | # lib will set state optimistically 37 | coordinator.async_set_updated_data(None) 38 | # backflush is enabled for 15 seconds 39 | # then turns off automatically 40 | await asyncio.sleep(BACKFLUSH_ENABLED_DURATION + 1) 41 | await coordinator.async_request_refresh() 42 | 43 | 44 | ENTITIES: tuple[LaMarzoccoButtonEntityDescription, ...] = ( 45 | LaMarzoccoButtonEntityDescription( 46 | key="start_backflush", 47 | translation_key="start_backflush", 48 | press_fn=async_backflush_and_update, 49 | ), 50 | ) 51 | 52 | 53 | async def async_setup_entry( 54 | hass: HomeAssistant, 55 | entry: LaMarzoccoConfigEntry, 56 | async_add_entities: AddConfigEntryEntitiesCallback, 57 | ) -> None: 58 | """Set up button entities.""" 59 | 60 | coordinator = entry.runtime_data.config_coordinator 61 | async_add_entities( 62 | LaMarzoccoButtonEntity(coordinator, description) 63 | for description in ENTITIES 64 | if description.supported_fn(coordinator) 65 | ) 66 | 67 | 68 | class LaMarzoccoButtonEntity(LaMarzoccoEntity, ButtonEntity): 69 | """La Marzocco Button Entity.""" 70 | 71 | entity_description: LaMarzoccoButtonEntityDescription 72 | 73 | async def async_press(self) -> None: 74 | """Press button.""" 75 | try: 76 | await self.entity_description.press_fn(self.coordinator) 77 | except RequestNotSuccessful as exc: 78 | raise HomeAssistantError( 79 | translation_domain=DOMAIN, 80 | translation_key="button_error", 81 | translation_placeholders={ 82 | "key": self.entity_description.key, 83 | }, 84 | ) from exc 85 | -------------------------------------------------------------------------------- /tests/test_update.py: -------------------------------------------------------------------------------- 1 | """Tests for the La Marzocco Update Entities.""" 2 | 3 | from unittest.mock import MagicMock, patch 4 | 5 | from pylamarzocco.const import FirmwareType 6 | from pylamarzocco.exceptions import RequestNotSuccessful 7 | import pytest 8 | from syrupy import SnapshotAssertion 9 | 10 | from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL 11 | from homeassistant.const import ATTR_ENTITY_ID, Platform 12 | from homeassistant.core import HomeAssistant 13 | from homeassistant.exceptions import HomeAssistantError 14 | from homeassistant.helpers import entity_registry as er 15 | 16 | from . import async_init_integration 17 | 18 | from tests.common import MockConfigEntry, snapshot_platform 19 | 20 | 21 | async def test_update( 22 | hass: HomeAssistant, 23 | mock_lamarzocco: MagicMock, 24 | mock_config_entry: MockConfigEntry, 25 | entity_registry: er.EntityRegistry, 26 | snapshot: SnapshotAssertion, 27 | ) -> None: 28 | """Test the La Marzocco updates.""" 29 | with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.UPDATE]): 30 | await async_init_integration(hass, mock_config_entry) 31 | await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) 32 | 33 | 34 | @pytest.mark.parametrize( 35 | ("entity_name", "component"), 36 | [ 37 | ("machine_firmware", FirmwareType.MACHINE), 38 | ("gateway_firmware", FirmwareType.GATEWAY), 39 | ], 40 | ) 41 | async def test_update_entites( 42 | hass: HomeAssistant, 43 | mock_lamarzocco: MagicMock, 44 | mock_config_entry: MockConfigEntry, 45 | entity_name: str, 46 | component: FirmwareType, 47 | ) -> None: 48 | """Test the La Marzocco update entities.""" 49 | 50 | serial_number = mock_lamarzocco.serial_number 51 | 52 | await async_init_integration(hass, mock_config_entry) 53 | 54 | await hass.services.async_call( 55 | UPDATE_DOMAIN, 56 | SERVICE_INSTALL, 57 | { 58 | ATTR_ENTITY_ID: f"update.{serial_number}_{entity_name}", 59 | }, 60 | blocking=True, 61 | ) 62 | 63 | mock_lamarzocco.update_firmware.assert_called_once_with(component) 64 | 65 | 66 | @pytest.mark.parametrize( 67 | ("attr", "value"), 68 | [ 69 | ("side_effect", RequestNotSuccessful("Boom")), 70 | ("return_value", False), 71 | ], 72 | ) 73 | async def test_update_error( 74 | hass: HomeAssistant, 75 | mock_lamarzocco: MagicMock, 76 | mock_config_entry: MockConfigEntry, 77 | attr: str, 78 | value: bool | Exception, 79 | ) -> None: 80 | """Test error during update.""" 81 | 82 | await async_init_integration(hass, mock_config_entry) 83 | 84 | state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_machine_firmware") 85 | assert state 86 | 87 | setattr(mock_lamarzocco.update_firmware, attr, value) 88 | 89 | with pytest.raises(HomeAssistantError) as exc_info: 90 | await hass.services.async_call( 91 | UPDATE_DOMAIN, 92 | SERVICE_INSTALL, 93 | { 94 | ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_machine_firmware", 95 | }, 96 | blocking=True, 97 | ) 98 | assert exc_info.value.translation_key == "update_failed" 99 | -------------------------------------------------------------------------------- /tests/fixtures/config_mini.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1", 3 | "preinfusionModesAvailable": ["ByDoseType"], 4 | "machineCapabilities": [ 5 | { 6 | "family": "LINEA", 7 | "groupsNumber": 1, 8 | "coffeeBoilersNumber": 1, 9 | "hasCupWarmer": false, 10 | "steamBoilersNumber": 1, 11 | "teaDosesNumber": 1, 12 | "machineModes": ["BrewingMode", "StandBy"], 13 | "schedulingType": "smartWakeUpSleep" 14 | } 15 | ], 16 | "machine_sn": "Sn01239157", 17 | "machine_hw": "0", 18 | "isPlumbedIn": false, 19 | "isBackFlushEnabled": false, 20 | "standByTime": 0, 21 | "tankStatus": true, 22 | "settings": [], 23 | "recipes": [ 24 | { 25 | "id": "Recipe1", 26 | "dose_mode": "Mass", 27 | "recipe_doses": [ 28 | { "id": "A", "target": 32 }, 29 | { "id": "B", "target": 45 } 30 | ] 31 | } 32 | ], 33 | "recipeAssignment": [ 34 | { 35 | "dose_index": "DoseA", 36 | "recipe_id": "Recipe1", 37 | "recipe_dose": "A", 38 | "group": "Group1" 39 | } 40 | ], 41 | "groupCapabilities": [ 42 | { 43 | "capabilities": { 44 | "groupType": "AV_Group", 45 | "groupNumber": "Group1", 46 | "boilerId": "CoffeeBoiler1", 47 | "hasScale": false, 48 | "hasFlowmeter": false, 49 | "numberOfDoses": 1 50 | }, 51 | "doses": [ 52 | { 53 | "groupNumber": "Group1", 54 | "doseIndex": "DoseA", 55 | "doseType": "MassType", 56 | "stopTarget": 32 57 | } 58 | ], 59 | "doseMode": { "groupNumber": "Group1", "brewingType": "ManualType" } 60 | } 61 | ], 62 | "machineMode": "StandBy", 63 | "teaDoses": { "DoseA": { "doseIndex": "DoseA", "stopTarget": 0 } }, 64 | "scale": { 65 | "connected": true, 66 | "address": "44:b7:d0:74:5f:90", 67 | "name": "LMZ-123A45", 68 | "battery": 64 69 | }, 70 | "boilers": [ 71 | { "id": "SteamBoiler", "isEnabled": false, "target": 0, "current": 0 }, 72 | { "id": "CoffeeBoiler1", "isEnabled": true, "target": 89, "current": 42 } 73 | ], 74 | "boilerTargetTemperature": { "SteamBoiler": 0, "CoffeeBoiler1": 89 }, 75 | "preinfusionMode": { 76 | "Group1": { 77 | "groupNumber": "Group1", 78 | "preinfusionStyle": "PreinfusionByDoseType" 79 | } 80 | }, 81 | "preinfusionSettings": { 82 | "mode": "TypeB", 83 | "Group1": [ 84 | { 85 | "mode": "TypeA", 86 | "groupNumber": "Group1", 87 | "doseType": "Continuous", 88 | "preWetTime": 2, 89 | "preWetHoldTime": 3 90 | }, 91 | { 92 | "mode": "TypeB", 93 | "groupNumber": "Group1", 94 | "doseType": "Continuous", 95 | "preWetTime": 0, 96 | "preWetHoldTime": 3 97 | } 98 | ] 99 | }, 100 | "wakeUpSleepEntries": [ 101 | { 102 | "id": "T6aLl42", 103 | "days": [ 104 | "monday", 105 | "tuesday", 106 | "wednesday", 107 | "thursday", 108 | "friday", 109 | "saturday", 110 | "sunday" 111 | ], 112 | "steam": false, 113 | "enabled": false, 114 | "timeOn": "24:0", 115 | "timeOff": "24:0" 116 | } 117 | ], 118 | "smartStandBy": { "mode": "LastBrewing", "minutes": 10, "enabled": true }, 119 | "clock": "2024-08-31T14:47:45", 120 | "firmwareVersions": [ 121 | { "name": "machine_firmware", "fw_version": "2.12" }, 122 | { "name": "gateway_firmware", "fw_version": "v3.6-rc4" } 123 | ] 124 | } 125 | -------------------------------------------------------------------------------- /tests/snapshots/test_diagnostics.ambr: -------------------------------------------------------------------------------- 1 | # serializer version: 1 2 | # name: test_diagnostics 3 | dict({ 4 | 'config': dict({ 5 | 'backflush_enabled': False, 6 | 'bbw_settings': None, 7 | 'boilers': dict({ 8 | 'CoffeeBoiler1': dict({ 9 | 'current_temperature': 96.5, 10 | 'enabled': True, 11 | 'target_temperature': 95, 12 | }), 13 | 'SteamBoiler': dict({ 14 | 'current_temperature': 123.80000305175781, 15 | 'enabled': True, 16 | 'target_temperature': 123.9000015258789, 17 | }), 18 | }), 19 | 'brew_active': False, 20 | 'brew_active_duration': 0, 21 | 'dose_hot_water': 8, 22 | 'doses': dict({ 23 | '1': 135, 24 | '2': 97, 25 | '3': 108, 26 | '4': 121, 27 | }), 28 | 'plumbed_in': True, 29 | 'prebrew_configuration': dict({ 30 | '1': list([ 31 | dict({ 32 | 'off_time': 1, 33 | 'on_time': 0.5, 34 | }), 35 | dict({ 36 | 'off_time': 4, 37 | 'on_time': 0, 38 | }), 39 | ]), 40 | '2': list([ 41 | dict({ 42 | 'off_time': 1, 43 | 'on_time': 0.5, 44 | }), 45 | dict({ 46 | 'off_time': 4, 47 | 'on_time': 0, 48 | }), 49 | ]), 50 | '3': list([ 51 | dict({ 52 | 'off_time': 3.3, 53 | 'on_time': 3.3, 54 | }), 55 | dict({ 56 | 'off_time': 4, 57 | 'on_time': 0, 58 | }), 59 | ]), 60 | '4': list([ 61 | dict({ 62 | 'off_time': 2, 63 | 'on_time': 2, 64 | }), 65 | dict({ 66 | 'off_time': 4, 67 | 'on_time': 0, 68 | }), 69 | ]), 70 | }), 71 | 'prebrew_mode': 'TypeB', 72 | 'scale': None, 73 | 'smart_standby': dict({ 74 | 'enabled': True, 75 | 'minutes': 10, 76 | 'mode': 'LastBrewing', 77 | }), 78 | 'turned_on': True, 79 | 'wake_up_sleep_entries': dict({ 80 | 'Os2OswX': dict({ 81 | 'days': list([ 82 | 'monday', 83 | 'tuesday', 84 | 'wednesday', 85 | 'thursday', 86 | 'friday', 87 | 'saturday', 88 | 'sunday', 89 | ]), 90 | 'enabled': True, 91 | 'entry_id': 'Os2OswX', 92 | 'steam': True, 93 | 'time_off': '24:0', 94 | 'time_on': '22:0', 95 | }), 96 | 'aXFz5bJ': dict({ 97 | 'days': list([ 98 | 'sunday', 99 | ]), 100 | 'enabled': True, 101 | 'entry_id': 'aXFz5bJ', 102 | 'steam': True, 103 | 'time_off': '7:30', 104 | 'time_on': '7:0', 105 | }), 106 | }), 107 | 'water_contact': True, 108 | }), 109 | 'firmware': list([ 110 | dict({ 111 | 'machine': dict({ 112 | 'current_version': '1.40', 113 | 'latest_version': '1.55', 114 | }), 115 | }), 116 | dict({ 117 | 'gateway': dict({ 118 | 'current_version': 'v3.1-rc4', 119 | 'latest_version': 'v3.5-rc3', 120 | }), 121 | }), 122 | ]), 123 | 'model': 'GS3 AV', 124 | 'statistics': dict({ 125 | 'continous': 2252, 126 | 'drink_stats': dict({ 127 | '1': 1047, 128 | '2': 560, 129 | '3': 468, 130 | '4': 312, 131 | }), 132 | 'total_flushes': 1740, 133 | }), 134 | }) 135 | # --- 136 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/icons.json: -------------------------------------------------------------------------------- 1 | { 2 | "entity": { 3 | "binary_sensor": { 4 | "water_tank": { 5 | "default": "mdi:water", 6 | "state": { 7 | "on": "mdi:water-alert", 8 | "off": "mdi:water-check" 9 | } 10 | }, 11 | "brew_active": { 12 | "default": "mdi:cup", 13 | "state": { 14 | "on": "mdi:cup-water", 15 | "off": "mdi:cup-off" 16 | } 17 | }, 18 | "backflush_enabled": { 19 | "default": "mdi:water-off", 20 | "state": { 21 | "on": "mdi:water" 22 | } 23 | } 24 | }, 25 | "button": { 26 | "start_backflush": { 27 | "default": "mdi:water-sync" 28 | } 29 | }, 30 | "number": { 31 | "coffee_temp": { 32 | "default": "mdi:thermometer-water" 33 | }, 34 | "dose": { 35 | "default": "mdi:cup-water" 36 | }, 37 | "prebrew_off": { 38 | "default": "mdi:water-off" 39 | }, 40 | "prebrew_on": { 41 | "default": "mdi:water" 42 | }, 43 | "preinfusion_off": { 44 | "default": "mdi:water" 45 | }, 46 | "scale_target": { 47 | "default": "mdi:scale-balance" 48 | }, 49 | "smart_standby_time": { 50 | "default": "mdi:timer" 51 | }, 52 | "steam_temp": { 53 | "default": "mdi:thermometer-water" 54 | }, 55 | "tea_water_duration": { 56 | "default": "mdi:timer-sand" 57 | } 58 | }, 59 | "select": { 60 | "active_bbw": { 61 | "default": "mdi:alpha-u", 62 | "state": { 63 | "a": "mdi:alpha-a", 64 | "b": "mdi:alpha-b" 65 | } 66 | }, 67 | "smart_standby_mode": { 68 | "default": "mdi:power", 69 | "state": { 70 | "poweron": "mdi:power", 71 | "lastbrewing": "mdi:coffee" 72 | } 73 | }, 74 | "steam_temp_select": { 75 | "default": "mdi:thermometer", 76 | "state": { 77 | "1": "mdi:thermometer-low", 78 | "2": "mdi:thermometer", 79 | "3": "mdi:thermometer-high" 80 | } 81 | }, 82 | "prebrew_infusion_select": { 83 | "default": "mdi:water-pump-off", 84 | "state": { 85 | "disabled": "mdi:water-pump-off", 86 | "prebrew": "mdi:water-pump", 87 | "typeb": "mdi:water-pump" 88 | } 89 | } 90 | }, 91 | "sensor": { 92 | "drink_stats_coffee": { 93 | "default": "mdi:chart-line" 94 | }, 95 | "drink_stats_flushing": { 96 | "default": "mdi:chart-line" 97 | }, 98 | "drink_stats_coffee_key": { 99 | "default": "mdi:chart-scatter-plot" 100 | }, 101 | "shot_timer": { 102 | "default": "mdi:timer" 103 | }, 104 | "current_temp_coffee": { 105 | "default": "mdi:thermometer" 106 | }, 107 | "current_temp_steam": { 108 | "default": "mdi:thermometer" 109 | } 110 | }, 111 | "switch": { 112 | "main": { 113 | "default": "mdi:power", 114 | "state": { 115 | "on": "mdi:power", 116 | "off": "mdi:power-off" 117 | } 118 | }, 119 | "auto_on_off": { 120 | "default": "mdi:alarm", 121 | "state": { 122 | "on": "mdi:alarm", 123 | "off": "mdi:alarm-off" 124 | } 125 | }, 126 | "smart_standby_enabled": { 127 | "state": { 128 | "on": "mdi:sleep", 129 | "off": "mdi:sleep-off" 130 | } 131 | }, 132 | "steam_boiler": { 133 | "default": "mdi:water-boiler", 134 | "state": { 135 | "on": "mdi:water-boiler", 136 | "off": "mdi:water-boiler-off" 137 | } 138 | } 139 | }, 140 | "update": { 141 | "machine_firmware": { 142 | "default": "mdi:cloud-download" 143 | }, 144 | "gateway_firmware": { 145 | "default": "mdi:cloud-download" 146 | } 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/entity.py: -------------------------------------------------------------------------------- 1 | """Base class for the La Marzocco entities.""" 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING 6 | 7 | from pylamarzocco.const import FirmwareType 8 | from pylamarzocco.devices.machine import LaMarzoccoMachine 9 | 10 | from homeassistant.const import CONF_ADDRESS, CONF_MAC 11 | from homeassistant.helpers.device_registry import ( 12 | CONNECTION_BLUETOOTH, 13 | CONNECTION_NETWORK_MAC, 14 | DeviceInfo, 15 | ) 16 | from homeassistant.helpers.entity import EntityDescription 17 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 18 | 19 | from .const import DOMAIN 20 | from .coordinator import LaMarzoccoUpdateCoordinator 21 | 22 | 23 | @dataclass(frozen=True, kw_only=True) 24 | class LaMarzoccoEntityDescription(EntityDescription): 25 | """Description for all LM entities.""" 26 | 27 | available_fn: Callable[[LaMarzoccoMachine], bool] = lambda _: True 28 | supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True 29 | 30 | 31 | class LaMarzoccoBaseEntity( 32 | CoordinatorEntity[LaMarzoccoUpdateCoordinator], 33 | ): 34 | """Common elements for all entities.""" 35 | 36 | _attr_has_entity_name = True 37 | 38 | def __init__( 39 | self, 40 | coordinator: LaMarzoccoUpdateCoordinator, 41 | key: str, 42 | ) -> None: 43 | """Initialize the entity.""" 44 | super().__init__(coordinator) 45 | device = coordinator.device 46 | self._attr_unique_id = f"{device.serial_number}_{key}" 47 | self._attr_device_info = DeviceInfo( 48 | identifiers={(DOMAIN, device.serial_number)}, 49 | name=device.name, 50 | manufacturer="La Marzocco", 51 | model=device.full_model_name, 52 | model_id=device.model, 53 | serial_number=device.serial_number, 54 | sw_version=device.firmware[FirmwareType.MACHINE].current_version, 55 | ) 56 | connections: set[tuple[str, str]] = set() 57 | if coordinator.config_entry.data.get(CONF_ADDRESS): 58 | connections.add( 59 | (CONNECTION_NETWORK_MAC, coordinator.config_entry.data[CONF_ADDRESS]) 60 | ) 61 | if coordinator.config_entry.data.get(CONF_MAC): 62 | connections.add( 63 | (CONNECTION_BLUETOOTH, coordinator.config_entry.data[CONF_MAC]) 64 | ) 65 | if connections: 66 | self._attr_device_info.update(DeviceInfo(connections=connections)) 67 | 68 | 69 | class LaMarzoccoEntity(LaMarzoccoBaseEntity): 70 | """Common elements for all entities.""" 71 | 72 | entity_description: LaMarzoccoEntityDescription 73 | 74 | @property 75 | def available(self) -> bool: 76 | """Return True if entity is available.""" 77 | if super().available: 78 | return self.entity_description.available_fn(self.coordinator.device) 79 | return False 80 | 81 | def __init__( 82 | self, 83 | coordinator: LaMarzoccoUpdateCoordinator, 84 | entity_description: LaMarzoccoEntityDescription, 85 | ) -> None: 86 | """Initialize the entity.""" 87 | super().__init__(coordinator, entity_description.key) 88 | self.entity_description = entity_description 89 | 90 | 91 | class LaMarzoccScaleEntity(LaMarzoccoEntity): 92 | """Common class for scale.""" 93 | 94 | def __init__( 95 | self, 96 | coordinator: LaMarzoccoUpdateCoordinator, 97 | entity_description: LaMarzoccoEntityDescription, 98 | ) -> None: 99 | """Initialize the entity.""" 100 | super().__init__(coordinator, entity_description) 101 | scale = coordinator.device.config.scale 102 | if TYPE_CHECKING: 103 | assert scale 104 | self._attr_device_info = DeviceInfo( 105 | identifiers={(DOMAIN, scale.address)}, 106 | name=scale.name, 107 | manufacturer="Acaia", 108 | model="Lunar", 109 | model_id="Y.301", 110 | via_device=(DOMAIN, coordinator.device.serial_number), 111 | ) 112 | -------------------------------------------------------------------------------- /tests/snapshots/test_update.ambr: -------------------------------------------------------------------------------- 1 | # serializer version: 1 2 | # name: test_update[update.gs012345_gateway_firmware-entry] 3 | EntityRegistryEntrySnapshot({ 4 | 'aliases': set({ 5 | }), 6 | 'area_id': None, 7 | 'capabilities': None, 8 | 'config_entry_id': , 9 | 'config_subentry_id': , 10 | 'device_class': None, 11 | 'device_id': , 12 | 'disabled_by': None, 13 | 'domain': 'update', 14 | 'entity_category': , 15 | 'entity_id': 'update.gs012345_gateway_firmware', 16 | 'has_entity_name': True, 17 | 'hidden_by': None, 18 | 'icon': None, 19 | 'id': , 20 | 'labels': set({ 21 | }), 22 | 'name': None, 23 | 'options': dict({ 24 | }), 25 | 'original_device_class': , 26 | 'original_icon': None, 27 | 'original_name': 'Gateway firmware', 28 | 'platform': 'lamarzocco', 29 | 'previous_unique_id': None, 30 | 'supported_features': , 31 | 'translation_key': 'gateway_firmware', 32 | 'unique_id': 'GS012345_gateway_firmware', 33 | 'unit_of_measurement': None, 34 | }) 35 | # --- 36 | # name: test_update[update.gs012345_gateway_firmware-state] 37 | StateSnapshot({ 38 | 'attributes': ReadOnlyDict({ 39 | 'auto_update': False, 40 | 'device_class': 'firmware', 41 | 'display_precision': 0, 42 | 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', 43 | 'friendly_name': 'GS012345 Gateway firmware', 44 | 'in_progress': False, 45 | 'installed_version': 'v3.1-rc4', 46 | 'latest_version': 'v3.5-rc3', 47 | 'release_summary': None, 48 | 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', 49 | 'skipped_version': None, 50 | 'supported_features': , 51 | 'title': None, 52 | 'update_percentage': None, 53 | }), 54 | 'context': , 55 | 'entity_id': 'update.gs012345_gateway_firmware', 56 | 'last_changed': , 57 | 'last_reported': , 58 | 'last_updated': , 59 | 'state': 'on', 60 | }) 61 | # --- 62 | # name: test_update[update.gs012345_machine_firmware-entry] 63 | EntityRegistryEntrySnapshot({ 64 | 'aliases': set({ 65 | }), 66 | 'area_id': None, 67 | 'capabilities': None, 68 | 'config_entry_id': , 69 | 'config_subentry_id': , 70 | 'device_class': None, 71 | 'device_id': , 72 | 'disabled_by': None, 73 | 'domain': 'update', 74 | 'entity_category': , 75 | 'entity_id': 'update.gs012345_machine_firmware', 76 | 'has_entity_name': True, 77 | 'hidden_by': None, 78 | 'icon': None, 79 | 'id': , 80 | 'labels': set({ 81 | }), 82 | 'name': None, 83 | 'options': dict({ 84 | }), 85 | 'original_device_class': , 86 | 'original_icon': None, 87 | 'original_name': 'Machine firmware', 88 | 'platform': 'lamarzocco', 89 | 'previous_unique_id': None, 90 | 'supported_features': , 91 | 'translation_key': 'machine_firmware', 92 | 'unique_id': 'GS012345_machine_firmware', 93 | 'unit_of_measurement': None, 94 | }) 95 | # --- 96 | # name: test_update[update.gs012345_machine_firmware-state] 97 | StateSnapshot({ 98 | 'attributes': ReadOnlyDict({ 99 | 'auto_update': False, 100 | 'device_class': 'firmware', 101 | 'display_precision': 0, 102 | 'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png', 103 | 'friendly_name': 'GS012345 Machine firmware', 104 | 'in_progress': False, 105 | 'installed_version': '1.40', 106 | 'latest_version': '1.55', 107 | 'release_summary': None, 108 | 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', 109 | 'skipped_version': None, 110 | 'supported_features': , 111 | 'title': None, 112 | 'update_percentage': None, 113 | }), 114 | 'context': , 115 | 'entity_id': 'update.gs012345_machine_firmware', 116 | 'last_changed': , 117 | 'last_reported': , 118 | 'last_updated': , 119 | 'state': 'on', 120 | }) 121 | # --- 122 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/update.py: -------------------------------------------------------------------------------- 1 | """Support for La Marzocco update entities.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | 6 | from pylamarzocco.const import FirmwareType 7 | from pylamarzocco.exceptions import RequestNotSuccessful 8 | 9 | from homeassistant.components.update import ( 10 | UpdateDeviceClass, 11 | UpdateEntity, 12 | UpdateEntityDescription, 13 | UpdateEntityFeature, 14 | ) 15 | from homeassistant.const import EntityCategory 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.exceptions import HomeAssistantError 18 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 19 | 20 | from .const import DOMAIN 21 | from .coordinator import LaMarzoccoConfigEntry 22 | from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription 23 | 24 | PARALLEL_UPDATES = 1 25 | 26 | 27 | @dataclass(frozen=True, kw_only=True) 28 | class LaMarzoccoUpdateEntityDescription( 29 | LaMarzoccoEntityDescription, 30 | UpdateEntityDescription, 31 | ): 32 | """Description of a La Marzocco update entities.""" 33 | 34 | component: FirmwareType 35 | 36 | 37 | ENTITIES: tuple[LaMarzoccoUpdateEntityDescription, ...] = ( 38 | LaMarzoccoUpdateEntityDescription( 39 | key="machine_firmware", 40 | translation_key="machine_firmware", 41 | device_class=UpdateDeviceClass.FIRMWARE, 42 | component=FirmwareType.MACHINE, 43 | entity_category=EntityCategory.DIAGNOSTIC, 44 | ), 45 | LaMarzoccoUpdateEntityDescription( 46 | key="gateway_firmware", 47 | translation_key="gateway_firmware", 48 | device_class=UpdateDeviceClass.FIRMWARE, 49 | component=FirmwareType.GATEWAY, 50 | entity_category=EntityCategory.DIAGNOSTIC, 51 | ), 52 | ) 53 | 54 | 55 | async def async_setup_entry( 56 | hass: HomeAssistant, 57 | entry: LaMarzoccoConfigEntry, 58 | async_add_entities: AddConfigEntryEntitiesCallback, 59 | ) -> None: 60 | """Create update entities.""" 61 | 62 | coordinator = entry.runtime_data.firmware_coordinator 63 | async_add_entities( 64 | LaMarzoccoUpdateEntity(coordinator, description) 65 | for description in ENTITIES 66 | if description.supported_fn(coordinator) 67 | ) 68 | 69 | 70 | class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): 71 | """Entity representing the update state.""" 72 | 73 | entity_description: LaMarzoccoUpdateEntityDescription 74 | _attr_supported_features = UpdateEntityFeature.INSTALL 75 | 76 | @property 77 | def installed_version(self) -> str | None: 78 | """Return the current firmware version.""" 79 | return self.coordinator.device.firmware[ 80 | self.entity_description.component 81 | ].current_version 82 | 83 | @property 84 | def latest_version(self) -> str: 85 | """Return the latest firmware version.""" 86 | return self.coordinator.device.firmware[ 87 | self.entity_description.component 88 | ].latest_version 89 | 90 | @property 91 | def release_url(self) -> str | None: 92 | """Return the release notes URL.""" 93 | return "https://support-iot.lamarzocco.com/firmware-updates/" 94 | 95 | async def async_install( 96 | self, version: str | None, backup: bool, **kwargs: Any 97 | ) -> None: 98 | """Install an update.""" 99 | self._attr_in_progress = True 100 | self.async_write_ha_state() 101 | try: 102 | success = await self.coordinator.device.update_firmware( 103 | self.entity_description.component 104 | ) 105 | except RequestNotSuccessful as exc: 106 | raise HomeAssistantError( 107 | translation_domain=DOMAIN, 108 | translation_key="update_failed", 109 | translation_placeholders={ 110 | "key": self.entity_description.key, 111 | }, 112 | ) from exc 113 | if not success: 114 | raise HomeAssistantError( 115 | translation_domain=DOMAIN, 116 | translation_key="update_failed", 117 | translation_placeholders={ 118 | "key": self.entity_description.key, 119 | }, 120 | ) 121 | self._attr_in_progress = False 122 | await self.coordinator.async_request_refresh() 123 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary Sensor platform for La Marzocco espresso machines.""" 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | 6 | from pylamarzocco.const import MachineModel 7 | from pylamarzocco.models import LaMarzoccoMachineConfig 8 | 9 | from homeassistant.components.binary_sensor import ( 10 | BinarySensorDeviceClass, 11 | BinarySensorEntity, 12 | BinarySensorEntityDescription, 13 | ) 14 | from homeassistant.const import EntityCategory 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 17 | 18 | from .coordinator import LaMarzoccoConfigEntry 19 | from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity 20 | 21 | # Coordinator is used to centralize the data updates 22 | PARALLEL_UPDATES = 0 23 | 24 | 25 | @dataclass(frozen=True, kw_only=True) 26 | class LaMarzoccoBinarySensorEntityDescription( 27 | LaMarzoccoEntityDescription, 28 | BinarySensorEntityDescription, 29 | ): 30 | """Description of a La Marzocco binary sensor.""" 31 | 32 | is_on_fn: Callable[[LaMarzoccoMachineConfig], bool | None] 33 | 34 | 35 | ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( 36 | LaMarzoccoBinarySensorEntityDescription( 37 | key="water_tank", 38 | translation_key="water_tank", 39 | device_class=BinarySensorDeviceClass.PROBLEM, 40 | is_on_fn=lambda config: not config.water_contact, 41 | entity_category=EntityCategory.DIAGNOSTIC, 42 | supported_fn=lambda coordinator: coordinator.local_connection_configured, 43 | ), 44 | LaMarzoccoBinarySensorEntityDescription( 45 | key="brew_active", 46 | translation_key="brew_active", 47 | device_class=BinarySensorDeviceClass.RUNNING, 48 | is_on_fn=lambda config: config.brew_active, 49 | available_fn=lambda device: device.websocket_connected, 50 | entity_category=EntityCategory.DIAGNOSTIC, 51 | ), 52 | LaMarzoccoBinarySensorEntityDescription( 53 | key="backflush_enabled", 54 | translation_key="backflush_enabled", 55 | device_class=BinarySensorDeviceClass.RUNNING, 56 | is_on_fn=lambda config: config.backflush_enabled, 57 | entity_category=EntityCategory.DIAGNOSTIC, 58 | ), 59 | ) 60 | 61 | SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( 62 | LaMarzoccoBinarySensorEntityDescription( 63 | key="connected", 64 | device_class=BinarySensorDeviceClass.CONNECTIVITY, 65 | is_on_fn=lambda config: config.scale.connected if config.scale else None, 66 | entity_category=EntityCategory.DIAGNOSTIC, 67 | ), 68 | ) 69 | 70 | 71 | async def async_setup_entry( 72 | hass: HomeAssistant, 73 | entry: LaMarzoccoConfigEntry, 74 | async_add_entities: AddConfigEntryEntitiesCallback, 75 | ) -> None: 76 | """Set up binary sensor entities.""" 77 | coordinator = entry.runtime_data.config_coordinator 78 | 79 | entities = [ 80 | LaMarzoccoBinarySensorEntity(coordinator, description) 81 | for description in ENTITIES 82 | if description.supported_fn(coordinator) 83 | ] 84 | 85 | if ( 86 | coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) 87 | and coordinator.device.config.scale 88 | ): 89 | entities.extend( 90 | LaMarzoccoScaleBinarySensorEntity(coordinator, description) 91 | for description in SCALE_ENTITIES 92 | ) 93 | 94 | def _async_add_new_scale() -> None: 95 | async_add_entities( 96 | LaMarzoccoScaleBinarySensorEntity(coordinator, description) 97 | for description in SCALE_ENTITIES 98 | ) 99 | 100 | coordinator.new_device_callback.append(_async_add_new_scale) 101 | 102 | async_add_entities(entities) 103 | 104 | 105 | class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): 106 | """Binary Sensor representing espresso machine water reservoir status.""" 107 | 108 | entity_description: LaMarzoccoBinarySensorEntityDescription 109 | 110 | @property 111 | def is_on(self) -> bool | None: 112 | """Return true if the binary sensor is on.""" 113 | return self.entity_description.is_on_fn(self.coordinator.device.config) 114 | 115 | 116 | class LaMarzoccoScaleBinarySensorEntity( 117 | LaMarzoccoBinarySensorEntity, LaMarzoccScaleEntity 118 | ): 119 | """Binary sensor for La Marzocco scales.""" 120 | 121 | entity_description: LaMarzoccoBinarySensorEntityDescription 122 | -------------------------------------------------------------------------------- /tests/test_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for La Marzocco sensors.""" 2 | 3 | from datetime import timedelta 4 | from unittest.mock import MagicMock, patch 5 | 6 | from freezegun.api import FrozenDateTimeFactory 7 | from pylamarzocco.const import MachineModel 8 | from pylamarzocco.models import LaMarzoccoScale 9 | import pytest 10 | from syrupy import SnapshotAssertion 11 | 12 | from homeassistant.const import STATE_UNAVAILABLE, Platform 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.helpers import entity_registry as er 15 | 16 | from . import async_init_integration 17 | 18 | from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform 19 | 20 | 21 | @pytest.mark.usefixtures("entity_registry_enabled_by_default") 22 | async def test_sensors( 23 | hass: HomeAssistant, 24 | mock_lamarzocco: MagicMock, 25 | entity_registry: er.EntityRegistry, 26 | mock_config_entry: MockConfigEntry, 27 | snapshot: SnapshotAssertion, 28 | ) -> None: 29 | """Test the La Marzocco sensors.""" 30 | 31 | with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]): 32 | await async_init_integration(hass, mock_config_entry) 33 | await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) 34 | 35 | 36 | async def test_shot_timer_not_exists( 37 | hass: HomeAssistant, 38 | mock_lamarzocco: MagicMock, 39 | mock_config_entry_no_local_connection: MockConfigEntry, 40 | ) -> None: 41 | """Test the La Marzocco shot timer doesn't exist if host not set.""" 42 | 43 | await async_init_integration(hass, mock_config_entry_no_local_connection) 44 | state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") 45 | assert state is None 46 | 47 | 48 | async def test_shot_timer_unavailable( 49 | hass: HomeAssistant, 50 | mock_lamarzocco: MagicMock, 51 | mock_config_entry: MockConfigEntry, 52 | ) -> None: 53 | """Test the La Marzocco brew_active becomes unavailable.""" 54 | 55 | mock_lamarzocco.websocket_connected = False 56 | await async_init_integration(hass, mock_config_entry) 57 | state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer") 58 | assert state 59 | assert state.state == STATE_UNAVAILABLE 60 | 61 | 62 | @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) 63 | async def test_no_steam_linea_mini( 64 | hass: HomeAssistant, 65 | mock_lamarzocco: MagicMock, 66 | mock_config_entry: MockConfigEntry, 67 | ) -> None: 68 | """Ensure Linea Mini has no steam temp.""" 69 | await async_init_integration(hass, mock_config_entry) 70 | 71 | serial_number = mock_lamarzocco.serial_number 72 | state = hass.states.get(f"sensor.{serial_number}_current_temp_steam") 73 | assert state is None 74 | 75 | 76 | @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) 77 | async def test_scale_battery( 78 | hass: HomeAssistant, 79 | mock_lamarzocco: MagicMock, 80 | mock_config_entry: MockConfigEntry, 81 | entity_registry: er.EntityRegistry, 82 | snapshot: SnapshotAssertion, 83 | ) -> None: 84 | """Test the scale battery sensor.""" 85 | await async_init_integration(hass, mock_config_entry) 86 | 87 | state = hass.states.get("sensor.lmz_123a45_battery") 88 | assert state 89 | assert state == snapshot 90 | 91 | entry = entity_registry.async_get(state.entity_id) 92 | assert entry 93 | assert entry.device_id 94 | assert entry == snapshot 95 | 96 | 97 | @pytest.mark.parametrize( 98 | "device_fixture", 99 | [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], 100 | ) 101 | async def test_other_models_no_scale_battery( 102 | hass: HomeAssistant, 103 | mock_lamarzocco: MagicMock, 104 | mock_config_entry: MockConfigEntry, 105 | snapshot: SnapshotAssertion, 106 | ) -> None: 107 | """Ensure the other models don't have a battery sensor.""" 108 | await async_init_integration(hass, mock_config_entry) 109 | 110 | state = hass.states.get("sensor.lmz_123a45_battery") 111 | assert state is None 112 | 113 | 114 | @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) 115 | async def test_battery_on_new_scale_added( 116 | hass: HomeAssistant, 117 | mock_lamarzocco: MagicMock, 118 | mock_config_entry: MockConfigEntry, 119 | freezer: FrozenDateTimeFactory, 120 | ) -> None: 121 | """Ensure the battery sensor for a new scale is added automatically.""" 122 | 123 | mock_lamarzocco.config.scale = None 124 | await async_init_integration(hass, mock_config_entry) 125 | 126 | state = hass.states.get("sensor.lmz_123a45_battery") 127 | assert state is None 128 | 129 | mock_lamarzocco.config.scale = LaMarzoccoScale( 130 | connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 131 | ) 132 | 133 | freezer.tick(timedelta(minutes=10)) 134 | async_fire_time_changed(hass) 135 | await hass.async_block_till_done() 136 | 137 | state = hass.states.get("sensor.scale_123a45_battery") 138 | assert state 139 | -------------------------------------------------------------------------------- /tests/fixtures/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1", 3 | "preinfusionModesAvailable": ["ByDoseType"], 4 | "machineCapabilities": [ 5 | { 6 | "family": "GS3AV", 7 | "groupsNumber": 1, 8 | "coffeeBoilersNumber": 1, 9 | "hasCupWarmer": false, 10 | "steamBoilersNumber": 1, 11 | "teaDosesNumber": 1, 12 | "machineModes": ["BrewingMode", "StandBy"], 13 | "schedulingType": "weeklyScheduling" 14 | } 15 | ], 16 | "machine_sn": "Sn01239157", 17 | "machine_hw": "2", 18 | "isPlumbedIn": true, 19 | "isBackFlushEnabled": false, 20 | "standByTime": 0, 21 | "smartStandBy": { 22 | "enabled": true, 23 | "minutes": 10, 24 | "mode": "LastBrewing" 25 | }, 26 | "tankStatus": true, 27 | "groupCapabilities": [ 28 | { 29 | "capabilities": { 30 | "groupType": "AV_Group", 31 | "groupNumber": "Group1", 32 | "boilerId": "CoffeeBoiler1", 33 | "hasScale": false, 34 | "hasFlowmeter": true, 35 | "numberOfDoses": 4 36 | }, 37 | "doses": [ 38 | { 39 | "groupNumber": "Group1", 40 | "doseIndex": "DoseA", 41 | "doseType": "PulsesType", 42 | "stopTarget": 135 43 | }, 44 | { 45 | "groupNumber": "Group1", 46 | "doseIndex": "DoseB", 47 | "doseType": "PulsesType", 48 | "stopTarget": 97 49 | }, 50 | { 51 | "groupNumber": "Group1", 52 | "doseIndex": "DoseC", 53 | "doseType": "PulsesType", 54 | "stopTarget": 108 55 | }, 56 | { 57 | "groupNumber": "Group1", 58 | "doseIndex": "DoseD", 59 | "doseType": "PulsesType", 60 | "stopTarget": 121 61 | } 62 | ], 63 | "doseMode": { 64 | "groupNumber": "Group1", 65 | "brewingType": "PulsesType" 66 | } 67 | } 68 | ], 69 | "machineMode": "BrewingMode", 70 | "teaDoses": { 71 | "DoseA": { 72 | "doseIndex": "DoseA", 73 | "stopTarget": 8 74 | } 75 | }, 76 | "boilers": [ 77 | { 78 | "id": "SteamBoiler", 79 | "isEnabled": true, 80 | "target": 123.90000152587891, 81 | "current": 123.80000305175781 82 | }, 83 | { 84 | "id": "CoffeeBoiler1", 85 | "isEnabled": true, 86 | "target": 95, 87 | "current": 96.5 88 | } 89 | ], 90 | "boilerTargetTemperature": { 91 | "SteamBoiler": 123.90000152587891, 92 | "CoffeeBoiler1": 95 93 | }, 94 | "preinfusionMode": { 95 | "Group1": { 96 | "groupNumber": "Group1", 97 | "preinfusionStyle": "PreinfusionByDoseType" 98 | } 99 | }, 100 | "preinfusionSettings": { 101 | "mode": "TypeB", 102 | "Group1": [ 103 | { 104 | "mode": "TypeA", 105 | "groupNumber": "Group1", 106 | "doseType": "DoseA", 107 | "preWetTime": 0.5, 108 | "preWetHoldTime": 1 109 | }, 110 | { 111 | "mode": "TypeB", 112 | "groupNumber": "Group1", 113 | "doseType": "DoseA", 114 | "preWetTime": 0, 115 | "preWetHoldTime": 4 116 | }, 117 | { 118 | "mode": "TypeA", 119 | "groupNumber": "Group1", 120 | "doseType": "DoseB", 121 | "preWetTime": 0.5, 122 | "preWetHoldTime": 1 123 | }, 124 | { 125 | "mode": "TypeB", 126 | "groupNumber": "Group1", 127 | "doseType": "DoseB", 128 | "preWetTime": 0, 129 | "preWetHoldTime": 4 130 | }, 131 | { 132 | "mode": "TypeA", 133 | "groupNumber": "Group1", 134 | "doseType": "DoseC", 135 | "preWetTime": 3.3, 136 | "preWetHoldTime": 3.3 137 | }, 138 | { 139 | "mode": "TypeB", 140 | "groupNumber": "Group1", 141 | "doseType": "DoseC", 142 | "preWetTime": 0, 143 | "preWetHoldTime": 4 144 | }, 145 | { 146 | "mode": "TypeA", 147 | "groupNumber": "Group1", 148 | "doseType": "DoseD", 149 | "preWetTime": 2, 150 | "preWetHoldTime": 2 151 | }, 152 | { 153 | "mode": "TypeB", 154 | "groupNumber": "Group1", 155 | "doseType": "DoseD", 156 | "preWetTime": 0, 157 | "preWetHoldTime": 4 158 | } 159 | ] 160 | }, 161 | "wakeUpSleepEntries": [ 162 | { 163 | "days": [ 164 | "monday", 165 | "tuesday", 166 | "wednesday", 167 | "thursday", 168 | "friday", 169 | "saturday", 170 | "sunday" 171 | ], 172 | "enabled": true, 173 | "id": "Os2OswX", 174 | "steam": true, 175 | "timeOff": "24:0", 176 | "timeOn": "22:0" 177 | }, 178 | { 179 | "days": ["sunday"], 180 | "enabled": true, 181 | "id": "aXFz5bJ", 182 | "steam": true, 183 | "timeOff": "7:30", 184 | "timeOn": "7:0" 185 | } 186 | ], 187 | "clock": "1901-07-08T10:29:00", 188 | "firmwareVersions": [ 189 | { 190 | "name": "machine_firmware", 191 | "fw_version": "1.40" 192 | }, 193 | { 194 | "name": "gateway_firmware", 195 | "fw_version": "v3.1-rc4" 196 | } 197 | ] 198 | } 199 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/calendar.py: -------------------------------------------------------------------------------- 1 | """Calendar platform for La Marzocco espresso machines.""" 2 | 3 | from collections.abc import Iterator 4 | from datetime import datetime, timedelta 5 | 6 | from pylamarzocco.models import LaMarzoccoWakeUpSleepEntry 7 | 8 | from homeassistant.components.calendar import CalendarEntity, CalendarEvent 9 | from homeassistant.core import HomeAssistant 10 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 11 | from homeassistant.util import dt as dt_util 12 | 13 | from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator 14 | from .entity import LaMarzoccoBaseEntity 15 | 16 | # Coordinator is used to centralize the data updates 17 | PARALLEL_UPDATES = 0 18 | 19 | CALENDAR_KEY = "auto_on_off_schedule" 20 | 21 | DAY_OF_WEEK = [ 22 | "monday", 23 | "tuesday", 24 | "wednesday", 25 | "thursday", 26 | "friday", 27 | "saturday", 28 | "sunday", 29 | ] 30 | 31 | 32 | async def async_setup_entry( 33 | hass: HomeAssistant, 34 | entry: LaMarzoccoConfigEntry, 35 | async_add_entities: AddConfigEntryEntitiesCallback, 36 | ) -> None: 37 | """Set up switch entities and services.""" 38 | 39 | coordinator = entry.runtime_data.config_coordinator 40 | async_add_entities( 41 | LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, wake_up_sleep_entry) 42 | for wake_up_sleep_entry in coordinator.device.config.wake_up_sleep_entries.values() 43 | ) 44 | 45 | 46 | class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): 47 | """Class representing a La Marzocco calendar.""" 48 | 49 | _attr_translation_key = CALENDAR_KEY 50 | 51 | def __init__( 52 | self, 53 | coordinator: LaMarzoccoUpdateCoordinator, 54 | key: str, 55 | wake_up_sleep_entry: LaMarzoccoWakeUpSleepEntry, 56 | ) -> None: 57 | """Set up calendar.""" 58 | super().__init__(coordinator, f"{key}_{wake_up_sleep_entry.entry_id}") 59 | self.wake_up_sleep_entry = wake_up_sleep_entry 60 | self._attr_translation_placeholders = {"id": wake_up_sleep_entry.entry_id} 61 | 62 | @property 63 | def event(self) -> CalendarEvent | None: 64 | """Return the next upcoming event.""" 65 | now = dt_util.now() 66 | 67 | events = self._get_events( 68 | start_date=now, 69 | end_date=now + timedelta(days=7), # only need to check a week ahead 70 | ) 71 | return next(iter(events), None) 72 | 73 | async def async_get_events( 74 | self, 75 | hass: HomeAssistant, 76 | start_date: datetime, 77 | end_date: datetime, 78 | ) -> list[CalendarEvent]: 79 | """Return calendar events within a datetime range.""" 80 | 81 | return self._get_events( 82 | start_date=start_date, 83 | end_date=end_date, 84 | ) 85 | 86 | def _get_events( 87 | self, 88 | start_date: datetime, 89 | end_date: datetime, 90 | ) -> list[CalendarEvent]: 91 | """Get calendar events within a datetime range.""" 92 | 93 | events: list[CalendarEvent] = [] 94 | for date in self._get_date_range(start_date, end_date): 95 | if scheduled := self._async_get_calendar_event(date): 96 | if scheduled.end < start_date: 97 | continue 98 | if scheduled.start > end_date: 99 | continue 100 | events.append(scheduled) 101 | return events 102 | 103 | def _get_date_range( 104 | self, start_date: datetime, end_date: datetime 105 | ) -> Iterator[datetime]: 106 | current_date = start_date 107 | while current_date.date() < end_date.date(): 108 | yield current_date 109 | current_date += timedelta(days=1) 110 | yield end_date 111 | 112 | def _async_get_calendar_event(self, date: datetime) -> CalendarEvent | None: 113 | """Return calendar event for a given weekday.""" 114 | 115 | # check first if auto/on off is turned on in general 116 | if not self.wake_up_sleep_entry.enabled: 117 | return None 118 | 119 | # parse the schedule for the day 120 | 121 | if DAY_OF_WEEK[date.weekday()] not in self.wake_up_sleep_entry.days: 122 | return None 123 | 124 | hour_on, minute_on = self.wake_up_sleep_entry.time_on.split(":") 125 | hour_off, minute_off = self.wake_up_sleep_entry.time_off.split(":") 126 | 127 | # if off time is 24:00, then it means the off time is the next day 128 | # only for legacy schedules 129 | day_offset = 0 130 | if hour_off == "24": 131 | day_offset = 1 132 | hour_off = "0" 133 | 134 | end_date = date.replace( 135 | hour=int(hour_off), 136 | minute=int(minute_off), 137 | ) 138 | end_date += timedelta(days=day_offset) 139 | 140 | return CalendarEvent( 141 | start=date.replace( 142 | hour=int(hour_on), 143 | minute=int(minute_on), 144 | ), 145 | end=end_date, 146 | summary=f"Machine {self.coordinator.config_entry.title} on", 147 | description="Machine is scheduled to turn on at the start time and off at the end time", 148 | ) 149 | -------------------------------------------------------------------------------- /tests/test_calendar.py: -------------------------------------------------------------------------------- 1 | """Tests for La Marzocco calendar.""" 2 | 3 | from datetime import datetime, timedelta 4 | from unittest.mock import MagicMock 5 | 6 | from freezegun.api import FrozenDateTimeFactory 7 | import pytest 8 | from syrupy import SnapshotAssertion 9 | 10 | from homeassistant.components.calendar import ( 11 | DOMAIN as CALENDAR_DOMAIN, 12 | EVENT_END_DATETIME, 13 | EVENT_START_DATETIME, 14 | SERVICE_GET_EVENTS, 15 | ) 16 | from homeassistant.const import ATTR_ENTITY_ID 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.helpers import entity_registry as er 19 | from homeassistant.util import dt as dt_util 20 | 21 | from . import WAKE_UP_SLEEP_ENTRY_IDS, async_init_integration 22 | 23 | from tests.common import MockConfigEntry 24 | 25 | 26 | async def test_calendar_events( 27 | hass: HomeAssistant, 28 | mock_lamarzocco: MagicMock, 29 | mock_config_entry: MockConfigEntry, 30 | entity_registry: er.EntityRegistry, 31 | snapshot: SnapshotAssertion, 32 | freezer: FrozenDateTimeFactory, 33 | ) -> None: 34 | """Test the calendar.""" 35 | 36 | test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.get_default_time_zone()) 37 | freezer.move_to(test_time) 38 | 39 | await async_init_integration(hass, mock_config_entry) 40 | 41 | serial_number = mock_lamarzocco.serial_number 42 | 43 | for identifier in WAKE_UP_SLEEP_ENTRY_IDS: 44 | identifier = identifier.lower() 45 | state = hass.states.get( 46 | f"calendar.{serial_number}_auto_on_off_schedule_{identifier}" 47 | ) 48 | assert state 49 | assert state == snapshot( 50 | name=f"state.{serial_number}_auto_on_off_schedule_{identifier}" 51 | ) 52 | 53 | entry = entity_registry.async_get(state.entity_id) 54 | assert entry 55 | assert entry == snapshot( 56 | name=f"entry.{serial_number}_auto_on_off_schedule_{identifier}" 57 | ) 58 | 59 | events = await hass.services.async_call( 60 | CALENDAR_DOMAIN, 61 | SERVICE_GET_EVENTS, 62 | { 63 | ATTR_ENTITY_ID: f"calendar.{serial_number}_auto_on_off_schedule_{identifier}", 64 | EVENT_START_DATETIME: test_time, 65 | EVENT_END_DATETIME: test_time + timedelta(days=23), 66 | }, 67 | blocking=True, 68 | return_response=True, 69 | ) 70 | 71 | assert events == snapshot( 72 | name=f"events.{serial_number}_auto_on_off_schedule_{identifier}" 73 | ) 74 | 75 | 76 | @pytest.mark.parametrize( 77 | ( 78 | "start_date", 79 | "end_date", 80 | ), 81 | [ 82 | (datetime(2024, 2, 11, 6, 0), datetime(2024, 2, 18, 6, 0)), 83 | (datetime(2024, 2, 11, 7, 15), datetime(2024, 2, 18, 6, 0)), 84 | (datetime(2024, 2, 11, 9, 0), datetime(2024, 2, 18, 7, 15)), 85 | (datetime(2024, 2, 11, 9, 0), datetime(2024, 2, 18, 8, 0)), 86 | (datetime(2024, 2, 11, 9, 0), datetime(2024, 2, 18, 6, 0)), 87 | (datetime(2024, 2, 11, 6, 0), datetime(2024, 2, 18, 8, 0)), 88 | ], 89 | ) 90 | async def test_calendar_edge_cases( 91 | hass: HomeAssistant, 92 | mock_lamarzocco: MagicMock, 93 | mock_config_entry: MockConfigEntry, 94 | snapshot: SnapshotAssertion, 95 | start_date: datetime, 96 | end_date: datetime, 97 | ) -> None: 98 | """Test edge cases.""" 99 | start_date = start_date.replace(tzinfo=dt_util.get_default_time_zone()) 100 | end_date = end_date.replace(tzinfo=dt_util.get_default_time_zone()) 101 | 102 | await async_init_integration(hass, mock_config_entry) 103 | 104 | events = await hass.services.async_call( 105 | CALENDAR_DOMAIN, 106 | SERVICE_GET_EVENTS, 107 | { 108 | ATTR_ENTITY_ID: f"calendar.{mock_lamarzocco.serial_number}_auto_on_off_schedule_{WAKE_UP_SLEEP_ENTRY_IDS[1].lower()}", 109 | EVENT_START_DATETIME: start_date, 110 | EVENT_END_DATETIME: end_date, 111 | }, 112 | blocking=True, 113 | return_response=True, 114 | ) 115 | 116 | assert events == snapshot 117 | 118 | 119 | async def test_no_calendar_events_global_disable( 120 | hass: HomeAssistant, 121 | mock_lamarzocco: MagicMock, 122 | mock_config_entry: MockConfigEntry, 123 | snapshot: SnapshotAssertion, 124 | freezer: FrozenDateTimeFactory, 125 | ) -> None: 126 | """Assert no events when global auto on/off is disabled.""" 127 | 128 | wake_up_sleep_entry_id = WAKE_UP_SLEEP_ENTRY_IDS[0] 129 | 130 | mock_lamarzocco.config.wake_up_sleep_entries[wake_up_sleep_entry_id].enabled = False 131 | test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.get_default_time_zone()) 132 | freezer.move_to(test_time) 133 | 134 | await async_init_integration(hass, mock_config_entry) 135 | 136 | serial_number = mock_lamarzocco.serial_number 137 | 138 | state = hass.states.get( 139 | f"calendar.{serial_number}_auto_on_off_schedule_{wake_up_sleep_entry_id.lower()}" 140 | ) 141 | assert state 142 | 143 | events = await hass.services.async_call( 144 | CALENDAR_DOMAIN, 145 | SERVICE_GET_EVENTS, 146 | { 147 | ATTR_ENTITY_ID: f"calendar.{serial_number}_auto_on_off_schedule_{wake_up_sleep_entry_id.lower()}", 148 | EVENT_START_DATETIME: test_time, 149 | EVENT_END_DATETIME: test_time + timedelta(days=23), 150 | }, 151 | blocking=True, 152 | return_response=True, 153 | ) 154 | assert events == snapshot 155 | -------------------------------------------------------------------------------- /tests/test_binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Tests for La Marzocco binary sensors.""" 2 | 3 | from datetime import timedelta 4 | from unittest.mock import MagicMock, patch 5 | 6 | from freezegun.api import FrozenDateTimeFactory 7 | from pylamarzocco.const import MachineModel 8 | from pylamarzocco.exceptions import RequestNotSuccessful 9 | from pylamarzocco.models import LaMarzoccoScale 10 | import pytest 11 | from syrupy import SnapshotAssertion 12 | 13 | from homeassistant.const import STATE_UNAVAILABLE, Platform 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.helpers import entity_registry as er 16 | 17 | from . import async_init_integration 18 | 19 | from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform 20 | 21 | 22 | async def test_binary_sensors( 23 | hass: HomeAssistant, 24 | mock_lamarzocco: MagicMock, 25 | mock_config_entry: MockConfigEntry, 26 | entity_registry: er.EntityRegistry, 27 | snapshot: SnapshotAssertion, 28 | ) -> None: 29 | """Test the La Marzocco binary sensors.""" 30 | 31 | with patch( 32 | "homeassistant.components.lamarzocco.PLATFORMS", [Platform.BINARY_SENSOR] 33 | ): 34 | await async_init_integration(hass, mock_config_entry) 35 | await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) 36 | 37 | 38 | async def test_brew_active_does_not_exists( 39 | hass: HomeAssistant, 40 | mock_lamarzocco: MagicMock, 41 | mock_config_entry_no_local_connection: MockConfigEntry, 42 | ) -> None: 43 | """Test the La Marzocco currently_making_coffee doesn't exist if host not set.""" 44 | 45 | await async_init_integration(hass, mock_config_entry_no_local_connection) 46 | state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_brewing_active") 47 | assert state is None 48 | 49 | 50 | async def test_brew_active_unavailable( 51 | hass: HomeAssistant, 52 | mock_lamarzocco: MagicMock, 53 | mock_config_entry: MockConfigEntry, 54 | ) -> None: 55 | """Test the La Marzocco currently_making_coffee becomes unavailable.""" 56 | 57 | mock_lamarzocco.websocket_connected = False 58 | await async_init_integration(hass, mock_config_entry) 59 | state = hass.states.get( 60 | f"binary_sensor.{mock_lamarzocco.serial_number}_brewing_active" 61 | ) 62 | assert state 63 | assert state.state == STATE_UNAVAILABLE 64 | 65 | 66 | async def test_sensor_going_unavailable( 67 | hass: HomeAssistant, 68 | mock_lamarzocco: MagicMock, 69 | mock_config_entry: MockConfigEntry, 70 | freezer: FrozenDateTimeFactory, 71 | ) -> None: 72 | """Test sensor is going unavailable after an unsuccessful update.""" 73 | brewing_active_sensor = ( 74 | f"binary_sensor.{mock_lamarzocco.serial_number}_brewing_active" 75 | ) 76 | await async_init_integration(hass, mock_config_entry) 77 | 78 | state = hass.states.get(brewing_active_sensor) 79 | assert state 80 | assert state.state != STATE_UNAVAILABLE 81 | 82 | mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("") 83 | freezer.tick(timedelta(minutes=10)) 84 | async_fire_time_changed(hass) 85 | await hass.async_block_till_done() 86 | 87 | state = hass.states.get(brewing_active_sensor) 88 | assert state 89 | assert state.state == STATE_UNAVAILABLE 90 | 91 | 92 | @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) 93 | async def test_scale_connectivity( 94 | hass: HomeAssistant, 95 | mock_lamarzocco: MagicMock, 96 | mock_config_entry: MockConfigEntry, 97 | entity_registry: er.EntityRegistry, 98 | snapshot: SnapshotAssertion, 99 | ) -> None: 100 | """Test the scale binary sensors.""" 101 | await async_init_integration(hass, mock_config_entry) 102 | 103 | state = hass.states.get("binary_sensor.lmz_123a45_connectivity") 104 | assert state 105 | assert state == snapshot 106 | 107 | entry = entity_registry.async_get(state.entity_id) 108 | assert entry 109 | assert entry.device_id 110 | assert entry == snapshot 111 | 112 | 113 | @pytest.mark.parametrize( 114 | "device_fixture", 115 | [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], 116 | ) 117 | async def test_other_models_no_scale_connectivity( 118 | hass: HomeAssistant, 119 | mock_lamarzocco: MagicMock, 120 | mock_config_entry: MockConfigEntry, 121 | snapshot: SnapshotAssertion, 122 | ) -> None: 123 | """Ensure the other models don't have a connectivity sensor.""" 124 | await async_init_integration(hass, mock_config_entry) 125 | 126 | state = hass.states.get("binary_sensor.lmz_123a45_connectivity") 127 | assert state is None 128 | 129 | 130 | @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) 131 | async def test_connectivity_on_new_scale_added( 132 | hass: HomeAssistant, 133 | mock_lamarzocco: MagicMock, 134 | mock_config_entry: MockConfigEntry, 135 | freezer: FrozenDateTimeFactory, 136 | ) -> None: 137 | """Ensure the connectivity binary sensor for a new scale is added automatically.""" 138 | 139 | mock_lamarzocco.config.scale = None 140 | await async_init_integration(hass, mock_config_entry) 141 | 142 | state = hass.states.get("binary_sensor.scale_123a45_connectivity") 143 | assert state is None 144 | 145 | mock_lamarzocco.config.scale = LaMarzoccoScale( 146 | connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 147 | ) 148 | 149 | freezer.tick(timedelta(minutes=10)) 150 | async_fire_time_changed(hass) 151 | await hass.async_block_till_done() 152 | 153 | state = hass.states.get("binary_sensor.scale_123a45_connectivity") 154 | assert state 155 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Lamarzocco session fixtures.""" 2 | 3 | from collections.abc import Generator 4 | import json 5 | from unittest.mock import AsyncMock, MagicMock, patch 6 | 7 | from bleak.backends.device import BLEDevice 8 | from pylamarzocco.const import FirmwareType, MachineModel, SteamLevel 9 | from pylamarzocco.devices.machine import LaMarzoccoMachine 10 | from pylamarzocco.models import LaMarzoccoDeviceInfo 11 | import pytest 12 | 13 | from homeassistant.components.lamarzocco.const import DOMAIN 14 | from homeassistant.const import ( 15 | CONF_ADDRESS, 16 | CONF_HOST, 17 | CONF_MODEL, 18 | CONF_NAME, 19 | CONF_TOKEN, 20 | ) 21 | from homeassistant.core import HomeAssistant 22 | 23 | from . import SERIAL_DICT, USER_INPUT, async_init_integration 24 | 25 | from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture 26 | 27 | 28 | @pytest.fixture 29 | def mock_setup_entry() -> Generator[AsyncMock]: 30 | """Override async_setup_entry.""" 31 | with patch( 32 | "homeassistant.components.lamarzocco.async_setup_entry", return_value=True 33 | ) as mock_setup_entry: 34 | yield mock_setup_entry 35 | 36 | 37 | @pytest.fixture 38 | def mock_config_entry( 39 | hass: HomeAssistant, mock_lamarzocco: MagicMock 40 | ) -> MockConfigEntry: 41 | """Return the default mocked config entry.""" 42 | return MockConfigEntry( 43 | title="My LaMarzocco", 44 | domain=DOMAIN, 45 | version=2, 46 | data=USER_INPUT 47 | | { 48 | CONF_MODEL: mock_lamarzocco.model, 49 | CONF_ADDRESS: "00:00:00:00:00:00", 50 | CONF_HOST: "host", 51 | CONF_TOKEN: "token", 52 | CONF_NAME: "GS3", 53 | }, 54 | unique_id=mock_lamarzocco.serial_number, 55 | ) 56 | 57 | 58 | @pytest.fixture 59 | def mock_config_entry_no_local_connection( 60 | hass: HomeAssistant, mock_lamarzocco: MagicMock 61 | ) -> MockConfigEntry: 62 | """Return the default mocked config entry.""" 63 | return MockConfigEntry( 64 | title="My LaMarzocco", 65 | domain=DOMAIN, 66 | version=2, 67 | data=USER_INPUT 68 | | { 69 | CONF_MODEL: mock_lamarzocco.model, 70 | CONF_TOKEN: "token", 71 | CONF_NAME: "GS3", 72 | }, 73 | unique_id=mock_lamarzocco.serial_number, 74 | ) 75 | 76 | 77 | @pytest.fixture 78 | async def init_integration( 79 | hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock 80 | ) -> MockConfigEntry: 81 | """Set up the La Marzocco integration for testing.""" 82 | await async_init_integration(hass, mock_config_entry) 83 | 84 | return mock_config_entry 85 | 86 | 87 | @pytest.fixture 88 | def device_fixture() -> MachineModel: 89 | """Return the device fixture for a specific device.""" 90 | return MachineModel.GS3_AV 91 | 92 | 93 | @pytest.fixture 94 | def mock_device_info(device_fixture: MachineModel) -> LaMarzoccoDeviceInfo: 95 | """Return a mocked La Marzocco device info.""" 96 | return LaMarzoccoDeviceInfo( 97 | model=device_fixture, 98 | serial_number=SERIAL_DICT[device_fixture], 99 | name="GS3", 100 | communication_key="token", 101 | ) 102 | 103 | 104 | @pytest.fixture 105 | def mock_cloud_client( 106 | mock_device_info: LaMarzoccoDeviceInfo, 107 | ) -> Generator[MagicMock]: 108 | """Return a mocked LM cloud client.""" 109 | with ( 110 | patch( 111 | "homeassistant.components.lamarzocco.config_flow.LaMarzoccoCloudClient", 112 | autospec=True, 113 | ) as cloud_client, 114 | patch( 115 | "homeassistant.components.lamarzocco.LaMarzoccoCloudClient", 116 | new=cloud_client, 117 | ), 118 | ): 119 | client = cloud_client.return_value 120 | client.get_customer_fleet.return_value = { 121 | mock_device_info.serial_number: mock_device_info 122 | } 123 | yield client 124 | 125 | 126 | @pytest.fixture 127 | def mock_lamarzocco(device_fixture: MachineModel) -> Generator[MagicMock]: 128 | """Return a mocked LM client.""" 129 | model = device_fixture 130 | 131 | serial_number = SERIAL_DICT[model] 132 | 133 | dummy_machine = LaMarzoccoMachine( 134 | model=model, 135 | serial_number=serial_number, 136 | name=serial_number, 137 | ) 138 | if device_fixture == MachineModel.LINEA_MINI: 139 | config = load_json_object_fixture("config_mini.json", DOMAIN) 140 | else: 141 | config = load_json_object_fixture("config.json", DOMAIN) 142 | statistics = json.loads(load_fixture("statistics.json", DOMAIN)) 143 | 144 | dummy_machine.parse_config(config) 145 | dummy_machine.parse_statistics(statistics) 146 | 147 | with ( 148 | patch( 149 | "homeassistant.components.lamarzocco.LaMarzoccoMachine", 150 | autospec=True, 151 | ) as lamarzocco_mock, 152 | ): 153 | lamarzocco = lamarzocco_mock.return_value 154 | 155 | lamarzocco.name = dummy_machine.name 156 | lamarzocco.model = dummy_machine.model 157 | lamarzocco.serial_number = dummy_machine.serial_number 158 | lamarzocco.full_model_name = dummy_machine.full_model_name 159 | lamarzocco.config = dummy_machine.config 160 | lamarzocco.statistics = dummy_machine.statistics 161 | lamarzocco.firmware = dummy_machine.firmware 162 | lamarzocco.steam_level = SteamLevel.LEVEL_1 163 | 164 | lamarzocco.firmware[FirmwareType.GATEWAY].latest_version = "v3.5-rc3" 165 | lamarzocco.firmware[FirmwareType.MACHINE].latest_version = "1.55" 166 | 167 | yield lamarzocco 168 | 169 | 170 | @pytest.fixture(autouse=True) 171 | def mock_bluetooth(enable_bluetooth: None) -> None: 172 | """Auto mock bluetooth.""" 173 | 174 | 175 | @pytest.fixture 176 | def mock_ble_device() -> BLEDevice: 177 | """Return a mock BLE device.""" 178 | return BLEDevice( 179 | "00:00:00:00:00:00", "GS_GS012345", details={"path": "path"}, rssi=50 180 | ) 181 | -------------------------------------------------------------------------------- /tests/snapshots/test_binary_sensor.ambr: -------------------------------------------------------------------------------- 1 | # serializer version: 1 2 | # name: test_binary_sensors[binary_sensor.gs012345_backflush_active-entry] 3 | EntityRegistryEntrySnapshot({ 4 | 'aliases': set({ 5 | }), 6 | 'area_id': None, 7 | 'capabilities': None, 8 | 'config_entry_id': , 9 | 'config_subentry_id': , 10 | 'device_class': None, 11 | 'device_id': , 12 | 'disabled_by': None, 13 | 'domain': 'binary_sensor', 14 | 'entity_category': , 15 | 'entity_id': 'binary_sensor.gs012345_backflush_active', 16 | 'has_entity_name': True, 17 | 'hidden_by': None, 18 | 'icon': None, 19 | 'id': , 20 | 'labels': set({ 21 | }), 22 | 'name': None, 23 | 'options': dict({ 24 | }), 25 | 'original_device_class': , 26 | 'original_icon': None, 27 | 'original_name': 'Backflush active', 28 | 'platform': 'lamarzocco', 29 | 'previous_unique_id': None, 30 | 'supported_features': 0, 31 | 'translation_key': 'backflush_enabled', 32 | 'unique_id': 'GS012345_backflush_enabled', 33 | 'unit_of_measurement': None, 34 | }) 35 | # --- 36 | # name: test_binary_sensors[binary_sensor.gs012345_backflush_active-state] 37 | StateSnapshot({ 38 | 'attributes': ReadOnlyDict({ 39 | 'device_class': 'running', 40 | 'friendly_name': 'GS012345 Backflush active', 41 | }), 42 | 'context': , 43 | 'entity_id': 'binary_sensor.gs012345_backflush_active', 44 | 'last_changed': , 45 | 'last_reported': , 46 | 'last_updated': , 47 | 'state': 'off', 48 | }) 49 | # --- 50 | # name: test_binary_sensors[binary_sensor.gs012345_brewing_active-entry] 51 | EntityRegistryEntrySnapshot({ 52 | 'aliases': set({ 53 | }), 54 | 'area_id': None, 55 | 'capabilities': None, 56 | 'config_entry_id': , 57 | 'config_subentry_id': , 58 | 'device_class': None, 59 | 'device_id': , 60 | 'disabled_by': None, 61 | 'domain': 'binary_sensor', 62 | 'entity_category': , 63 | 'entity_id': 'binary_sensor.gs012345_brewing_active', 64 | 'has_entity_name': True, 65 | 'hidden_by': None, 66 | 'icon': None, 67 | 'id': , 68 | 'labels': set({ 69 | }), 70 | 'name': None, 71 | 'options': dict({ 72 | }), 73 | 'original_device_class': , 74 | 'original_icon': None, 75 | 'original_name': 'Brewing active', 76 | 'platform': 'lamarzocco', 77 | 'previous_unique_id': None, 78 | 'supported_features': 0, 79 | 'translation_key': 'brew_active', 80 | 'unique_id': 'GS012345_brew_active', 81 | 'unit_of_measurement': None, 82 | }) 83 | # --- 84 | # name: test_binary_sensors[binary_sensor.gs012345_brewing_active-state] 85 | StateSnapshot({ 86 | 'attributes': ReadOnlyDict({ 87 | 'device_class': 'running', 88 | 'friendly_name': 'GS012345 Brewing active', 89 | }), 90 | 'context': , 91 | 'entity_id': 'binary_sensor.gs012345_brewing_active', 92 | 'last_changed': , 93 | 'last_reported': , 94 | 'last_updated': , 95 | 'state': 'off', 96 | }) 97 | # --- 98 | # name: test_binary_sensors[binary_sensor.gs012345_water_tank_empty-entry] 99 | EntityRegistryEntrySnapshot({ 100 | 'aliases': set({ 101 | }), 102 | 'area_id': None, 103 | 'capabilities': None, 104 | 'config_entry_id': , 105 | 'config_subentry_id': , 106 | 'device_class': None, 107 | 'device_id': , 108 | 'disabled_by': None, 109 | 'domain': 'binary_sensor', 110 | 'entity_category': , 111 | 'entity_id': 'binary_sensor.gs012345_water_tank_empty', 112 | 'has_entity_name': True, 113 | 'hidden_by': None, 114 | 'icon': None, 115 | 'id': , 116 | 'labels': set({ 117 | }), 118 | 'name': None, 119 | 'options': dict({ 120 | }), 121 | 'original_device_class': , 122 | 'original_icon': None, 123 | 'original_name': 'Water tank empty', 124 | 'platform': 'lamarzocco', 125 | 'previous_unique_id': None, 126 | 'supported_features': 0, 127 | 'translation_key': 'water_tank', 128 | 'unique_id': 'GS012345_water_tank', 129 | 'unit_of_measurement': None, 130 | }) 131 | # --- 132 | # name: test_binary_sensors[binary_sensor.gs012345_water_tank_empty-state] 133 | StateSnapshot({ 134 | 'attributes': ReadOnlyDict({ 135 | 'device_class': 'problem', 136 | 'friendly_name': 'GS012345 Water tank empty', 137 | }), 138 | 'context': , 139 | 'entity_id': 'binary_sensor.gs012345_water_tank_empty', 140 | 'last_changed': , 141 | 'last_reported': , 142 | 'last_updated': , 143 | 'state': 'off', 144 | }) 145 | # --- 146 | # name: test_scale_connectivity[Linea Mini] 147 | StateSnapshot({ 148 | 'attributes': ReadOnlyDict({ 149 | 'device_class': 'connectivity', 150 | 'friendly_name': 'LMZ-123A45 Connectivity', 151 | }), 152 | 'context': , 153 | 'entity_id': 'binary_sensor.lmz_123a45_connectivity', 154 | 'last_changed': , 155 | 'last_reported': , 156 | 'last_updated': , 157 | 'state': 'on', 158 | }) 159 | # --- 160 | # name: test_scale_connectivity[Linea Mini].1 161 | EntityRegistryEntrySnapshot({ 162 | 'aliases': set({ 163 | }), 164 | 'area_id': None, 165 | 'capabilities': None, 166 | 'config_entry_id': , 167 | 'config_subentry_id': , 168 | 'device_class': None, 169 | 'device_id': , 170 | 'disabled_by': None, 171 | 'domain': 'binary_sensor', 172 | 'entity_category': , 173 | 'entity_id': 'binary_sensor.lmz_123a45_connectivity', 174 | 'has_entity_name': True, 175 | 'hidden_by': None, 176 | 'icon': None, 177 | 'id': , 178 | 'labels': set({ 179 | }), 180 | 'name': None, 181 | 'options': dict({ 182 | }), 183 | 'original_device_class': , 184 | 'original_icon': None, 185 | 'original_name': 'Connectivity', 186 | 'platform': 'lamarzocco', 187 | 'previous_unique_id': None, 188 | 'supported_features': 0, 189 | 'translation_key': None, 190 | 'unique_id': 'LM012345_connected', 191 | 'unit_of_measurement': None, 192 | }) 193 | # --- 194 | -------------------------------------------------------------------------------- /tests/test_switch.py: -------------------------------------------------------------------------------- 1 | """Tests for La Marzocco switches.""" 2 | 3 | from typing import Any 4 | from unittest.mock import MagicMock, patch 5 | 6 | from pylamarzocco.exceptions import RequestNotSuccessful 7 | import pytest 8 | from syrupy import SnapshotAssertion 9 | 10 | from homeassistant.components.switch import ( 11 | DOMAIN as SWITCH_DOMAIN, 12 | SERVICE_TURN_OFF, 13 | SERVICE_TURN_ON, 14 | ) 15 | from homeassistant.const import ATTR_ENTITY_ID, Platform 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.exceptions import HomeAssistantError 18 | from homeassistant.helpers import entity_registry as er 19 | 20 | from . import WAKE_UP_SLEEP_ENTRY_IDS, async_init_integration 21 | 22 | from tests.common import MockConfigEntry, snapshot_platform 23 | 24 | 25 | async def test_switches( 26 | hass: HomeAssistant, 27 | mock_lamarzocco: MagicMock, 28 | mock_config_entry: MockConfigEntry, 29 | entity_registry: er.EntityRegistry, 30 | snapshot: SnapshotAssertion, 31 | ) -> None: 32 | """Test the La Marzocco switches.""" 33 | with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SWITCH]): 34 | await async_init_integration(hass, mock_config_entry) 35 | await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) 36 | 37 | 38 | @pytest.mark.parametrize( 39 | ( 40 | "entity_name", 41 | "method_name", 42 | "kwargs", 43 | ), 44 | [ 45 | ("", "set_power", {}), 46 | ("_steam_boiler", "set_steam", {}), 47 | ( 48 | "_smart_standby_enabled", 49 | "set_smart_standby", 50 | {"mode": "LastBrewing", "minutes": 10}, 51 | ), 52 | ], 53 | ) 54 | async def test_switches_actions( 55 | hass: HomeAssistant, 56 | mock_lamarzocco: MagicMock, 57 | mock_config_entry: MockConfigEntry, 58 | entity_name: str, 59 | method_name: str, 60 | kwargs: dict[str, Any], 61 | ) -> None: 62 | """Test the La Marzocco switches.""" 63 | await async_init_integration(hass, mock_config_entry) 64 | 65 | serial_number = mock_lamarzocco.serial_number 66 | 67 | control_fn = getattr(mock_lamarzocco, method_name) 68 | 69 | await hass.services.async_call( 70 | SWITCH_DOMAIN, 71 | SERVICE_TURN_OFF, 72 | { 73 | ATTR_ENTITY_ID: f"switch.{serial_number}{entity_name}", 74 | }, 75 | blocking=True, 76 | ) 77 | 78 | assert len(control_fn.mock_calls) == 1 79 | control_fn.assert_called_once_with(enabled=False, **kwargs) 80 | 81 | await hass.services.async_call( 82 | SWITCH_DOMAIN, 83 | SERVICE_TURN_ON, 84 | { 85 | ATTR_ENTITY_ID: f"switch.{serial_number}{entity_name}", 86 | }, 87 | blocking=True, 88 | ) 89 | 90 | assert len(control_fn.mock_calls) == 2 91 | control_fn.assert_called_with(enabled=True, **kwargs) 92 | 93 | 94 | async def test_auto_on_off_switches( 95 | hass: HomeAssistant, 96 | mock_lamarzocco: MagicMock, 97 | mock_config_entry: MockConfigEntry, 98 | entity_registry: er.EntityRegistry, 99 | snapshot: SnapshotAssertion, 100 | ) -> None: 101 | """Test the auto on off/switches.""" 102 | 103 | await async_init_integration(hass, mock_config_entry) 104 | 105 | serial_number = mock_lamarzocco.serial_number 106 | 107 | for wake_up_sleep_entry_id in WAKE_UP_SLEEP_ENTRY_IDS: 108 | state = hass.states.get( 109 | f"switch.{serial_number}_auto_on_off_{wake_up_sleep_entry_id}" 110 | ) 111 | assert state 112 | assert state == snapshot(name=f"state.auto_on_off_{wake_up_sleep_entry_id}") 113 | 114 | entry = entity_registry.async_get(state.entity_id) 115 | assert entry 116 | assert entry == snapshot(name=f"entry.auto_on_off_{wake_up_sleep_entry_id}") 117 | 118 | await hass.services.async_call( 119 | SWITCH_DOMAIN, 120 | SERVICE_TURN_OFF, 121 | { 122 | ATTR_ENTITY_ID: f"switch.{serial_number}_auto_on_off_{wake_up_sleep_entry_id}", 123 | }, 124 | blocking=True, 125 | ) 126 | 127 | wake_up_sleep_entry = mock_lamarzocco.config.wake_up_sleep_entries[ 128 | wake_up_sleep_entry_id 129 | ] 130 | wake_up_sleep_entry.enabled = False 131 | 132 | mock_lamarzocco.set_wake_up_sleep.assert_called_with(wake_up_sleep_entry) 133 | 134 | await hass.services.async_call( 135 | SWITCH_DOMAIN, 136 | SERVICE_TURN_ON, 137 | { 138 | ATTR_ENTITY_ID: f"switch.{serial_number}_auto_on_off_{wake_up_sleep_entry_id}", 139 | }, 140 | blocking=True, 141 | ) 142 | wake_up_sleep_entry.enabled = True 143 | mock_lamarzocco.set_wake_up_sleep.assert_called_with(wake_up_sleep_entry) 144 | 145 | 146 | async def test_switch_exceptions( 147 | hass: HomeAssistant, 148 | mock_lamarzocco: MagicMock, 149 | mock_config_entry: MockConfigEntry, 150 | ) -> None: 151 | """Test the La Marzocco switches.""" 152 | await async_init_integration(hass, mock_config_entry) 153 | 154 | serial_number = mock_lamarzocco.serial_number 155 | 156 | state = hass.states.get(f"switch.{serial_number}") 157 | assert state 158 | 159 | mock_lamarzocco.set_power.side_effect = RequestNotSuccessful("Boom") 160 | 161 | with pytest.raises(HomeAssistantError) as exc_info: 162 | await hass.services.async_call( 163 | SWITCH_DOMAIN, 164 | SERVICE_TURN_OFF, 165 | { 166 | ATTR_ENTITY_ID: f"switch.{serial_number}", 167 | }, 168 | blocking=True, 169 | ) 170 | assert exc_info.value.translation_key == "switch_off_error" 171 | 172 | with pytest.raises(HomeAssistantError) as exc_info: 173 | await hass.services.async_call( 174 | SWITCH_DOMAIN, 175 | SERVICE_TURN_ON, 176 | { 177 | ATTR_ENTITY_ID: f"switch.{serial_number}", 178 | }, 179 | blocking=True, 180 | ) 181 | assert exc_info.value.translation_key == "switch_on_error" 182 | 183 | state = hass.states.get(f"switch.{serial_number}_auto_on_off_os2oswx") 184 | assert state 185 | 186 | mock_lamarzocco.set_wake_up_sleep.side_effect = RequestNotSuccessful("Boom") 187 | with pytest.raises(HomeAssistantError) as exc_info: 188 | await hass.services.async_call( 189 | SWITCH_DOMAIN, 190 | SERVICE_TURN_OFF, 191 | { 192 | ATTR_ENTITY_ID: f"switch.{serial_number}_auto_on_off_os2oswx", 193 | }, 194 | blocking=True, 195 | ) 196 | assert exc_info.value.translation_key == "auto_on_off_error" 197 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/switch.py: -------------------------------------------------------------------------------- 1 | """Switch platform for La Marzocco espresso machines.""" 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | from typing import Any 6 | 7 | from pylamarzocco.const import BoilerType 8 | from pylamarzocco.devices.machine import LaMarzoccoMachine 9 | from pylamarzocco.exceptions import RequestNotSuccessful 10 | from pylamarzocco.models import LaMarzoccoMachineConfig 11 | 12 | from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription 13 | from homeassistant.const import EntityCategory 14 | from homeassistant.core import HomeAssistant 15 | from homeassistant.exceptions import HomeAssistantError 16 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 17 | 18 | from .const import DOMAIN 19 | from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator 20 | from .entity import LaMarzoccoBaseEntity, LaMarzoccoEntity, LaMarzoccoEntityDescription 21 | 22 | PARALLEL_UPDATES = 1 23 | 24 | 25 | @dataclass(frozen=True, kw_only=True) 26 | class LaMarzoccoSwitchEntityDescription( 27 | LaMarzoccoEntityDescription, 28 | SwitchEntityDescription, 29 | ): 30 | """Description of a La Marzocco Switch.""" 31 | 32 | control_fn: Callable[[LaMarzoccoMachine, bool], Coroutine[Any, Any, bool]] 33 | is_on_fn: Callable[[LaMarzoccoMachineConfig], bool] 34 | 35 | 36 | ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = ( 37 | LaMarzoccoSwitchEntityDescription( 38 | key="main", 39 | translation_key="main", 40 | name=None, 41 | control_fn=lambda machine, state: machine.set_power(state), 42 | is_on_fn=lambda config: config.turned_on, 43 | ), 44 | LaMarzoccoSwitchEntityDescription( 45 | key="steam_boiler_enable", 46 | translation_key="steam_boiler", 47 | control_fn=lambda machine, state: machine.set_steam(state), 48 | is_on_fn=lambda config: config.boilers[BoilerType.STEAM].enabled, 49 | ), 50 | LaMarzoccoSwitchEntityDescription( 51 | key="smart_standby_enabled", 52 | translation_key="smart_standby_enabled", 53 | entity_category=EntityCategory.CONFIG, 54 | control_fn=lambda machine, state: machine.set_smart_standby( 55 | enabled=state, 56 | mode=machine.config.smart_standby.mode, 57 | minutes=machine.config.smart_standby.minutes, 58 | ), 59 | is_on_fn=lambda config: config.smart_standby.enabled, 60 | ), 61 | ) 62 | 63 | 64 | async def async_setup_entry( 65 | hass: HomeAssistant, 66 | entry: LaMarzoccoConfigEntry, 67 | async_add_entities: AddConfigEntryEntitiesCallback, 68 | ) -> None: 69 | """Set up switch entities and services.""" 70 | 71 | coordinator = entry.runtime_data.config_coordinator 72 | 73 | entities: list[SwitchEntity] = [] 74 | entities.extend( 75 | LaMarzoccoSwitchEntity(coordinator, description) 76 | for description in ENTITIES 77 | if description.supported_fn(coordinator) 78 | ) 79 | 80 | entities.extend( 81 | LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry_id) 82 | for wake_up_sleep_entry_id in coordinator.device.config.wake_up_sleep_entries 83 | ) 84 | 85 | async_add_entities(entities) 86 | 87 | 88 | class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity): 89 | """Switches representing espresso machine power, prebrew, and auto on/off.""" 90 | 91 | entity_description: LaMarzoccoSwitchEntityDescription 92 | 93 | async def async_turn_on(self, **kwargs: Any) -> None: 94 | """Turn device on.""" 95 | try: 96 | await self.entity_description.control_fn(self.coordinator.device, True) 97 | except RequestNotSuccessful as exc: 98 | raise HomeAssistantError( 99 | translation_domain=DOMAIN, 100 | translation_key="switch_on_error", 101 | translation_placeholders={"key": self.entity_description.key}, 102 | ) from exc 103 | self.async_write_ha_state() 104 | 105 | async def async_turn_off(self, **kwargs: Any) -> None: 106 | """Turn device off.""" 107 | try: 108 | await self.entity_description.control_fn(self.coordinator.device, False) 109 | except RequestNotSuccessful as exc: 110 | raise HomeAssistantError( 111 | translation_domain=DOMAIN, 112 | translation_key="switch_off_error", 113 | translation_placeholders={"key": self.entity_description.key}, 114 | ) from exc 115 | self.async_write_ha_state() 116 | 117 | @property 118 | def is_on(self) -> bool: 119 | """Return true if device is on.""" 120 | return self.entity_description.is_on_fn(self.coordinator.device.config) 121 | 122 | 123 | class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity): 124 | """Switch representing espresso machine auto on/off.""" 125 | 126 | coordinator: LaMarzoccoUpdateCoordinator 127 | _attr_translation_key = "auto_on_off" 128 | 129 | def __init__( 130 | self, 131 | coordinator: LaMarzoccoUpdateCoordinator, 132 | identifier: str, 133 | ) -> None: 134 | """Initialize the switch.""" 135 | super().__init__(coordinator, f"auto_on_off_{identifier}") 136 | self._identifier = identifier 137 | self._attr_translation_placeholders = {"id": identifier} 138 | self.entity_category = EntityCategory.CONFIG 139 | 140 | async def _async_enable(self, state: bool) -> None: 141 | """Enable or disable the auto on/off schedule.""" 142 | wake_up_sleep_entry = self.coordinator.device.config.wake_up_sleep_entries[ 143 | self._identifier 144 | ] 145 | wake_up_sleep_entry.enabled = state 146 | try: 147 | await self.coordinator.device.set_wake_up_sleep(wake_up_sleep_entry) 148 | except RequestNotSuccessful as exc: 149 | raise HomeAssistantError( 150 | translation_domain=DOMAIN, 151 | translation_key="auto_on_off_error", 152 | translation_placeholders={"id": self._identifier, "state": str(state)}, 153 | ) from exc 154 | self.async_write_ha_state() 155 | 156 | async def async_turn_on(self, **kwargs: Any) -> None: 157 | """Turn switch on.""" 158 | await self._async_enable(True) 159 | 160 | async def async_turn_off(self, **kwargs: Any) -> None: 161 | """Turn switch off.""" 162 | await self._async_enable(False) 163 | 164 | @property 165 | def is_on(self) -> bool: 166 | """Return true if switch is on.""" 167 | return self.coordinator.device.config.wake_up_sleep_entries[ 168 | self._identifier 169 | ].enabled 170 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/coordinator.py: -------------------------------------------------------------------------------- 1 | """Coordinator for La Marzocco API.""" 2 | 3 | from __future__ import annotations 4 | 5 | from abc import abstractmethod 6 | from collections.abc import Callable 7 | from dataclasses import dataclass 8 | from datetime import timedelta 9 | import logging 10 | from typing import Any 11 | 12 | from pylamarzocco.clients.local import LaMarzoccoLocalClient 13 | from pylamarzocco.devices.machine import LaMarzoccoMachine 14 | from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful 15 | 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.const import EVENT_HOMEASSISTANT_STOP 18 | from homeassistant.core import HomeAssistant, callback 19 | from homeassistant.exceptions import ConfigEntryAuthFailed 20 | from homeassistant.helpers import device_registry as dr 21 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 22 | 23 | from .const import DOMAIN 24 | 25 | SCAN_INTERVAL = timedelta(seconds=30) 26 | FIRMWARE_UPDATE_INTERVAL = timedelta(hours=1) 27 | STATISTICS_UPDATE_INTERVAL = timedelta(minutes=5) 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | 31 | @dataclass 32 | class LaMarzoccoRuntimeData: 33 | """Runtime data for La Marzocco.""" 34 | 35 | config_coordinator: LaMarzoccoConfigUpdateCoordinator 36 | firmware_coordinator: LaMarzoccoFirmwareUpdateCoordinator 37 | statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator 38 | 39 | 40 | type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData] 41 | 42 | 43 | class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): 44 | """Base class for La Marzocco coordinators.""" 45 | 46 | _default_update_interval = SCAN_INTERVAL 47 | config_entry: LaMarzoccoConfigEntry 48 | 49 | def __init__( 50 | self, 51 | hass: HomeAssistant, 52 | entry: LaMarzoccoConfigEntry, 53 | device: LaMarzoccoMachine, 54 | local_client: LaMarzoccoLocalClient | None = None, 55 | ) -> None: 56 | """Initialize coordinator.""" 57 | super().__init__( 58 | hass, 59 | _LOGGER, 60 | config_entry=entry, 61 | name=DOMAIN, 62 | update_interval=self._default_update_interval, 63 | ) 64 | self.device = device 65 | self.local_connection_configured = local_client is not None 66 | self._local_client = local_client 67 | self.new_device_callback: list[Callable] = [] 68 | 69 | async def _async_update_data(self) -> None: 70 | """Do the data update.""" 71 | try: 72 | await self._internal_async_update_data() 73 | except AuthFail as ex: 74 | _LOGGER.debug("Authentication failed", exc_info=True) 75 | raise ConfigEntryAuthFailed( 76 | translation_domain=DOMAIN, translation_key="authentication_failed" 77 | ) from ex 78 | except RequestNotSuccessful as ex: 79 | _LOGGER.debug(ex, exc_info=True) 80 | raise UpdateFailed( 81 | translation_domain=DOMAIN, translation_key="api_error" 82 | ) from ex 83 | 84 | @abstractmethod 85 | async def _internal_async_update_data(self) -> None: 86 | """Actual data update logic.""" 87 | 88 | 89 | class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator): 90 | """Class to handle fetching data from the La Marzocco API centrally.""" 91 | 92 | _scale_address: str | None = None 93 | 94 | async def _async_connect_websocket(self) -> None: 95 | """Set up the coordinator.""" 96 | if self._local_client is not None and ( 97 | self._local_client.websocket is None or self._local_client.websocket.closed 98 | ): 99 | _LOGGER.debug("Init WebSocket in background task") 100 | 101 | self.config_entry.async_create_background_task( 102 | hass=self.hass, 103 | target=self.device.websocket_connect( 104 | notify_callback=lambda: self.async_set_updated_data(None) 105 | ), 106 | name="lm_websocket_task", 107 | ) 108 | 109 | async def websocket_close(_: Any | None = None) -> None: 110 | if ( 111 | self._local_client is not None 112 | and self._local_client.websocket is not None 113 | and not self._local_client.websocket.closed 114 | ): 115 | await self._local_client.websocket.close() 116 | 117 | self.config_entry.async_on_unload( 118 | self.hass.bus.async_listen_once( 119 | EVENT_HOMEASSISTANT_STOP, websocket_close 120 | ) 121 | ) 122 | self.config_entry.async_on_unload(websocket_close) 123 | 124 | async def _internal_async_update_data(self) -> None: 125 | """Fetch data from API endpoint.""" 126 | await self.device.get_config() 127 | _LOGGER.debug("Current status: %s", str(self.device.config)) 128 | await self._async_connect_websocket() 129 | self._async_add_remove_scale() 130 | 131 | @callback 132 | def _async_add_remove_scale(self) -> None: 133 | """Add or remove a scale when added or removed.""" 134 | if self.device.config.scale and not self._scale_address: 135 | self._scale_address = self.device.config.scale.address 136 | for scale_callback in self.new_device_callback: 137 | scale_callback() 138 | elif not self.device.config.scale and self._scale_address: 139 | device_registry = dr.async_get(self.hass) 140 | if device := device_registry.async_get_device( 141 | identifiers={(DOMAIN, self._scale_address)} 142 | ): 143 | device_registry.async_update_device( 144 | device_id=device.id, 145 | remove_config_entry_id=self.config_entry.entry_id, 146 | ) 147 | self._scale_address = None 148 | 149 | 150 | class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator): 151 | """Coordinator for La Marzocco firmware.""" 152 | 153 | _default_update_interval = FIRMWARE_UPDATE_INTERVAL 154 | 155 | async def _internal_async_update_data(self) -> None: 156 | """Fetch data from API endpoint.""" 157 | await self.device.get_firmware() 158 | _LOGGER.debug("Current firmware: %s", str(self.device.firmware)) 159 | 160 | 161 | class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator): 162 | """Coordinator for La Marzocco statistics.""" 163 | 164 | _default_update_interval = STATISTICS_UPDATE_INTERVAL 165 | 166 | async def _internal_async_update_data(self) -> None: 167 | """Fetch data from API endpoint.""" 168 | await self.device.get_statistics() 169 | _LOGGER.debug("Current statistics: %s", str(self.device.statistics)) 170 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/select.py: -------------------------------------------------------------------------------- 1 | """Select platform for La Marzocco espresso machines.""" 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | from typing import Any 6 | 7 | from pylamarzocco.const import ( 8 | MachineModel, 9 | PhysicalKey, 10 | PrebrewMode, 11 | SmartStandbyMode, 12 | SteamLevel, 13 | ) 14 | from pylamarzocco.devices.machine import LaMarzoccoMachine 15 | from pylamarzocco.exceptions import RequestNotSuccessful 16 | from pylamarzocco.models import LaMarzoccoMachineConfig 17 | 18 | from homeassistant.components.select import SelectEntity, SelectEntityDescription 19 | from homeassistant.const import EntityCategory 20 | from homeassistant.core import HomeAssistant 21 | from homeassistant.exceptions import HomeAssistantError 22 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 23 | 24 | from .const import DOMAIN 25 | from .coordinator import LaMarzoccoConfigEntry 26 | from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity 27 | 28 | PARALLEL_UPDATES = 1 29 | 30 | STEAM_LEVEL_HA_TO_LM = { 31 | "1": SteamLevel.LEVEL_1, 32 | "2": SteamLevel.LEVEL_2, 33 | "3": SteamLevel.LEVEL_3, 34 | } 35 | 36 | STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items()} 37 | 38 | PREBREW_MODE_HA_TO_LM = { 39 | "disabled": PrebrewMode.DISABLED, 40 | "prebrew": PrebrewMode.PREBREW, 41 | "prebrew_enabled": PrebrewMode.PREBREW_ENABLED, 42 | "preinfusion": PrebrewMode.PREINFUSION, 43 | } 44 | 45 | PREBREW_MODE_LM_TO_HA = {value: key for key, value in PREBREW_MODE_HA_TO_LM.items()} 46 | 47 | STANDBY_MODE_HA_TO_LM = { 48 | "power_on": SmartStandbyMode.POWER_ON, 49 | "last_brewing": SmartStandbyMode.LAST_BREWING, 50 | } 51 | 52 | STANDBY_MODE_LM_TO_HA = {value: key for key, value in STANDBY_MODE_HA_TO_LM.items()} 53 | 54 | 55 | @dataclass(frozen=True, kw_only=True) 56 | class LaMarzoccoSelectEntityDescription( 57 | LaMarzoccoEntityDescription, 58 | SelectEntityDescription, 59 | ): 60 | """Description of a La Marzocco select entity.""" 61 | 62 | current_option_fn: Callable[[LaMarzoccoMachineConfig], str | None] 63 | select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]] 64 | 65 | 66 | ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( 67 | LaMarzoccoSelectEntityDescription( 68 | key="steam_temp_select", 69 | translation_key="steam_temp_select", 70 | options=["1", "2", "3"], 71 | select_option_fn=lambda machine, option: machine.set_steam_level( 72 | STEAM_LEVEL_HA_TO_LM[option] 73 | ), 74 | current_option_fn=lambda config: STEAM_LEVEL_LM_TO_HA[config.steam_level], 75 | supported_fn=lambda coordinator: coordinator.device.model 76 | == MachineModel.LINEA_MICRA, 77 | ), 78 | LaMarzoccoSelectEntityDescription( 79 | key="prebrew_infusion_select", 80 | translation_key="prebrew_infusion_select", 81 | entity_category=EntityCategory.CONFIG, 82 | options=["disabled", "prebrew", "preinfusion"], 83 | select_option_fn=lambda machine, option: machine.set_prebrew_mode( 84 | PREBREW_MODE_HA_TO_LM[option] 85 | ), 86 | current_option_fn=lambda config: PREBREW_MODE_LM_TO_HA[config.prebrew_mode], 87 | supported_fn=lambda coordinator: coordinator.device.model 88 | in ( 89 | MachineModel.GS3_AV, 90 | MachineModel.LINEA_MICRA, 91 | MachineModel.LINEA_MINI, 92 | MachineModel.LINEA_MINI_R, 93 | ), 94 | ), 95 | LaMarzoccoSelectEntityDescription( 96 | key="smart_standby_mode", 97 | translation_key="smart_standby_mode", 98 | entity_category=EntityCategory.CONFIG, 99 | options=["power_on", "last_brewing"], 100 | select_option_fn=lambda machine, option: machine.set_smart_standby( 101 | enabled=machine.config.smart_standby.enabled, 102 | mode=STANDBY_MODE_HA_TO_LM[option], 103 | minutes=machine.config.smart_standby.minutes, 104 | ), 105 | current_option_fn=lambda config: STANDBY_MODE_LM_TO_HA[ 106 | config.smart_standby.mode 107 | ], 108 | ), 109 | ) 110 | 111 | SCALE_ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = ( 112 | LaMarzoccoSelectEntityDescription( 113 | key="active_bbw", 114 | translation_key="active_bbw", 115 | options=["a", "b"], 116 | select_option_fn=lambda machine, option: machine.set_active_bbw_recipe( 117 | PhysicalKey[option.upper()] 118 | ), 119 | current_option_fn=lambda config: ( 120 | config.bbw_settings.active_dose.name.lower() 121 | if config.bbw_settings 122 | else None 123 | ), 124 | ), 125 | ) 126 | 127 | 128 | async def async_setup_entry( 129 | hass: HomeAssistant, 130 | entry: LaMarzoccoConfigEntry, 131 | async_add_entities: AddConfigEntryEntitiesCallback, 132 | ) -> None: 133 | """Set up select entities.""" 134 | coordinator = entry.runtime_data.config_coordinator 135 | 136 | entities = [ 137 | LaMarzoccoSelectEntity(coordinator, description) 138 | for description in ENTITIES 139 | if description.supported_fn(coordinator) 140 | ] 141 | 142 | if ( 143 | coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) 144 | and coordinator.device.config.scale 145 | ): 146 | entities.extend( 147 | LaMarzoccoScaleSelectEntity(coordinator, description) 148 | for description in SCALE_ENTITIES 149 | ) 150 | 151 | def _async_add_new_scale() -> None: 152 | async_add_entities( 153 | LaMarzoccoScaleSelectEntity(coordinator, description) 154 | for description in SCALE_ENTITIES 155 | ) 156 | 157 | coordinator.new_device_callback.append(_async_add_new_scale) 158 | 159 | async_add_entities(entities) 160 | 161 | 162 | class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity): 163 | """La Marzocco select entity.""" 164 | 165 | entity_description: LaMarzoccoSelectEntityDescription 166 | 167 | @property 168 | def current_option(self) -> str | None: 169 | """Return the current selected option.""" 170 | return str( 171 | self.entity_description.current_option_fn(self.coordinator.device.config) 172 | ) 173 | 174 | async def async_select_option(self, option: str) -> None: 175 | """Change the selected option.""" 176 | if option != self.current_option: 177 | try: 178 | await self.entity_description.select_option_fn( 179 | self.coordinator.device, option 180 | ) 181 | except RequestNotSuccessful as exc: 182 | raise HomeAssistantError( 183 | translation_domain=DOMAIN, 184 | translation_key="select_option_error", 185 | translation_placeholders={ 186 | "key": self.entity_description.key, 187 | "option": option, 188 | }, 189 | ) from exc 190 | self.async_write_ha_state() 191 | 192 | 193 | class LaMarzoccoScaleSelectEntity(LaMarzoccoSelectEntity, LaMarzoccScaleEntity): 194 | """Select entity for La Marzocco scales.""" 195 | 196 | entity_description: LaMarzoccoSelectEntityDescription 197 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", 5 | "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", 6 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 7 | }, 8 | "error": { 9 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 10 | "machine_not_found": "Discovered machine not found in given account", 11 | "no_machines": "No machines found in account", 12 | "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" 13 | }, 14 | "step": { 15 | "user": { 16 | "data": { 17 | "username": "[%key:common::config_flow::data::username%]", 18 | "password": "[%key:common::config_flow::data::password%]" 19 | }, 20 | "data_description": { 21 | "username": "Your username from the La Marzocco app", 22 | "password": "Your password from the La Marzocco app" 23 | } 24 | }, 25 | "bluetooth_selection": { 26 | "description": "Select your device from available Bluetooth devices.", 27 | "data": { 28 | "mac": "[%key:common::config_flow::data::device%]" 29 | }, 30 | "data_description": { 31 | "mac": "Select the Bluetooth device that is your machine" 32 | } 33 | }, 34 | "machine_selection": { 35 | "description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.", 36 | "data": { 37 | "host": "[%key:common::config_flow::data::ip%]", 38 | "machine": "Machine" 39 | }, 40 | "data_description": { 41 | "host": "Local IP address of the machine", 42 | "machine": "Select the machine you want to integrate" 43 | } 44 | }, 45 | "reauth_confirm": { 46 | "description": "Re-authentication required. Please enter your password again.", 47 | "data": { 48 | "password": "[%key:common::config_flow::data::password%]" 49 | }, 50 | "data_description": { 51 | "password": "[%key:component::lamarzocco::config::step::user::data_description::password%]" 52 | } 53 | }, 54 | "reconfigure": { 55 | "data": { 56 | "username": "[%key:common::config_flow::data::username%]", 57 | "password": "[%key:common::config_flow::data::password%]" 58 | }, 59 | "data_description": { 60 | "username": "[%key:component::lamarzocco::config::step::user::data_description::username%]", 61 | "password": "[%key:component::lamarzocco::config::step::user::data_description::password%]" 62 | } 63 | } 64 | } 65 | }, 66 | "options": { 67 | "step": { 68 | "init": { 69 | "data": { 70 | "use_bluetooth": "Use Bluetooth" 71 | }, 72 | "data_description": { 73 | "use_bluetooth": "Should the integration try to use Bluetooth to control the machine?" 74 | } 75 | } 76 | } 77 | }, 78 | "entity": { 79 | "binary_sensor": { 80 | "backflush_enabled": { 81 | "name": "Backflush active" 82 | }, 83 | "brew_active": { 84 | "name": "Brewing active" 85 | }, 86 | "water_tank": { 87 | "name": "Water tank empty" 88 | } 89 | }, 90 | "button": { 91 | "start_backflush": { 92 | "name": "Start backflush" 93 | } 94 | }, 95 | "calendar": { 96 | "auto_on_off_schedule": { 97 | "name": "Auto on/off schedule ({id})" 98 | } 99 | }, 100 | "number": { 101 | "coffee_temp": { 102 | "name": "Coffee target temperature" 103 | }, 104 | "dose_key": { 105 | "name": "Dose Key {key}" 106 | }, 107 | "prebrew_on": { 108 | "name": "Prebrew on time" 109 | }, 110 | "prebrew_on_key": { 111 | "name": "Prebrew on time Key {key}" 112 | }, 113 | "prebrew_off": { 114 | "name": "Prebrew off time" 115 | }, 116 | "prebrew_off_key": { 117 | "name": "Prebrew off time Key {key}" 118 | }, 119 | "preinfusion_off": { 120 | "name": "Preinfusion time" 121 | }, 122 | "preinfusion_off_key": { 123 | "name": "Preinfusion time Key {key}" 124 | }, 125 | "scale_target_key": { 126 | "name": "Brew by weight target {key}" 127 | }, 128 | "smart_standby_time": { 129 | "name": "Smart standby time" 130 | }, 131 | "steam_temp": { 132 | "name": "Steam target temperature" 133 | }, 134 | "tea_water_duration": { 135 | "name": "Tea water duration" 136 | } 137 | }, 138 | "select": { 139 | "active_bbw": { 140 | "name": "Active brew by weight recipe", 141 | "state": { 142 | "a": "Recipe A", 143 | "b": "Recipe B" 144 | } 145 | }, 146 | "prebrew_infusion_select": { 147 | "name": "Prebrew/-infusion mode", 148 | "state": { 149 | "disabled": "[%key:common::state::disabled%]", 150 | "prebrew": "Prebrew", 151 | "prebrew_enabled": "Prebrew", 152 | "preinfusion": "Preinfusion" 153 | } 154 | }, 155 | "smart_standby_mode": { 156 | "name": "Smart standby mode", 157 | "state": { 158 | "last_brewing": "Last brewing", 159 | "power_on": "Power on" 160 | } 161 | }, 162 | "steam_temp_select": { 163 | "name": "Steam level", 164 | "state": { 165 | "1": "1", 166 | "2": "2", 167 | "3": "3" 168 | } 169 | } 170 | }, 171 | "sensor": { 172 | "current_temp_coffee": { 173 | "name": "Current coffee temperature" 174 | }, 175 | "current_temp_steam": { 176 | "name": "Current steam temperature" 177 | }, 178 | "drink_stats_coffee": { 179 | "name": "Total coffees made", 180 | "unit_of_measurement": "coffees" 181 | }, 182 | "drink_stats_coffee_key": { 183 | "name": "Coffees made Key {key}", 184 | "unit_of_measurement": "coffees" 185 | }, 186 | "drink_stats_flushing": { 187 | "name": "Total flushes made", 188 | "unit_of_measurement": "flushes" 189 | }, 190 | "shot_timer": { 191 | "name": "Shot timer" 192 | } 193 | }, 194 | "switch": { 195 | "auto_on_off": { 196 | "name": "Auto on/off ({id})" 197 | }, 198 | "smart_standby_enabled": { 199 | "name": "Smart standby enabled" 200 | }, 201 | "steam_boiler": { 202 | "name": "Steam boiler" 203 | } 204 | }, 205 | "update": { 206 | "machine_firmware": { 207 | "name": "Machine firmware" 208 | }, 209 | "gateway_firmware": { 210 | "name": "Gateway firmware" 211 | } 212 | } 213 | }, 214 | "issues": { 215 | "unsupported_gateway_firmware": { 216 | "title": "Unsupported gateway firmware", 217 | "description": "Gateway firmware {gateway_version} is no longer supported by this integration, please update." 218 | } 219 | }, 220 | "exceptions": { 221 | "api_error": { 222 | "message": "Error while communicating with the API" 223 | }, 224 | "authentication_failed": { 225 | "message": "Authentication failed" 226 | }, 227 | "auto_on_off_error": { 228 | "message": "Error while setting auto on/off to {state} for {id}" 229 | }, 230 | "button_error": { 231 | "message": "Error while executing button {key}" 232 | }, 233 | "number_exception": { 234 | "message": "Error while setting value {value} for number {key}" 235 | }, 236 | "number_exception_key": { 237 | "message": "Error while setting value {value} for number {key}, key {physical_key}" 238 | }, 239 | "select_option_error": { 240 | "message": "Error while setting select option {option} for {key}" 241 | }, 242 | "switch_on_error": { 243 | "message": "Error while turning on switch {key}" 244 | }, 245 | "switch_off_error": { 246 | "message": "Error while turning off switch {key}" 247 | }, 248 | "update_failed": { 249 | "message": "Error while updating {key}" 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/__init__.py: -------------------------------------------------------------------------------- 1 | """The La Marzocco integration.""" 2 | 3 | import logging 4 | 5 | from packaging import version 6 | from pylamarzocco.clients.bluetooth import LaMarzoccoBluetoothClient 7 | from pylamarzocco.clients.cloud import LaMarzoccoCloudClient 8 | from pylamarzocco.clients.local import LaMarzoccoLocalClient 9 | from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType 10 | from pylamarzocco.devices.machine import LaMarzoccoMachine 11 | from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful 12 | 13 | from homeassistant.components.bluetooth import async_discovered_service_info 14 | from homeassistant.const import ( 15 | CONF_HOST, 16 | CONF_MAC, 17 | CONF_MODEL, 18 | CONF_NAME, 19 | CONF_PASSWORD, 20 | CONF_TOKEN, 21 | CONF_USERNAME, 22 | Platform, 23 | ) 24 | from homeassistant.core import HomeAssistant 25 | from homeassistant.helpers import issue_registry as ir 26 | from homeassistant.helpers.aiohttp_client import async_create_clientsession 27 | 28 | from .const import CONF_USE_BLUETOOTH, DOMAIN 29 | from .coordinator import ( 30 | LaMarzoccoConfigEntry, 31 | LaMarzoccoConfigUpdateCoordinator, 32 | LaMarzoccoFirmwareUpdateCoordinator, 33 | LaMarzoccoRuntimeData, 34 | LaMarzoccoStatisticsUpdateCoordinator, 35 | ) 36 | 37 | PLATFORMS = [ 38 | Platform.BINARY_SENSOR, 39 | Platform.BUTTON, 40 | Platform.CALENDAR, 41 | Platform.NUMBER, 42 | Platform.SELECT, 43 | Platform.SENSOR, 44 | Platform.SWITCH, 45 | Platform.UPDATE, 46 | ] 47 | 48 | _LOGGER = logging.getLogger(__name__) 49 | 50 | 51 | async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -> bool: 52 | """Set up La Marzocco as config entry.""" 53 | 54 | assert entry.unique_id 55 | serial = entry.unique_id 56 | 57 | client = async_create_clientsession(hass) 58 | cloud_client = LaMarzoccoCloudClient( 59 | username=entry.data[CONF_USERNAME], 60 | password=entry.data[CONF_PASSWORD], 61 | client=client, 62 | ) 63 | 64 | # initialize the firmware update coordinator early to check the firmware version 65 | firmware_device = LaMarzoccoMachine( 66 | model=entry.data[CONF_MODEL], 67 | serial_number=entry.unique_id, 68 | name=entry.data[CONF_NAME], 69 | cloud_client=cloud_client, 70 | ) 71 | 72 | firmware_coordinator = LaMarzoccoFirmwareUpdateCoordinator( 73 | hass, entry, firmware_device 74 | ) 75 | await firmware_coordinator.async_config_entry_first_refresh() 76 | gateway_version = version.parse( 77 | firmware_device.firmware[FirmwareType.GATEWAY].current_version 78 | ) 79 | 80 | if gateway_version >= version.parse("v5.0.9"): 81 | # remove host from config entry, it is not supported anymore 82 | data = {k: v for k, v in entry.data.items() if k != CONF_HOST} 83 | hass.config_entries.async_update_entry( 84 | entry, 85 | data=data, 86 | ) 87 | 88 | elif gateway_version < version.parse("v3.4-rc5"): 89 | # incompatible gateway firmware, create an issue 90 | ir.async_create_issue( 91 | hass, 92 | DOMAIN, 93 | "unsupported_gateway_firmware", 94 | is_fixable=False, 95 | severity=ir.IssueSeverity.ERROR, 96 | translation_key="unsupported_gateway_firmware", 97 | translation_placeholders={"gateway_version": str(gateway_version)}, 98 | ) 99 | 100 | # initialize local API 101 | local_client: LaMarzoccoLocalClient | None = None 102 | if (host := entry.data.get(CONF_HOST)) is not None: 103 | _LOGGER.debug("Initializing local API") 104 | local_client = LaMarzoccoLocalClient( 105 | host=host, 106 | local_bearer=entry.data[CONF_TOKEN], 107 | client=client, 108 | ) 109 | 110 | # initialize Bluetooth 111 | bluetooth_client: LaMarzoccoBluetoothClient | None = None 112 | if entry.options.get(CONF_USE_BLUETOOTH, True): 113 | 114 | def bluetooth_configured() -> bool: 115 | return entry.data.get(CONF_MAC, "") and entry.data.get(CONF_NAME, "") 116 | 117 | if not bluetooth_configured(): 118 | for discovery_info in async_discovered_service_info(hass): 119 | if ( 120 | (name := discovery_info.name) 121 | and name.startswith(BT_MODEL_PREFIXES) 122 | and name.split("_")[1] == serial 123 | ): 124 | _LOGGER.debug("Found Bluetooth device, configuring with Bluetooth") 125 | # found a device, add MAC address to config entry 126 | hass.config_entries.async_update_entry( 127 | entry, 128 | data={ 129 | **entry.data, 130 | CONF_MAC: discovery_info.address, 131 | CONF_NAME: discovery_info.name, 132 | }, 133 | ) 134 | break 135 | 136 | if bluetooth_configured(): 137 | _LOGGER.debug("Initializing Bluetooth device") 138 | bluetooth_client = LaMarzoccoBluetoothClient( 139 | username=entry.data[CONF_USERNAME], 140 | serial_number=serial, 141 | token=entry.data[CONF_TOKEN], 142 | address_or_ble_device=entry.data[CONF_MAC], 143 | ) 144 | 145 | device = LaMarzoccoMachine( 146 | model=entry.data[CONF_MODEL], 147 | serial_number=entry.unique_id, 148 | name=entry.data[CONF_NAME], 149 | cloud_client=cloud_client, 150 | local_client=local_client, 151 | bluetooth_client=bluetooth_client, 152 | ) 153 | 154 | coordinators = LaMarzoccoRuntimeData( 155 | LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client), 156 | firmware_coordinator, 157 | LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device), 158 | ) 159 | 160 | # API does not like concurrent requests, so no asyncio.gather here 161 | await coordinators.config_coordinator.async_config_entry_first_refresh() 162 | await coordinators.statistics_coordinator.async_config_entry_first_refresh() 163 | 164 | entry.runtime_data = coordinators 165 | 166 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 167 | 168 | async def update_listener( 169 | hass: HomeAssistant, entry: LaMarzoccoConfigEntry 170 | ) -> None: 171 | await hass.config_entries.async_reload(entry.entry_id) 172 | 173 | entry.async_on_unload(entry.add_update_listener(update_listener)) 174 | 175 | return True 176 | 177 | 178 | async def async_unload_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -> bool: 179 | """Unload a config entry.""" 180 | return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 181 | 182 | 183 | async def async_migrate_entry( 184 | hass: HomeAssistant, entry: LaMarzoccoConfigEntry 185 | ) -> bool: 186 | """Migrate config entry.""" 187 | if entry.version > 2: 188 | # guard against downgrade from a future version 189 | return False 190 | 191 | if entry.version == 1: 192 | cloud_client = LaMarzoccoCloudClient( 193 | username=entry.data[CONF_USERNAME], 194 | password=entry.data[CONF_PASSWORD], 195 | ) 196 | try: 197 | fleet = await cloud_client.get_customer_fleet() 198 | except (AuthFail, RequestNotSuccessful) as exc: 199 | _LOGGER.error("Migration failed with error %s", exc) 200 | return False 201 | 202 | assert entry.unique_id is not None 203 | device = fleet[entry.unique_id] 204 | v2_data = { 205 | CONF_USERNAME: entry.data[CONF_USERNAME], 206 | CONF_PASSWORD: entry.data[CONF_PASSWORD], 207 | CONF_MODEL: device.model, 208 | CONF_NAME: device.name, 209 | CONF_TOKEN: device.communication_key, 210 | } 211 | 212 | if CONF_HOST in entry.data: 213 | v2_data[CONF_HOST] = entry.data[CONF_HOST] 214 | 215 | if CONF_MAC in entry.data: 216 | v2_data[CONF_MAC] = entry.data[CONF_MAC] 217 | 218 | hass.config_entries.async_update_entry( 219 | entry, 220 | data=v2_data, 221 | version=2, 222 | ) 223 | _LOGGER.debug("Migrated La Marzocco config entry to version 2") 224 | return True 225 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for La Marzocco espresso machines.""" 2 | 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | 6 | from pylamarzocco.const import KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey 7 | from pylamarzocco.devices.machine import LaMarzoccoMachine 8 | 9 | from homeassistant.components.sensor import ( 10 | SensorDeviceClass, 11 | SensorEntity, 12 | SensorEntityDescription, 13 | SensorStateClass, 14 | ) 15 | from homeassistant.const import ( 16 | PERCENTAGE, 17 | EntityCategory, 18 | UnitOfTemperature, 19 | UnitOfTime, 20 | ) 21 | from homeassistant.core import HomeAssistant 22 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 23 | 24 | from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator 25 | from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity 26 | 27 | # Coordinator is used to centralize the data updates 28 | PARALLEL_UPDATES = 0 29 | 30 | 31 | @dataclass(frozen=True, kw_only=True) 32 | class LaMarzoccoSensorEntityDescription( 33 | LaMarzoccoEntityDescription, SensorEntityDescription 34 | ): 35 | """Description of a La Marzocco sensor.""" 36 | 37 | value_fn: Callable[[LaMarzoccoMachine], float | int] 38 | 39 | 40 | @dataclass(frozen=True, kw_only=True) 41 | class LaMarzoccoKeySensorEntityDescription( 42 | LaMarzoccoEntityDescription, SensorEntityDescription 43 | ): 44 | """Description of a keyed La Marzocco sensor.""" 45 | 46 | value_fn: Callable[[LaMarzoccoMachine, PhysicalKey], int | None] 47 | 48 | 49 | ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( 50 | LaMarzoccoSensorEntityDescription( 51 | key="shot_timer", 52 | translation_key="shot_timer", 53 | native_unit_of_measurement=UnitOfTime.SECONDS, 54 | state_class=SensorStateClass.MEASUREMENT, 55 | device_class=SensorDeviceClass.DURATION, 56 | value_fn=lambda device: device.config.brew_active_duration, 57 | available_fn=lambda device: device.websocket_connected, 58 | entity_category=EntityCategory.DIAGNOSTIC, 59 | supported_fn=lambda coordinator: coordinator.local_connection_configured, 60 | ), 61 | LaMarzoccoSensorEntityDescription( 62 | key="current_temp_coffee", 63 | translation_key="current_temp_coffee", 64 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 65 | suggested_display_precision=1, 66 | state_class=SensorStateClass.MEASUREMENT, 67 | device_class=SensorDeviceClass.TEMPERATURE, 68 | value_fn=lambda device: device.config.boilers[ 69 | BoilerType.COFFEE 70 | ].current_temperature, 71 | ), 72 | LaMarzoccoSensorEntityDescription( 73 | key="current_temp_steam", 74 | translation_key="current_temp_steam", 75 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 76 | suggested_display_precision=1, 77 | state_class=SensorStateClass.MEASUREMENT, 78 | device_class=SensorDeviceClass.TEMPERATURE, 79 | value_fn=lambda device: device.config.boilers[ 80 | BoilerType.STEAM 81 | ].current_temperature, 82 | supported_fn=lambda coordinator: coordinator.device.model 83 | not in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R), 84 | ), 85 | ) 86 | 87 | STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( 88 | LaMarzoccoSensorEntityDescription( 89 | key="drink_stats_coffee", 90 | translation_key="drink_stats_coffee", 91 | state_class=SensorStateClass.TOTAL_INCREASING, 92 | value_fn=lambda device: device.statistics.total_coffee, 93 | available_fn=lambda device: len(device.statistics.drink_stats) > 0, 94 | entity_category=EntityCategory.DIAGNOSTIC, 95 | ), 96 | LaMarzoccoSensorEntityDescription( 97 | key="drink_stats_flushing", 98 | translation_key="drink_stats_flushing", 99 | state_class=SensorStateClass.TOTAL_INCREASING, 100 | value_fn=lambda device: device.statistics.total_flushes, 101 | available_fn=lambda device: len(device.statistics.drink_stats) > 0, 102 | entity_category=EntityCategory.DIAGNOSTIC, 103 | ), 104 | ) 105 | 106 | KEY_STATISTIC_ENTITIES: tuple[LaMarzoccoKeySensorEntityDescription, ...] = ( 107 | LaMarzoccoKeySensorEntityDescription( 108 | key="drink_stats_coffee_key", 109 | translation_key="drink_stats_coffee_key", 110 | state_class=SensorStateClass.TOTAL_INCREASING, 111 | value_fn=lambda device, key: device.statistics.drink_stats.get(key), 112 | available_fn=lambda device: len(device.statistics.drink_stats) > 0, 113 | entity_category=EntityCategory.DIAGNOSTIC, 114 | entity_registry_enabled_default=False, 115 | ), 116 | ) 117 | 118 | SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( 119 | LaMarzoccoSensorEntityDescription( 120 | key="scale_battery", 121 | native_unit_of_measurement=PERCENTAGE, 122 | state_class=SensorStateClass.MEASUREMENT, 123 | device_class=SensorDeviceClass.BATTERY, 124 | value_fn=lambda device: ( 125 | device.config.scale.battery if device.config.scale else 0 126 | ), 127 | supported_fn=( 128 | lambda coordinator: coordinator.device.model 129 | in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) 130 | ), 131 | ), 132 | ) 133 | 134 | 135 | async def async_setup_entry( 136 | hass: HomeAssistant, 137 | entry: LaMarzoccoConfigEntry, 138 | async_add_entities: AddConfigEntryEntitiesCallback, 139 | ) -> None: 140 | """Set up sensor entities.""" 141 | config_coordinator = entry.runtime_data.config_coordinator 142 | 143 | entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = [] 144 | 145 | entities = [ 146 | LaMarzoccoSensorEntity(config_coordinator, description) 147 | for description in ENTITIES 148 | if description.supported_fn(config_coordinator) 149 | ] 150 | 151 | if ( 152 | config_coordinator.device.model 153 | in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) 154 | and config_coordinator.device.config.scale 155 | ): 156 | entities.extend( 157 | LaMarzoccoScaleSensorEntity(config_coordinator, description) 158 | for description in SCALE_ENTITIES 159 | ) 160 | 161 | statistics_coordinator = entry.runtime_data.statistics_coordinator 162 | entities.extend( 163 | LaMarzoccoSensorEntity(statistics_coordinator, description) 164 | for description in STATISTIC_ENTITIES 165 | if description.supported_fn(statistics_coordinator) 166 | ) 167 | 168 | num_keys = KEYS_PER_MODEL[MachineModel(config_coordinator.device.model)] 169 | if num_keys > 0: 170 | entities.extend( 171 | LaMarzoccoKeySensorEntity(statistics_coordinator, description, key) 172 | for description in KEY_STATISTIC_ENTITIES 173 | for key in range(1, num_keys + 1) 174 | ) 175 | 176 | def _async_add_new_scale() -> None: 177 | async_add_entities( 178 | LaMarzoccoScaleSensorEntity(config_coordinator, description) 179 | for description in SCALE_ENTITIES 180 | ) 181 | 182 | config_coordinator.new_device_callback.append(_async_add_new_scale) 183 | 184 | async_add_entities(entities) 185 | 186 | 187 | class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): 188 | """Sensor representing espresso machine temperature data.""" 189 | 190 | entity_description: LaMarzoccoSensorEntityDescription 191 | 192 | @property 193 | def native_value(self) -> int | float | None: 194 | """State of the sensor.""" 195 | return self.entity_description.value_fn(self.coordinator.device) 196 | 197 | 198 | class LaMarzoccoKeySensorEntity(LaMarzoccoEntity, SensorEntity): 199 | """Sensor for a La Marzocco key.""" 200 | 201 | entity_description: LaMarzoccoKeySensorEntityDescription 202 | 203 | def __init__( 204 | self, 205 | coordinator: LaMarzoccoUpdateCoordinator, 206 | description: LaMarzoccoKeySensorEntityDescription, 207 | key: int, 208 | ) -> None: 209 | """Initialize the sensor.""" 210 | super().__init__(coordinator, description) 211 | self.key = key 212 | self._attr_translation_placeholders = {"key": str(key)} 213 | self._attr_unique_id = f"{super()._attr_unique_id}_key{key}" 214 | 215 | @property 216 | def native_value(self) -> int | None: 217 | """State of the sensor.""" 218 | return self.entity_description.value_fn( 219 | self.coordinator.device, PhysicalKey(self.key) 220 | ) 221 | 222 | 223 | class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity): 224 | """Sensor for a La Marzocco scale.""" 225 | 226 | entity_description: LaMarzoccoSensorEntityDescription 227 | -------------------------------------------------------------------------------- /tests/test_select.py: -------------------------------------------------------------------------------- 1 | """Tests for the La Marzocco select entities.""" 2 | 3 | from datetime import timedelta 4 | from unittest.mock import MagicMock 5 | 6 | from freezegun.api import FrozenDateTimeFactory 7 | from pylamarzocco.const import ( 8 | MachineModel, 9 | PhysicalKey, 10 | PrebrewMode, 11 | SmartStandbyMode, 12 | SteamLevel, 13 | ) 14 | from pylamarzocco.exceptions import RequestNotSuccessful 15 | from pylamarzocco.models import LaMarzoccoScale 16 | import pytest 17 | from syrupy import SnapshotAssertion 18 | 19 | from homeassistant.components.select import ( 20 | ATTR_OPTION, 21 | DOMAIN as SELECT_DOMAIN, 22 | SERVICE_SELECT_OPTION, 23 | ) 24 | from homeassistant.const import ATTR_ENTITY_ID 25 | from homeassistant.core import HomeAssistant 26 | from homeassistant.exceptions import HomeAssistantError 27 | from homeassistant.helpers import entity_registry as er 28 | 29 | from . import async_init_integration 30 | 31 | from tests.common import MockConfigEntry, async_fire_time_changed 32 | 33 | pytest.mark.usefixtures("init_integration") 34 | 35 | 36 | @pytest.mark.usefixtures("init_integration") 37 | @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MICRA]) 38 | async def test_steam_boiler_level( 39 | hass: HomeAssistant, 40 | entity_registry: er.EntityRegistry, 41 | mock_lamarzocco: MagicMock, 42 | snapshot: SnapshotAssertion, 43 | ) -> None: 44 | """Test the La Marzocco Steam Level Select (only for Micra Models).""" 45 | 46 | serial_number = mock_lamarzocco.serial_number 47 | 48 | state = hass.states.get(f"select.{serial_number}_steam_level") 49 | 50 | assert state 51 | assert state == snapshot 52 | 53 | entry = entity_registry.async_get(state.entity_id) 54 | assert entry 55 | assert entry == snapshot 56 | 57 | # on/off service calls 58 | await hass.services.async_call( 59 | SELECT_DOMAIN, 60 | SERVICE_SELECT_OPTION, 61 | { 62 | ATTR_ENTITY_ID: f"select.{serial_number}_steam_level", 63 | ATTR_OPTION: "2", 64 | }, 65 | blocking=True, 66 | ) 67 | 68 | mock_lamarzocco.set_steam_level.assert_called_once_with(level=SteamLevel.LEVEL_2) 69 | 70 | 71 | @pytest.mark.parametrize( 72 | "device_fixture", 73 | [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MINI], 74 | ) 75 | async def test_steam_boiler_level_none( 76 | hass: HomeAssistant, 77 | mock_lamarzocco: MagicMock, 78 | ) -> None: 79 | """Ensure the La Marzocco Steam Level Select is not created for non-Micra models.""" 80 | serial_number = mock_lamarzocco.serial_number 81 | state = hass.states.get(f"select.{serial_number}_steam_level") 82 | 83 | assert state is None 84 | 85 | 86 | @pytest.mark.usefixtures("init_integration") 87 | @pytest.mark.parametrize( 88 | "device_fixture", 89 | [MachineModel.LINEA_MICRA, MachineModel.GS3_AV, MachineModel.LINEA_MINI], 90 | ) 91 | async def test_pre_brew_infusion_select( 92 | hass: HomeAssistant, 93 | entity_registry: er.EntityRegistry, 94 | mock_lamarzocco: MagicMock, 95 | snapshot: SnapshotAssertion, 96 | ) -> None: 97 | """Test the Prebrew/-infusion select.""" 98 | 99 | serial_number = mock_lamarzocco.serial_number 100 | 101 | state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") 102 | 103 | assert state 104 | assert state == snapshot 105 | 106 | entry = entity_registry.async_get(state.entity_id) 107 | assert entry 108 | assert entry == snapshot 109 | 110 | # on/off service calls 111 | await hass.services.async_call( 112 | SELECT_DOMAIN, 113 | SERVICE_SELECT_OPTION, 114 | { 115 | ATTR_ENTITY_ID: f"select.{serial_number}_prebrew_infusion_mode", 116 | ATTR_OPTION: "prebrew", 117 | }, 118 | blocking=True, 119 | ) 120 | 121 | mock_lamarzocco.set_prebrew_mode.assert_called_once_with(mode=PrebrewMode.PREBREW) 122 | 123 | 124 | @pytest.mark.usefixtures("init_integration") 125 | @pytest.mark.parametrize( 126 | "device_fixture", 127 | [MachineModel.GS3_MP], 128 | ) 129 | async def test_pre_brew_infusion_select_none( 130 | hass: HomeAssistant, 131 | mock_lamarzocco: MagicMock, 132 | ) -> None: 133 | """Ensure the La Marzocco Steam Level Select is not created for non-Micra models.""" 134 | serial_number = mock_lamarzocco.serial_number 135 | state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") 136 | 137 | assert state is None 138 | 139 | 140 | @pytest.mark.usefixtures("init_integration") 141 | async def test_smart_standby_mode( 142 | hass: HomeAssistant, 143 | entity_registry: er.EntityRegistry, 144 | mock_lamarzocco: MagicMock, 145 | snapshot: SnapshotAssertion, 146 | ) -> None: 147 | """Test the La Marzocco Smart Standby mode select.""" 148 | 149 | serial_number = mock_lamarzocco.serial_number 150 | 151 | state = hass.states.get(f"select.{serial_number}_smart_standby_mode") 152 | 153 | assert state 154 | assert state == snapshot 155 | 156 | entry = entity_registry.async_get(state.entity_id) 157 | assert entry 158 | assert entry == snapshot 159 | 160 | await hass.services.async_call( 161 | SELECT_DOMAIN, 162 | SERVICE_SELECT_OPTION, 163 | { 164 | ATTR_ENTITY_ID: f"select.{serial_number}_smart_standby_mode", 165 | ATTR_OPTION: "power_on", 166 | }, 167 | blocking=True, 168 | ) 169 | 170 | mock_lamarzocco.set_smart_standby.assert_called_once_with( 171 | enabled=True, mode=SmartStandbyMode.POWER_ON, minutes=10 172 | ) 173 | 174 | 175 | @pytest.mark.usefixtures("init_integration") 176 | async def test_select_errors( 177 | hass: HomeAssistant, 178 | mock_lamarzocco: MagicMock, 179 | ) -> None: 180 | """Test select errors.""" 181 | serial_number = mock_lamarzocco.serial_number 182 | 183 | state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode") 184 | assert state 185 | 186 | mock_lamarzocco.set_prebrew_mode.side_effect = RequestNotSuccessful("Boom") 187 | 188 | # Test setting invalid option 189 | with pytest.raises(HomeAssistantError) as exc_info: 190 | await hass.services.async_call( 191 | SELECT_DOMAIN, 192 | SERVICE_SELECT_OPTION, 193 | { 194 | ATTR_ENTITY_ID: f"select.{serial_number}_prebrew_infusion_mode", 195 | ATTR_OPTION: "prebrew", 196 | }, 197 | blocking=True, 198 | ) 199 | assert exc_info.value.translation_key == "select_option_error" 200 | 201 | 202 | @pytest.mark.usefixtures("init_integration") 203 | @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) 204 | async def test_active_bbw_recipe( 205 | hass: HomeAssistant, 206 | entity_registry: er.EntityRegistry, 207 | mock_lamarzocco: MagicMock, 208 | snapshot: SnapshotAssertion, 209 | ) -> None: 210 | """Test the La Marzocco active bbw recipe select.""" 211 | 212 | state = hass.states.get("select.lmz_123a45_active_brew_by_weight_recipe") 213 | 214 | assert state 215 | assert state == snapshot 216 | 217 | entry = entity_registry.async_get(state.entity_id) 218 | assert entry 219 | assert entry == snapshot 220 | 221 | await hass.services.async_call( 222 | SELECT_DOMAIN, 223 | SERVICE_SELECT_OPTION, 224 | { 225 | ATTR_ENTITY_ID: "select.lmz_123a45_active_brew_by_weight_recipe", 226 | ATTR_OPTION: "b", 227 | }, 228 | blocking=True, 229 | ) 230 | 231 | mock_lamarzocco.set_active_bbw_recipe.assert_called_once_with(PhysicalKey.B) 232 | 233 | 234 | @pytest.mark.usefixtures("init_integration") 235 | @pytest.mark.parametrize( 236 | "device_fixture", 237 | [MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA], 238 | ) 239 | async def test_other_models_no_active_bbw_select( 240 | hass: HomeAssistant, 241 | mock_lamarzocco: MagicMock, 242 | ) -> None: 243 | """Ensure the other models don't have a battery sensor.""" 244 | 245 | state = hass.states.get("select.lmz_123a45_active_brew_by_weight_recipe") 246 | assert state is None 247 | 248 | 249 | @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) 250 | async def test_active_bbw_select_on_new_scale_added( 251 | hass: HomeAssistant, 252 | mock_lamarzocco: MagicMock, 253 | mock_config_entry: MockConfigEntry, 254 | freezer: FrozenDateTimeFactory, 255 | ) -> None: 256 | """Ensure the active bbw select for a new scale is added automatically.""" 257 | 258 | mock_lamarzocco.config.scale = None 259 | await async_init_integration(hass, mock_config_entry) 260 | 261 | state = hass.states.get("select.scale_123a45_active_brew_by_weight_recipe") 262 | assert state is None 263 | 264 | mock_lamarzocco.config.scale = LaMarzoccoScale( 265 | connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50 266 | ) 267 | 268 | freezer.tick(timedelta(minutes=10)) 269 | async_fire_time_changed(hass) 270 | await hass.async_block_till_done() 271 | 272 | state = hass.states.get("select.scale_123a45_active_brew_by_weight_recipe") 273 | assert state 274 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured", 5 | "reauth_successful": "Re-authentication was successful", 6 | "reconfigure_successful": "Re-configuration was successful" 7 | }, 8 | "error": { 9 | "cannot_connect": "Failed to connect", 10 | "invalid_auth": "Invalid authentication", 11 | "machine_not_found": "Discovered machine not found in given account", 12 | "no_machines": "No machines found in account" 13 | }, 14 | "step": { 15 | "bluetooth_selection": { 16 | "data": { 17 | "mac": "Device" 18 | }, 19 | "data_description": { 20 | "mac": "Select the Bluetooth device that is your machine" 21 | }, 22 | "description": "Select your device from available Bluetooth devices." 23 | }, 24 | "machine_selection": { 25 | "data": { 26 | "host": "IP address", 27 | "machine": "Machine" 28 | }, 29 | "data_description": { 30 | "host": "Local IP address of the machine", 31 | "machine": "Select the machine you want to integrate" 32 | }, 33 | "description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors." 34 | }, 35 | "reauth_confirm": { 36 | "data": { 37 | "password": "Password" 38 | }, 39 | "data_description": { 40 | "password": "Your password from the La Marzocco app" 41 | }, 42 | "description": "Re-authentication required. Please enter your password again." 43 | }, 44 | "reconfigure": { 45 | "data": { 46 | "password": "Password", 47 | "username": "Username" 48 | }, 49 | "data_description": { 50 | "password": "Your password from the La Marzocco app", 51 | "username": "Your username from the La Marzocco app" 52 | } 53 | }, 54 | "user": { 55 | "data": { 56 | "password": "Password", 57 | "username": "Username" 58 | }, 59 | "data_description": { 60 | "password": "Your password from the La Marzocco app", 61 | "username": "Your username from the La Marzocco app" 62 | } 63 | } 64 | } 65 | }, 66 | "entity": { 67 | "binary_sensor": { 68 | "backflush_enabled": { 69 | "name": "Backflush active" 70 | }, 71 | "brew_active": { 72 | "name": "Brewing active" 73 | }, 74 | "water_tank": { 75 | "name": "Water tank empty" 76 | } 77 | }, 78 | "button": { 79 | "start_backflush": { 80 | "name": "Start backflush" 81 | } 82 | }, 83 | "calendar": { 84 | "auto_on_off_schedule": { 85 | "name": "Auto on/off schedule ({id})" 86 | } 87 | }, 88 | "number": { 89 | "coffee_temp": { 90 | "name": "Coffee target temperature" 91 | }, 92 | "dose_key": { 93 | "name": "Dose Key {key}" 94 | }, 95 | "prebrew_off": { 96 | "name": "Prebrew off time" 97 | }, 98 | "prebrew_off_key": { 99 | "name": "Prebrew off time Key {key}" 100 | }, 101 | "prebrew_on": { 102 | "name": "Prebrew on time" 103 | }, 104 | "prebrew_on_key": { 105 | "name": "Prebrew on time Key {key}" 106 | }, 107 | "preinfusion_off": { 108 | "name": "Preinfusion time" 109 | }, 110 | "preinfusion_off_key": { 111 | "name": "Preinfusion time Key {key}" 112 | }, 113 | "scale_target_key": { 114 | "name": "Brew by weight target {key}" 115 | }, 116 | "smart_standby_time": { 117 | "name": "Smart standby time" 118 | }, 119 | "steam_temp": { 120 | "name": "Steam target temperature" 121 | }, 122 | "tea_water_duration": { 123 | "name": "Tea water duration" 124 | } 125 | }, 126 | "select": { 127 | "active_bbw": { 128 | "name": "Active brew by weight recipe", 129 | "state": { 130 | "a": "Recipe A", 131 | "b": "Recipe B" 132 | } 133 | }, 134 | "prebrew_infusion_select": { 135 | "name": "Prebrew/-infusion mode", 136 | "state": { 137 | "disabled": "Disabled", 138 | "prebrew": "Prebrew", 139 | "prebrew_enabled": "Prebrew", 140 | "preinfusion": "Preinfusion" 141 | } 142 | }, 143 | "smart_standby_mode": { 144 | "name": "Smart standby mode", 145 | "state": { 146 | "last_brewing": "Last brewing", 147 | "power_on": "Power on" 148 | } 149 | }, 150 | "steam_temp_select": { 151 | "name": "Steam level", 152 | "state": { 153 | "1": "1", 154 | "2": "2", 155 | "3": "3" 156 | } 157 | } 158 | }, 159 | "sensor": { 160 | "current_temp_coffee": { 161 | "name": "Current coffee temperature" 162 | }, 163 | "current_temp_steam": { 164 | "name": "Current steam temperature" 165 | }, 166 | "drink_stats_coffee": { 167 | "name": "Total coffees made", 168 | "unit_of_measurement": "coffees" 169 | }, 170 | "drink_stats_coffee_key": { 171 | "name": "Coffees made Key {key}", 172 | "unit_of_measurement": "coffees" 173 | }, 174 | "drink_stats_flushing": { 175 | "name": "Total flushes made", 176 | "unit_of_measurement": "flushes" 177 | }, 178 | "shot_timer": { 179 | "name": "Shot timer" 180 | } 181 | }, 182 | "switch": { 183 | "auto_on_off": { 184 | "name": "Auto on/off ({id})" 185 | }, 186 | "smart_standby_enabled": { 187 | "name": "Smart standby enabled" 188 | }, 189 | "steam_boiler": { 190 | "name": "Steam boiler" 191 | } 192 | }, 193 | "update": { 194 | "gateway_firmware": { 195 | "name": "Gateway firmware" 196 | }, 197 | "machine_firmware": { 198 | "name": "Machine firmware" 199 | } 200 | } 201 | }, 202 | "exceptions": { 203 | "api_error": { 204 | "message": "Error while communicating with the API" 205 | }, 206 | "authentication_failed": { 207 | "message": "Authentication failed" 208 | }, 209 | "auto_on_off_error": { 210 | "message": "Error while setting auto on/off to {state} for {id}" 211 | }, 212 | "button_error": { 213 | "message": "Error while executing button {key}" 214 | }, 215 | "number_exception": { 216 | "message": "Error while setting value {value} for number {key}" 217 | }, 218 | "number_exception_key": { 219 | "message": "Error while setting value {value} for number {key}, key {physical_key}" 220 | }, 221 | "select_option_error": { 222 | "message": "Error while setting select option {option} for {key}" 223 | }, 224 | "switch_off_error": { 225 | "message": "Error while turning off switch {key}" 226 | }, 227 | "switch_on_error": { 228 | "message": "Error while turning on switch {key}" 229 | }, 230 | "update_failed": { 231 | "message": "Error while updating {key}" 232 | } 233 | }, 234 | "issues": { 235 | "unsupported_gateway_firmware": { 236 | "description": "Gateway firmware {gateway_version} is no longer supported by this integration, please update.", 237 | "title": "Unsupported gateway firmware" 238 | } 239 | }, 240 | "options": { 241 | "step": { 242 | "init": { 243 | "data": { 244 | "use_bluetooth": "Use Bluetooth" 245 | }, 246 | "data_description": { 247 | "use_bluetooth": "Should the integration try to use Bluetooth to control the machine?" 248 | } 249 | } 250 | } 251 | } 252 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!CAUTION] 2 | > This integration does not support Gateway v5 (use the core integration instead) and is only intended for backwards compability. 3 | 4 | # La Marzocco Home Assistant Integration (Gateway v3) 5 | 6 | ## Overview 7 | 8 | This is an integration for recent La Marzocco espresso machines that use WiFi to connect to the cloud and can be controlled via the La Marzocco mobile app. This capability was rolled out in late 2019, and La Marzocco supposedly offers a retrofit kit to add it to earlier models. This repo started as fork for https://github.com/rccoleman/lamarzocco, but with the release of La Marzocco's Gateway V3 in 2023 became the new default. 9 | 10 | Based on the investigation from Plonx on the Home Assistant forum [here](https://community.home-assistant.io/t/la-marzocco-gs-3-linea-mini-support/203581), this integration presents a comprehensive machine status through several entities and allows the user to change the machine configuration from Home Assistant. 11 | 12 | 13 | ### Bluetooth 14 | This integration can communicate to the machine through Bluetooth, in which case some of the commands (e.g. turning on/off) are not sent through the cloud. If your server doesn't have a bluetooth interface, or is not close enough to your machine ESPHome's [Bluetooth Proxies](https://esphome.github.io/bluetooth-proxies/) are a very good solution. 15 | 16 | ### WebSockets 17 | This integration opens a WebSocket connection to your machine to stream information. In case you are encountering any issues, for example with the official app connecting, you can disable the WebSocket connections in the integration's settings. 18 | 19 | ### Lovelace 20 | 21 | A companion Lovelace card that uses this integration to retrieve data and control the machine can be found [here](https://github.com/rccoleman/lovelace-lamarzocco-config-card). 22 | 23 | ### Feedback 24 | 25 | This integration currently only supports a single espresso machine. It's possible to support multiple machines, but I only have one and I suspect that'll be the case for most folks. If anyone has a fleet of espresso machines and is willing to provide data and feedback, We're happy to entertain adding support for more than one machine. 26 | 27 | ## Installation 28 | 29 | ### HACS 30 | 31 | This integration is compatible with [HACS](https://hacs.xyz/), which means that you can easily download and manage updates for it.
32 | Click the button below to add it to your HACS installation
33 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=zweckj&repository=lamarzocco&category=integration) 34 | 35 | 41 | 42 | or add the repo to HACS manually: 43 | 44 | 1. Launch the HACS panel from the left sidebar 45 | 2. Click "Integrations` 46 | 3. Click the three dots in the top right corner 47 | 4. Click "Add custom repository" 48 | 5. Add the link to this repo (`https://github.com/zweckj/lamarzocco`) 49 | 6. Click "Explore & Download Repositories" 50 | 7. Install "La Marzocco" 51 | 52 | ### Manual 53 | 54 | If you don't have HACS installed or would prefer to install manually. 55 | 56 | 1. Create a `config/custom_comoponents` directory if it doesn't already exist 57 | 2. Clone this repo and move `lamarzocco` into `config/custom_components`. Your directory tree should look like `config/custom_components/lamarzocco/...files...` 58 | 59 | #### Restart Home Assistant 60 | 61 | ## Configuration 62 | 63 | Click the following button to set up this integration in Home Assistant. 64 | 65 | [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=lamarzocco) 66 | 67 | Alternatively, you may add the integration manually. 68 | 69 | 1. Navigate to Settings->Devices & Services 70 | 2. Hit the "+ Add Integration" button in the lower-right 71 | 3. Search for "La Marzocco" and select it 72 | 4. You'll be presented with a dialog box like this: 73 | 74 | ![Config Flow](https://github.com/zweckj/lamarzocco/blob/main/images/Config_Flow.png) 75 | 76 | 5. Fill in the info 77 | 6. Hit "Submit" 78 | 79 | ### Configured Integration 80 | 81 | Once you configured the integration, you should see this in Configuration->Integrations: 82 | 83 | image 84 | 85 | ## Usage 86 | 87 | In Dev->States, you should see several new entities, with your machine model dictating which ones: 88 | 89 | - `water_heater._coffee` 90 | - `water_heater._steam` 91 | - `sensor._total_drinks` 92 | - `binary_sensor._water_reservoir` 93 | - `switch._main` 94 | - `switch._auto_on_off` 95 | - `switch._prebrew` 96 | - `switch._preinfusion` 97 | - `button._start_backflush` 98 | - `switch._steam_boiler_enable` 99 | 100 | Thw switches control their respective functions globally, i.e., enable/disable auto on/off for the whole machine, enable/disable prebrewing for all front-panel keys. 101 | 102 | ## Services 103 | 104 | The `water_heater` and `switch` entities support the standard services for those domains, described [here](https://www.home-assistant.io/integrations/water_heater/) and [here](https://www.home-assistant.io/integrations/switch/), respectively. 105 | 106 | The following domain-specific services are also available (model-dependent): 107 | 108 | #### Service `lamarzocco.set_auto_on_off_enable` 109 | 110 | Enable or disable auto on/off for a specific day of the week. 111 | 112 | | Service data attribute | Optional | Description | 113 | | ---------------------- | -------- | ------------------------------------------------------------------------------------- | 114 | | `day_of_week` | no | The day of the week to enable (sun, mon, tue, wed, thu, fri, sat) | 115 | | `enable` | no | Boolean value indicating whether to enable or disable auto on/off, e.g. "on" or "off" | 116 | 117 | #### Service `lamarzocco.set_auto_on_off_times` 118 | 119 | Set the auto on and off times for each day of the week. 120 | 121 | | Service data attribute | Optional | Description | 122 | | ---------------------- | -------- | ----------------------------------------------------------------- | 123 | | `day_of_week` | no | The day of the week to enable (sun, mon, tue, wed, thu, fri, sat) | 124 | | `hour_on` | no | The hour to turn the machine on (0..23) | 125 | | `minute_on` | yes | The minute to turn the machine on (0..59) | 126 | | `hour_off` | no | The hour to turn the machine off (0..23) | 127 | | `minute_off` | yes | The minute to turn the machine off (0..59) | 128 | 129 | #### Service `lamarzocco.set_dose` 130 | 131 | Sets the dose for a specific key. 132 | 133 | | Service data attribute | Optional | Description | 134 | | ---------------------- | -------- | ------------------------------------------------------- | 135 | | `key` | no | The key to program (1-5) | 136 | | `pulses` | no | The dose in pulses (roughly ~0.5ml per pulse), e.g. 120 | 137 | 138 | #### Service `lamarzocco.set_dose_hot_water` 139 | 140 | Sets the dose for hot water. 141 | 142 | | Service data attribute | Optional | Description | 143 | | ---------------------- | -------- | -------------------------------------------------- | 144 | | `seconds` | no | The number of seconds to stream hot water, e.g. 8 | 145 | 146 | #### Service `lamarzocco.set_prebrew_times` 147 | 148 | Set the prebrewing "on" and "off" times for a specific key. 149 | 150 | | Service data attribute | Optional | Description | 151 | | ---------------------- | -------- | ------------------------------------------------------------------- | 152 | | `key` | no | The key to program (1-4) | 153 | | `seconds_on` | no | The time in seconds for the pump to run during prebrewing (0-5.9s) | 154 | | `seconds_off` | no | The time in seconds for the pump to stop during prebrewing (0-5.9s) | 155 | 156 | #### Service `lamarzocco.set_preinfusion_time` 157 | 158 | Set the preinfusion time for a specific key. 159 | 160 | | Service data attribute | Optional | Description | 161 | | ---------------------- | -------- | ------------------------------------------------------------------- | 162 | | `key` | no | The key to program (1-4) | 163 | | `seconds` | no | The time in seconds for preinfusion (0-24.9s) | 164 | 165 | > **_NOTE:_** The machine won't allow more than one device to connect at once, so you may need to wait to allow the mobile app to connect while the integration is running. The integration only maintains the connection while it's sending or receiving information and polls every 30s, so you should still be able to use the mobile app. 166 | 167 | If you have any questions or find any issues, either file them here or post to the thread on the Home Assistant forum [here](https://community.home-assistant.io/t/la-marzocco-gs-3-linea-mini-support/203581). 168 | -------------------------------------------------------------------------------- /tests/snapshots/test_select.ambr: -------------------------------------------------------------------------------- 1 | # serializer version: 1 2 | # name: test_active_bbw_recipe[Linea Mini] 3 | StateSnapshot({ 4 | 'attributes': ReadOnlyDict({ 5 | 'friendly_name': 'LMZ-123A45 Active brew by weight recipe', 6 | 'options': list([ 7 | 'a', 8 | 'b', 9 | ]), 10 | }), 11 | 'context': , 12 | 'entity_id': 'select.lmz_123a45_active_brew_by_weight_recipe', 13 | 'last_changed': , 14 | 'last_reported': , 15 | 'last_updated': , 16 | 'state': 'a', 17 | }) 18 | # --- 19 | # name: test_active_bbw_recipe[Linea Mini].1 20 | EntityRegistryEntrySnapshot({ 21 | 'aliases': set({ 22 | }), 23 | 'area_id': None, 24 | 'capabilities': dict({ 25 | 'options': list([ 26 | 'a', 27 | 'b', 28 | ]), 29 | }), 30 | 'config_entry_id': , 31 | 'config_subentry_id': , 32 | 'device_class': None, 33 | 'device_id': , 34 | 'disabled_by': None, 35 | 'domain': 'select', 36 | 'entity_category': None, 37 | 'entity_id': 'select.lmz_123a45_active_brew_by_weight_recipe', 38 | 'has_entity_name': True, 39 | 'hidden_by': None, 40 | 'icon': None, 41 | 'id': , 42 | 'labels': set({ 43 | }), 44 | 'name': None, 45 | 'options': dict({ 46 | }), 47 | 'original_device_class': None, 48 | 'original_icon': None, 49 | 'original_name': 'Active brew by weight recipe', 50 | 'platform': 'lamarzocco', 51 | 'previous_unique_id': None, 52 | 'supported_features': 0, 53 | 'translation_key': 'active_bbw', 54 | 'unique_id': 'LM012345_active_bbw', 55 | 'unit_of_measurement': None, 56 | }) 57 | # --- 58 | # name: test_pre_brew_infusion_select[GS3 AV] 59 | StateSnapshot({ 60 | 'attributes': ReadOnlyDict({ 61 | 'friendly_name': 'GS012345 Prebrew/-infusion mode', 62 | 'options': list([ 63 | 'disabled', 64 | 'prebrew', 65 | 'preinfusion', 66 | ]), 67 | }), 68 | 'context': , 69 | 'entity_id': 'select.gs012345_prebrew_infusion_mode', 70 | 'last_changed': , 71 | 'last_reported': , 72 | 'last_updated': , 73 | 'state': 'preinfusion', 74 | }) 75 | # --- 76 | # name: test_pre_brew_infusion_select[GS3 AV].1 77 | EntityRegistryEntrySnapshot({ 78 | 'aliases': set({ 79 | }), 80 | 'area_id': None, 81 | 'capabilities': dict({ 82 | 'options': list([ 83 | 'disabled', 84 | 'prebrew', 85 | 'preinfusion', 86 | ]), 87 | }), 88 | 'config_entry_id': , 89 | 'config_subentry_id': , 90 | 'device_class': None, 91 | 'device_id': , 92 | 'disabled_by': None, 93 | 'domain': 'select', 94 | 'entity_category': , 95 | 'entity_id': 'select.gs012345_prebrew_infusion_mode', 96 | 'has_entity_name': True, 97 | 'hidden_by': None, 98 | 'icon': None, 99 | 'id': , 100 | 'labels': set({ 101 | }), 102 | 'name': None, 103 | 'options': dict({ 104 | }), 105 | 'original_device_class': None, 106 | 'original_icon': None, 107 | 'original_name': 'Prebrew/-infusion mode', 108 | 'platform': 'lamarzocco', 109 | 'previous_unique_id': None, 110 | 'supported_features': 0, 111 | 'translation_key': 'prebrew_infusion_select', 112 | 'unique_id': 'GS012345_prebrew_infusion_select', 113 | 'unit_of_measurement': None, 114 | }) 115 | # --- 116 | # name: test_pre_brew_infusion_select[Linea Mini] 117 | StateSnapshot({ 118 | 'attributes': ReadOnlyDict({ 119 | 'friendly_name': 'LM012345 Prebrew/-infusion mode', 120 | 'options': list([ 121 | 'disabled', 122 | 'prebrew', 123 | 'preinfusion', 124 | ]), 125 | }), 126 | 'context': , 127 | 'entity_id': 'select.lm012345_prebrew_infusion_mode', 128 | 'last_changed': , 129 | 'last_reported': , 130 | 'last_updated': , 131 | 'state': 'preinfusion', 132 | }) 133 | # --- 134 | # name: test_pre_brew_infusion_select[Linea Mini].1 135 | EntityRegistryEntrySnapshot({ 136 | 'aliases': set({ 137 | }), 138 | 'area_id': None, 139 | 'capabilities': dict({ 140 | 'options': list([ 141 | 'disabled', 142 | 'prebrew', 143 | 'preinfusion', 144 | ]), 145 | }), 146 | 'config_entry_id': , 147 | 'config_subentry_id': , 148 | 'device_class': None, 149 | 'device_id': , 150 | 'disabled_by': None, 151 | 'domain': 'select', 152 | 'entity_category': , 153 | 'entity_id': 'select.lm012345_prebrew_infusion_mode', 154 | 'has_entity_name': True, 155 | 'hidden_by': None, 156 | 'icon': None, 157 | 'id': , 158 | 'labels': set({ 159 | }), 160 | 'name': None, 161 | 'options': dict({ 162 | }), 163 | 'original_device_class': None, 164 | 'original_icon': None, 165 | 'original_name': 'Prebrew/-infusion mode', 166 | 'platform': 'lamarzocco', 167 | 'previous_unique_id': None, 168 | 'supported_features': 0, 169 | 'translation_key': 'prebrew_infusion_select', 170 | 'unique_id': 'LM012345_prebrew_infusion_select', 171 | 'unit_of_measurement': None, 172 | }) 173 | # --- 174 | # name: test_pre_brew_infusion_select[Micra] 175 | StateSnapshot({ 176 | 'attributes': ReadOnlyDict({ 177 | 'friendly_name': 'MR012345 Prebrew/-infusion mode', 178 | 'options': list([ 179 | 'disabled', 180 | 'prebrew', 181 | 'preinfusion', 182 | ]), 183 | }), 184 | 'context': , 185 | 'entity_id': 'select.mr012345_prebrew_infusion_mode', 186 | 'last_changed': , 187 | 'last_reported': , 188 | 'last_updated': , 189 | 'state': 'preinfusion', 190 | }) 191 | # --- 192 | # name: test_pre_brew_infusion_select[Micra].1 193 | EntityRegistryEntrySnapshot({ 194 | 'aliases': set({ 195 | }), 196 | 'area_id': None, 197 | 'capabilities': dict({ 198 | 'options': list([ 199 | 'disabled', 200 | 'prebrew', 201 | 'preinfusion', 202 | ]), 203 | }), 204 | 'config_entry_id': , 205 | 'config_subentry_id': , 206 | 'device_class': None, 207 | 'device_id': , 208 | 'disabled_by': None, 209 | 'domain': 'select', 210 | 'entity_category': , 211 | 'entity_id': 'select.mr012345_prebrew_infusion_mode', 212 | 'has_entity_name': True, 213 | 'hidden_by': None, 214 | 'icon': None, 215 | 'id': , 216 | 'labels': set({ 217 | }), 218 | 'name': None, 219 | 'options': dict({ 220 | }), 221 | 'original_device_class': None, 222 | 'original_icon': None, 223 | 'original_name': 'Prebrew/-infusion mode', 224 | 'platform': 'lamarzocco', 225 | 'previous_unique_id': None, 226 | 'supported_features': 0, 227 | 'translation_key': 'prebrew_infusion_select', 228 | 'unique_id': 'MR012345_prebrew_infusion_select', 229 | 'unit_of_measurement': None, 230 | }) 231 | # --- 232 | # name: test_smart_standby_mode 233 | StateSnapshot({ 234 | 'attributes': ReadOnlyDict({ 235 | 'friendly_name': 'GS012345 Smart standby mode', 236 | 'options': list([ 237 | 'power_on', 238 | 'last_brewing', 239 | ]), 240 | }), 241 | 'context': , 242 | 'entity_id': 'select.gs012345_smart_standby_mode', 243 | 'last_changed': , 244 | 'last_reported': , 245 | 'last_updated': , 246 | 'state': 'last_brewing', 247 | }) 248 | # --- 249 | # name: test_smart_standby_mode.1 250 | EntityRegistryEntrySnapshot({ 251 | 'aliases': set({ 252 | }), 253 | 'area_id': None, 254 | 'capabilities': dict({ 255 | 'options': list([ 256 | 'power_on', 257 | 'last_brewing', 258 | ]), 259 | }), 260 | 'config_entry_id': , 261 | 'config_subentry_id': , 262 | 'device_class': None, 263 | 'device_id': , 264 | 'disabled_by': None, 265 | 'domain': 'select', 266 | 'entity_category': , 267 | 'entity_id': 'select.gs012345_smart_standby_mode', 268 | 'has_entity_name': True, 269 | 'hidden_by': None, 270 | 'icon': None, 271 | 'id': , 272 | 'labels': set({ 273 | }), 274 | 'name': None, 275 | 'options': dict({ 276 | }), 277 | 'original_device_class': None, 278 | 'original_icon': None, 279 | 'original_name': 'Smart standby mode', 280 | 'platform': 'lamarzocco', 281 | 'previous_unique_id': None, 282 | 'supported_features': 0, 283 | 'translation_key': 'smart_standby_mode', 284 | 'unique_id': 'GS012345_smart_standby_mode', 285 | 'unit_of_measurement': None, 286 | }) 287 | # --- 288 | # name: test_steam_boiler_level[Micra] 289 | StateSnapshot({ 290 | 'attributes': ReadOnlyDict({ 291 | 'friendly_name': 'MR012345 Steam level', 292 | 'options': list([ 293 | '1', 294 | '2', 295 | '3', 296 | ]), 297 | }), 298 | 'context': , 299 | 'entity_id': 'select.mr012345_steam_level', 300 | 'last_changed': , 301 | 'last_reported': , 302 | 'last_updated': , 303 | 'state': '1', 304 | }) 305 | # --- 306 | # name: test_steam_boiler_level[Micra].1 307 | EntityRegistryEntrySnapshot({ 308 | 'aliases': set({ 309 | }), 310 | 'area_id': None, 311 | 'capabilities': dict({ 312 | 'options': list([ 313 | '1', 314 | '2', 315 | '3', 316 | ]), 317 | }), 318 | 'config_entry_id': , 319 | 'config_subentry_id': , 320 | 'device_class': None, 321 | 'device_id': , 322 | 'disabled_by': None, 323 | 'domain': 'select', 324 | 'entity_category': None, 325 | 'entity_id': 'select.mr012345_steam_level', 326 | 'has_entity_name': True, 327 | 'hidden_by': None, 328 | 'icon': None, 329 | 'id': , 330 | 'labels': set({ 331 | }), 332 | 'name': None, 333 | 'options': dict({ 334 | }), 335 | 'original_device_class': None, 336 | 'original_icon': None, 337 | 'original_name': 'Steam level', 338 | 'platform': 'lamarzocco', 339 | 'previous_unique_id': None, 340 | 'supported_features': 0, 341 | 'translation_key': 'steam_temp_select', 342 | 'unique_id': 'MR012345_steam_temp_select', 343 | 'unit_of_measurement': None, 344 | }) 345 | # --- 346 | -------------------------------------------------------------------------------- /tests/snapshots/test_switch.ambr: -------------------------------------------------------------------------------- 1 | # serializer version: 1 2 | # name: test_auto_on_off_switches[entry.auto_on_off_Os2OswX] 3 | EntityRegistryEntrySnapshot({ 4 | 'aliases': set({ 5 | }), 6 | 'area_id': None, 7 | 'capabilities': None, 8 | 'config_entry_id': , 9 | 'config_subentry_id': , 10 | 'device_class': None, 11 | 'device_id': , 12 | 'disabled_by': None, 13 | 'domain': 'switch', 14 | 'entity_category': , 15 | 'entity_id': 'switch.gs012345_auto_on_off_os2oswx', 16 | 'has_entity_name': True, 17 | 'hidden_by': None, 18 | 'icon': None, 19 | 'id': , 20 | 'labels': set({ 21 | }), 22 | 'name': None, 23 | 'options': dict({ 24 | }), 25 | 'original_device_class': None, 26 | 'original_icon': None, 27 | 'original_name': 'Auto on/off (Os2OswX)', 28 | 'platform': 'lamarzocco', 29 | 'previous_unique_id': None, 30 | 'supported_features': 0, 31 | 'translation_key': 'auto_on_off', 32 | 'unique_id': 'GS012345_auto_on_off_Os2OswX', 33 | 'unit_of_measurement': None, 34 | }) 35 | # --- 36 | # name: test_auto_on_off_switches[entry.auto_on_off_aXFz5bJ] 37 | EntityRegistryEntrySnapshot({ 38 | 'aliases': set({ 39 | }), 40 | 'area_id': None, 41 | 'capabilities': None, 42 | 'config_entry_id': , 43 | 'config_subentry_id': , 44 | 'device_class': None, 45 | 'device_id': , 46 | 'disabled_by': None, 47 | 'domain': 'switch', 48 | 'entity_category': , 49 | 'entity_id': 'switch.gs012345_auto_on_off_axfz5bj', 50 | 'has_entity_name': True, 51 | 'hidden_by': None, 52 | 'icon': None, 53 | 'id': , 54 | 'labels': set({ 55 | }), 56 | 'name': None, 57 | 'options': dict({ 58 | }), 59 | 'original_device_class': None, 60 | 'original_icon': None, 61 | 'original_name': 'Auto on/off (aXFz5bJ)', 62 | 'platform': 'lamarzocco', 63 | 'previous_unique_id': None, 64 | 'supported_features': 0, 65 | 'translation_key': 'auto_on_off', 66 | 'unique_id': 'GS012345_auto_on_off_aXFz5bJ', 67 | 'unit_of_measurement': None, 68 | }) 69 | # --- 70 | # name: test_auto_on_off_switches[state.auto_on_off_Os2OswX] 71 | StateSnapshot({ 72 | 'attributes': ReadOnlyDict({ 73 | 'friendly_name': 'GS012345 Auto on/off (Os2OswX)', 74 | }), 75 | 'context': , 76 | 'entity_id': 'switch.gs012345_auto_on_off_os2oswx', 77 | 'last_changed': , 78 | 'last_reported': , 79 | 'last_updated': , 80 | 'state': 'on', 81 | }) 82 | # --- 83 | # name: test_auto_on_off_switches[state.auto_on_off_aXFz5bJ] 84 | StateSnapshot({ 85 | 'attributes': ReadOnlyDict({ 86 | 'friendly_name': 'GS012345 Auto on/off (aXFz5bJ)', 87 | }), 88 | 'context': , 89 | 'entity_id': 'switch.gs012345_auto_on_off_axfz5bj', 90 | 'last_changed': , 91 | 'last_reported': , 92 | 'last_updated': , 93 | 'state': 'on', 94 | }) 95 | # --- 96 | # name: test_switches[switch.gs012345-entry] 97 | EntityRegistryEntrySnapshot({ 98 | 'aliases': set({ 99 | }), 100 | 'area_id': None, 101 | 'capabilities': None, 102 | 'config_entry_id': , 103 | 'config_subentry_id': , 104 | 'device_class': None, 105 | 'device_id': , 106 | 'disabled_by': None, 107 | 'domain': 'switch', 108 | 'entity_category': None, 109 | 'entity_id': 'switch.gs012345', 110 | 'has_entity_name': True, 111 | 'hidden_by': None, 112 | 'icon': None, 113 | 'id': , 114 | 'labels': set({ 115 | }), 116 | 'name': None, 117 | 'options': dict({ 118 | }), 119 | 'original_device_class': None, 120 | 'original_icon': None, 121 | 'original_name': None, 122 | 'platform': 'lamarzocco', 123 | 'previous_unique_id': None, 124 | 'supported_features': 0, 125 | 'translation_key': 'main', 126 | 'unique_id': 'GS012345_main', 127 | 'unit_of_measurement': None, 128 | }) 129 | # --- 130 | # name: test_switches[switch.gs012345-state] 131 | StateSnapshot({ 132 | 'attributes': ReadOnlyDict({ 133 | 'friendly_name': 'GS012345', 134 | }), 135 | 'context': , 136 | 'entity_id': 'switch.gs012345', 137 | 'last_changed': , 138 | 'last_reported': , 139 | 'last_updated': , 140 | 'state': 'on', 141 | }) 142 | # --- 143 | # name: test_switches[switch.gs012345_auto_on_off_axfz5bj-entry] 144 | EntityRegistryEntrySnapshot({ 145 | 'aliases': set({ 146 | }), 147 | 'area_id': None, 148 | 'capabilities': None, 149 | 'config_entry_id': , 150 | 'config_subentry_id': , 151 | 'device_class': None, 152 | 'device_id': , 153 | 'disabled_by': None, 154 | 'domain': 'switch', 155 | 'entity_category': , 156 | 'entity_id': 'switch.gs012345_auto_on_off_axfz5bj', 157 | 'has_entity_name': True, 158 | 'hidden_by': None, 159 | 'icon': None, 160 | 'id': , 161 | 'labels': set({ 162 | }), 163 | 'name': None, 164 | 'options': dict({ 165 | }), 166 | 'original_device_class': None, 167 | 'original_icon': None, 168 | 'original_name': 'Auto on/off (aXFz5bJ)', 169 | 'platform': 'lamarzocco', 170 | 'previous_unique_id': None, 171 | 'supported_features': 0, 172 | 'translation_key': 'auto_on_off', 173 | 'unique_id': 'GS012345_auto_on_off_aXFz5bJ', 174 | 'unit_of_measurement': None, 175 | }) 176 | # --- 177 | # name: test_switches[switch.gs012345_auto_on_off_axfz5bj-state] 178 | StateSnapshot({ 179 | 'attributes': ReadOnlyDict({ 180 | 'friendly_name': 'GS012345 Auto on/off (aXFz5bJ)', 181 | }), 182 | 'context': , 183 | 'entity_id': 'switch.gs012345_auto_on_off_axfz5bj', 184 | 'last_changed': , 185 | 'last_reported': , 186 | 'last_updated': , 187 | 'state': 'on', 188 | }) 189 | # --- 190 | # name: test_switches[switch.gs012345_auto_on_off_os2oswx-entry] 191 | EntityRegistryEntrySnapshot({ 192 | 'aliases': set({ 193 | }), 194 | 'area_id': None, 195 | 'capabilities': None, 196 | 'config_entry_id': , 197 | 'config_subentry_id': , 198 | 'device_class': None, 199 | 'device_id': , 200 | 'disabled_by': None, 201 | 'domain': 'switch', 202 | 'entity_category': , 203 | 'entity_id': 'switch.gs012345_auto_on_off_os2oswx', 204 | 'has_entity_name': True, 205 | 'hidden_by': None, 206 | 'icon': None, 207 | 'id': , 208 | 'labels': set({ 209 | }), 210 | 'name': None, 211 | 'options': dict({ 212 | }), 213 | 'original_device_class': None, 214 | 'original_icon': None, 215 | 'original_name': 'Auto on/off (Os2OswX)', 216 | 'platform': 'lamarzocco', 217 | 'previous_unique_id': None, 218 | 'supported_features': 0, 219 | 'translation_key': 'auto_on_off', 220 | 'unique_id': 'GS012345_auto_on_off_Os2OswX', 221 | 'unit_of_measurement': None, 222 | }) 223 | # --- 224 | # name: test_switches[switch.gs012345_auto_on_off_os2oswx-state] 225 | StateSnapshot({ 226 | 'attributes': ReadOnlyDict({ 227 | 'friendly_name': 'GS012345 Auto on/off (Os2OswX)', 228 | }), 229 | 'context': , 230 | 'entity_id': 'switch.gs012345_auto_on_off_os2oswx', 231 | 'last_changed': , 232 | 'last_reported': , 233 | 'last_updated': , 234 | 'state': 'on', 235 | }) 236 | # --- 237 | # name: test_switches[switch.gs012345_smart_standby_enabled-entry] 238 | EntityRegistryEntrySnapshot({ 239 | 'aliases': set({ 240 | }), 241 | 'area_id': None, 242 | 'capabilities': None, 243 | 'config_entry_id': , 244 | 'config_subentry_id': , 245 | 'device_class': None, 246 | 'device_id': , 247 | 'disabled_by': None, 248 | 'domain': 'switch', 249 | 'entity_category': , 250 | 'entity_id': 'switch.gs012345_smart_standby_enabled', 251 | 'has_entity_name': True, 252 | 'hidden_by': None, 253 | 'icon': None, 254 | 'id': , 255 | 'labels': set({ 256 | }), 257 | 'name': None, 258 | 'options': dict({ 259 | }), 260 | 'original_device_class': None, 261 | 'original_icon': None, 262 | 'original_name': 'Smart standby enabled', 263 | 'platform': 'lamarzocco', 264 | 'previous_unique_id': None, 265 | 'supported_features': 0, 266 | 'translation_key': 'smart_standby_enabled', 267 | 'unique_id': 'GS012345_smart_standby_enabled', 268 | 'unit_of_measurement': None, 269 | }) 270 | # --- 271 | # name: test_switches[switch.gs012345_smart_standby_enabled-state] 272 | StateSnapshot({ 273 | 'attributes': ReadOnlyDict({ 274 | 'friendly_name': 'GS012345 Smart standby enabled', 275 | }), 276 | 'context': , 277 | 'entity_id': 'switch.gs012345_smart_standby_enabled', 278 | 'last_changed': , 279 | 'last_reported': , 280 | 'last_updated': , 281 | 'state': 'on', 282 | }) 283 | # --- 284 | # name: test_switches[switch.gs012345_steam_boiler-entry] 285 | EntityRegistryEntrySnapshot({ 286 | 'aliases': set({ 287 | }), 288 | 'area_id': None, 289 | 'capabilities': None, 290 | 'config_entry_id': , 291 | 'config_subentry_id': , 292 | 'device_class': None, 293 | 'device_id': , 294 | 'disabled_by': None, 295 | 'domain': 'switch', 296 | 'entity_category': None, 297 | 'entity_id': 'switch.gs012345_steam_boiler', 298 | 'has_entity_name': True, 299 | 'hidden_by': None, 300 | 'icon': None, 301 | 'id': , 302 | 'labels': set({ 303 | }), 304 | 'name': None, 305 | 'options': dict({ 306 | }), 307 | 'original_device_class': None, 308 | 'original_icon': None, 309 | 'original_name': 'Steam boiler', 310 | 'platform': 'lamarzocco', 311 | 'previous_unique_id': None, 312 | 'supported_features': 0, 313 | 'translation_key': 'steam_boiler', 314 | 'unique_id': 'GS012345_steam_boiler_enable', 315 | 'unit_of_measurement': None, 316 | }) 317 | # --- 318 | # name: test_switches[switch.gs012345_steam_boiler-state] 319 | StateSnapshot({ 320 | 'attributes': ReadOnlyDict({ 321 | 'friendly_name': 'GS012345 Steam boiler', 322 | }), 323 | 'context': , 324 | 'entity_id': 'switch.gs012345_steam_boiler', 325 | 'last_changed': , 326 | 'last_reported': , 327 | 'last_updated': , 328 | 'state': 'on', 329 | }) 330 | # --- 331 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | """Test initialization of lamarzocco.""" 2 | 3 | from datetime import timedelta 4 | from unittest.mock import AsyncMock, MagicMock, patch 5 | 6 | from freezegun.api import FrozenDateTimeFactory 7 | from pylamarzocco.const import FirmwareType, MachineModel 8 | from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful 9 | import pytest 10 | from syrupy import SnapshotAssertion 11 | 12 | from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE 13 | from homeassistant.components.lamarzocco.const import DOMAIN 14 | from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState 15 | from homeassistant.const import ( 16 | CONF_HOST, 17 | CONF_MAC, 18 | CONF_MODEL, 19 | CONF_NAME, 20 | CONF_TOKEN, 21 | EVENT_HOMEASSISTANT_STOP, 22 | ) 23 | from homeassistant.core import HomeAssistant 24 | from homeassistant.helpers import ( 25 | device_registry as dr, 26 | entity_registry as er, 27 | issue_registry as ir, 28 | ) 29 | 30 | from . import USER_INPUT, async_init_integration, get_bluetooth_service_info 31 | 32 | from tests.common import MockConfigEntry, async_fire_time_changed 33 | 34 | 35 | async def test_load_unload_config_entry( 36 | hass: HomeAssistant, 37 | mock_config_entry: MockConfigEntry, 38 | mock_lamarzocco: MagicMock, 39 | ) -> None: 40 | """Test loading and unloading the integration.""" 41 | await async_init_integration(hass, mock_config_entry) 42 | 43 | assert mock_config_entry.state is ConfigEntryState.LOADED 44 | 45 | await hass.config_entries.async_unload(mock_config_entry.entry_id) 46 | await hass.async_block_till_done() 47 | 48 | assert mock_config_entry.state is ConfigEntryState.NOT_LOADED 49 | 50 | 51 | async def test_config_entry_not_ready( 52 | hass: HomeAssistant, 53 | mock_config_entry: MockConfigEntry, 54 | mock_lamarzocco: MagicMock, 55 | ) -> None: 56 | """Test the La Marzocco configuration entry not ready.""" 57 | mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("") 58 | 59 | await async_init_integration(hass, mock_config_entry) 60 | 61 | assert len(mock_lamarzocco.get_config.mock_calls) == 1 62 | assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY 63 | 64 | 65 | async def test_invalid_auth( 66 | hass: HomeAssistant, 67 | mock_config_entry: MockConfigEntry, 68 | mock_lamarzocco: MagicMock, 69 | ) -> None: 70 | """Test auth error during setup.""" 71 | mock_lamarzocco.get_config.side_effect = AuthFail("") 72 | await async_init_integration(hass, mock_config_entry) 73 | 74 | assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR 75 | assert len(mock_lamarzocco.get_config.mock_calls) == 1 76 | 77 | flows = hass.config_entries.flow.async_progress() 78 | assert len(flows) == 1 79 | 80 | flow = flows[0] 81 | assert flow.get("step_id") == "reauth_confirm" 82 | assert flow.get("handler") == DOMAIN 83 | 84 | assert "context" in flow 85 | assert flow["context"].get("source") == SOURCE_REAUTH 86 | assert flow["context"].get("entry_id") == mock_config_entry.entry_id 87 | 88 | 89 | async def test_v1_migration( 90 | hass: HomeAssistant, 91 | mock_cloud_client: MagicMock, 92 | mock_lamarzocco: MagicMock, 93 | ) -> None: 94 | """Test v1 -> v2 Migration.""" 95 | common_data = { 96 | **USER_INPUT, 97 | CONF_HOST: "host", 98 | CONF_MAC: "aa:bb:cc:dd:ee:ff", 99 | } 100 | entry_v1 = MockConfigEntry( 101 | domain=DOMAIN, 102 | version=1, 103 | unique_id=mock_lamarzocco.serial_number, 104 | data={ 105 | **common_data, 106 | CONF_MACHINE: mock_lamarzocco.serial_number, 107 | }, 108 | ) 109 | 110 | entry_v1.add_to_hass(hass) 111 | await hass.config_entries.async_setup(entry_v1.entry_id) 112 | await hass.async_block_till_done() 113 | 114 | assert entry_v1.version == 2 115 | assert dict(entry_v1.data) == { 116 | **common_data, 117 | CONF_NAME: "GS3", 118 | CONF_MODEL: mock_lamarzocco.model, 119 | CONF_TOKEN: "token", 120 | } 121 | 122 | 123 | async def test_migration_errors( 124 | hass: HomeAssistant, 125 | mock_config_entry: MockConfigEntry, 126 | mock_cloud_client: MagicMock, 127 | mock_lamarzocco: MagicMock, 128 | ) -> None: 129 | """Test errors during migration.""" 130 | 131 | mock_cloud_client.get_customer_fleet.side_effect = RequestNotSuccessful("Error") 132 | 133 | entry_v1 = MockConfigEntry( 134 | domain=DOMAIN, 135 | version=1, 136 | unique_id=mock_lamarzocco.serial_number, 137 | data={ 138 | **USER_INPUT, 139 | CONF_MACHINE: mock_lamarzocco.serial_number, 140 | }, 141 | ) 142 | entry_v1.add_to_hass(hass) 143 | 144 | assert not await hass.config_entries.async_setup(entry_v1.entry_id) 145 | assert entry_v1.state is ConfigEntryState.MIGRATION_ERROR 146 | 147 | 148 | async def test_config_flow_entry_migration_downgrade( 149 | hass: HomeAssistant, 150 | ) -> None: 151 | """Test that config entry fails setup if the version is from the future.""" 152 | entry = MockConfigEntry(domain=DOMAIN, version=3) 153 | entry.add_to_hass(hass) 154 | 155 | assert not await hass.config_entries.async_setup(entry.entry_id) 156 | 157 | 158 | async def test_bluetooth_is_set_from_discovery( 159 | hass: HomeAssistant, 160 | mock_config_entry: MockConfigEntry, 161 | mock_lamarzocco: MagicMock, 162 | ) -> None: 163 | """Check we can fill a device from discovery info.""" 164 | 165 | service_info = get_bluetooth_service_info( 166 | mock_lamarzocco.model, mock_lamarzocco.serial_number 167 | ) 168 | with ( 169 | patch( 170 | "homeassistant.components.lamarzocco.async_discovered_service_info", 171 | return_value=[service_info], 172 | ) as discovery, 173 | patch( 174 | "homeassistant.components.lamarzocco.LaMarzoccoMachine" 175 | ) as mock_machine_class, 176 | ): 177 | mock_machine = MagicMock() 178 | mock_machine.get_firmware = AsyncMock() 179 | mock_machine.firmware = mock_lamarzocco.firmware 180 | mock_machine_class.return_value = mock_machine 181 | await async_init_integration(hass, mock_config_entry) 182 | discovery.assert_called_once() 183 | assert mock_machine_class.call_count == 2 184 | _, kwargs = mock_machine_class.call_args 185 | assert kwargs["bluetooth_client"] is not None 186 | assert mock_config_entry.data[CONF_NAME] == service_info.name 187 | assert mock_config_entry.data[CONF_MAC] == service_info.address 188 | 189 | 190 | async def test_websocket_closed_on_unload( 191 | hass: HomeAssistant, 192 | mock_config_entry: MockConfigEntry, 193 | mock_lamarzocco: MagicMock, 194 | ) -> None: 195 | """Test the websocket is closed on unload.""" 196 | with patch( 197 | "homeassistant.components.lamarzocco.LaMarzoccoLocalClient", 198 | autospec=True, 199 | ) as local_client: 200 | client = local_client.return_value 201 | client.websocket = AsyncMock() 202 | 203 | await async_init_integration(hass, mock_config_entry) 204 | mock_lamarzocco.websocket_connect.assert_called_once() 205 | 206 | client.websocket.closed = False 207 | hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) 208 | await hass.async_block_till_done() 209 | client.websocket.close.assert_called_once() 210 | 211 | 212 | @pytest.mark.parametrize( 213 | ("version", "issue_exists"), [("v3.5-rc6", False), ("v3.3-rc4", True)] 214 | ) 215 | async def test_gateway_version_issue( 216 | hass: HomeAssistant, 217 | mock_config_entry: MockConfigEntry, 218 | mock_lamarzocco: MagicMock, 219 | version: str, 220 | issue_exists: bool, 221 | ) -> None: 222 | """Make sure we get the issue for certain gateway firmware versions.""" 223 | mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = version 224 | 225 | await async_init_integration(hass, mock_config_entry) 226 | 227 | issue_registry = ir.async_get(hass) 228 | issue = issue_registry.async_get_issue(DOMAIN, "unsupported_gateway_firmware") 229 | assert (issue is not None) == issue_exists 230 | 231 | 232 | async def test_conf_host_removed_for_new_gateway( 233 | hass: HomeAssistant, 234 | mock_config_entry: MockConfigEntry, 235 | mock_lamarzocco: MagicMock, 236 | ) -> None: 237 | """Make sure we get the issue for certain gateway firmware versions.""" 238 | mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = "v5.0.9" 239 | 240 | await async_init_integration(hass, mock_config_entry) 241 | 242 | assert CONF_HOST not in mock_config_entry.data 243 | 244 | 245 | async def test_device( 246 | hass: HomeAssistant, 247 | mock_lamarzocco: MagicMock, 248 | mock_config_entry: MockConfigEntry, 249 | device_registry: dr.DeviceRegistry, 250 | entity_registry: er.EntityRegistry, 251 | snapshot: SnapshotAssertion, 252 | ) -> None: 253 | """Test the device.""" 254 | 255 | await async_init_integration(hass, mock_config_entry) 256 | 257 | hass.config_entries.async_update_entry( 258 | mock_config_entry, 259 | data={**mock_config_entry.data, CONF_MAC: "aa:bb:cc:dd:ee:ff"}, 260 | ) 261 | 262 | state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}") 263 | assert state 264 | 265 | entry = entity_registry.async_get(state.entity_id) 266 | assert entry 267 | assert entry.device_id 268 | 269 | device = device_registry.async_get(entry.device_id) 270 | assert device 271 | assert device == snapshot 272 | 273 | 274 | @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) 275 | async def test_scale_device( 276 | hass: HomeAssistant, 277 | mock_lamarzocco: MagicMock, 278 | mock_config_entry: MockConfigEntry, 279 | device_registry: dr.DeviceRegistry, 280 | snapshot: SnapshotAssertion, 281 | ) -> None: 282 | """Test the device.""" 283 | 284 | await async_init_integration(hass, mock_config_entry) 285 | 286 | device = device_registry.async_get_device( 287 | identifiers={(DOMAIN, mock_lamarzocco.config.scale.address)} 288 | ) 289 | assert device 290 | assert device == snapshot 291 | 292 | 293 | @pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI]) 294 | async def test_remove_stale_scale( 295 | hass: HomeAssistant, 296 | mock_lamarzocco: MagicMock, 297 | mock_config_entry: MockConfigEntry, 298 | device_registry: dr.DeviceRegistry, 299 | freezer: FrozenDateTimeFactory, 300 | ) -> None: 301 | """Ensure stale scale is cleaned up.""" 302 | 303 | await async_init_integration(hass, mock_config_entry) 304 | 305 | scale_address = mock_lamarzocco.config.scale.address 306 | 307 | device = device_registry.async_get_device(identifiers={(DOMAIN, scale_address)}) 308 | assert device 309 | 310 | mock_lamarzocco.config.scale = None 311 | 312 | freezer.tick(timedelta(minutes=10)) 313 | async_fire_time_changed(hass) 314 | await hass.async_block_till_done() 315 | 316 | device = device_registry.async_get_device(identifiers={(DOMAIN, scale_address)}) 317 | assert device is None 318 | -------------------------------------------------------------------------------- /custom_components/lamarzocco/number.py: -------------------------------------------------------------------------------- 1 | """Number platform for La Marzocco espresso machines.""" 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | from typing import Any 6 | 7 | from pylamarzocco.const import ( 8 | KEYS_PER_MODEL, 9 | BoilerType, 10 | MachineModel, 11 | PhysicalKey, 12 | PrebrewMode, 13 | ) 14 | from pylamarzocco.devices.machine import LaMarzoccoMachine 15 | from pylamarzocco.exceptions import RequestNotSuccessful 16 | from pylamarzocco.models import LaMarzoccoMachineConfig 17 | 18 | from homeassistant.components.number import ( 19 | NumberDeviceClass, 20 | NumberEntity, 21 | NumberEntityDescription, 22 | ) 23 | from homeassistant.const import ( 24 | PRECISION_TENTHS, 25 | PRECISION_WHOLE, 26 | EntityCategory, 27 | UnitOfTemperature, 28 | UnitOfTime, 29 | ) 30 | from homeassistant.core import HomeAssistant 31 | from homeassistant.exceptions import HomeAssistantError 32 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 33 | 34 | from .const import DOMAIN 35 | from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator 36 | from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity 37 | 38 | PARALLEL_UPDATES = 1 39 | 40 | 41 | @dataclass(frozen=True, kw_only=True) 42 | class LaMarzoccoNumberEntityDescription( 43 | LaMarzoccoEntityDescription, 44 | NumberEntityDescription, 45 | ): 46 | """Description of a La Marzocco number entity.""" 47 | 48 | native_value_fn: Callable[[LaMarzoccoMachineConfig], float | int] 49 | set_value_fn: Callable[[LaMarzoccoMachine, float | int], Coroutine[Any, Any, bool]] 50 | 51 | 52 | @dataclass(frozen=True, kw_only=True) 53 | class LaMarzoccoKeyNumberEntityDescription( 54 | LaMarzoccoEntityDescription, 55 | NumberEntityDescription, 56 | ): 57 | """Description of an La Marzocco number entity with keys.""" 58 | 59 | native_value_fn: Callable[ 60 | [LaMarzoccoMachineConfig, PhysicalKey], float | int | None 61 | ] 62 | set_value_fn: Callable[ 63 | [LaMarzoccoMachine, float | int, PhysicalKey], Coroutine[Any, Any, bool] 64 | ] 65 | 66 | 67 | ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( 68 | LaMarzoccoNumberEntityDescription( 69 | key="coffee_temp", 70 | translation_key="coffee_temp", 71 | device_class=NumberDeviceClass.TEMPERATURE, 72 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 73 | native_step=PRECISION_TENTHS, 74 | native_min_value=85, 75 | native_max_value=104, 76 | set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.COFFEE, temp), 77 | native_value_fn=lambda config: config.boilers[ 78 | BoilerType.COFFEE 79 | ].target_temperature, 80 | ), 81 | LaMarzoccoNumberEntityDescription( 82 | key="steam_temp", 83 | translation_key="steam_temp", 84 | device_class=NumberDeviceClass.TEMPERATURE, 85 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 86 | native_step=PRECISION_WHOLE, 87 | native_min_value=126, 88 | native_max_value=131, 89 | set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.STEAM, temp), 90 | native_value_fn=lambda config: config.boilers[ 91 | BoilerType.STEAM 92 | ].target_temperature, 93 | supported_fn=lambda coordinator: coordinator.device.model 94 | in ( 95 | MachineModel.GS3_AV, 96 | MachineModel.GS3_MP, 97 | ), 98 | ), 99 | LaMarzoccoNumberEntityDescription( 100 | key="tea_water_duration", 101 | translation_key="tea_water_duration", 102 | device_class=NumberDeviceClass.DURATION, 103 | native_unit_of_measurement=UnitOfTime.SECONDS, 104 | native_step=PRECISION_WHOLE, 105 | native_min_value=0, 106 | native_max_value=30, 107 | set_value_fn=lambda machine, value: machine.set_dose_tea_water(int(value)), 108 | native_value_fn=lambda config: config.dose_hot_water, 109 | supported_fn=lambda coordinator: coordinator.device.model 110 | in ( 111 | MachineModel.GS3_AV, 112 | MachineModel.GS3_MP, 113 | ), 114 | ), 115 | LaMarzoccoNumberEntityDescription( 116 | key="smart_standby_time", 117 | translation_key="smart_standby_time", 118 | device_class=NumberDeviceClass.DURATION, 119 | native_unit_of_measurement=UnitOfTime.MINUTES, 120 | native_step=10, 121 | native_min_value=10, 122 | native_max_value=240, 123 | entity_category=EntityCategory.CONFIG, 124 | set_value_fn=lambda machine, value: machine.set_smart_standby( 125 | enabled=machine.config.smart_standby.enabled, 126 | mode=machine.config.smart_standby.mode, 127 | minutes=int(value), 128 | ), 129 | native_value_fn=lambda config: config.smart_standby.minutes, 130 | ), 131 | ) 132 | 133 | 134 | KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( 135 | LaMarzoccoKeyNumberEntityDescription( 136 | key="prebrew_off", 137 | translation_key="prebrew_off", 138 | device_class=NumberDeviceClass.DURATION, 139 | native_unit_of_measurement=UnitOfTime.SECONDS, 140 | native_step=PRECISION_TENTHS, 141 | native_min_value=1, 142 | native_max_value=10, 143 | entity_category=EntityCategory.CONFIG, 144 | set_value_fn=lambda machine, value, key: machine.set_prebrew_time( 145 | prebrew_off_time=value, key=key 146 | ), 147 | native_value_fn=lambda config, key: config.prebrew_configuration[key][ 148 | 0 149 | ].off_time, 150 | available_fn=lambda device: len(device.config.prebrew_configuration) > 0 151 | and device.config.prebrew_mode 152 | in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED), 153 | supported_fn=lambda coordinator: coordinator.device.model 154 | != MachineModel.GS3_MP, 155 | ), 156 | LaMarzoccoKeyNumberEntityDescription( 157 | key="prebrew_on", 158 | translation_key="prebrew_on", 159 | device_class=NumberDeviceClass.DURATION, 160 | native_unit_of_measurement=UnitOfTime.SECONDS, 161 | native_step=PRECISION_TENTHS, 162 | native_min_value=2, 163 | native_max_value=10, 164 | entity_category=EntityCategory.CONFIG, 165 | set_value_fn=lambda machine, value, key: machine.set_prebrew_time( 166 | prebrew_on_time=value, key=key 167 | ), 168 | native_value_fn=lambda config, key: config.prebrew_configuration[key][ 169 | 0 170 | ].off_time, 171 | available_fn=lambda device: len(device.config.prebrew_configuration) > 0 172 | and device.config.prebrew_mode 173 | in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED), 174 | supported_fn=lambda coordinator: coordinator.device.model 175 | != MachineModel.GS3_MP, 176 | ), 177 | LaMarzoccoKeyNumberEntityDescription( 178 | key="preinfusion_off", 179 | translation_key="preinfusion_off", 180 | device_class=NumberDeviceClass.DURATION, 181 | native_unit_of_measurement=UnitOfTime.SECONDS, 182 | native_step=PRECISION_TENTHS, 183 | native_min_value=2, 184 | native_max_value=29, 185 | entity_category=EntityCategory.CONFIG, 186 | set_value_fn=lambda machine, value, key: machine.set_preinfusion_time( 187 | preinfusion_time=value, key=key 188 | ), 189 | native_value_fn=lambda config, key: config.prebrew_configuration[key][ 190 | 1 191 | ].preinfusion_time, 192 | available_fn=lambda device: len(device.config.prebrew_configuration) > 0 193 | and device.config.prebrew_mode == PrebrewMode.PREINFUSION, 194 | supported_fn=lambda coordinator: coordinator.device.model 195 | != MachineModel.GS3_MP, 196 | ), 197 | LaMarzoccoKeyNumberEntityDescription( 198 | key="dose", 199 | translation_key="dose", 200 | native_unit_of_measurement="ticks", 201 | native_step=PRECISION_WHOLE, 202 | native_min_value=0, 203 | native_max_value=999, 204 | entity_category=EntityCategory.CONFIG, 205 | set_value_fn=lambda machine, ticks, key: machine.set_dose( 206 | dose=int(ticks), key=key 207 | ), 208 | native_value_fn=lambda config, key: config.doses[key], 209 | supported_fn=lambda coordinator: coordinator.device.model 210 | == MachineModel.GS3_AV, 211 | ), 212 | ) 213 | 214 | SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( 215 | LaMarzoccoKeyNumberEntityDescription( 216 | key="scale_target", 217 | translation_key="scale_target", 218 | native_step=PRECISION_WHOLE, 219 | native_min_value=1, 220 | native_max_value=100, 221 | entity_category=EntityCategory.CONFIG, 222 | set_value_fn=lambda machine, weight, key: machine.set_bbw_recipe_target( 223 | key, int(weight) 224 | ), 225 | native_value_fn=lambda config, key: ( 226 | config.bbw_settings.doses[key] if config.bbw_settings else None 227 | ), 228 | supported_fn=( 229 | lambda coordinator: coordinator.device.model 230 | in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R) 231 | and coordinator.device.config.scale is not None 232 | ), 233 | ), 234 | ) 235 | 236 | 237 | async def async_setup_entry( 238 | hass: HomeAssistant, 239 | entry: LaMarzoccoConfigEntry, 240 | async_add_entities: AddConfigEntryEntitiesCallback, 241 | ) -> None: 242 | """Set up number entities.""" 243 | coordinator = entry.runtime_data.config_coordinator 244 | entities: list[NumberEntity] = [ 245 | LaMarzoccoNumberEntity(coordinator, description) 246 | for description in ENTITIES 247 | if description.supported_fn(coordinator) 248 | ] 249 | 250 | for description in KEY_ENTITIES: 251 | if description.supported_fn(coordinator): 252 | num_keys = KEYS_PER_MODEL[MachineModel(coordinator.device.model)] 253 | entities.extend( 254 | LaMarzoccoKeyNumberEntity(coordinator, description, key) 255 | for key in range(min(num_keys, 1), num_keys + 1) 256 | ) 257 | 258 | for description in SCALE_KEY_ENTITIES: 259 | if description.supported_fn(coordinator): 260 | if bbw_settings := coordinator.device.config.bbw_settings: 261 | entities.extend( 262 | LaMarzoccoScaleTargetNumberEntity( 263 | coordinator, description, int(key) 264 | ) 265 | for key in bbw_settings.doses 266 | ) 267 | 268 | def _async_add_new_scale() -> None: 269 | if bbw_settings := coordinator.device.config.bbw_settings: 270 | async_add_entities( 271 | LaMarzoccoScaleTargetNumberEntity(coordinator, description, int(key)) 272 | for description in SCALE_KEY_ENTITIES 273 | for key in bbw_settings.doses 274 | ) 275 | 276 | coordinator.new_device_callback.append(_async_add_new_scale) 277 | 278 | async_add_entities(entities) 279 | 280 | 281 | class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): 282 | """La Marzocco number entity.""" 283 | 284 | entity_description: LaMarzoccoNumberEntityDescription 285 | 286 | @property 287 | def native_value(self) -> float: 288 | """Return the current value.""" 289 | return self.entity_description.native_value_fn(self.coordinator.device.config) 290 | 291 | async def async_set_native_value(self, value: float) -> None: 292 | """Set the value.""" 293 | if value != self.native_value: 294 | try: 295 | await self.entity_description.set_value_fn( 296 | self.coordinator.device, value 297 | ) 298 | except RequestNotSuccessful as exc: 299 | raise HomeAssistantError( 300 | translation_domain=DOMAIN, 301 | translation_key="number_exception", 302 | translation_placeholders={ 303 | "key": self.entity_description.key, 304 | "value": str(value), 305 | }, 306 | ) from exc 307 | self.async_write_ha_state() 308 | 309 | 310 | class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity): 311 | """Number representing espresso machine with key support.""" 312 | 313 | entity_description: LaMarzoccoKeyNumberEntityDescription 314 | 315 | def __init__( 316 | self, 317 | coordinator: LaMarzoccoUpdateCoordinator, 318 | description: LaMarzoccoKeyNumberEntityDescription, 319 | pyhsical_key: int, 320 | ) -> None: 321 | """Initialize the entity.""" 322 | super().__init__(coordinator, description) 323 | 324 | # Physical Key on the machine the entity represents. 325 | if pyhsical_key == 0: 326 | pyhsical_key = 1 327 | else: 328 | self._attr_translation_key = f"{description.translation_key}_key" 329 | self._attr_translation_placeholders = {"key": str(pyhsical_key)} 330 | self._attr_unique_id = f"{super()._attr_unique_id}_key{pyhsical_key}" 331 | self._attr_entity_registry_enabled_default = False 332 | self.pyhsical_key = pyhsical_key 333 | 334 | @property 335 | def native_value(self) -> float | None: 336 | """Return the current value.""" 337 | return self.entity_description.native_value_fn( 338 | self.coordinator.device.config, PhysicalKey(self.pyhsical_key) 339 | ) 340 | 341 | async def async_set_native_value(self, value: float) -> None: 342 | """Set the value.""" 343 | if value != self.native_value: 344 | try: 345 | await self.entity_description.set_value_fn( 346 | self.coordinator.device, value, PhysicalKey(self.pyhsical_key) 347 | ) 348 | except RequestNotSuccessful as exc: 349 | raise HomeAssistantError( 350 | translation_domain=DOMAIN, 351 | translation_key="number_exception_key", 352 | translation_placeholders={ 353 | "key": self.entity_description.key, 354 | "value": str(value), 355 | "physical_key": str(self.pyhsical_key), 356 | }, 357 | ) from exc 358 | self.async_write_ha_state() 359 | 360 | 361 | class LaMarzoccoScaleTargetNumberEntity( 362 | LaMarzoccoKeyNumberEntity, LaMarzoccScaleEntity 363 | ): 364 | """Entity representing a key number on the scale.""" 365 | 366 | entity_description: LaMarzoccoKeyNumberEntityDescription 367 | --------------------------------------------------------------------------------