Add integration lamarzocco (#102291)

Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: tronikos <tronikos@users.noreply.github.com>
Co-authored-by: Luke Lashley <conway220@gmail.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: dupondje <jean-louis@dupond.be>
This commit is contained in:
Josef Zweck 2024-01-16 15:24:16 +01:00 committed by GitHub
parent a874895a81
commit 6bc36666b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1482 additions and 0 deletions

View File

@ -248,6 +248,7 @@ homeassistant.components.knx.*
homeassistant.components.kraken.*
homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.*
homeassistant.components.lamarzocco.*
homeassistant.components.lametric.*
homeassistant.components.laundrify.*
homeassistant.components.lawn_mower.*

View File

@ -688,6 +688,8 @@ build.json @home-assistant/supervisor
/tests/components/kulersky/ @emlove
/homeassistant/components/lacrosse_view/ @IceBotYT
/tests/components/lacrosse_view/ @IceBotYT
/homeassistant/components/lamarzocco/ @zweckj
/tests/components/lamarzocco/ @zweckj
/homeassistant/components/lametric/ @robbiet480 @frenck @bachya
/tests/components/lametric/ @robbiet480 @frenck @bachya
/homeassistant/components/landisgyr_heat_meter/ @vpathuis

View File

@ -0,0 +1,36 @@
"""The La Marzocco integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import LaMarzoccoUpdateCoordinator
PLATFORMS = [
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up La Marzocco as config entry."""
coordinator = LaMarzoccoUpdateCoordinator(hass)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,156 @@
"""Config flow for La Marzocco integration."""
from collections.abc import Mapping
import logging
from typing import Any
from lmcloud import LMCloud as LaMarzoccoClient
from lmcloud.exceptions import AuthFail, RequestNotSuccessful
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_MACHINE, DOMAIN
_LOGGER = logging.getLogger(__name__)
class LmConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for La Marzocco."""
def __init__(self) -> None:
"""Initialize the config flow."""
self.reauth_entry: ConfigEntry | None = None
self._config: dict[str, Any] = {}
self._machines: list[tuple[str, str]] = []
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors = {}
if user_input:
data: dict[str, Any] = {}
if self.reauth_entry:
data = dict(self.reauth_entry.data)
data = {
**data,
**user_input,
}
lm = LaMarzoccoClient()
try:
self._machines = await lm.get_all_machines(data)
except AuthFail:
_LOGGER.debug("Server rejected login credentials")
errors["base"] = "invalid_auth"
except RequestNotSuccessful as exc:
_LOGGER.exception("Error connecting to server: %s", str(exc))
errors["base"] = "cannot_connect"
else:
if not self._machines:
errors["base"] = "no_machines"
if not errors:
if self.reauth_entry:
self.hass.config_entries.async_update_entry(
self.reauth_entry, data=user_input
)
await self.hass.config_entries.async_reload(
self.reauth_entry.entry_id
)
return self.async_abort(reason="reauth_successful")
if not errors:
self._config = data
return await self.async_step_machine_selection()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
)
async def async_step_machine_selection(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Let user select machine to connect to."""
errors: dict[str, str] = {}
if user_input:
serial_number = user_input[CONF_MACHINE]
await self.async_set_unique_id(serial_number)
self._abort_if_unique_id_configured()
# validate local connection if host is provided
if user_input.get(CONF_HOST):
lm = LaMarzoccoClient()
if not await lm.check_local_connection(
credentials=self._config,
host=user_input[CONF_HOST],
serial=serial_number,
):
errors[CONF_HOST] = "cannot_connect"
if not errors:
return self.async_create_entry(
title=serial_number,
data=self._config | user_input,
)
machine_options = [
SelectOptionDict(
value=serial_number,
label=f"{model_name} ({serial_number})",
)
for serial_number, model_name in self._machines
]
machine_selection_schema = vol.Schema(
{
vol.Required(
CONF_MACHINE, default=machine_options[0]["value"]
): SelectSelector(
SelectSelectorConfig(
options=machine_options,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_HOST): cv.string,
}
)
return self.async_show_form(
step_id="machine_selection",
data_schema=machine_selection_schema,
errors=errors,
)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
)

View File

@ -0,0 +1,7 @@
"""Constants for the La Marzocco integration."""
from typing import Final
DOMAIN: Final = "lamarzocco"
CONF_MACHINE: Final = "machine"

View File

@ -0,0 +1,96 @@
"""Coordinator for La Marzocco API."""
from collections.abc import Callable, Coroutine
from datetime import timedelta
import logging
from typing import Any
from lmcloud import LMCloud as LaMarzoccoClient
from lmcloud.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_MACHINE, DOMAIN
SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
"""Class to handle fetching data from the La Marzocco API centrally."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize coordinator."""
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL)
self.lm = LaMarzoccoClient(
callback_websocket_notify=self.async_update_listeners,
)
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
if not self.lm.initialized:
await self._async_init_client()
await self._async_handle_request(
self.lm.update_local_machine_status, force_update=True
)
_LOGGER.debug("Current status: %s", str(self.lm.current_status))
async def _async_init_client(self) -> None:
"""Initialize the La Marzocco Client."""
# Initialize cloud API
_LOGGER.debug("Initializing Cloud API")
await self._async_handle_request(
self.lm.init_cloud_api,
credentials=self.config_entry.data,
machine_serial=self.config_entry.data[CONF_MACHINE],
)
_LOGGER.debug("Model name: %s", self.lm.model_name)
# initialize local API
if (host := self.config_entry.data.get(CONF_HOST)) is not None:
_LOGGER.debug("Initializing local API")
await self.lm.init_local_api(
host=host,
client=get_async_client(self.hass),
)
_LOGGER.debug("Init WebSocket in Background Task")
self.config_entry.async_create_background_task(
hass=self.hass,
target=self.lm.lm_local_api.websocket_connect(
callback=self.lm.on_websocket_message_received,
use_sigterm_handler=False,
),
name="lm_websocket_task",
)
self.lm.initialized = True
async def _async_handle_request(
self,
func: Callable[..., Coroutine[None, None, None]],
*args: Any,
**kwargs: Any,
) -> None:
"""Handle a request to the API."""
try:
await func(*args, **kwargs)
except AuthFail as ex:
msg = "Authentication failed."
_LOGGER.debug(msg, exc_info=True)
raise ConfigEntryAuthFailed(msg) from ex
except RequestNotSuccessful as ex:
_LOGGER.debug(ex, exc_info=True)
raise UpdateFailed("Querying API failed. Error: %s" % ex) from ex

View File

@ -0,0 +1,50 @@
"""Base class for the La Marzocco entities."""
from dataclasses import dataclass
from lmcloud.const import LaMarzoccoModel
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LaMarzoccoUpdateCoordinator
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoEntityDescription(EntityDescription):
"""Description for all LM entities."""
supported_models: tuple[LaMarzoccoModel, ...] = (
LaMarzoccoModel.GS3_AV,
LaMarzoccoModel.GS3_MP,
LaMarzoccoModel.LINEA_MICRA,
LaMarzoccoModel.LINEA_MINI,
)
class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]):
"""Common elements for all entities."""
entity_description: LaMarzoccoEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: LaMarzoccoUpdateCoordinator,
entity_description: LaMarzoccoEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
lm = coordinator.lm
self._attr_unique_id = f"{lm.serial_number}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, lm.serial_number)},
name=lm.machine_name,
manufacturer="La Marzocco",
model=lm.true_model_name,
serial_number=lm.serial_number,
sw_version=lm.firmware_version,
)

View File

@ -0,0 +1,11 @@
{
"domain": "lamarzocco",
"name": "La Marzocco",
"codeowners": ["@zweckj"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lamarzocco",
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["lmcloud"],
"requirements": ["lmcloud==0.4.34"]
}

View File

@ -0,0 +1,48 @@
{
"config": {
"flow_title": "La Marzocco Espresso {host}",
"abort": {
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_machines": "No machines found in account",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"machine_not_found": "The configured machine was not found in your account. Did you login to the correct account?",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "Your username from the La Marzocco app",
"password": "Your password from the La Marzocco app"
}
},
"machine_selection": {
"description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.",
"data": {
"host": "[%key:common::config_flow::data::ip%]",
"machine": "Machine"
},
"data_description": {
"host": "Local IP address of the machine"
}
}
}
},
"entity": {
"switch": {
"auto_on_off": {
"name": "Auto on/off"
},
"steam_boiler": {
"name": "Steam boiler"
}
}
}
}

View File

@ -0,0 +1,92 @@
"""Switch platform for La Marzocco espresso machines."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoSwitchEntityDescription(
LaMarzoccoEntityDescription,
SwitchEntityDescription,
):
"""Description of a La Marzocco Switch."""
control_fn: Callable[[LaMarzoccoUpdateCoordinator, bool], Coroutine[Any, Any, bool]]
is_on_fn: Callable[[LaMarzoccoUpdateCoordinator], bool]
ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
LaMarzoccoSwitchEntityDescription(
key="main",
name=None,
icon="mdi:power",
control_fn=lambda coordinator, state: coordinator.lm.set_power(state),
is_on_fn=lambda coordinator: coordinator.lm.current_status["power"],
),
LaMarzoccoSwitchEntityDescription(
key="auto_on_off",
translation_key="auto_on_off",
icon="mdi:alarm",
control_fn=lambda coordinator, state: coordinator.lm.set_auto_on_off_global(
state
),
is_on_fn=lambda coordinator: coordinator.lm.current_status["global_auto"]
== "Enabled",
entity_category=EntityCategory.CONFIG,
),
LaMarzoccoSwitchEntityDescription(
key="steam_boiler_enable",
translation_key="steam_boiler",
icon="mdi:water-boiler",
control_fn=lambda coordinator, state: coordinator.lm.set_steam(state),
is_on_fn=lambda coordinator: coordinator.lm.current_status[
"steam_boiler_enable"
],
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switch entities and services."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
LaMarzoccoSwitchEntity(coordinator, description)
for description in ENTITIES
if coordinator.lm.model_name in description.supported_models
)
class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity):
"""Switches representing espresso machine power, prebrew, and auto on/off."""
entity_description: LaMarzoccoSwitchEntityDescription
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn device on."""
await self.entity_description.control_fn(self.coordinator, True)
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn device off."""
await self.entity_description.control_fn(self.coordinator, False)
self.async_write_ha_state()
@property
def is_on(self) -> bool:
"""Return true if device is on."""
return self.entity_description.is_on_fn(self.coordinator)

View File

@ -261,6 +261,7 @@ FLOWS = {
"kraken",
"kulersky",
"lacrosse_view",
"lamarzocco",
"lametric",
"landisgyr_heat_meter",
"lastfm",

View File

@ -2999,6 +2999,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"lamarzocco": {
"name": "La Marzocco",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
},
"lametric": {
"name": "LaMetric",
"integration_type": "device",

View File

@ -2241,6 +2241,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.lamarzocco.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.lametric.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -1209,6 +1209,9 @@ linear-garage-door==0.2.7
# homeassistant.components.linode
linode-api==4.1.9b1
# homeassistant.components.lamarzocco
lmcloud==0.4.34
# homeassistant.components.google_maps
locationsharinglib==5.0.1

View File

@ -954,6 +954,9 @@ libsoundtouch==0.8
# homeassistant.components.linear_garage_door
linear-garage-door==0.2.7
# homeassistant.components.lamarzocco
lmcloud==0.4.34
# homeassistant.components.logi_circle
logi-circle==0.2.3

View File

@ -0,0 +1,25 @@
"""Mock inputs for tests."""
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
HOST_SELECTION = {
CONF_HOST: "192.168.1.1",
}
PASSWORD_SELECTION = {
CONF_PASSWORD: "password",
}
USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"}
async def async_init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Set up the La Marzocco integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

View File

@ -0,0 +1,104 @@
"""Lamarzocco session fixtures."""
from collections.abc import Generator
from unittest.mock import MagicMock, patch
from lmcloud.const import LaMarzoccoModel
import pytest
from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN
from homeassistant.core import HomeAssistant
from . import USER_INPUT, async_init_integration
from tests.common import (
MockConfigEntry,
load_json_array_fixture,
load_json_object_fixture,
)
@pytest.fixture
def mock_config_entry(mock_lamarzocco: MagicMock) -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="My LaMarzocco",
domain=DOMAIN,
data=USER_INPUT | {CONF_MACHINE: mock_lamarzocco.serial_number},
unique_id=mock_lamarzocco.serial_number,
)
@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lamarzocco: MagicMock
) -> MockConfigEntry:
"""Set up the LaMetric integration for testing."""
await async_init_integration(hass, mock_config_entry)
return mock_config_entry
@pytest.fixture
def device_fixture() -> LaMarzoccoModel:
"""Return the device fixture for a specific device."""
return LaMarzoccoModel.GS3_AV
@pytest.fixture
def mock_lamarzocco(
request: pytest.FixtureRequest, device_fixture: LaMarzoccoModel
) -> Generator[MagicMock, None, None]:
"""Return a mocked LM client."""
model_name = device_fixture
if model_name == LaMarzoccoModel.GS3_AV:
serial_number = "GS01234"
true_model_name = "GS3 AV"
elif model_name == LaMarzoccoModel.GS3_MP:
serial_number = "GS01234"
true_model_name = "GS3 MP"
elif model_name == LaMarzoccoModel.LINEA_MICRA:
serial_number = "MR01234"
true_model_name = "Linea Micra"
elif model_name == LaMarzoccoModel.LINEA_MINI:
serial_number = "LM01234"
true_model_name = "Linea Mini"
with patch(
"homeassistant.components.lamarzocco.coordinator.LaMarzoccoClient",
autospec=True,
) as lamarzocco_mock, patch(
"homeassistant.components.lamarzocco.config_flow.LaMarzoccoClient",
new=lamarzocco_mock,
):
lamarzocco = lamarzocco_mock.return_value
lamarzocco.machine_info = {
"machine_name": serial_number,
"serial_number": serial_number,
}
lamarzocco.model_name = model_name
lamarzocco.true_model_name = true_model_name
lamarzocco.machine_name = serial_number
lamarzocco.serial_number = serial_number
lamarzocco.firmware_version = "1.1"
lamarzocco.latest_firmware_version = "1.1"
lamarzocco.gateway_version = "v2.2-rc0"
lamarzocco.latest_gateway_version = "v3.1-rc4"
lamarzocco.current_status = load_json_object_fixture(
"current_status.json", DOMAIN
)
lamarzocco.config = load_json_object_fixture("config.json", DOMAIN)
lamarzocco.statistics = load_json_array_fixture("statistics.json", DOMAIN)
lamarzocco.get_all_machines.return_value = [
(serial_number, model_name),
]
lamarzocco.check_local_connection.return_value = True
lamarzocco.initialized = False
yield lamarzocco

View File

@ -0,0 +1,187 @@
{
"version": "v1",
"preinfusionModesAvailable": ["ByDoseType"],
"machineCapabilities": [
{
"family": "GS3AV",
"groupsNumber": 1,
"coffeeBoilersNumber": 1,
"hasCupWarmer": false,
"steamBoilersNumber": 1,
"teaDosesNumber": 1,
"machineModes": ["BrewingMode", "StandBy"],
"schedulingType": "weeklyScheduling"
}
],
"machine_sn": "GS01234",
"machine_hw": "2",
"isPlumbedIn": true,
"isBackFlushEnabled": false,
"standByTime": 0,
"tankStatus": true,
"groupCapabilities": [
{
"capabilities": {
"groupType": "AV_Group",
"groupNumber": "Group1",
"boilerId": "CoffeeBoiler1",
"hasScale": false,
"hasFlowmeter": true,
"numberOfDoses": 4
},
"doses": [
{
"groupNumber": "Group1",
"doseIndex": "DoseA",
"doseType": "PulsesType",
"stopTarget": 135
},
{
"groupNumber": "Group1",
"doseIndex": "DoseB",
"doseType": "PulsesType",
"stopTarget": 97
},
{
"groupNumber": "Group1",
"doseIndex": "DoseC",
"doseType": "PulsesType",
"stopTarget": 108
},
{
"groupNumber": "Group1",
"doseIndex": "DoseD",
"doseType": "PulsesType",
"stopTarget": 121
}
],
"doseMode": {
"groupNumber": "Group1",
"brewingType": "PulsesType"
}
}
],
"machineMode": "BrewingMode",
"teaDoses": {
"DoseA": {
"doseIndex": "DoseA",
"stopTarget": 8
}
},
"boilers": [
{
"id": "SteamBoiler",
"isEnabled": true,
"target": 123.90000152587891,
"current": 123.80000305175781
},
{
"id": "CoffeeBoiler1",
"isEnabled": true,
"target": 95,
"current": 96.5
}
],
"boilerTargetTemperature": {
"SteamBoiler": 123.90000152587891,
"CoffeeBoiler1": 95
},
"preinfusionMode": {
"Group1": {
"groupNumber": "Group1",
"preinfusionStyle": "PreinfusionByDoseType"
}
},
"preinfusionSettings": {
"mode": "TypeB",
"Group1": [
{
"groupNumber": "Group1",
"doseType": "DoseA",
"preWetTime": 0.5,
"preWetHoldTime": 1
},
{
"groupNumber": "Group1",
"doseType": "DoseB",
"preWetTime": 0.5,
"preWetHoldTime": 1
},
{
"groupNumber": "Group1",
"doseType": "DoseC",
"preWetTime": 3.2999999523162842,
"preWetHoldTime": 3.2999999523162842
},
{
"groupNumber": "Group1",
"doseType": "DoseD",
"preWetTime": 2,
"preWetHoldTime": 2
}
]
},
"weeklySchedulingConfig": {
"enabled": true,
"monday": {
"enabled": true,
"h_on": 6,
"h_off": 16,
"m_on": 0,
"m_off": 0
},
"tuesday": {
"enabled": true,
"h_on": 6,
"h_off": 16,
"m_on": 0,
"m_off": 0
},
"wednesday": {
"enabled": true,
"h_on": 6,
"h_off": 16,
"m_on": 0,
"m_off": 0
},
"thursday": {
"enabled": true,
"h_on": 6,
"h_off": 16,
"m_on": 0,
"m_off": 0
},
"friday": {
"enabled": true,
"h_on": 6,
"h_off": 16,
"m_on": 0,
"m_off": 0
},
"saturday": {
"enabled": true,
"h_on": 6,
"h_off": 16,
"m_on": 0,
"m_off": 0
},
"sunday": {
"enabled": true,
"h_on": 6,
"h_off": 16,
"m_on": 0,
"m_off": 0
}
},
"clock": "1901-07-08T10:29:00",
"firmwareVersions": [
{
"name": "machine_firmware",
"fw_version": "1.40"
},
{
"name": "gateway_firmware",
"fw_version": "v3.1-rc4"
}
]
}

View File

@ -0,0 +1,61 @@
{
"power": true,
"global_auto": "Enabled",
"enable_prebrewing": true,
"coffee_boiler_on": true,
"steam_boiler_on": true,
"enable_preinfusion": false,
"steam_boiler_enable": true,
"steam_temp": 113,
"steam_set_temp": 128,
"coffee_temp": 93,
"coffee_set_temp": 95,
"water_reservoir_contact": true,
"brew_active": false,
"drinks_k1": 13,
"drinks_k2": 2,
"drinks_k3": 42,
"drinks_k4": 34,
"total_flushing": 69,
"mon_auto": "Disabled",
"mon_on_time": "00:00",
"mon_off_time": "00:00",
"tue_auto": "Disabled",
"tue_on_time": "00:00",
"tue_off_time": "00:00",
"wed_auto": "Disabled",
"wed_on_time": "00:00",
"wed_off_time": "00:00",
"thu_auto": "Disabled",
"thu_on_time": "00:00",
"thu_off_time": "00:00",
"fri_auto": "Disabled",
"fri_on_time": "00:00",
"fri_off_time": "00:00",
"sat_auto": "Disabled",
"sat_on_time": "00:00",
"sat_off_time": "00:00",
"sun_auto": "Disabled",
"sun_on_time": "00:00",
"sun_off_time": "00:00",
"dose_k1": 1023,
"dose_k2": 1023,
"dose_k3": 1023,
"dose_k4": 1023,
"dose_k5": 1023,
"prebrewing_ton_k1": 3,
"prebrewing_toff_k1": 5,
"prebrewing_ton_k2": 3,
"prebrewing_toff_k2": 5,
"prebrewing_ton_k3": 3,
"prebrewing_toff_k3": 5,
"prebrewing_ton_k4": 3,
"prebrewing_toff_k4": 5,
"prebrewing_ton_k5": 3,
"prebrewing_toff_k5": 5,
"preinfusion_k1": 4,
"preinfusion_k2": 4,
"preinfusion_k3": 4,
"preinfusion_k4": 4,
"preinfusion_k5": 4
}

View File

@ -0,0 +1,26 @@
[
{
"count": 1047,
"coffeeType": 0
},
{
"count": 560,
"coffeeType": 1
},
{
"count": 468,
"coffeeType": 2
},
{
"count": 312,
"coffeeType": 3
},
{
"count": 2252,
"coffeeType": 4
},
{
"coffeeType": -1,
"count": 1740
}
]

View File

@ -0,0 +1,161 @@
# serializer version: 1
# name: test_device
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'lamarzocco',
'GS01234',
),
}),
'is_new': False,
'manufacturer': 'La Marzocco',
'model': 'GS3 AV',
'name': 'GS01234',
'name_by_user': None,
'serial_number': 'GS01234',
'suggested_area': None,
'sw_version': '1.1',
'via_device_id': None,
})
# ---
# name: test_switches[-set_power]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GS01234',
'icon': 'mdi:power',
}),
'context': <ANY>,
'entity_id': 'switch.gs01234',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[-set_power].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.gs01234',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:power',
'original_name': None,
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'GS01234_main',
'unit_of_measurement': None,
})
# ---
# name: test_switches[_auto_on_off-set_auto_on_off_global]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GS01234 Auto on/off',
'icon': 'mdi:alarm',
}),
'context': <ANY>,
'entity_id': 'switch.gs01234_auto_on_off',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[_auto_on_off-set_auto_on_off_global].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.gs01234_auto_on_off',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:alarm',
'original_name': 'Auto on/off',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'auto_on_off',
'unique_id': 'GS01234_auto_on_off',
'unit_of_measurement': None,
})
# ---
# name: test_switches[_steam_boiler-set_steam]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GS01234 Steam boiler',
'icon': 'mdi:water-boiler',
}),
'context': <ANY>,
'entity_id': 'switch.gs01234_steam_boiler',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_switches[_steam_boiler-set_steam].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.gs01234_steam_boiler',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': 'mdi:water-boiler',
'original_name': 'Steam boiler',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'steam_boiler',
'unique_id': 'GS01234_steam_boiler_enable',
'unit_of_measurement': None,
})
# ---

View File

@ -0,0 +1,235 @@
"""Test the La Marzocco config flow."""
from unittest.mock import MagicMock
from lmcloud.exceptions import AuthFail, RequestNotSuccessful
from homeassistant import config_entries
from homeassistant.components.lamarzocco.const import CONF_MACHINE, DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult, FlowResultType
from . import PASSWORD_SELECTION, USER_INPUT
from tests.common import MockConfigEntry
async def __do_successful_user_step(
hass: HomeAssistant, result: FlowResult
) -> FlowResult:
"""Successfully configure the user step."""
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "machine_selection"
return result2
async def __do_sucessful_machine_selection_step(
hass: HomeAssistant, result2: FlowResult, mock_lamarzocco: MagicMock
) -> None:
"""Successfully configure the machine selection step."""
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_lamarzocco.serial_number,
},
)
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.CREATE_ENTRY
assert result3["title"] == mock_lamarzocco.serial_number
assert result3["data"] == {
**USER_INPUT,
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_lamarzocco.serial_number,
}
async def test_form(hass: HomeAssistant, mock_lamarzocco: MagicMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {}
assert result["step_id"] == "user"
result2 = await __do_successful_user_step(hass, result)
await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco)
assert len(mock_lamarzocco.check_local_connection.mock_calls) == 1
async def test_form_abort_already_configured(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test we abort if already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "machine_selection"
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_lamarzocco.serial_number,
},
)
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.ABORT
assert result3["reason"] == "already_configured"
async def test_form_invalid_auth(
hass: HomeAssistant, mock_lamarzocco: MagicMock
) -> None:
"""Test invalid auth error."""
mock_lamarzocco.get_all_machines.side_effect = AuthFail("")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1
# test recovery from failure
mock_lamarzocco.get_all_machines.side_effect = None
result2 = await __do_successful_user_step(hass, result)
await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco)
async def test_form_invalid_host(
hass: HomeAssistant, mock_lamarzocco: MagicMock
) -> None:
"""Test invalid auth error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
await hass.async_block_till_done()
mock_lamarzocco.check_local_connection.return_value = False
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "machine_selection"
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_lamarzocco.serial_number,
},
)
await hass.async_block_till_done()
assert result3["type"] == FlowResultType.FORM
assert result3["errors"] == {"host": "cannot_connect"}
assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1
# test recovery from failure
mock_lamarzocco.check_local_connection.return_value = True
await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco)
async def test_form_cannot_connect(
hass: HomeAssistant, mock_lamarzocco: MagicMock
) -> None:
"""Test cannot connect error."""
mock_lamarzocco.get_all_machines.return_value = []
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "no_machines"}
assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1
mock_lamarzocco.get_all_machines.side_effect = RequestNotSuccessful("")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
assert len(mock_lamarzocco.get_all_machines.mock_calls) == 2
# test recovery from failure
mock_lamarzocco.get_all_machines.side_effect = None
mock_lamarzocco.get_all_machines.return_value = [
(mock_lamarzocco.serial_number, mock_lamarzocco.model_name)
]
result2 = await __do_successful_user_step(hass, result)
await __do_sucessful_machine_selection_step(hass, result2, mock_lamarzocco)
async def test_reauth_flow(
hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test that the reauth flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": SOURCE_REAUTH,
"unique_id": mock_config_entry.unique_id,
"entry_id": mock_config_entry.entry_id,
},
data=mock_config_entry.data,
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
PASSWORD_SELECTION,
)
assert result2["type"] == FlowResultType.ABORT
await hass.async_block_till_done()
assert result2["reason"] == "reauth_successful"
assert len(mock_lamarzocco.get_all_machines.mock_calls) == 1

View File

@ -0,0 +1,70 @@
"""Test initialization of lamarzocco."""
from unittest.mock import MagicMock
from lmcloud.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.components.lamarzocco.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lamarzocco: MagicMock,
) -> None:
"""Test loading and unloading the integration."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_config_entry_not_ready(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lamarzocco: MagicMock,
) -> None:
"""Test the La Marzocco configuration entry not ready."""
mock_lamarzocco.update_local_machine_status.side_effect = RequestNotSuccessful("")
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_invalid_auth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lamarzocco: MagicMock,
) -> None:
"""Test auth error during setup."""
mock_lamarzocco.update_local_machine_status.side_effect = AuthFail("")
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert len(mock_lamarzocco.update_local_machine_status.mock_calls) == 1
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
flow = flows[0]
assert flow.get("step_id") == "user"
assert flow.get("handler") == DOMAIN
assert "context" in flow
assert flow["context"].get("source") == SOURCE_REAUTH
assert flow["context"].get("entry_id") == mock_config_entry.entry_id

View File

@ -0,0 +1,91 @@
"""Tests for La Marzocco switches."""
from unittest.mock import MagicMock
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
pytestmark = pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("entity_name", "method_name"),
[
("", "set_power"),
("_auto_on_off", "set_auto_on_off_global"),
("_steam_boiler", "set_steam"),
],
)
async def test_switches(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
entity_name: str,
method_name: str,
) -> None:
"""Test the La Marzocco switches."""
serial_number = mock_lamarzocco.serial_number
control_fn = getattr(mock_lamarzocco, method_name)
state = hass.states.get(f"switch.{serial_number}{entity_name}")
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry == snapshot
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{
ATTR_ENTITY_ID: f"switch.{serial_number}{entity_name}",
},
blocking=True,
)
assert len(control_fn.mock_calls) == 1
control_fn.assert_called_once_with(False)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: f"switch.{serial_number}{entity_name}",
},
blocking=True,
)
assert len(control_fn.mock_calls) == 2
control_fn.assert_called_with(True)
async def test_device(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device for one switch."""
state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}")
assert state
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry.device_id
device = device_registry.async_get(entry.device_id)
assert device
assert device == snapshot