Merge branch 'dev' into esphome_reconfig

This commit is contained in:
J. Nick Koston 2025-04-17 22:11:44 -10:00 committed by GitHub
commit 3ba98c64ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 4748 additions and 4829 deletions

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from aioesphomeapi import APIClient
from homeassistant.components import ffmpeg, zeroconf
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import async_remove_scanner
from homeassistant.const import (
CONF_HOST,
@ -17,13 +17,10 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.helpers.typing import ConfigType
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN
from .dashboard import async_setup as async_setup_dashboard
from . import dashboard, ffmpeg_proxy
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
from .domain_data import DomainData
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .ffmpeg_proxy import FFmpegProxyData, FFmpegProxyView
from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -33,12 +30,8 @@ CLIENT_INFO = f"Home Assistant {ha_version}"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the esphome component."""
proxy_data = hass.data[DATA_FFMPEG_PROXY] = FFmpegProxyData()
await async_setup_dashboard(hass)
hass.http.register_view(
FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data)
)
ffmpeg_proxy.async_setup(hass)
await dashboard.async_setup(hass)
return True

View File

@ -49,6 +49,7 @@ from .const import (
DOMAIN,
)
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
from .entry_data import ESPHomeConfigEntry
from .manager import async_replace_device
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
@ -710,7 +711,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
config_entry: ESPHomeConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()

View File

@ -25,5 +25,3 @@ PROJECT_URLS = {
# ESPHome always uses .0 for the changelog URL
STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0"
DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html"
DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy"

View File

@ -194,6 +194,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
_static_info: _InfoT
_state: _StateT
_has_state: bool
device_entry: dr.DeviceEntry
def __init__(
self,

View File

@ -11,17 +11,20 @@ from typing import Final
from aiohttp import web
from aiohttp.abc import AbstractStreamWriter, BaseRequest
from homeassistant.components import ffmpeg
from homeassistant.components.ffmpeg import FFmpegManager
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.hass_dict import HassKey
from .const import DATA_FFMPEG_PROXY
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_MAX_CONVERSIONS_PER_DEVICE: Final[int] = 2
@callback
def async_create_proxy_url(
hass: HomeAssistant,
device_id: str,
@ -32,7 +35,7 @@ def async_create_proxy_url(
width: int | None = None,
) -> str:
"""Create a use proxy URL that automatically converts the media."""
data: FFmpegProxyData = hass.data[DATA_FFMPEG_PROXY]
data = hass.data[DATA_FFMPEG_PROXY]
return data.async_create_proxy_url(
device_id, media_url, media_format, rate, channels, width
)
@ -313,3 +316,16 @@ class FFmpegProxyView(HomeAssistantView):
assert writer is not None
await resp.transcode(request, writer)
return resp
DATA_FFMPEG_PROXY: HassKey[FFmpegProxyData] = HassKey(f"{DOMAIN}.ffmpeg_proxy")
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the ffmpeg proxy."""
proxy_data = FFmpegProxyData()
hass.data[DATA_FFMPEG_PROXY] = proxy_data
hass.http.register_view(
FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data)
)

View File

@ -0,0 +1,20 @@
{
"entity": {
"binary_sensor": {
"assist_in_progress": {
"default": "mdi:timer-sand"
}
},
"select": {
"pipeline": {
"default": "mdi:filter-outline"
},
"vad_sensitivity": {
"default": "mdi:volume-high"
},
"wake_word": {
"default": "mdi:microphone"
}
}
}
}

View File

@ -148,10 +148,6 @@ class EsphomeMediaPlayer(
announcement: bool,
) -> str | None:
"""Get URL for ffmpeg proxy."""
if self.device_entry is None:
# Device id is required
return None
# Choose the first default or announcement supported format
format_to_use: MediaPlayerSupportedFormat | None = None
for supported_format in supported_formats:

View File

@ -20,13 +20,13 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
from .entity import EsphomeEntity, platform_async_setup_entry
from .entry_data import ESPHomeConfigEntry
from .enum_mapper import EsphomeEnumMapper
PARALLEL_UPDATES = 0
@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: ESPHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up esphome sensors based on a config entry."""

View File

@ -18,7 +18,6 @@ from homeassistant.components.update import (
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
@ -36,7 +35,7 @@ from .entity import (
esphome_state_property,
platform_async_setup_entry,
)
from .entry_data import RuntimeEntryData
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
PARALLEL_UPDATES = 0
@ -47,7 +46,7 @@ NO_FEATURES = UpdateEntityFeature(0)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: ESPHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ESPHome update based on a config entry."""

View File

@ -252,9 +252,7 @@ class HomeConnectCoordinator(
appliance_data = await self._get_appliance_data(
appliance_info, self.data.get(appliance_info.ha_id)
)
if event_message_ha_id in self.data:
self.data[event_message_ha_id].update(appliance_data)
else:
if event_message_ha_id not in self.data:
self.data[event_message_ha_id] = appliance_data
for listener, context in self._special_listeners.values():
if (

View File

@ -17,7 +17,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import (
APPLIANCES_WITH_PROGRAMS,
AVAILABLE_MAPS_ENUM,
BEAN_AMOUNT_OPTIONS,
BEAN_CONTAINER_OPTIONS,
@ -313,7 +312,7 @@ def _get_entities_for_appliance(
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
]
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
if appliance.programs
else []
),
*[

View File

@ -1,27 +1,27 @@
"""The La Marzocco integration."""
import asyncio
import logging
from packaging import version
from pylamarzocco.clients.bluetooth import LaMarzoccoBluetoothClient
from pylamarzocco.clients.cloud import LaMarzoccoCloudClient
from pylamarzocco.clients.local import LaMarzoccoLocalClient
from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType
from pylamarzocco.devices.machine import LaMarzoccoMachine
from pylamarzocco import (
LaMarzoccoBluetoothClient,
LaMarzoccoCloudClient,
LaMarzoccoMachine,
)
from pylamarzocco.const import FirmwareType
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.components.bluetooth import async_discovered_service_info
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
CONF_MODEL,
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_create_clientsession
@ -29,9 +29,9 @@ from .const import CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import (
LaMarzoccoConfigEntry,
LaMarzoccoConfigUpdateCoordinator,
LaMarzoccoFirmwareUpdateCoordinator,
LaMarzoccoRuntimeData,
LaMarzoccoStatisticsUpdateCoordinator,
LaMarzoccoScheduleUpdateCoordinator,
LaMarzoccoSettingsUpdateCoordinator,
)
PLATFORMS = [
@ -40,11 +40,12 @@ PLATFORMS = [
Platform.CALENDAR,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
BT_MODEL_PREFIXES = ("MICRA", "MINI", "GS3")
_LOGGER = logging.getLogger(__name__)
@ -61,31 +62,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
client=client,
)
# initialize the firmware update coordinator early to check the firmware version
firmware_device = LaMarzoccoMachine(
model=entry.data[CONF_MODEL],
serial_number=entry.unique_id,
name=entry.data[CONF_NAME],
cloud_client=cloud_client,
)
try:
settings = await cloud_client.get_thing_settings(serial)
except AuthFail as ex:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from ex
except RequestNotSuccessful as ex:
_LOGGER.debug(ex, exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="api_error"
) from ex
firmware_coordinator = LaMarzoccoFirmwareUpdateCoordinator(
hass, entry, firmware_device
)
await firmware_coordinator.async_config_entry_first_refresh()
gateway_version = version.parse(
firmware_device.firmware[FirmwareType.GATEWAY].current_version
settings.firmwares[FirmwareType.GATEWAY].build_version
)
if gateway_version >= version.parse("v5.0.9"):
# remove host from config entry, it is not supported anymore
data = {k: v for k, v in entry.data.items() if k != CONF_HOST}
hass.config_entries.async_update_entry(
entry,
data=data,
)
elif gateway_version < version.parse("v3.4-rc5"):
if gateway_version < version.parse("v5.0.9"):
# incompatible gateway firmware, create an issue
ir.async_create_issue(
hass,
@ -97,24 +90,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
translation_placeholders={"gateway_version": str(gateway_version)},
)
# initialize local API
local_client: LaMarzoccoLocalClient | None = None
if (host := entry.data.get(CONF_HOST)) is not None:
_LOGGER.debug("Initializing local API")
local_client = LaMarzoccoLocalClient(
host=host,
local_bearer=entry.data[CONF_TOKEN],
client=client,
)
# initialize Bluetooth
bluetooth_client: LaMarzoccoBluetoothClient | None = None
if entry.options.get(CONF_USE_BLUETOOTH, True):
def bluetooth_configured() -> bool:
return entry.data.get(CONF_MAC, "") and entry.data.get(CONF_NAME, "")
if not bluetooth_configured():
if entry.options.get(CONF_USE_BLUETOOTH, True) and (
token := settings.ble_auth_token
):
if CONF_MAC not in entry.data:
for discovery_info in async_discovered_service_info(hass):
if (
(name := discovery_info.name)
@ -128,38 +109,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
data={
**entry.data,
CONF_MAC: discovery_info.address,
CONF_NAME: discovery_info.name,
},
)
break
if bluetooth_configured():
if not entry.data[CONF_TOKEN]:
# update the token in the config entry
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_TOKEN: token,
},
)
if CONF_MAC in entry.data:
_LOGGER.debug("Initializing Bluetooth device")
bluetooth_client = LaMarzoccoBluetoothClient(
username=entry.data[CONF_USERNAME],
serial_number=serial,
token=entry.data[CONF_TOKEN],
address_or_ble_device=entry.data[CONF_MAC],
ble_token=token,
)
device = LaMarzoccoMachine(
model=entry.data[CONF_MODEL],
serial_number=entry.unique_id,
name=entry.data[CONF_NAME],
cloud_client=cloud_client,
local_client=local_client,
bluetooth_client=bluetooth_client,
)
coordinators = LaMarzoccoRuntimeData(
LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client),
firmware_coordinator,
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
LaMarzoccoConfigUpdateCoordinator(hass, entry, device),
LaMarzoccoSettingsUpdateCoordinator(hass, entry, device),
LaMarzoccoScheduleUpdateCoordinator(hass, entry, device),
)
# API does not like concurrent requests, so no asyncio.gather here
await coordinators.config_coordinator.async_config_entry_first_refresh()
await coordinators.statistics_coordinator.async_config_entry_first_refresh()
await asyncio.gather(
coordinators.config_coordinator.async_config_entry_first_refresh(),
coordinators.settings_coordinator.async_config_entry_first_refresh(),
coordinators.schedule_coordinator.async_config_entry_first_refresh(),
)
entry.runtime_data = coordinators
@ -184,41 +170,45 @@ async def async_migrate_entry(
hass: HomeAssistant, entry: LaMarzoccoConfigEntry
) -> bool:
"""Migrate config entry."""
if entry.version > 2:
if entry.version > 3:
# guard against downgrade from a future version
return False
if entry.version == 1:
_LOGGER.error(
"Migration from version 1 is no longer supported, please remove and re-add the integration"
)
return False
if entry.version == 2:
cloud_client = LaMarzoccoCloudClient(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
try:
fleet = await cloud_client.get_customer_fleet()
things = await cloud_client.list_things()
except (AuthFail, RequestNotSuccessful) as exc:
_LOGGER.error("Migration failed with error %s", exc)
return False
assert entry.unique_id is not None
device = fleet[entry.unique_id]
v2_data = {
v3_data = {
CONF_USERNAME: entry.data[CONF_USERNAME],
CONF_PASSWORD: entry.data[CONF_PASSWORD],
CONF_MODEL: device.model,
CONF_NAME: device.name,
CONF_TOKEN: device.communication_key,
CONF_TOKEN: next(
(
thing.ble_auth_token
for thing in things
if thing.serial_number == entry.unique_id
),
None,
),
}
if CONF_HOST in entry.data:
v2_data[CONF_HOST] = entry.data[CONF_HOST]
if CONF_MAC in entry.data:
v2_data[CONF_MAC] = entry.data[CONF_MAC]
v3_data[CONF_MAC] = entry.data[CONF_MAC]
hass.config_entries.async_update_entry(
entry,
data=v2_data,
version=2,
data=v3_data,
version=3,
)
_LOGGER.debug("Migrated La Marzocco config entry to version 2")
return True

View File

@ -2,9 +2,10 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from pylamarzocco.const import MachineModel
from pylamarzocco.models import LaMarzoccoMachineConfig
from pylamarzocco.const import BackFlushStatus, MachineState, WidgetType
from pylamarzocco.models import BackFlush, BaseWidgetOutput, MachineStatus
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@ -29,7 +30,7 @@ class LaMarzoccoBinarySensorEntityDescription(
):
"""Description of a La Marzocco binary sensor."""
is_on_fn: Callable[[LaMarzoccoMachineConfig], bool | None]
is_on_fn: Callable[[dict[WidgetType, BaseWidgetOutput]], bool | None]
ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
@ -37,32 +38,30 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
key="water_tank",
translation_key="water_tank",
device_class=BinarySensorDeviceClass.PROBLEM,
is_on_fn=lambda config: not config.water_contact,
is_on_fn=lambda config: WidgetType.CM_NO_WATER in config,
entity_category=EntityCategory.DIAGNOSTIC,
supported_fn=lambda coordinator: coordinator.local_connection_configured,
),
LaMarzoccoBinarySensorEntityDescription(
key="brew_active",
translation_key="brew_active",
device_class=BinarySensorDeviceClass.RUNNING,
is_on_fn=lambda config: config.brew_active,
available_fn=lambda device: device.websocket_connected,
is_on_fn=(
lambda config: cast(
MachineStatus, config[WidgetType.CM_MACHINE_STATUS]
).status
is MachineState.BREWING
),
available_fn=lambda device: device.websocket.connected,
entity_category=EntityCategory.DIAGNOSTIC,
),
LaMarzoccoBinarySensorEntityDescription(
key="backflush_enabled",
translation_key="backflush_enabled",
device_class=BinarySensorDeviceClass.RUNNING,
is_on_fn=lambda config: config.backflush_enabled,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
LaMarzoccoBinarySensorEntityDescription(
key="connected",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
is_on_fn=lambda config: config.scale.connected if config.scale else None,
is_on_fn=(
lambda config: cast(BackFlush, config[WidgetType.CM_BACK_FLUSH]).status
is BackFlushStatus.REQUESTED
),
entity_category=EntityCategory.DIAGNOSTIC,
),
)
@ -76,30 +75,11 @@ async def async_setup_entry(
"""Set up binary sensor entities."""
coordinator = entry.runtime_data.config_coordinator
entities = [
async_add_entities(
LaMarzoccoBinarySensorEntity(coordinator, description)
for description in ENTITIES
if description.supported_fn(coordinator)
]
if (
coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
and coordinator.device.config.scale
):
entities.extend(
LaMarzoccoScaleBinarySensorEntity(coordinator, description)
for description in SCALE_ENTITIES
)
def _async_add_new_scale() -> None:
async_add_entities(
LaMarzoccoScaleBinarySensorEntity(coordinator, description)
for description in SCALE_ENTITIES
)
coordinator.new_device_callback.append(_async_add_new_scale)
async_add_entities(entities)
)
class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
@ -110,12 +90,6 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.is_on_fn(self.coordinator.device.config)
class LaMarzoccoScaleBinarySensorEntity(
LaMarzoccoBinarySensorEntity, LaMarzoccScaleEntity
):
"""Binary sensor for La Marzocco scales."""
entity_description: LaMarzoccoBinarySensorEntityDescription
return self.entity_description.is_on_fn(
self.coordinator.device.dashboard.config
)

View File

@ -3,7 +3,7 @@
from collections.abc import Iterator
from datetime import datetime, timedelta
from pylamarzocco.models import LaMarzoccoWakeUpSleepEntry
from pylamarzocco.const import WeekDay
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
@ -18,15 +18,15 @@ PARALLEL_UPDATES = 0
CALENDAR_KEY = "auto_on_off_schedule"
DAY_OF_WEEK = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
]
WEEKDAY_TO_ENUM = {
0: WeekDay.MONDAY,
1: WeekDay.TUESDAY,
2: WeekDay.WEDNESDAY,
3: WeekDay.THURSDAY,
4: WeekDay.FRIDAY,
5: WeekDay.SATURDAY,
6: WeekDay.SUNDAY,
}
async def async_setup_entry(
@ -36,10 +36,12 @@ async def async_setup_entry(
) -> None:
"""Set up switch entities and services."""
coordinator = entry.runtime_data.config_coordinator
coordinator = entry.runtime_data.schedule_coordinator
async_add_entities(
LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, wake_up_sleep_entry)
for wake_up_sleep_entry in coordinator.device.config.wake_up_sleep_entries.values()
LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, schedule.identifier)
for schedule in coordinator.device.schedule.smart_wake_up_sleep.schedules
if schedule.identifier
)
@ -52,12 +54,12 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity):
self,
coordinator: LaMarzoccoUpdateCoordinator,
key: str,
wake_up_sleep_entry: LaMarzoccoWakeUpSleepEntry,
identifier: str,
) -> None:
"""Set up calendar."""
super().__init__(coordinator, f"{key}_{wake_up_sleep_entry.entry_id}")
self.wake_up_sleep_entry = wake_up_sleep_entry
self._attr_translation_placeholders = {"id": wake_up_sleep_entry.entry_id}
super().__init__(coordinator, f"{key}_{identifier}")
self._identifier = identifier
self._attr_translation_placeholders = {"id": identifier}
@property
def event(self) -> CalendarEvent | None:
@ -112,24 +114,31 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity):
def _async_get_calendar_event(self, date: datetime) -> CalendarEvent | None:
"""Return calendar event for a given weekday."""
schedule_entry = (
self.coordinator.device.schedule.smart_wake_up_sleep.schedules_dict[
self._identifier
]
)
# check first if auto/on off is turned on in general
if not self.wake_up_sleep_entry.enabled:
if not schedule_entry.enabled:
return None
# parse the schedule for the day
if DAY_OF_WEEK[date.weekday()] not in self.wake_up_sleep_entry.days:
if WEEKDAY_TO_ENUM[date.weekday()] not in schedule_entry.days:
return None
hour_on, minute_on = self.wake_up_sleep_entry.time_on.split(":")
hour_off, minute_off = self.wake_up_sleep_entry.time_off.split(":")
hour_on = schedule_entry.on_time_minutes // 60
minute_on = schedule_entry.on_time_minutes % 60
hour_off = schedule_entry.off_time_minutes // 60
minute_off = schedule_entry.off_time_minutes % 60
# if off time is 24:00, then it means the off time is the next day
# only for legacy schedules
day_offset = 0
if hour_off == "24":
if hour_off == 24:
# if the machine is scheduled to turn off at midnight, we need to
# set the end date to the next day
day_offset = 1
hour_off = "0"
hour_off = 0
end_date = date.replace(
hour=int(hour_off),

View File

@ -7,10 +7,9 @@ import logging
from typing import Any
from aiohttp import ClientSession
from pylamarzocco.clients.cloud import LaMarzoccoCloudClient
from pylamarzocco.clients.local import LaMarzoccoLocalClient
from pylamarzocco import LaMarzoccoCloudClient
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoDeviceInfo
from pylamarzocco.models import Thing
import voluptuous as vol
from homeassistant.components.bluetooth import (
@ -26,9 +25,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import (
CONF_ADDRESS,
CONF_HOST,
CONF_MAC,
CONF_MODEL,
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
@ -59,14 +56,14 @@ _LOGGER = logging.getLogger(__name__)
class LmConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for La Marzocco."""
VERSION = 2
VERSION = 3
_client: ClientSession
def __init__(self) -> None:
"""Initialize the config flow."""
self._config: dict[str, Any] = {}
self._fleet: dict[str, LaMarzoccoDeviceInfo] = {}
self._things: dict[str, Thing] = {}
self._discovered: dict[str, str] = {}
async def async_step_user(
@ -83,7 +80,6 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
data = {
**data,
**user_input,
**self._discovered,
}
self._client = async_create_clientsession(self.hass)
@ -93,7 +89,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
client=self._client,
)
try:
self._fleet = await cloud_client.get_customer_fleet()
things = await cloud_client.list_things()
except AuthFail:
_LOGGER.debug("Server rejected login credentials")
errors["base"] = "invalid_auth"
@ -101,37 +97,30 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("Error connecting to server: %s", exc)
errors["base"] = "cannot_connect"
else:
if not self._fleet:
self._things = {thing.serial_number: thing for thing in things}
if not self._things:
errors["base"] = "no_machines"
if not errors:
self._config = data
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
if self._discovered:
if self._discovered[CONF_MACHINE] not in self._fleet:
if self._discovered[CONF_MACHINE] not in self._things:
errors["base"] = "machine_not_found"
else:
self._config = data
# if DHCP discovery was used, auto fill machine selection
if CONF_HOST in self._discovered:
return await self.async_step_machine_selection(
user_input={
CONF_HOST: self._discovered[CONF_HOST],
CONF_MACHINE: self._discovered[CONF_MACHINE],
}
)
# if Bluetooth discovery was used, only select host
return self.async_show_form(
step_id="machine_selection",
data_schema=vol.Schema(
{vol.Optional(CONF_HOST): cv.string}
),
)
# store discovered connection address
if CONF_MAC in self._discovered:
self._config[CONF_MAC] = self._discovered[CONF_MAC]
if CONF_ADDRESS in self._discovered:
self._config[CONF_ADDRESS] = self._discovered[CONF_ADDRESS]
return await self.async_step_machine_selection(
user_input={CONF_MACHINE: self._discovered[CONF_MACHINE]}
)
if not errors:
self._config = data
return await self.async_step_machine_selection()
placeholders: dict[str, str] | None = None
@ -175,18 +164,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
else:
serial_number = self._discovered[CONF_MACHINE]
selected_device = self._fleet[serial_number]
# validate local connection if host is provided
if user_input.get(CONF_HOST):
if not await LaMarzoccoLocalClient.validate_connection(
client=self._client,
host=user_input[CONF_HOST],
token=selected_device.communication_key,
):
errors[CONF_HOST] = "cannot_connect"
else:
self._config[CONF_HOST] = user_input[CONF_HOST]
selected_device = self._things[serial_number]
if not errors:
if self.source == SOURCE_RECONFIGURE:
@ -200,18 +178,16 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
title=selected_device.name,
data={
**self._config,
CONF_NAME: selected_device.name,
CONF_MODEL: selected_device.model,
CONF_TOKEN: selected_device.communication_key,
CONF_TOKEN: self._things[serial_number].ble_auth_token,
},
)
machine_options = [
SelectOptionDict(
value=device.serial_number,
label=f"{device.model} ({device.serial_number})",
value=thing.serial_number,
label=f"{thing.name} ({thing.serial_number})",
)
for device in self._fleet.values()
for thing in self._things.values()
]
machine_selection_schema = vol.Schema(
@ -224,7 +200,6 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_HOST): cv.string,
}
)
@ -304,7 +279,6 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured(
updates={
CONF_HOST: discovery_info.ip,
CONF_ADDRESS: discovery_info.macaddress,
}
)
@ -316,8 +290,8 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
discovery_info.ip,
)
self._discovered[CONF_NAME] = discovery_info.hostname
self._discovered[CONF_MACHINE] = serial
self._discovered[CONF_HOST] = discovery_info.ip
self._discovered[CONF_ADDRESS] = discovery_info.macaddress
return await self.async_step_user()

View File

@ -3,28 +3,25 @@
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any
from pylamarzocco.clients.local import LaMarzoccoLocalClient
from pylamarzocco.devices.machine import LaMarzoccoMachine
from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
SCAN_INTERVAL = timedelta(seconds=30)
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=1)
STATISTICS_UPDATE_INTERVAL = timedelta(minutes=5)
SETTINGS_UPDATE_INTERVAL = timedelta(hours=1)
SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5)
_LOGGER = logging.getLogger(__name__)
@ -33,8 +30,8 @@ class LaMarzoccoRuntimeData:
"""Runtime data for La Marzocco."""
config_coordinator: LaMarzoccoConfigUpdateCoordinator
firmware_coordinator: LaMarzoccoFirmwareUpdateCoordinator
statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator
settings_coordinator: LaMarzoccoSettingsUpdateCoordinator
schedule_coordinator: LaMarzoccoScheduleUpdateCoordinator
type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData]
@ -51,7 +48,6 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
device: LaMarzoccoMachine,
local_client: LaMarzoccoLocalClient | None = None,
) -> None:
"""Initialize coordinator."""
super().__init__(
@ -62,9 +58,6 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
update_interval=self._default_update_interval,
)
self.device = device
self.local_connection_configured = local_client is not None
self._local_client = local_client
self.new_device_callback: list[Callable] = []
async def _async_update_data(self) -> None:
"""Do the data update."""
@ -89,30 +82,22 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Class to handle fetching data from the La Marzocco API centrally."""
_scale_address: str | None = None
async def _async_connect_websocket(self) -> None:
"""Set up the coordinator."""
if self._local_client is not None and (
self._local_client.websocket is None or self._local_client.websocket.closed
):
if not self.device.websocket.connected:
_LOGGER.debug("Init WebSocket in background task")
self.config_entry.async_create_background_task(
hass=self.hass,
target=self.device.websocket_connect(
notify_callback=lambda: self.async_set_updated_data(None)
target=self.device.connect_dashboard_websocket(
update_callback=lambda _: self.async_set_updated_data(None)
),
name="lm_websocket_task",
)
async def websocket_close(_: Any | None = None) -> None:
if (
self._local_client is not None
and self._local_client.websocket is not None
and not self._local_client.websocket.closed
):
await self._local_client.websocket.close()
if self.device.websocket.connected:
await self.device.websocket.disconnect()
self.config_entry.async_on_unload(
self.hass.bus.async_listen_once(
@ -123,47 +108,28 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
async def _internal_async_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.device.get_config()
_LOGGER.debug("Current status: %s", str(self.device.config))
await self.device.get_dashboard()
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
await self._async_connect_websocket()
self._async_add_remove_scale()
@callback
def _async_add_remove_scale(self) -> None:
"""Add or remove a scale when added or removed."""
if self.device.config.scale and not self._scale_address:
self._scale_address = self.device.config.scale.address
for scale_callback in self.new_device_callback:
scale_callback()
elif not self.device.config.scale and self._scale_address:
device_registry = dr.async_get(self.hass)
if device := device_registry.async_get_device(
identifiers={(DOMAIN, self._scale_address)}
):
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self._scale_address = None
class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Coordinator for La Marzocco firmware."""
class LaMarzoccoSettingsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Coordinator for La Marzocco settings."""
_default_update_interval = FIRMWARE_UPDATE_INTERVAL
_default_update_interval = SETTINGS_UPDATE_INTERVAL
async def _internal_async_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.device.get_firmware()
_LOGGER.debug("Current firmware: %s", str(self.device.firmware))
await self.device.get_settings()
_LOGGER.debug("Current settings: %s", self.device.settings.to_dict())
class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Coordinator for La Marzocco statistics."""
class LaMarzoccoScheduleUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Coordinator for La Marzocco schedule."""
_default_update_interval = STATISTICS_UPDATE_INTERVAL
_default_update_interval = SCHEDULE_UPDATE_INTERVAL
async def _internal_async_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.device.get_statistics()
_LOGGER.debug("Current statistics: %s", str(self.device.statistics))
await self.device.get_schedule()
_LOGGER.debug("Current schedule: %s", self.device.schedule.to_dict())

View File

@ -2,10 +2,7 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Any, TypedDict
from pylamarzocco.const import FirmwareType
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
@ -17,15 +14,6 @@ TO_REDACT = {
}
class DiagnosticsData(TypedDict):
"""Diagnostic data for La Marzocco."""
model: str
config: dict[str, Any]
firmware: list[dict[FirmwareType, dict[str, Any]]]
statistics: dict[str, Any]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
@ -33,12 +21,4 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data.config_coordinator
device = coordinator.device
# collect all data sources
diagnostics_data = DiagnosticsData(
model=device.model,
config=asdict(device.config),
firmware=[{key: asdict(firmware)} for key, firmware in device.firmware.items()],
statistics=asdict(device.statistics),
)
return async_redact_data(diagnostics_data, TO_REDACT)
return async_redact_data(device.to_dict(), TO_REDACT)

View File

@ -2,10 +2,9 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.const import FirmwareType
from pylamarzocco.devices.machine import LaMarzoccoMachine
from homeassistant.const import CONF_ADDRESS, CONF_MAC
from homeassistant.helpers.device_registry import (
@ -46,12 +45,12 @@ class LaMarzoccoBaseEntity(
self._attr_unique_id = f"{device.serial_number}_{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.serial_number)},
name=device.name,
name=device.dashboard.name,
manufacturer="La Marzocco",
model=device.full_model_name,
model_id=device.model,
model=device.dashboard.model_name.value,
model_id=device.dashboard.model_code.value,
serial_number=device.serial_number,
sw_version=device.firmware[FirmwareType.MACHINE].current_version,
sw_version=device.settings.firmwares[FirmwareType.MACHINE].build_version,
)
connections: set[tuple[str, str]] = set()
if coordinator.config_entry.data.get(CONF_ADDRESS):
@ -86,26 +85,3 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity):
"""Initialize the entity."""
super().__init__(coordinator, entity_description.key)
self.entity_description = entity_description
class LaMarzoccScaleEntity(LaMarzoccoEntity):
"""Common class for scale."""
def __init__(
self,
coordinator: LaMarzoccoUpdateCoordinator,
entity_description: LaMarzoccoEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, entity_description)
scale = coordinator.device.config.scale
if TYPE_CHECKING:
assert scale
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, scale.address)},
name=scale.name,
manufacturer="Acaia",
model="Lunar",
model_id="Y.301",
via_device=(DOMAIN, coordinator.device.serial_number),
)

View File

@ -34,36 +34,11 @@
"dose": {
"default": "mdi:cup-water"
},
"prebrew_off": {
"default": "mdi:water-off"
},
"prebrew_on": {
"default": "mdi:water"
},
"preinfusion_off": {
"default": "mdi:water"
},
"scale_target": {
"default": "mdi:scale-balance"
},
"smart_standby_time": {
"default": "mdi:timer"
},
"steam_temp": {
"default": "mdi:thermometer-water"
},
"tea_water_duration": {
"default": "mdi:timer-sand"
}
},
"select": {
"active_bbw": {
"default": "mdi:alpha-u",
"state": {
"a": "mdi:alpha-a",
"b": "mdi:alpha-b"
}
},
"smart_standby_mode": {
"default": "mdi:power",
"state": {
@ -88,26 +63,6 @@
}
}
},
"sensor": {
"drink_stats_coffee": {
"default": "mdi:chart-line"
},
"drink_stats_flushing": {
"default": "mdi:chart-line"
},
"drink_stats_coffee_key": {
"default": "mdi:chart-scatter-plot"
},
"shot_timer": {
"default": "mdi:timer"
},
"current_temp_coffee": {
"default": "mdi:thermometer"
},
"current_temp_steam": {
"default": "mdi:thermometer"
}
},
"switch": {
"main": {
"default": "mdi:power",

View File

@ -34,8 +34,8 @@
],
"documentation": "https://www.home-assistant.io/integrations/lamarzocco",
"integration_type": "device",
"iot_class": "cloud_polling",
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==1.4.9"]
"requirements": ["pylamarzocco==2.0.0b1"]
}

View File

@ -2,18 +2,12 @@
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from typing import Any, cast
from pylamarzocco.const import (
KEYS_PER_MODEL,
BoilerType,
MachineModel,
PhysicalKey,
PrebrewMode,
)
from pylamarzocco.devices.machine import LaMarzoccoMachine
from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.const import WidgetType
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoMachineConfig
from pylamarzocco.models import CoffeeBoiler
from homeassistant.components.number import (
NumberDeviceClass,
@ -32,8 +26,8 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
PARALLEL_UPDATES = 1
@ -45,25 +39,10 @@ class LaMarzoccoNumberEntityDescription(
):
"""Description of a La Marzocco number entity."""
native_value_fn: Callable[[LaMarzoccoMachineConfig], float | int]
native_value_fn: Callable[[LaMarzoccoMachine], float | int]
set_value_fn: Callable[[LaMarzoccoMachine, float | int], Coroutine[Any, Any, bool]]
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoKeyNumberEntityDescription(
LaMarzoccoEntityDescription,
NumberEntityDescription,
):
"""Description of an La Marzocco number entity with keys."""
native_value_fn: Callable[
[LaMarzoccoMachineConfig, PhysicalKey], float | int | None
]
set_value_fn: Callable[
[LaMarzoccoMachine, float | int, PhysicalKey], Coroutine[Any, Any, bool]
]
ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
LaMarzoccoNumberEntityDescription(
key="coffee_temp",
@ -73,43 +52,11 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
native_step=PRECISION_TENTHS,
native_min_value=85,
native_max_value=104,
set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.COFFEE, temp),
native_value_fn=lambda config: config.boilers[
BoilerType.COFFEE
].target_temperature,
),
LaMarzoccoNumberEntityDescription(
key="steam_temp",
translation_key="steam_temp",
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
native_step=PRECISION_WHOLE,
native_min_value=126,
native_max_value=131,
set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.STEAM, temp),
native_value_fn=lambda config: config.boilers[
BoilerType.STEAM
].target_temperature,
supported_fn=lambda coordinator: coordinator.device.model
in (
MachineModel.GS3_AV,
MachineModel.GS3_MP,
),
),
LaMarzoccoNumberEntityDescription(
key="tea_water_duration",
translation_key="tea_water_duration",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_step=PRECISION_WHOLE,
native_min_value=0,
native_max_value=30,
set_value_fn=lambda machine, value: machine.set_dose_tea_water(int(value)),
native_value_fn=lambda config: config.dose_hot_water,
supported_fn=lambda coordinator: coordinator.device.model
in (
MachineModel.GS3_AV,
MachineModel.GS3_MP,
set_value_fn=lambda machine, temp: machine.set_coffee_target_temperature(temp),
native_value_fn=(
lambda machine: cast(
CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
).target_temperature
),
),
LaMarzoccoNumberEntityDescription(
@ -117,119 +64,18 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
translation_key="smart_standby_time",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
native_step=10,
native_min_value=10,
native_max_value=240,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda machine, value: machine.set_smart_standby(
enabled=machine.config.smart_standby.enabled,
mode=machine.config.smart_standby.mode,
minutes=int(value),
),
native_value_fn=lambda config: config.smart_standby.minutes,
),
)
KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
LaMarzoccoKeyNumberEntityDescription(
key="prebrew_off",
translation_key="prebrew_off",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_step=PRECISION_TENTHS,
native_min_value=1,
native_max_value=10,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda machine, value, key: machine.set_prebrew_time(
prebrew_off_time=value, key=key
),
native_value_fn=lambda config, key: config.prebrew_configuration[key][
0
].off_time,
available_fn=lambda device: len(device.config.prebrew_configuration) > 0
and device.config.prebrew_mode
in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED),
supported_fn=lambda coordinator: coordinator.device.model
!= MachineModel.GS3_MP,
),
LaMarzoccoKeyNumberEntityDescription(
key="prebrew_on",
translation_key="prebrew_on",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_step=PRECISION_TENTHS,
native_min_value=2,
native_max_value=10,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda machine, value, key: machine.set_prebrew_time(
prebrew_on_time=value, key=key
),
native_value_fn=lambda config, key: config.prebrew_configuration[key][
0
].off_time,
available_fn=lambda device: len(device.config.prebrew_configuration) > 0
and device.config.prebrew_mode
in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED),
supported_fn=lambda coordinator: coordinator.device.model
!= MachineModel.GS3_MP,
),
LaMarzoccoKeyNumberEntityDescription(
key="preinfusion_off",
translation_key="preinfusion_off",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_step=PRECISION_TENTHS,
native_min_value=2,
native_max_value=29,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda machine, value, key: machine.set_preinfusion_time(
preinfusion_time=value, key=key
),
native_value_fn=lambda config, key: config.prebrew_configuration[key][
1
].preinfusion_time,
available_fn=lambda device: len(device.config.prebrew_configuration) > 0
and device.config.prebrew_mode == PrebrewMode.PREINFUSION,
supported_fn=lambda coordinator: coordinator.device.model
!= MachineModel.GS3_MP,
),
LaMarzoccoKeyNumberEntityDescription(
key="dose",
translation_key="dose",
native_unit_of_measurement="ticks",
native_step=PRECISION_WHOLE,
native_min_value=0,
native_max_value=999,
native_max_value=240,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda machine, ticks, key: machine.set_dose(
dose=int(ticks), key=key
),
native_value_fn=lambda config, key: config.doses[key],
supported_fn=lambda coordinator: coordinator.device.model
== MachineModel.GS3_AV,
),
)
SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
LaMarzoccoKeyNumberEntityDescription(
key="scale_target",
translation_key="scale_target",
native_step=PRECISION_WHOLE,
native_min_value=1,
native_max_value=100,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda machine, weight, key: machine.set_bbw_recipe_target(
key, int(weight)
),
native_value_fn=lambda config, key: (
config.bbw_settings.doses[key] if config.bbw_settings else None
),
supported_fn=(
lambda coordinator: coordinator.device.model
in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
and coordinator.device.config.scale is not None
set_value_fn=(
lambda machine, value: machine.set_smart_standby(
enabled=machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
mode=machine.schedule.smart_wake_up_sleep.smart_stand_by_after,
minutes=int(value),
)
),
native_value_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
),
)
@ -247,34 +93,6 @@ async def async_setup_entry(
if description.supported_fn(coordinator)
]
for description in KEY_ENTITIES:
if description.supported_fn(coordinator):
num_keys = KEYS_PER_MODEL[MachineModel(coordinator.device.model)]
entities.extend(
LaMarzoccoKeyNumberEntity(coordinator, description, key)
for key in range(min(num_keys, 1), num_keys + 1)
)
for description in SCALE_KEY_ENTITIES:
if description.supported_fn(coordinator):
if bbw_settings := coordinator.device.config.bbw_settings:
entities.extend(
LaMarzoccoScaleTargetNumberEntity(
coordinator, description, int(key)
)
for key in bbw_settings.doses
)
def _async_add_new_scale() -> None:
if bbw_settings := coordinator.device.config.bbw_settings:
async_add_entities(
LaMarzoccoScaleTargetNumberEntity(coordinator, description, int(key))
for description in SCALE_KEY_ENTITIES
for key in bbw_settings.doses
)
coordinator.new_device_callback.append(_async_add_new_scale)
async_add_entities(entities)
@ -286,7 +104,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity):
@property
def native_value(self) -> float:
"""Return the current value."""
return self.entity_description.native_value_fn(self.coordinator.device.config)
return self.entity_description.native_value_fn(self.coordinator.device)
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
@ -305,62 +123,3 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity):
},
) from exc
self.async_write_ha_state()
class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity):
"""Number representing espresso machine with key support."""
entity_description: LaMarzoccoKeyNumberEntityDescription
def __init__(
self,
coordinator: LaMarzoccoUpdateCoordinator,
description: LaMarzoccoKeyNumberEntityDescription,
pyhsical_key: int,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, description)
# Physical Key on the machine the entity represents.
if pyhsical_key == 0:
pyhsical_key = 1
else:
self._attr_translation_key = f"{description.translation_key}_key"
self._attr_translation_placeholders = {"key": str(pyhsical_key)}
self._attr_unique_id = f"{super()._attr_unique_id}_key{pyhsical_key}"
self._attr_entity_registry_enabled_default = False
self.pyhsical_key = pyhsical_key
@property
def native_value(self) -> float | None:
"""Return the current value."""
return self.entity_description.native_value_fn(
self.coordinator.device.config, PhysicalKey(self.pyhsical_key)
)
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
if value != self.native_value:
try:
await self.entity_description.set_value_fn(
self.coordinator.device, value, PhysicalKey(self.pyhsical_key)
)
except RequestNotSuccessful as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="number_exception_key",
translation_placeholders={
"key": self.entity_description.key,
"value": str(value),
"physical_key": str(self.pyhsical_key),
},
) from exc
self.async_write_ha_state()
class LaMarzoccoScaleTargetNumberEntity(
LaMarzoccoKeyNumberEntity, LaMarzoccScaleEntity
):
"""Entity representing a key number on the scale."""
entity_description: LaMarzoccoKeyNumberEntityDescription

View File

@ -2,18 +2,18 @@
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from typing import Any, cast
from pylamarzocco.const import (
MachineModel,
PhysicalKey,
PrebrewMode,
SmartStandbyMode,
SteamLevel,
ModelName,
PreExtractionMode,
SmartStandByType,
SteamTargetLevel,
WidgetType,
)
from pylamarzocco.devices.machine import LaMarzoccoMachine
from pylamarzocco.devices import LaMarzoccoMachine
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoMachineConfig
from pylamarzocco.models import PreBrewing, SteamBoilerLevel
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
@ -23,30 +23,29 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
PARALLEL_UPDATES = 1
STEAM_LEVEL_HA_TO_LM = {
"1": SteamLevel.LEVEL_1,
"2": SteamLevel.LEVEL_2,
"3": SteamLevel.LEVEL_3,
"1": SteamTargetLevel.LEVEL_1,
"2": SteamTargetLevel.LEVEL_2,
"3": SteamTargetLevel.LEVEL_3,
}
STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items()}
PREBREW_MODE_HA_TO_LM = {
"disabled": PrebrewMode.DISABLED,
"prebrew": PrebrewMode.PREBREW,
"prebrew_enabled": PrebrewMode.PREBREW_ENABLED,
"preinfusion": PrebrewMode.PREINFUSION,
"disabled": PreExtractionMode.DISABLED,
"prebrew": PreExtractionMode.PREBREWING,
"preinfusion": PreExtractionMode.PREINFUSION,
}
PREBREW_MODE_LM_TO_HA = {value: key for key, value in PREBREW_MODE_HA_TO_LM.items()}
STANDBY_MODE_HA_TO_LM = {
"power_on": SmartStandbyMode.POWER_ON,
"last_brewing": SmartStandbyMode.LAST_BREWING,
"power_on": SmartStandByType.POWER_ON,
"last_brewing": SmartStandByType.LAST_BREW,
}
STANDBY_MODE_LM_TO_HA = {value: key for key, value in STANDBY_MODE_HA_TO_LM.items()}
@ -59,7 +58,7 @@ class LaMarzoccoSelectEntityDescription(
):
"""Description of a La Marzocco select entity."""
current_option_fn: Callable[[LaMarzoccoMachineConfig], str | None]
current_option_fn: Callable[[LaMarzoccoMachine], str | None]
select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]]
@ -71,25 +70,36 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
select_option_fn=lambda machine, option: machine.set_steam_level(
STEAM_LEVEL_HA_TO_LM[option]
),
current_option_fn=lambda config: STEAM_LEVEL_LM_TO_HA[config.steam_level],
supported_fn=lambda coordinator: coordinator.device.model
== MachineModel.LINEA_MICRA,
current_option_fn=lambda machine: STEAM_LEVEL_LM_TO_HA[
cast(
SteamBoilerLevel,
machine.dashboard.config[WidgetType.CM_STEAM_BOILER_LEVEL],
).target_level
],
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
),
),
LaMarzoccoSelectEntityDescription(
key="prebrew_infusion_select",
translation_key="prebrew_infusion_select",
entity_category=EntityCategory.CONFIG,
options=["disabled", "prebrew", "preinfusion"],
select_option_fn=lambda machine, option: machine.set_prebrew_mode(
select_option_fn=lambda machine, option: machine.set_pre_extraction_mode(
PREBREW_MODE_HA_TO_LM[option]
),
current_option_fn=lambda config: PREBREW_MODE_LM_TO_HA[config.prebrew_mode],
supported_fn=lambda coordinator: coordinator.device.model
in (
MachineModel.GS3_AV,
MachineModel.LINEA_MICRA,
MachineModel.LINEA_MINI,
MachineModel.LINEA_MINI_R,
current_option_fn=lambda machine: PREBREW_MODE_LM_TO_HA[
cast(PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]).mode
],
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (
ModelName.LINEA_MICRA,
ModelName.LINEA_MINI,
ModelName.LINEA_MINI_R,
ModelName.GS3_AV,
)
),
),
LaMarzoccoSelectEntityDescription(
@ -98,32 +108,16 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
options=["power_on", "last_brewing"],
select_option_fn=lambda machine, option: machine.set_smart_standby(
enabled=machine.config.smart_standby.enabled,
enabled=machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
mode=STANDBY_MODE_HA_TO_LM[option],
minutes=machine.config.smart_standby.minutes,
minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
),
current_option_fn=lambda config: STANDBY_MODE_LM_TO_HA[
config.smart_standby.mode
current_option_fn=lambda machine: STANDBY_MODE_LM_TO_HA[
machine.schedule.smart_wake_up_sleep.smart_stand_by_after
],
),
)
SCALE_ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
LaMarzoccoSelectEntityDescription(
key="active_bbw",
translation_key="active_bbw",
options=["a", "b"],
select_option_fn=lambda machine, option: machine.set_active_bbw_recipe(
PhysicalKey[option.upper()]
),
current_option_fn=lambda config: (
config.bbw_settings.active_dose.name.lower()
if config.bbw_settings
else None
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
@ -133,30 +127,11 @@ async def async_setup_entry(
"""Set up select entities."""
coordinator = entry.runtime_data.config_coordinator
entities = [
async_add_entities(
LaMarzoccoSelectEntity(coordinator, description)
for description in ENTITIES
if description.supported_fn(coordinator)
]
if (
coordinator.device.model in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
and coordinator.device.config.scale
):
entities.extend(
LaMarzoccoScaleSelectEntity(coordinator, description)
for description in SCALE_ENTITIES
)
def _async_add_new_scale() -> None:
async_add_entities(
LaMarzoccoScaleSelectEntity(coordinator, description)
for description in SCALE_ENTITIES
)
coordinator.new_device_callback.append(_async_add_new_scale)
async_add_entities(entities)
)
class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
@ -167,9 +142,7 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
@property
def current_option(self) -> str | None:
"""Return the current selected option."""
return str(
self.entity_description.current_option_fn(self.coordinator.device.config)
)
return self.entity_description.current_option_fn(self.coordinator.device)
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
@ -188,9 +161,3 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
},
) from exc
self.async_write_ha_state()
class LaMarzoccoScaleSelectEntity(LaMarzoccoSelectEntity, LaMarzoccScaleEntity):
"""Select entity for La Marzocco scales."""
entity_description: LaMarzoccoSelectEntityDescription

View File

@ -1,226 +0,0 @@
"""Sensor platform for La Marzocco espresso machines."""
from collections.abc import Callable
from dataclasses import dataclass
from pylamarzocco.const import KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey
from pylamarzocco.devices.machine import LaMarzoccoMachine
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoSensorEntityDescription(
LaMarzoccoEntityDescription, SensorEntityDescription
):
"""Description of a La Marzocco sensor."""
value_fn: Callable[[LaMarzoccoMachine], float | int]
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoKeySensorEntityDescription(
LaMarzoccoEntityDescription, SensorEntityDescription
):
"""Description of a keyed La Marzocco sensor."""
value_fn: Callable[[LaMarzoccoMachine, PhysicalKey], int | None]
ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription(
key="shot_timer",
translation_key="shot_timer",
native_unit_of_measurement=UnitOfTime.SECONDS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DURATION,
value_fn=lambda device: device.config.brew_active_duration,
available_fn=lambda device: device.websocket_connected,
entity_category=EntityCategory.DIAGNOSTIC,
supported_fn=lambda coordinator: coordinator.local_connection_configured,
),
LaMarzoccoSensorEntityDescription(
key="current_temp_coffee",
translation_key="current_temp_coffee",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=lambda device: device.config.boilers[
BoilerType.COFFEE
].current_temperature,
),
LaMarzoccoSensorEntityDescription(
key="current_temp_steam",
translation_key="current_temp_steam",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=lambda device: device.config.boilers[
BoilerType.STEAM
].current_temperature,
supported_fn=lambda coordinator: coordinator.device.model
not in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R),
),
)
STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription(
key="drink_stats_coffee",
translation_key="drink_stats_coffee",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device: device.statistics.total_coffee,
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
entity_category=EntityCategory.DIAGNOSTIC,
),
LaMarzoccoSensorEntityDescription(
key="drink_stats_flushing",
translation_key="drink_stats_flushing",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device: device.statistics.total_flushes,
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
KEY_STATISTIC_ENTITIES: tuple[LaMarzoccoKeySensorEntityDescription, ...] = (
LaMarzoccoKeySensorEntityDescription(
key="drink_stats_coffee_key",
translation_key="drink_stats_coffee_key",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device, key: device.statistics.drink_stats.get(key),
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)
SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription(
key="scale_battery",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
value_fn=lambda device: (
device.config.scale.battery if device.config.scale else 0
),
supported_fn=(
lambda coordinator: coordinator.device.model
in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities."""
config_coordinator = entry.runtime_data.config_coordinator
entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = []
entities = [
LaMarzoccoSensorEntity(config_coordinator, description)
for description in ENTITIES
if description.supported_fn(config_coordinator)
]
if (
config_coordinator.device.model
in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
and config_coordinator.device.config.scale
):
entities.extend(
LaMarzoccoScaleSensorEntity(config_coordinator, description)
for description in SCALE_ENTITIES
)
statistics_coordinator = entry.runtime_data.statistics_coordinator
entities.extend(
LaMarzoccoSensorEntity(statistics_coordinator, description)
for description in STATISTIC_ENTITIES
if description.supported_fn(statistics_coordinator)
)
num_keys = KEYS_PER_MODEL[MachineModel(config_coordinator.device.model)]
if num_keys > 0:
entities.extend(
LaMarzoccoKeySensorEntity(statistics_coordinator, description, key)
for description in KEY_STATISTIC_ENTITIES
for key in range(1, num_keys + 1)
)
def _async_add_new_scale() -> None:
async_add_entities(
LaMarzoccoScaleSensorEntity(config_coordinator, description)
for description in SCALE_ENTITIES
)
config_coordinator.new_device_callback.append(_async_add_new_scale)
async_add_entities(entities)
class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
"""Sensor representing espresso machine temperature data."""
entity_description: LaMarzoccoSensorEntityDescription
@property
def native_value(self) -> int | float | None:
"""State of the sensor."""
return self.entity_description.value_fn(self.coordinator.device)
class LaMarzoccoKeySensorEntity(LaMarzoccoEntity, SensorEntity):
"""Sensor for a La Marzocco key."""
entity_description: LaMarzoccoKeySensorEntityDescription
def __init__(
self,
coordinator: LaMarzoccoUpdateCoordinator,
description: LaMarzoccoKeySensorEntityDescription,
key: int,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, description)
self.key = key
self._attr_translation_placeholders = {"key": str(key)}
self._attr_unique_id = f"{super()._attr_unique_id}_key{key}"
@property
def native_value(self) -> int | None:
"""State of the sensor."""
return self.entity_description.value_fn(
self.coordinator.device, PhysicalKey(self.key)
)
class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity):
"""Sensor for a La Marzocco scale."""
entity_description: LaMarzoccoSensorEntityDescription

View File

@ -32,13 +32,11 @@
}
},
"machine_selection": {
"description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.",
"description": "Select the machine you want to integrate.",
"data": {
"host": "[%key:common::config_flow::data::ip%]",
"machine": "Machine"
},
"data_description": {
"host": "Local IP address of the machine",
"machine": "Select the machine you want to integrate"
}
},
@ -101,54 +99,16 @@
"coffee_temp": {
"name": "Coffee target temperature"
},
"dose_key": {
"name": "Dose Key {key}"
},
"prebrew_on": {
"name": "Prebrew on time"
},
"prebrew_on_key": {
"name": "Prebrew on time Key {key}"
},
"prebrew_off": {
"name": "Prebrew off time"
},
"prebrew_off_key": {
"name": "Prebrew off time Key {key}"
},
"preinfusion_off": {
"name": "Preinfusion time"
},
"preinfusion_off_key": {
"name": "Preinfusion time Key {key}"
},
"scale_target_key": {
"name": "Brew by weight target {key}"
},
"smart_standby_time": {
"name": "Smart standby time"
},
"steam_temp": {
"name": "Steam target temperature"
},
"tea_water_duration": {
"name": "Tea water duration"
}
},
"select": {
"active_bbw": {
"name": "Active brew by weight recipe",
"state": {
"a": "Recipe A",
"b": "Recipe B"
}
},
"prebrew_infusion_select": {
"name": "Prebrew/-infusion mode",
"state": {
"disabled": "[%key:common::state::disabled%]",
"prebrew": "Prebrew",
"prebrew_enabled": "Prebrew",
"preinfusion": "Preinfusion"
}
},
@ -168,29 +128,6 @@
}
}
},
"sensor": {
"current_temp_coffee": {
"name": "Current coffee temperature"
},
"current_temp_steam": {
"name": "Current steam temperature"
},
"drink_stats_coffee": {
"name": "Total coffees made",
"unit_of_measurement": "coffees"
},
"drink_stats_coffee_key": {
"name": "Coffees made Key {key}",
"unit_of_measurement": "coffees"
},
"drink_stats_flushing": {
"name": "Total flushes made",
"unit_of_measurement": "flushes"
},
"shot_timer": {
"name": "Shot timer"
}
},
"switch": {
"auto_on_off": {
"name": "Auto on/off ({id})"
@ -233,9 +170,6 @@
"number_exception": {
"message": "Error while setting value {value} for number {key}"
},
"number_exception_key": {
"message": "Error while setting value {value} for number {key}, key {physical_key}"
},
"select_option_error": {
"message": "Error while setting select option {option} for {key}"
},

View File

@ -2,12 +2,17 @@
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from typing import Any, cast
from pylamarzocco.const import BoilerType
from pylamarzocco.devices.machine import LaMarzoccoMachine
from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.const import MachineMode, ModelName, WidgetType
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoMachineConfig
from pylamarzocco.models import (
MachineStatus,
SteamBoilerLevel,
SteamBoilerTemperature,
WakeUpScheduleSettings,
)
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
@ -30,7 +35,7 @@ class LaMarzoccoSwitchEntityDescription(
"""Description of a La Marzocco Switch."""
control_fn: Callable[[LaMarzoccoMachine, bool], Coroutine[Any, Any, bool]]
is_on_fn: Callable[[LaMarzoccoMachineConfig], bool]
is_on_fn: Callable[[LaMarzoccoMachine], bool]
ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
@ -39,13 +44,42 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
translation_key="main",
name=None,
control_fn=lambda machine, state: machine.set_power(state),
is_on_fn=lambda config: config.turned_on,
is_on_fn=(
lambda machine: cast(
MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS]
).mode
is MachineMode.BREWING_MODE
),
),
LaMarzoccoSwitchEntityDescription(
key="steam_boiler_enable",
translation_key="steam_boiler",
control_fn=lambda machine, state: machine.set_steam(state),
is_on_fn=lambda config: config.boilers[BoilerType.STEAM].enabled,
is_on_fn=(
lambda machine: cast(
SteamBoilerLevel,
machine.dashboard.config[WidgetType.CM_STEAM_BOILER_LEVEL],
).enabled
),
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
),
),
LaMarzoccoSwitchEntityDescription(
key="steam_boiler_enable",
translation_key="steam_boiler",
control_fn=lambda machine, state: machine.set_steam(state),
is_on_fn=(
lambda machine: cast(
SteamBoilerTemperature,
machine.dashboard.config[WidgetType.CM_STEAM_BOILER_TEMPERATURE],
).enabled
),
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
not in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
),
),
LaMarzoccoSwitchEntityDescription(
key="smart_standby_enabled",
@ -53,10 +87,10 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
control_fn=lambda machine, state: machine.set_smart_standby(
enabled=state,
mode=machine.config.smart_standby.mode,
minutes=machine.config.smart_standby.minutes,
mode=machine.schedule.smart_wake_up_sleep.smart_stand_by_after,
minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
),
is_on_fn=lambda config: config.smart_standby.enabled,
is_on_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
),
)
@ -78,8 +112,8 @@ async def async_setup_entry(
)
entities.extend(
LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry_id)
for wake_up_sleep_entry_id in coordinator.device.config.wake_up_sleep_entries
LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry)
for wake_up_sleep_entry in coordinator.device.schedule.smart_wake_up_sleep.schedules
)
async_add_entities(entities)
@ -117,7 +151,7 @@ class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity):
@property
def is_on(self) -> bool:
"""Return true if device is on."""
return self.entity_description.is_on_fn(self.coordinator.device.config)
return self.entity_description.is_on_fn(self.coordinator.device)
class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity):
@ -129,22 +163,21 @@ class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity):
def __init__(
self,
coordinator: LaMarzoccoUpdateCoordinator,
identifier: str,
schedule_entry: WakeUpScheduleSettings,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator, f"auto_on_off_{identifier}")
self._identifier = identifier
self._attr_translation_placeholders = {"id": identifier}
self.entity_category = EntityCategory.CONFIG
super().__init__(coordinator, f"auto_on_off_{schedule_entry.identifier}")
assert schedule_entry.identifier
self._schedule_entry = schedule_entry
self._identifier = schedule_entry.identifier
self._attr_translation_placeholders = {"id": schedule_entry.identifier}
self._attr_entity_category = EntityCategory.CONFIG
async def _async_enable(self, state: bool) -> None:
"""Enable or disable the auto on/off schedule."""
wake_up_sleep_entry = self.coordinator.device.config.wake_up_sleep_entries[
self._identifier
]
wake_up_sleep_entry.enabled = state
self._schedule_entry.enabled = state
try:
await self.coordinator.device.set_wake_up_sleep(wake_up_sleep_entry)
await self.coordinator.device.set_wakeup_schedule(self._schedule_entry)
except RequestNotSuccessful as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
@ -164,6 +197,4 @@ class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity):
@property
def is_on(self) -> bool:
"""Return true if switch is on."""
return self.coordinator.device.config.wake_up_sleep_entries[
self._identifier
].enabled
return self._schedule_entry.enabled

View File

@ -59,7 +59,7 @@ async def async_setup_entry(
) -> None:
"""Create update entities."""
coordinator = entry.runtime_data.firmware_coordinator
coordinator = entry.runtime_data.settings_coordinator
async_add_entities(
LaMarzoccoUpdateEntity(coordinator, description)
for description in ENTITIES
@ -74,18 +74,20 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
_attr_supported_features = UpdateEntityFeature.INSTALL
@property
def installed_version(self) -> str | None:
def installed_version(self) -> str:
"""Return the current firmware version."""
return self.coordinator.device.firmware[
return self.coordinator.device.settings.firmwares[
self.entity_description.component
].current_version
].build_version
@property
def latest_version(self) -> str:
"""Return the latest firmware version."""
return self.coordinator.device.firmware[
if available_update := self.coordinator.device.settings.firmwares[
self.entity_description.component
].latest_version
].available_update:
return available_update.build_version
return self.installed_version
@property
def release_url(self) -> str | None:
@ -99,9 +101,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
self._attr_in_progress = True
self.async_write_ha_state()
try:
success = await self.coordinator.device.update_firmware(
self.entity_description.component
)
await self.coordinator.device.update_firmware()
except RequestNotSuccessful as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
@ -110,13 +110,5 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
"key": self.entity_description.key,
},
) from exc
if not success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={
"key": self.entity_description.key,
},
)
self._attr_in_progress = False
await self.coordinator.async_request_refresh()

View File

@ -251,7 +251,7 @@ DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
platform=Platform.UPDATE,
entity_description=UpdateEntityDescription(
key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE, name=None
key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE
),
entity_class=MatterUpdate,
required_attributes=(

View File

@ -0,0 +1,80 @@
"""Diagnostics support for Miele."""
from __future__ import annotations
import hashlib
from typing import Any, cast
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import MieleConfigEntry
TO_REDACT = {"access_token", "refresh_token", "fabNumber"}
def hash_identifier(key: str) -> str:
"""Hash the identifier string."""
return f"**REDACTED_{hashlib.sha256(key.encode()).hexdigest()[:16]}"
def redact_identifiers(in_data: dict[str, Any]) -> dict[str, Any]:
"""Redact identifiers from the data."""
for key in in_data:
in_data[hash_identifier(key)] = in_data.pop(key)
return in_data
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: MieleConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
miele_data = {
"devices": redact_identifiers(
{
device_id: device_data.raw
for device_id, device_data in config_entry.runtime_data.data.devices.items()
}
),
"actions": redact_identifiers(
{
device_id: action_data.raw
for device_id, action_data in config_entry.runtime_data.data.actions.items()
}
),
}
return {
"config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT),
"miele_data": async_redact_data(miele_data, TO_REDACT),
}
async def async_get_device_diagnostics(
hass: HomeAssistant, config_entry: MieleConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
info = {
"manufacturer": device.manufacturer,
"model": device.model,
}
coordinator = config_entry.runtime_data
device_id = cast(str, device.serial_number)
miele_data = {
"devices": {
hash_identifier(device_id): coordinator.data.devices[device_id].raw
},
"actions": {
hash_identifier(device_id): coordinator.data.actions[device_id].raw
},
"programs": "Not implemented",
}
return {
"info": async_redact_data(info, TO_REDACT),
"data": async_redact_data(config_entry.data, TO_REDACT),
"miele_data": async_redact_data(miele_data, TO_REDACT),
}

View File

@ -104,7 +104,7 @@ class LazyState(State):
return self._last_updated_ts
@cached_property
def last_changed_timestamp(self) -> float: # type: ignore[override]
def last_changed_timestamp(self) -> float:
"""Last changed timestamp."""
ts = self._last_changed_ts or self._last_updated_ts
if TYPE_CHECKING:
@ -112,7 +112,7 @@ class LazyState(State):
return ts
@cached_property
def last_reported_timestamp(self) -> float: # type: ignore[override]
def last_reported_timestamp(self) -> float:
"""Last reported timestamp."""
ts = self._last_reported_ts or self._last_updated_ts
if TYPE_CHECKING:

View File

@ -22,34 +22,34 @@ from homeassistant.helpers.network import is_internal_request
from .const import UNPLAYABLE_TYPES
LIBRARY = [
"Favorites",
"Artists",
"Albums",
"Tracks",
"Playlists",
"Genres",
"New Music",
"Album Artists",
"Apps",
"Radios",
"favorites",
"artists",
"albums",
"tracks",
"playlists",
"genres",
"new music",
"album artists",
"apps",
"radios",
]
MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = {
"Favorites": "favorites",
"Artists": "artists",
"Albums": "albums",
"Tracks": "titles",
"Playlists": "playlists",
"Genres": "genres",
"New Music": "new music",
"Album Artists": "album artists",
"favorites": "favorites",
"artists": "artists",
"albums": "albums",
"tracks": "titles",
"playlists": "playlists",
"genres": "genres",
"new music": "new music",
"album artists": "album artists",
MediaType.ALBUM: "album",
MediaType.ARTIST: "artist",
MediaType.TRACK: "title",
MediaType.PLAYLIST: "playlist",
MediaType.GENRE: "genre",
"Apps": "apps",
"Radios": "radios",
MediaType.APPS: "apps",
"radios": "radios",
}
SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = {
@ -58,22 +58,20 @@ SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = {
MediaType.TRACK: "track_id",
MediaType.PLAYLIST: "playlist_id",
MediaType.GENRE: "genre_id",
"Favorites": "item_id",
"favorites": "item_id",
MediaType.APPS: "item_id",
}
CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | str]] = {
"Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
"Apps": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP},
"Radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP},
"App": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
"Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
"Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
"Tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
"Playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST},
"Genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE},
"New Music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
"Album Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
"favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
"radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP},
"artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
"albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
"tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
"playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST},
"genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE},
"new music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
"album artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK},
MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM},
MediaType.TRACK: {"item": MediaClass.TRACK, "children": ""},
@ -91,17 +89,15 @@ CONTENT_TYPE_TO_CHILD_TYPE: dict[
MediaType.PLAYLIST: MediaType.PLAYLIST,
MediaType.ARTIST: MediaType.ALBUM,
MediaType.GENRE: MediaType.ARTIST,
"Artists": MediaType.ARTIST,
"Albums": MediaType.ALBUM,
"Tracks": MediaType.TRACK,
"Playlists": MediaType.PLAYLIST,
"Genres": MediaType.GENRE,
"Favorites": None, # can only be determined after inspecting the item
"Apps": MediaClass.APP,
"Radios": MediaClass.APP,
"App": None, # can only be determined after inspecting the item
"New Music": MediaType.ALBUM,
"Album Artists": MediaType.ARTIST,
"artists": MediaType.ARTIST,
"albums": MediaType.ALBUM,
"tracks": MediaType.TRACK,
"playlists": MediaType.PLAYLIST,
"genres": MediaType.GENRE,
"favorites": None, # can only be determined after inspecting the item
"radios": MediaClass.APP,
"new music": MediaType.ALBUM,
"album artists": MediaType.ARTIST,
MediaType.APPS: MediaType.APP,
MediaType.APP: MediaType.TRACK,
}
@ -173,7 +169,7 @@ def _build_response_known_app(
def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia:
"""Build item for Favorites."""
"""Build item for favorites."""
if "album_id" in item:
return BrowseMedia(
media_content_id=str(item["album_id"]),
@ -183,21 +179,21 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia:
can_expand=True,
can_play=True,
)
if item["hasitems"] and not item["isaudio"]:
if item.get("hasitems") and not item.get("isaudio"):
return BrowseMedia(
media_content_id=item["id"],
title=item["title"],
media_content_type="Favorites",
media_class=CONTENT_TYPE_MEDIA_CLASS["Favorites"]["item"],
media_content_type="favorites",
media_class=CONTENT_TYPE_MEDIA_CLASS["favorites"]["item"],
can_expand=True,
can_play=False,
)
return BrowseMedia(
media_content_id=item["id"],
title=item["title"],
media_content_type="Favorites",
media_content_type="favorites",
media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]["item"],
can_expand=item["hasitems"],
can_expand=bool(item.get("hasitems")),
can_play=bool(item["isaudio"] and item.get("url")),
)
@ -220,7 +216,7 @@ def _get_item_thumbnail(
item_type, item["id"], artwork_track_id
)
elif search_type in ["Apps", "Radios"]:
elif search_type in ["apps", "radios"]:
item_thumbnail = player.generate_image_url(item["icon"])
if item_thumbnail is None:
item_thumbnail = item.get("image_url") # will not be proxied by HA
@ -265,10 +261,10 @@ async def build_item_response(
for item in result["items"]:
# Force the item id to a string in case it's numeric from some lms
item["id"] = str(item.get("id", ""))
if search_type == "Favorites":
if search_type == "favorites":
child_media = _build_response_favorites(item)
elif search_type in ["Apps", "Radios"]:
elif search_type in ["apps", "radios"]:
# item["cmd"] contains the name of the command to use with the cli for the app
# add the command to the dictionaries
if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES:
@ -364,11 +360,11 @@ async def library_payload(
assert media_class["children"] is not None
library_info["children"].append(
BrowseMedia(
title=item,
title=item.title(),
media_class=media_class["children"],
media_content_id=item,
media_content_type=item,
can_play=item not in ["Favorites", "Apps", "Radios"],
can_play=item not in ["favorites", "apps", "radios"],
can_expand=True,
)
)

View File

@ -446,6 +446,9 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
"""Send the play_media command to the media player."""
index = None
if media_type:
media_type = media_type.lower()
enqueue: MediaPlayerEnqueue | None = kwargs.get(ATTR_MEDIA_ENQUEUE)
if enqueue == MediaPlayerEnqueue.ADD:
@ -617,6 +620,9 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
media_content_id,
)
if media_content_type:
media_content_type = media_content_type.lower()
if media_content_type in [None, "library"]:
return await library_payload(self.hass, self._player, self._browse_data)

View File

@ -0,0 +1,92 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
The integration does not provide any additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup:
status: todo
comment: |
When fetch_appliances fails, ConfigEntryNotReady should be raised.
unique-config-entry: done
# Silver
action-exceptions:
status: todo
comment: |
- The calls to the api can be changed to return bool, and services can then raise HomeAssistantError
- Current services raise ValueError and should raise ServiceValidationError instead.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no configuration parameters
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: done
test-coverage:
status: todo
comment: |
- Test helper init_integration() does not set a unique_id
- Merge test_setup_http_exception and test_setup_auth_account_locked
- The climate platform is at 94%
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: |
This integration is a cloud service and thus does not support discovery.
discovery:
status: exempt
comment: |
This integration is a cloud service and thus does not support discovery.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class:
status: todo
comment: The "unknown" state should not be part of the enum for the dispense level sensor.
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations:
status: todo
comment: |
Time remaining sensor still has hardcoded icon.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No known use cases for repair issues or flows, yet
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@ -363,11 +363,17 @@ class DriverEvents:
self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)})
for node in controller.nodes.values()
]
provisioned_devices = [
self.dev_reg.async_get(entry.additional_properties["device_id"])
for entry in await controller.async_get_provisioning_entries()
if entry.additional_properties
and "device_id" in entry.additional_properties
]
# Devices that are in the device registry that are not known by the controller
# can be removed
for device in stored_devices:
if device not in known_devices:
if device not in known_devices and device not in provisioned_devices:
self.dev_reg.async_remove_device(device.id)
# run discovery on controller node
@ -448,6 +454,8 @@ class ControllerEvents:
)
)
await self.async_check_preprovisioned_device(node)
if node.is_controller_node:
# Create a controller status sensor for each device
async_dispatcher_send(
@ -497,7 +505,7 @@ class ControllerEvents:
# we do submit the node to device registry so user has
# some visual feedback that something is (in the process of) being added
self.register_node_in_dev_reg(node)
await self.async_register_node_in_dev_reg(node)
@callback
def async_on_node_removed(self, event: dict) -> None:
@ -574,18 +582,52 @@ class ControllerEvents:
f"{DOMAIN}.identify_controller.{dev_id[1]}",
)
@callback
def register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry:
async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None:
"""Check if the node was preprovisioned and update the device registry."""
provisioning_entry = (
await self.driver_events.driver.controller.async_get_provisioning_entry(
node.node_id
)
)
if (
provisioning_entry
and provisioning_entry.additional_properties
and "device_id" in provisioning_entry.additional_properties
):
preprovisioned_device = self.dev_reg.async_get(
provisioning_entry.additional_properties["device_id"]
)
if preprovisioned_device:
dsk = provisioning_entry.dsk
dsk_identifier = (DOMAIN, f"provision_{dsk}")
# If the pre-provisioned device has the DSK identifier, remove it
if dsk_identifier in preprovisioned_device.identifiers:
driver = self.driver_events.driver
device_id = get_device_id(driver, node)
device_id_ext = get_device_id_ext(driver, node)
new_identifiers = preprovisioned_device.identifiers.copy()
new_identifiers.remove(dsk_identifier)
new_identifiers.add(device_id)
if device_id_ext:
new_identifiers.add(device_id_ext)
self.dev_reg.async_update_device(
preprovisioned_device.id,
new_identifiers=new_identifiers,
)
async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry:
"""Register node in dev reg."""
driver = self.driver_events.driver
device_id = get_device_id(driver, node)
device_id_ext = get_device_id_ext(driver, node)
node_id_device = self.dev_reg.async_get_device(identifiers={device_id})
via_device_id = None
via_identifier = None
controller = driver.controller
# Get the controller node device ID if this node is not the controller
if controller.own_node and controller.own_node != node:
via_device_id = get_device_id(driver, controller.own_node)
via_identifier = get_device_id(driver, controller.own_node)
if device_id_ext:
# If there is a device with this node ID but with a different hardware
@ -632,7 +674,7 @@ class ControllerEvents:
model=node.device_config.label,
manufacturer=node.device_config.manufacturer,
suggested_area=node.location if node.location else UNDEFINED,
via_device=via_device_id,
via_device=via_identifier,
)
async_dispatcher_send(self.hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device)
@ -666,7 +708,7 @@ class NodeEvents:
"""Handle node ready event."""
LOGGER.debug("Processing node %s", node)
# register (or update) node in device registry
device = self.controller_events.register_node_in_dev_reg(node)
device = await self.controller_events.async_register_node_in_dev_reg(node)
# Remove any old value ids if this is a reinterview.
self.controller_events.discovered_value_ids.pop(device.id, None)

View File

@ -91,6 +91,7 @@ from .const import (
from .helpers import (
async_enable_statistics,
async_get_node_from_device_id,
async_get_provisioning_entry_from_device_id,
get_device_id,
)
@ -171,6 +172,10 @@ ADDITIONAL_PROPERTIES = "additional_properties"
STATUS = "status"
REQUESTED_SECURITY_CLASSES = "requestedSecurityClasses"
PROTOCOL = "protocol"
DEVICE_NAME = "device_name"
AREA_ID = "area_id"
FEATURE = "feature"
STRATEGY = "strategy"
@ -398,6 +403,7 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_subscribe_s2_inclusion)
websocket_api.async_register_command(hass, websocket_grant_security_classes)
websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin)
websocket_api.async_register_command(hass, websocket_subscribe_new_devices)
websocket_api.async_register_command(hass, websocket_provision_smart_start_node)
websocket_api.async_register_command(hass, websocket_unprovision_smart_start_node)
websocket_api.async_register_command(hass, websocket_get_provisioning_entries)
@ -631,14 +637,38 @@ async def websocket_node_metadata(
}
)
@websocket_api.async_response
@async_get_node
async def websocket_node_alerts(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
node: Node,
) -> None:
"""Get the alerts for a Z-Wave JS node."""
try:
node = async_get_node_from_device_id(hass, msg[DEVICE_ID])
except ValueError as err:
if "can't be found" in err.args[0]:
provisioning_entry = await async_get_provisioning_entry_from_device_id(
hass, msg[DEVICE_ID]
)
if provisioning_entry:
connection.send_result(
msg[ID],
{
"comments": [
{
"level": "info",
"text": "This device has been provisioned but is not yet included in the "
"network.",
}
],
},
)
else:
connection.send_error(msg[ID], ERR_NOT_FOUND, str(err))
else:
connection.send_error(msg[ID], ERR_NOT_LOADED, str(err))
return
connection.send_result(
msg[ID],
{
@ -971,12 +1001,58 @@ async def websocket_validate_dsk_and_enter_pin(
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/subscribe_new_devices",
vol.Required(ENTRY_ID): str,
}
)
@websocket_api.async_response
async def websocket_subscribe_new_devices(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to new devices."""
@callback
def async_cleanup() -> None:
for unsub in unsubs:
unsub()
@callback
def device_registered(device: dr.DeviceEntry) -> None:
device_details = {
"name": device.name,
"id": device.id,
"manufacturer": device.manufacturer,
"model": device.model,
}
connection.send_message(
websocket_api.event_message(
msg[ID], {"event": "device registered", "device": device_details}
)
)
connection.subscriptions[msg["id"]] = async_cleanup
msg[DATA_UNSUBSCRIBE] = unsubs = [
async_dispatcher_connect(
hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered
),
]
connection.send_result(msg[ID])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/provision_smart_start_node",
vol.Required(ENTRY_ID): str,
vol.Required(QR_PROVISIONING_INFORMATION): QR_PROVISIONING_INFORMATION_SCHEMA,
vol.Optional(PROTOCOL): vol.Coerce(Protocols),
vol.Optional(DEVICE_NAME): str,
vol.Optional(AREA_ID): str,
}
)
@websocket_api.async_response
@ -991,18 +1067,68 @@ async def websocket_provision_smart_start_node(
driver: Driver,
) -> None:
"""Pre-provision a smart start node."""
qr_info = msg[QR_PROVISIONING_INFORMATION]
provisioning_info = msg[QR_PROVISIONING_INFORMATION]
if provisioning_info.version == QRCodeVersion.S2:
if qr_info.version == QRCodeVersion.S2:
connection.send_error(
msg[ID],
ERR_INVALID_FORMAT,
"QR code version S2 is not supported for this command",
)
return
provisioning_info = ProvisioningEntry(
dsk=qr_info.dsk,
security_classes=qr_info.security_classes,
requested_security_classes=qr_info.requested_security_classes,
protocol=msg.get(PROTOCOL),
additional_properties=qr_info.additional_properties,
)
device = None
# Create an empty device if device_name is provided
if device_name := msg.get(DEVICE_NAME):
dev_reg = dr.async_get(hass)
# Create a unique device identifier using the DSK
device_identifier = (DOMAIN, f"provision_{qr_info.dsk}")
manufacturer = None
model = None
device_info = await driver.config_manager.lookup_device(
qr_info.manufacturer_id,
qr_info.product_type,
qr_info.product_id,
)
if device_info:
manufacturer = device_info.manufacturer
model = device_info.label
# Create an empty device
device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={device_identifier},
name=device_name,
manufacturer=manufacturer,
model=model,
via_device=get_device_id(driver, driver.controller.own_node)
if driver.controller.own_node
else None,
)
dev_reg.async_update_device(
device.id, area_id=msg.get(AREA_ID), name_by_user=device_name
)
if provisioning_info.additional_properties is None:
provisioning_info.additional_properties = {}
provisioning_info.additional_properties["device_id"] = device.id
await driver.controller.async_provision_smart_start_node(provisioning_info)
connection.send_result(msg[ID])
if device:
connection.send_result(msg[ID], device.id)
else:
connection.send_result(msg[ID])
@websocket_api.require_admin
@ -1036,7 +1162,24 @@ async def websocket_unprovision_smart_start_node(
)
return
dsk_or_node_id = msg.get(DSK) or msg[NODE_ID]
provisioning_entry = await driver.controller.async_get_provisioning_entry(
dsk_or_node_id
)
if (
provisioning_entry
and provisioning_entry.additional_properties
and "device_id" in provisioning_entry.additional_properties
):
device_identifier = (DOMAIN, f"provision_{provisioning_entry.dsk}")
device_id = provisioning_entry.additional_properties["device_id"]
dev_reg = dr.async_get(hass)
device = dev_reg.async_get(device_id)
if device and device.identifiers == {device_identifier}:
# Only remove the device if nothing else has claimed it
dev_reg.async_remove_device(device_id)
await driver.controller.async_unprovision_smart_start_node(dsk_or_node_id)
connection.send_result(msg[ID])

View File

@ -15,7 +15,7 @@ from zwave_js_server.const import (
ConfigurationValueType,
LogLevel,
)
from zwave_js_server.model.controller import Controller
from zwave_js_server.model.controller import Controller, ProvisioningEntry
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.log_config import LogConfig
from zwave_js_server.model.node import Node as ZwaveNode
@ -233,7 +233,7 @@ def get_home_and_node_id_from_device_entry(
),
None,
)
if device_id is None:
if device_id is None or device_id.startswith("provision_"):
return None
id_ = device_id.split("-")
return (id_[0], int(id_[1]))
@ -264,12 +264,12 @@ def async_get_node_from_device_id(
),
None,
)
if entry and entry.state != ConfigEntryState.LOADED:
raise ValueError(f"Device {device_id} config entry is not loaded")
if entry is None:
raise ValueError(
f"Device {device_id} is not from an existing zwave_js config entry"
)
if entry.state != ConfigEntryState.LOADED:
raise ValueError(f"Device {device_id} config entry is not loaded")
client: ZwaveClient = entry.runtime_data[DATA_CLIENT]
driver = client.driver
@ -289,6 +289,53 @@ def async_get_node_from_device_id(
return driver.controller.nodes[node_id]
async def async_get_provisioning_entry_from_device_id(
hass: HomeAssistant, device_id: str
) -> ProvisioningEntry | None:
"""Get provisioning entry from a device ID.
Raises ValueError if device is invalid
"""
dev_reg = dr.async_get(hass)
if not (device_entry := dev_reg.async_get(device_id)):
raise ValueError(f"Device ID {device_id} is not valid")
# Use device config entry ID's to validate that this is a valid zwave_js device
# and to get the client
config_entry_ids = device_entry.config_entries
entry = next(
(
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.entry_id in config_entry_ids
),
None,
)
if entry is None:
raise ValueError(
f"Device {device_id} is not from an existing zwave_js config entry"
)
if entry.state != ConfigEntryState.LOADED:
raise ValueError(f"Device {device_id} config entry is not loaded")
client: ZwaveClient = entry.runtime_data[DATA_CLIENT]
driver = client.driver
if driver is None:
raise ValueError("Driver is not ready.")
provisioning_entries = await driver.controller.async_get_provisioning_entries()
for provisioning_entry in provisioning_entries:
if (
provisioning_entry.additional_properties
and provisioning_entry.additional_properties.get("device_id") == device_id
):
return provisioning_entry
return None
@callback
def async_get_node_from_entity_id(
hass: HomeAssistant,

View File

@ -3329,7 +3329,7 @@
"name": "La Marzocco",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
"iot_class": "cloud_push"
},
"lametric": {
"name": "LaMetric",

View File

@ -1072,7 +1072,7 @@ class TemplateStateBase(State):
raise KeyError
@under_cached_property
def entity_id(self) -> str: # type: ignore[override]
def entity_id(self) -> str:
"""Wrap State.entity_id.
Intentionally does not collect state
@ -1128,7 +1128,7 @@ class TemplateStateBase(State):
return self._state.object_id
@property
def name(self) -> str: # type: ignore[override]
def name(self) -> str:
"""Wrap State.name."""
self._collect_state()
return self._state.name

2
requirements_all.txt generated
View File

@ -2089,7 +2089,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
pylamarzocco==1.4.9
pylamarzocco==2.0.0b1
# homeassistant.components.lastfm
pylast==5.1.0

View File

@ -10,9 +10,10 @@
astroid==3.3.9
coverage==7.6.12
freezegun==1.5.1
go2rtc-client==0.1.2
license-expression==30.4.1
mock-open==1.4.0
mypy-dev==1.16.0a7
mypy-dev==1.16.0a8
pre-commit==4.0.0
pydantic==2.11.3
pylint==3.3.6

View File

@ -1704,7 +1704,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.8
# homeassistant.components.lamarzocco
pylamarzocco==1.4.9
pylamarzocco==2.0.0b1
# homeassistant.components.lastfm
pylast==5.1.0

View File

@ -1100,7 +1100,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"weatherkit",
"webmin",
"wemo",
"whirlpool",
"whois",
"wiffi",
"wilight",

View File

@ -181,5 +181,29 @@
}
]
}
},
"Hood": {
"data": {
"programs": [
{
"key": "Cooking.Common.Program.Hood.Automatic",
"constraints": {
"execution": "selectandstart"
}
},
{
"key": "Cooking.Common.Program.Hood.Venting",
"constraints": {
"execution": "selectandstart"
}
},
{
"key": "Cooking.Common.Program.Hood.DelayedShutOff",
"constraints": {
"execution": "selectandstart"
}
}
]
}
}
}

View File

@ -90,6 +90,9 @@
'ha_id': 'BOSCH-HCS000000-D00000000004',
'name': 'Hood',
'programs': list([
'Cooking.Common.Program.Hood.Automatic',
'Cooking.Common.Program.Hood.Venting',
'Cooking.Common.Program.Hood.DelayedShutOff',
]),
'settings': dict({
'BSH.Common.Setting.AmbientLightBrightness': 70,

View File

@ -10,6 +10,7 @@ from aiohomeconnect.model import (
EventMessage,
EventType,
HomeAppliance,
StatusKey,
)
from aiohomeconnect.model.error import HomeConnectApiError
import pytest
@ -105,9 +106,19 @@ async def test_paired_depaired_devices_flow(
assert entity_registry.async_get(entity_entry.entity_id)
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
@pytest.mark.parametrize(
("appliance", "keys_to_check"),
[
(
"Washer",
(StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE,),
)
],
indirect=["appliance"],
)
async def test_connected_devices(
appliance: HomeAppliance,
keys_to_check: tuple,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
@ -138,7 +149,17 @@ async def test_connected_devices(
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert entity_registry.async_get_entity_id(
Platform.BINARY_SENSOR,
DOMAIN,
f"{appliance.ha_id}-{EventKey.BSH_COMMON_APPLIANCE_CONNECTED}",
)
for key in keys_to_check:
assert not entity_registry.async_get_entity_id(
Platform.BINARY_SENSOR,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
await client.add_events(
[
@ -151,10 +172,12 @@ async def test_connected_devices(
)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert len(new_entity_entries) > len(entity_entries)
for key in (*keys_to_check, EventKey.BSH_COMMON_APPLIANCE_CONNECTED):
assert entity_registry.async_get_entity_id(
Platform.BINARY_SENSOR,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")

View File

@ -99,9 +99,19 @@ async def test_paired_depaired_devices_flow(
assert entity_registry.async_get(entity_entry.entity_id)
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
@pytest.mark.parametrize(
("appliance", "keys_to_check"),
[
(
"Washer",
(CommandKey.BSH_COMMON_PAUSE_PROGRAM,),
)
],
indirect=["appliance"],
)
async def test_connected_devices(
appliance: HomeAppliance,
keys_to_check: tuple,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
@ -116,7 +126,7 @@ async def test_connected_devices(
not be obtained while disconnected and once connected, the entities are added.
"""
get_available_commands_original_mock = client.get_available_commands
get_available_programs_mock = client.get_available_programs
get_all_programs_mock = client.get_all_programs
async def get_available_commands_side_effect(ha_id: str):
if ha_id == appliance.ha_id:
@ -125,28 +135,36 @@ async def test_connected_devices(
)
return await get_available_commands_original_mock.side_effect(ha_id)
async def get_available_programs_side_effect(ha_id: str):
async def get_all_programs_side_effect(ha_id: str):
if ha_id == appliance.ha_id:
raise HomeConnectApiError(
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
)
return await get_available_programs_mock.side_effect(ha_id)
return await get_all_programs_mock.side_effect(ha_id)
client.get_available_commands = AsyncMock(
side_effect=get_available_commands_side_effect
)
client.get_available_programs = AsyncMock(
side_effect=get_available_programs_side_effect
)
client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
client.get_available_commands = get_available_commands_original_mock
client.get_available_programs = get_available_programs_mock
client.get_all_programs = get_all_programs_mock
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert entity_registry.async_get_entity_id(
Platform.BUTTON,
DOMAIN,
f"{appliance.ha_id}-StopProgram",
)
for key in keys_to_check:
assert not entity_registry.async_get_entity_id(
Platform.BUTTON,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
await client.add_events(
[
@ -159,10 +177,12 @@ async def test_connected_devices(
)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert len(new_entity_entries) > len(entity_entries)
for key in (*keys_to_check, "StopProgram"):
assert entity_registry.async_get_entity_id(
Platform.BUTTON,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)

View File

@ -119,9 +119,19 @@ async def test_paired_depaired_devices_flow(
assert entity_registry.async_get(entity_entry.entity_id)
@pytest.mark.parametrize("appliance", ["Hood"], indirect=True)
@pytest.mark.parametrize(
("appliance", "keys_to_check"),
[
(
"Hood",
(SettingKey.COOKING_COMMON_LIGHTING,),
)
],
indirect=["appliance"],
)
async def test_connected_devices(
appliance: HomeAppliance,
keys_to_check: tuple,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
@ -136,7 +146,6 @@ async def test_connected_devices(
not be obtained while disconnected and once connected, the entities are added.
"""
get_settings_original_mock = client.get_settings
get_available_programs_mock = client.get_available_programs
async def get_settings_side_effect(ha_id: str):
if ha_id == appliance.ha_id:
@ -145,26 +154,20 @@ async def test_connected_devices(
)
return await get_settings_original_mock.side_effect(ha_id)
async def get_available_programs_side_effect(ha_id: str):
if ha_id == appliance.ha_id:
raise HomeConnectApiError(
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
)
return await get_available_programs_mock.side_effect(ha_id)
client.get_settings = AsyncMock(side_effect=get_settings_side_effect)
client.get_available_programs = AsyncMock(
side_effect=get_available_programs_side_effect
)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
client.get_settings = get_settings_original_mock
client.get_available_programs = get_available_programs_mock
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
for key in keys_to_check:
assert not entity_registry.async_get_entity_id(
Platform.LIGHT,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
await client.add_events(
[
@ -177,10 +180,12 @@ async def test_connected_devices(
)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert len(new_entity_entries) > len(entity_entries)
for key in keys_to_check:
assert entity_registry.async_get_entity_id(
Platform.LIGHT,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
@pytest.mark.parametrize("appliance", ["Hood"], indirect=True)

View File

@ -135,9 +135,21 @@ async def test_paired_depaired_devices_flow(
assert entity_registry.async_get(entity_entry.entity_id)
@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True)
@pytest.mark.parametrize(
("appliance", "keys_to_check"),
[
(
"FridgeFreezer",
(
SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
),
)
],
indirect=["appliance"],
)
async def test_connected_devices(
appliance: HomeAppliance,
keys_to_check: tuple,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
@ -168,7 +180,12 @@ async def test_connected_devices(
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
for key in keys_to_check:
assert not entity_registry.async_get_entity_id(
Platform.NUMBER,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
await client.add_events(
[
@ -181,10 +198,12 @@ async def test_connected_devices(
)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert len(new_entity_entries) > len(entity_entries)
for key in keys_to_check:
assert entity_registry.async_get_entity_id(
Platform.NUMBER,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True)

View File

@ -20,6 +20,7 @@ from aiohomeconnect.model import (
)
from aiohomeconnect.model.error import (
ActiveProgramNotSetError,
HomeConnectApiError,
HomeConnectError,
SelectedProgramNotSetError,
TooManyRequestsError,
@ -138,9 +139,23 @@ async def test_paired_depaired_devices_flow(
assert entity_registry.async_get(entity_entry.entity_id)
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
@pytest.mark.parametrize(
("appliance", "keys_to_check"),
[
(
"Hood",
(
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
SettingKey.COOKING_HOOD_COLOR_TEMPERATURE,
),
)
],
indirect=["appliance"],
)
async def test_connected_devices(
appliance: HomeAppliance,
keys_to_check: tuple,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
@ -154,13 +169,39 @@ async def test_connected_devices(
Specifically those devices whose settings, status, etc. could
not be obtained while disconnected and once connected, the entities are added.
"""
get_settings_original_mock = client.get_settings
get_all_programs_mock = client.get_all_programs
async def get_settings_side_effect(ha_id: str):
if ha_id == appliance.ha_id:
raise HomeConnectApiError(
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
)
return await get_settings_original_mock.side_effect(ha_id)
async def get_all_programs_side_effect(ha_id: str):
if ha_id == appliance.ha_id:
raise HomeConnectApiError(
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
)
return await get_all_programs_mock.side_effect(ha_id)
client.get_settings = AsyncMock(side_effect=get_settings_side_effect)
client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
client.get_settings = get_settings_original_mock
client.get_all_programs = get_all_programs_mock
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
for key in keys_to_check:
assert not entity_registry.async_get_entity_id(
Platform.SELECT,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
await client.add_events(
[
@ -173,10 +214,12 @@ async def test_connected_devices(
)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert entity_entries
for key in keys_to_check:
assert entity_registry.async_get_entity_id(
Platform.SELECT,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)

View File

@ -178,9 +178,19 @@ async def test_paired_depaired_devices_flow(
assert hass.states.is_state("sensor.washer_poor_i_dos_1_fill_level", "present")
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
@pytest.mark.parametrize(
("appliance", "keys_to_check"),
[
(
"Washer",
(StatusKey.BSH_COMMON_OPERATION_STATE,),
)
],
indirect=["appliance"],
)
async def test_connected_devices(
appliance: HomeAppliance,
keys_to_check: tuple,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
@ -211,7 +221,12 @@ async def test_connected_devices(
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
for key in keys_to_check:
assert not entity_registry.async_get_entity_id(
Platform.SENSOR,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
await client.add_events(
[
@ -224,10 +239,12 @@ async def test_connected_devices(
)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert len(new_entity_entries) > len(entity_entries)
for key in keys_to_check:
assert entity_registry.async_get_entity_id(
Platform.SENSOR,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True)

View File

@ -147,9 +147,23 @@ async def test_paired_depaired_devices_flow(
assert entity_registry.async_get(entity_entry.entity_id)
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
@pytest.mark.parametrize(
("appliance", "keys_to_check"),
[
(
"Washer",
(
SettingKey.BSH_COMMON_POWER_STATE,
SettingKey.BSH_COMMON_CHILD_LOCK,
"Program Cotton",
),
)
],
indirect=["appliance"],
)
async def test_connected_devices(
appliance: HomeAppliance,
keys_to_check: tuple,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
@ -164,7 +178,7 @@ async def test_connected_devices(
not be obtained while disconnected and once connected, the entities are added.
"""
get_settings_original_mock = client.get_settings
get_available_programs_mock = client.get_available_programs
get_all_programs_mock = client.get_all_programs
async def get_settings_side_effect(ha_id: str):
if ha_id == appliance.ha_id:
@ -173,26 +187,29 @@ async def test_connected_devices(
)
return await get_settings_original_mock.side_effect(ha_id)
async def get_available_programs_side_effect(ha_id: str):
async def get_all_programs_side_effect(ha_id: str):
if ha_id == appliance.ha_id:
raise HomeConnectApiError(
"SDK.Error.HomeAppliance.Connection.Initialization.Failed"
)
return await get_available_programs_mock.side_effect(ha_id)
return await get_all_programs_mock.side_effect(ha_id)
client.get_settings = AsyncMock(side_effect=get_settings_side_effect)
client.get_available_programs = AsyncMock(
side_effect=get_available_programs_side_effect
)
client.get_all_programs = AsyncMock(side_effect=get_all_programs_side_effect)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
client.get_settings = get_settings_original_mock
client.get_available_programs = get_available_programs_mock
client.get_all_programs = get_all_programs_mock
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
for key in keys_to_check:
assert not entity_registry.async_get_entity_id(
Platform.SWITCH,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
await client.add_events(
[
@ -205,10 +222,12 @@ async def test_connected_devices(
)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert len(new_entity_entries) > len(entity_entries)
for key in keys_to_check:
assert entity_registry.async_get_entity_id(
Platform.SWITCH,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")

View File

@ -113,9 +113,19 @@ async def test_paired_depaired_devices_flow(
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize("appliance", ["Oven"], indirect=True)
@pytest.mark.parametrize(
("appliance", "keys_to_check"),
[
(
"Oven",
(SettingKey.BSH_COMMON_ALARM_CLOCK,),
)
],
indirect=["appliance"],
)
async def test_connected_devices(
appliance: HomeAppliance,
keys_to_check: tuple,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
@ -146,7 +156,12 @@ async def test_connected_devices(
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
for key in keys_to_check:
assert not entity_registry.async_get_entity_id(
Platform.TIME,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
await client.add_events(
[
@ -159,10 +174,12 @@ async def test_connected_devices(
)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)})
assert device
new_entity_entries = entity_registry.entities.get_entries_for_device_id(device.id)
assert len(new_entity_entries) > len(entity_entries)
for key in keys_to_check:
assert entity_registry.async_get_entity_id(
Platform.TIME,
DOMAIN,
f"{appliance.ha_id}-{key}",
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")

View File

@ -1,6 +1,6 @@
"""Mock inputs for tests."""
from pylamarzocco.const import MachineModel
from pylamarzocco.const import ModelName
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
@ -19,10 +19,10 @@ PASSWORD_SELECTION = {
USER_INPUT = PASSWORD_SELECTION | {CONF_USERNAME: "username"}
SERIAL_DICT = {
MachineModel.GS3_AV: "GS012345",
MachineModel.GS3_MP: "GS012345",
MachineModel.LINEA_MICRA: "MR012345",
MachineModel.LINEA_MINI: "LM012345",
ModelName.GS3_AV: "GS012345",
ModelName.GS3_MP: "GS012345",
ModelName.LINEA_MICRA: "MR012345",
ModelName.LINEA_MINI: "LM012345",
}
WAKE_UP_SLEEP_ENTRY_IDS = ["Os2OswX", "aXFz5bJ"]
@ -37,15 +37,13 @@ async def async_init_integration(
await hass.async_block_till_done()
def get_bluetooth_service_info(
model: MachineModel, serial: str
) -> BluetoothServiceInfo:
def get_bluetooth_service_info(model: ModelName, serial: str) -> BluetoothServiceInfo:
"""Return a mocked BluetoothServiceInfo."""
if model in (MachineModel.GS3_AV, MachineModel.GS3_MP):
if model in (ModelName.GS3_AV, ModelName.GS3_MP):
name = f"GS3_{serial}"
elif model == MachineModel.LINEA_MINI:
elif model == ModelName.LINEA_MINI:
name = f"MINI_{serial}"
elif model == MachineModel.LINEA_MICRA:
elif model == ModelName.LINEA_MICRA:
name = f"MICRA_{serial}"
return BluetoothServiceInfo(
name=name,

View File

@ -1,37 +1,25 @@
"""Lamarzocco session fixtures."""
from collections.abc import Generator
import json
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
from bleak.backends.device import BLEDevice
from pylamarzocco.const import FirmwareType, MachineModel, SteamLevel
from pylamarzocco.devices.machine import LaMarzoccoMachine
from pylamarzocco.models import LaMarzoccoDeviceInfo
from pylamarzocco.const import ModelName
from pylamarzocco.models import (
Thing,
ThingDashboardConfig,
ThingSchedulingSettings,
ThingSettings,
)
import pytest
from homeassistant.components.lamarzocco.const import DOMAIN
from homeassistant.const import (
CONF_ADDRESS,
CONF_HOST,
CONF_MODEL,
CONF_NAME,
CONF_TOKEN,
)
from homeassistant.const import CONF_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from . import SERIAL_DICT, USER_INPUT, async_init_integration
from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.lamarzocco.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture
@ -42,33 +30,11 @@ def mock_config_entry(
return MockConfigEntry(
title="My LaMarzocco",
domain=DOMAIN,
version=2,
version=3,
data=USER_INPUT
| {
CONF_MODEL: mock_lamarzocco.model,
CONF_ADDRESS: "00:00:00:00:00:00",
CONF_HOST: "host",
CONF_TOKEN: "token",
CONF_NAME: "GS3",
},
unique_id=mock_lamarzocco.serial_number,
)
@pytest.fixture
def mock_config_entry_no_local_connection(
hass: HomeAssistant, mock_lamarzocco: MagicMock
) -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="My LaMarzocco",
domain=DOMAIN,
version=2,
data=USER_INPUT
| {
CONF_MODEL: mock_lamarzocco.model,
CONF_TOKEN: "token",
CONF_NAME: "GS3",
},
unique_id=mock_lamarzocco.serial_number,
)
@ -85,26 +51,13 @@ async def init_integration(
@pytest.fixture
def device_fixture() -> MachineModel:
def device_fixture() -> ModelName:
"""Return the device fixture for a specific device."""
return MachineModel.GS3_AV
return ModelName.GS3_AV
@pytest.fixture
def mock_device_info(device_fixture: MachineModel) -> LaMarzoccoDeviceInfo:
"""Return a mocked La Marzocco device info."""
return LaMarzoccoDeviceInfo(
model=device_fixture,
serial_number=SERIAL_DICT[device_fixture],
name="GS3",
communication_key="token",
)
@pytest.fixture
def mock_cloud_client(
mock_device_info: LaMarzoccoDeviceInfo,
) -> Generator[MagicMock]:
@pytest.fixture(autouse=True)
def mock_cloud_client() -> Generator[MagicMock]:
"""Return a mocked LM cloud client."""
with (
patch(
@ -117,54 +70,48 @@ def mock_cloud_client(
),
):
client = cloud_client.return_value
client.get_customer_fleet.return_value = {
mock_device_info.serial_number: mock_device_info
}
client.list_things.return_value = [
Thing.from_dict(load_json_object_fixture("thing.json", DOMAIN))
]
client.get_thing_settings.return_value = ThingSettings.from_dict(
load_json_object_fixture("settings.json", DOMAIN)
)
yield client
@pytest.fixture
def mock_lamarzocco(device_fixture: MachineModel) -> Generator[MagicMock]:
def mock_lamarzocco(device_fixture: ModelName) -> Generator[MagicMock]:
"""Return a mocked LM client."""
model = device_fixture
serial_number = SERIAL_DICT[model]
dummy_machine = LaMarzoccoMachine(
model=model,
serial_number=serial_number,
name=serial_number,
)
if device_fixture == MachineModel.LINEA_MINI:
if device_fixture == ModelName.LINEA_MINI:
config = load_json_object_fixture("config_mini.json", DOMAIN)
elif device_fixture == ModelName.LINEA_MICRA:
config = load_json_object_fixture("config_micra.json", DOMAIN)
else:
config = load_json_object_fixture("config.json", DOMAIN)
statistics = json.loads(load_fixture("statistics.json", DOMAIN))
dummy_machine.parse_config(config)
dummy_machine.parse_statistics(statistics)
config = load_json_object_fixture("config_gs3.json", DOMAIN)
schedule = load_json_object_fixture("schedule.json", DOMAIN)
settings = load_json_object_fixture("settings.json", DOMAIN)
with (
patch(
"homeassistant.components.lamarzocco.LaMarzoccoMachine",
autospec=True,
) as lamarzocco_mock,
) as machine_mock_init,
):
lamarzocco = lamarzocco_mock.return_value
machine_mock = machine_mock_init.return_value
lamarzocco.name = dummy_machine.name
lamarzocco.model = dummy_machine.model
lamarzocco.serial_number = dummy_machine.serial_number
lamarzocco.full_model_name = dummy_machine.full_model_name
lamarzocco.config = dummy_machine.config
lamarzocco.statistics = dummy_machine.statistics
lamarzocco.firmware = dummy_machine.firmware
lamarzocco.steam_level = SteamLevel.LEVEL_1
lamarzocco.firmware[FirmwareType.GATEWAY].latest_version = "v3.5-rc3"
lamarzocco.firmware[FirmwareType.MACHINE].latest_version = "1.55"
yield lamarzocco
machine_mock.serial_number = SERIAL_DICT[device_fixture]
machine_mock.dashboard = ThingDashboardConfig.from_dict(config)
machine_mock.schedule = ThingSchedulingSettings.from_dict(schedule)
machine_mock.settings = ThingSettings.from_dict(settings)
machine_mock.dashboard.model_name = device_fixture
machine_mock.to_dict.return_value = {
"serial_number": machine_mock.serial_number,
"dashboard": machine_mock.dashboard.to_dict(),
"schedule": machine_mock.schedule.to_dict(),
"settings": machine_mock.settings.to_dict(),
}
yield machine_mock
@pytest.fixture(autouse=True)

View File

@ -1,198 +0,0 @@
{
"version": "v1",
"preinfusionModesAvailable": ["ByDoseType"],
"machineCapabilities": [
{
"family": "GS3AV",
"groupsNumber": 1,
"coffeeBoilersNumber": 1,
"hasCupWarmer": false,
"steamBoilersNumber": 1,
"teaDosesNumber": 1,
"machineModes": ["BrewingMode", "StandBy"],
"schedulingType": "weeklyScheduling"
}
],
"machine_sn": "Sn01239157",
"machine_hw": "2",
"isPlumbedIn": true,
"isBackFlushEnabled": false,
"standByTime": 0,
"smartStandBy": {
"enabled": true,
"minutes": 10,
"mode": "LastBrewing"
},
"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": [
{
"mode": "TypeA",
"groupNumber": "Group1",
"doseType": "DoseA",
"preWetTime": 0.5,
"preWetHoldTime": 1
},
{
"mode": "TypeB",
"groupNumber": "Group1",
"doseType": "DoseA",
"preWetTime": 0,
"preWetHoldTime": 4
},
{
"mode": "TypeA",
"groupNumber": "Group1",
"doseType": "DoseB",
"preWetTime": 0.5,
"preWetHoldTime": 1
},
{
"mode": "TypeB",
"groupNumber": "Group1",
"doseType": "DoseB",
"preWetTime": 0,
"preWetHoldTime": 4
},
{
"mode": "TypeA",
"groupNumber": "Group1",
"doseType": "DoseC",
"preWetTime": 3.3,
"preWetHoldTime": 3.3
},
{
"mode": "TypeB",
"groupNumber": "Group1",
"doseType": "DoseC",
"preWetTime": 0,
"preWetHoldTime": 4
},
{
"mode": "TypeA",
"groupNumber": "Group1",
"doseType": "DoseD",
"preWetTime": 2,
"preWetHoldTime": 2
},
{
"mode": "TypeB",
"groupNumber": "Group1",
"doseType": "DoseD",
"preWetTime": 0,
"preWetHoldTime": 4
}
]
},
"wakeUpSleepEntries": [
{
"days": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
],
"enabled": true,
"id": "Os2OswX",
"steam": true,
"timeOff": "24:0",
"timeOn": "22:0"
},
{
"days": ["sunday"],
"enabled": true,
"id": "aXFz5bJ",
"steam": true,
"timeOff": "7:30",
"timeOn": "7: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,377 @@
{
"serialNumber": "GS012345",
"type": "CoffeeMachine",
"name": "GS012345",
"location": "HOME",
"modelCode": "GS3AV",
"modelName": "GS3AV",
"connected": true,
"connectionDate": 1742489087479,
"offlineMode": false,
"requireFirmwareUpdate": false,
"availableFirmwareUpdate": false,
"coffeeStation": null,
"imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/gs3av/gs3av-1.png",
"bleAuthToken": null,
"widgets": [
{
"code": "CMMachineStatus",
"index": 1,
"output": {
"status": "PoweredOn",
"availableModes": ["BrewingMode", "StandBy"],
"mode": "BrewingMode",
"nextStatus": {
"status": "StandBy",
"startTime": 1742857195332
},
"brewingStartTime": null
},
"tutorialUrl": null
},
{
"code": "CMCoffeeBoiler",
"index": 1,
"output": {
"status": "Ready",
"enabled": true,
"enabledSupported": false,
"targetTemperature": 95.0,
"targetTemperatureMin": 80,
"targetTemperatureMax": 110,
"targetTemperatureStep": 0.1,
"readyStartTime": null
},
"tutorialUrl": null
},
{
"code": "CMSteamBoilerTemperature",
"index": 1,
"output": {
"status": "Off",
"enabled": true,
"enabledSupported": true,
"targetTemperature": 123.9,
"targetTemperatureSupported": true,
"targetTemperatureMin": 95,
"targetTemperatureMax": 140,
"targetTemperatureStep": 0.1,
"readyStartTime": null
},
"tutorialUrl": null
},
{
"code": "CMGroupDoses",
"index": 1,
"output": {
"mirrorWithGroup1Supported": false,
"mirrorWithGroup1": null,
"mirrorWithGroup1NotEffective": false,
"availableModes": ["PulsesType"],
"mode": "PulsesType",
"profile": null,
"doses": {
"PulsesType": [
{
"doseIndex": "DoseA",
"dose": 126.0,
"doseMin": 0,
"doseMax": 9999,
"doseStep": 1
},
{
"doseIndex": "DoseB",
"dose": 126.0,
"doseMin": 0,
"doseMax": 9999,
"doseStep": 1
},
{
"doseIndex": "DoseC",
"dose": 160.0,
"doseMin": 0,
"doseMax": 9999,
"doseStep": 1
},
{
"doseIndex": "DoseD",
"dose": 77.0,
"doseMin": 0,
"doseMax": 9999,
"doseStep": 1
}
]
},
"continuousDoseSupported": false,
"continuousDose": null,
"brewingPressureSupported": false,
"brewingPressure": null
},
"tutorialUrl": null
},
{
"code": "CMPreBrewing",
"index": 1,
"output": {
"availableModes": ["PreBrewing", "PreInfusion", "Disabled"],
"mode": "PreInfusion",
"times": {
"PreBrewing": [
{
"doseIndex": "DoseA",
"seconds": {
"In": 0.5,
"Out": 1.0
},
"secondsMin": {
"In": 0,
"Out": 0
},
"secondsMax": {
"In": 10,
"Out": 10
},
"secondsStep": {
"In": 0.1,
"Out": 0.1
}
},
{
"doseIndex": "DoseB",
"seconds": {
"In": 0.5,
"Out": 1.0
},
"secondsMin": {
"In": 0,
"Out": 0
},
"secondsMax": {
"In": 10,
"Out": 10
},
"secondsStep": {
"In": 0.1,
"Out": 0.1
}
},
{
"doseIndex": "DoseC",
"seconds": {
"In": 3.3,
"Out": 3.3
},
"secondsMin": {
"In": 0,
"Out": 0
},
"secondsMax": {
"In": 10,
"Out": 10
},
"secondsStep": {
"In": 0.1,
"Out": 0.1
}
},
{
"doseIndex": "DoseD",
"seconds": {
"In": 2.0,
"Out": 2.0
},
"secondsMin": {
"In": 0,
"Out": 0
},
"secondsMax": {
"In": 10,
"Out": 10
},
"secondsStep": {
"In": 0.1,
"Out": 0.1
}
}
],
"PreInfusion": [
{
"doseIndex": "DoseA",
"seconds": {
"In": 0.0,
"Out": 4.0
},
"secondsMin": {
"In": 0,
"Out": 0
},
"secondsMax": {
"In": 25,
"Out": 25
},
"secondsStep": {
"In": 0.1,
"Out": 0.1
}
},
{
"doseIndex": "DoseB",
"seconds": {
"In": 0.0,
"Out": 4.0
},
"secondsMin": {
"In": 0,
"Out": 0
},
"secondsMax": {
"In": 25,
"Out": 25
},
"secondsStep": {
"In": 0.1,
"Out": 0.1
}
},
{
"doseIndex": "DoseC",
"seconds": {
"In": 0.0,
"Out": 4.0
},
"secondsMin": {
"In": 0,
"Out": 0
},
"secondsMax": {
"In": 25,
"Out": 25
},
"secondsStep": {
"In": 0.1,
"Out": 0.1
}
},
{
"doseIndex": "DoseD",
"seconds": {
"In": 0.0,
"Out": 4.0
},
"secondsMin": {
"In": 0,
"Out": 0
},
"secondsMax": {
"In": 25,
"Out": 25
},
"secondsStep": {
"In": 0.1,
"Out": 0.1
}
}
]
},
"doseIndexSupported": true
},
"tutorialUrl": "https://www.lamarzocco.com/it/en/app/support/brewing-features/#gs3-av-linea-micra-linea-mini-home"
},
{
"code": "CMHotWaterDose",
"index": 1,
"output": {
"enabledSupported": false,
"enabled": true,
"doses": [
{
"doseIndex": "DoseA",
"dose": 8.0,
"doseMin": 0,
"doseMax": 90,
"doseStep": 1
}
]
},
"tutorialUrl": null
},
{
"code": "CMBackFlush",
"index": 1,
"output": {
"lastCleaningStartTime": null,
"status": "Off"
},
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/cleaning-and-backflush/#gs3-av"
}
],
"invalidWidgets": [
{
"code": "CMMachineGroupStatus",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMSteamBoilerLevel",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMPreExtraction",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMPreInfusionEnable",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMPreInfusion",
"index": 1,
"output": null,
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/brewing-features/#commercial"
},
{
"code": "CMBrewByWeightDoses",
"index": 1,
"output": null,
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/brew-by-weight"
},
{
"code": "CMCupWarmer",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMAutoFlush",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMRinseFlush",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMSteamFlush",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMNoWater",
"index": 1,
"output": null,
"tutorialUrl": null
}
],
"runningCommands": []
}

View File

@ -0,0 +1,237 @@
{
"serialNumber": "MR012345",
"type": "CoffeeMachine",
"name": "MR012345",
"location": null,
"modelCode": "LINEAMICRA",
"modelName": "LINEA MICRA",
"connected": true,
"connectionDate": 1742526019892,
"offlineMode": false,
"requireFirmwareUpdate": false,
"availableFirmwareUpdate": false,
"coffeeStation": null,
"imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png",
"bleAuthToken": null,
"widgets": [
{
"code": "CMMachineStatus",
"index": 1,
"output": {
"status": "StandBy",
"availableModes": ["BrewingMode", "StandBy"],
"mode": "StandBy",
"nextStatus": null,
"brewingStartTime": null
},
"tutorialUrl": null
},
{
"code": "CMCoffeeBoiler",
"index": 1,
"output": {
"status": "StandBy",
"enabled": true,
"enabledSupported": false,
"targetTemperature": 94.0,
"targetTemperatureMin": 80,
"targetTemperatureMax": 100,
"targetTemperatureStep": 0.1,
"readyStartTime": null
},
"tutorialUrl": null
},
{
"code": "CMSteamBoilerLevel",
"index": 1,
"output": {
"status": "StandBy",
"enabled": true,
"enabledSupported": true,
"targetLevel": "Level3",
"targetLevelSupported": true,
"readyStartTime": null
},
"tutorialUrl": null
},
{
"code": "CMPreExtraction",
"index": 1,
"output": {
"availableModes": ["PreBrewing", "PreInfusion", "Disabled"],
"mode": "PreInfusion",
"times": {
"In": {
"seconds": 0.0,
"secondsMin": {
"PreBrewing": 2,
"PreInfusion": 2
},
"secondsMax": {
"PreBrewing": 9,
"PreInfusion": 9
},
"secondsStep": {
"PreBrewing": 0.1,
"PreInfusion": 0.1
}
},
"Out": {
"seconds": 4.0,
"secondsMin": {
"PreBrewing": 1,
"PreInfusion": 1
},
"secondsMax": {
"PreBrewing": 9,
"PreInfusion": 25
},
"secondsStep": {
"PreBrewing": 0.1,
"PreInfusion": 0.1
}
}
}
},
"tutorialUrl": null
},
{
"code": "CMPreBrewing",
"index": 1,
"output": {
"availableModes": ["PreBrewing", "PreInfusion", "Disabled"],
"mode": "PreInfusion",
"times": {
"PreInfusion": [
{
"doseIndex": "ByGroup",
"seconds": {
"Out": 4.0,
"In": 0.0
},
"secondsMin": {
"Out": 1,
"In": 1
},
"secondsMax": {
"Out": 25,
"In": 25
},
"secondsStep": {
"Out": 0.1,
"In": 0.1
}
}
],
"PreBrewing": [
{
"doseIndex": "ByGroup",
"seconds": {
"Out": 5.0,
"In": 5.0
},
"secondsMin": {
"Out": 1,
"In": 1
},
"secondsMax": {
"Out": 9,
"In": 9
},
"secondsStep": {
"Out": 0.1,
"In": 0.1
}
}
]
},
"doseIndexSupported": false
},
"tutorialUrl": "https://www.lamarzocco.com/it/en/app/support/brewing-features/#gs3-av-linea-micra-linea-mini-home"
},
{
"code": "CMBackFlush",
"index": 1,
"output": {
"lastCleaningStartTime": null,
"status": "Off"
},
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/cleaning-and-backflush/#linea-micra"
}
],
"invalidWidgets": [
{
"code": "CMMachineGroupStatus",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMSteamBoilerTemperature",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMGroupDoses",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMPreInfusionEnable",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMPreInfusion",
"index": 1,
"output": null,
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/brewing-features/#commercial"
},
{
"code": "CMBrewByWeightDoses",
"index": 1,
"output": null,
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/brew-by-weight"
},
{
"code": "CMCupWarmer",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMHotWaterDose",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMAutoFlush",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMRinseFlush",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMSteamFlush",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMNoWater",
"index": 1,
"output": null,
"tutorialUrl": null
}
],
"runningCommands": []
}

View File

@ -1,124 +1,284 @@
{
"version": "v1",
"preinfusionModesAvailable": ["ByDoseType"],
"machineCapabilities": [
{
"family": "LINEA",
"groupsNumber": 1,
"coffeeBoilersNumber": 1,
"hasCupWarmer": false,
"steamBoilersNumber": 1,
"teaDosesNumber": 1,
"machineModes": ["BrewingMode", "StandBy"],
"schedulingType": "smartWakeUpSleep"
}
],
"machine_sn": "Sn01239157",
"machine_hw": "0",
"isPlumbedIn": false,
"isBackFlushEnabled": false,
"standByTime": 0,
"tankStatus": true,
"settings": [],
"recipes": [
{
"id": "Recipe1",
"dose_mode": "Mass",
"recipe_doses": [
{ "id": "A", "target": 32 },
{ "id": "B", "target": 45 }
]
}
],
"recipeAssignment": [
{
"dose_index": "DoseA",
"recipe_id": "Recipe1",
"recipe_dose": "A",
"group": "Group1"
}
],
"groupCapabilities": [
{
"capabilities": {
"groupType": "AV_Group",
"groupNumber": "Group1",
"boilerId": "CoffeeBoiler1",
"hasScale": false,
"hasFlowmeter": false,
"numberOfDoses": 1
},
"doses": [
{
"groupNumber": "Group1",
"doseIndex": "DoseA",
"doseType": "MassType",
"stopTarget": 32
}
],
"doseMode": { "groupNumber": "Group1", "brewingType": "ManualType" }
}
],
"machineMode": "StandBy",
"teaDoses": { "DoseA": { "doseIndex": "DoseA", "stopTarget": 0 } },
"scale": {
"connected": true,
"address": "44:b7:d0:74:5f:90",
"name": "LMZ-123A45",
"battery": 64
},
"boilers": [
{ "id": "SteamBoiler", "isEnabled": false, "target": 0, "current": 0 },
{ "id": "CoffeeBoiler1", "isEnabled": true, "target": 89, "current": 42 }
],
"boilerTargetTemperature": { "SteamBoiler": 0, "CoffeeBoiler1": 89 },
"preinfusionMode": {
"Group1": {
"groupNumber": "Group1",
"preinfusionStyle": "PreinfusionByDoseType"
}
},
"preinfusionSettings": {
"mode": "TypeB",
"Group1": [
"serialNumber": "LM012345",
"type": "CoffeeMachine",
"name": "LM012345",
"location": null,
"modelCode": "LINEAMINI",
"modelName": "LINEA MINI",
"connected": true,
"connectionDate": 1742683649814,
"offlineMode": false,
"requireFirmwareUpdate": false,
"availableFirmwareUpdate": true,
"coffeeStation": {
"id": "a59cd870-dc75-428f-b73e-e5a247c6db73",
"name": "My coffee station",
"coffeeMachine": {
"serialNumber": "LM012345",
"type": "CoffeeMachine",
"name": null,
"location": null,
"modelCode": "LINEAMINI",
"modelName": "LINEA MINI",
"connected": true,
"connectionDate": 1742683649814,
"offlineMode": false,
"requireFirmwareUpdate": false,
"availableFirmwareUpdate": true,
"imageUrl": "https://lion.lamarzocco.io/img/thing-model/list/lineamini/lineamini-1-c-nero_op.png",
"bleAuthToken": null
},
"grinders": [],
"accessories": [
{
"mode": "TypeA",
"groupNumber": "Group1",
"doseType": "Continuous",
"preWetTime": 2,
"preWetHoldTime": 3
},
{
"mode": "TypeB",
"groupNumber": "Group1",
"doseType": "Continuous",
"preWetTime": 0,
"preWetHoldTime": 3
"type": "ScaleAcaiaLunar",
"name": "LMZ-123A12",
"connected": false,
"batteryLevel": null,
"imageUrl": null
}
]
},
"wakeUpSleepEntries": [
"imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamini/lineamini-1-c-nero_op.png",
"bleAuthToken": null,
"widgets": [
{
"id": "T6aLl42",
"days": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday"
],
"steam": false,
"enabled": false,
"timeOn": "24:0",
"timeOff": "24:0"
"code": "CMMachineStatus",
"index": 1,
"output": {
"status": "StandBy",
"availableModes": ["BrewingMode", "StandBy"],
"mode": "StandBy",
"nextStatus": null,
"brewingStartTime": null
},
"tutorialUrl": null
},
{
"code": "CMCoffeeBoiler",
"index": 1,
"output": {
"status": "StandBy",
"enabled": true,
"enabledSupported": false,
"targetTemperature": 90.0,
"targetTemperatureMin": 80,
"targetTemperatureMax": 100,
"targetTemperatureStep": 0.1,
"readyStartTime": null
},
"tutorialUrl": null
},
{
"code": "CMSteamBoilerTemperature",
"index": 1,
"output": {
"status": "Off",
"enabled": false,
"enabledSupported": true,
"targetTemperature": 0.0,
"targetTemperatureSupported": false,
"targetTemperatureMin": 95,
"targetTemperatureMax": 140,
"targetTemperatureStep": 0.1,
"readyStartTime": null
},
"tutorialUrl": null
},
{
"code": "CMPreExtraction",
"index": 1,
"output": {
"availableModes": ["PreBrewing", "Disabled"],
"mode": "Disabled",
"times": {
"In": {
"seconds": 2.0,
"secondsMin": {
"PreBrewing": 2,
"PreInfusion": 2
},
"secondsMax": {
"PreBrewing": 9,
"PreInfusion": 9
},
"secondsStep": {
"PreBrewing": 0.1,
"PreInfusion": 0.1
}
},
"Out": {
"seconds": 3.0,
"secondsMin": {
"PreBrewing": 1,
"PreInfusion": 1
},
"secondsMax": {
"PreBrewing": 9,
"PreInfusion": 25
},
"secondsStep": {
"PreBrewing": 0.1,
"PreInfusion": 0.1
}
}
}
},
"tutorialUrl": null
},
{
"code": "CMPreBrewing",
"index": 1,
"output": {
"availableModes": ["PreBrewing", "Disabled"],
"mode": "Disabled",
"times": {
"PreBrewing": [
{
"doseIndex": "ByGroup",
"seconds": {
"Out": 3.0,
"In": 2.0
},
"secondsMin": {
"Out": 1,
"In": 1
},
"secondsMax": {
"Out": 9,
"In": 9
},
"secondsStep": {
"Out": 0.1,
"In": 0.1
}
}
]
},
"doseIndexSupported": false
},
"tutorialUrl": "https://www.lamarzocco.com/it/en/app/support/brewing-features/#gs3-av-linea-micra-linea-mini-home"
},
{
"code": "CMBrewByWeightDoses",
"index": 1,
"output": {
"scaleConnected": false,
"availableModes": ["Continuous"],
"mode": "Continuous",
"doses": {
"Dose1": {
"dose": 34.5,
"doseMin": 5,
"doseMax": 100,
"doseStep": 0.1
},
"Dose2": {
"dose": 17.5,
"doseMin": 5,
"doseMax": 100,
"doseStep": 0.1
}
}
},
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/brew-by-weight"
},
{
"code": "CMBackFlush",
"index": 1,
"output": {
"lastCleaningStartTime": 1742731776135,
"status": "Off"
},
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/cleaning-and-backflush/#linea-mini"
},
{
"code": "ThingScale",
"index": 2,
"output": {
"name": "LMZ-123A12",
"connected": false,
"batteryLevel": 0.0,
"calibrationRequired": false
},
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/training-scale-location/#linea-mini"
}
],
"smartStandBy": { "mode": "LastBrewing", "minutes": 10, "enabled": true },
"clock": "2024-08-31T14:47:45",
"firmwareVersions": [
{ "name": "machine_firmware", "fw_version": "2.12" },
{ "name": "gateway_firmware", "fw_version": "v3.6-rc4" }
]
"invalidWidgets": [
{
"code": "CMMachineGroupStatus",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMSteamBoilerLevel",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMGroupDoses",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMPreInfusionEnable",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMPreInfusion",
"index": 1,
"output": null,
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/brewing-features/#commercial"
},
{
"code": "CMCupWarmer",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMHotWaterDose",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMAutoFlush",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMRinseFlush",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMSteamFlush",
"index": 1,
"output": null,
"tutorialUrl": null
},
{
"code": "CMNoWater",
"index": 1,
"output": {
"allarm": false
},
"tutorialUrl": null
},
{
"code": "ThingScale",
"index": 1,
"output": null,
"tutorialUrl": "http://lamarzocco.com/it/en/app/support/training-scale-location/#linea-mini"
}
],
"runningCommands": []
}

View File

@ -0,0 +1,61 @@
{
"serialNumber": "MR123456",
"type": "CoffeeMachine",
"name": "MR123456",
"location": null,
"modelCode": "LINEAMICRA",
"modelName": "LINEA MICRA",
"connected": true,
"connectionDate": 1742526019892,
"offlineMode": false,
"requireFirmwareUpdate": false,
"availableFirmwareUpdate": false,
"coffeeStation": null,
"imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png",
"bleAuthToken": null,
"smartWakeUpSleepSupported": true,
"smartWakeUpSleep": {
"smartStandByEnabled": true,
"smartStandByMinutes": 10,
"smartStandByMinutesMin": 1,
"smartStandByMinutesMax": 30,
"smartStandByMinutesStep": 1,
"smartStandByAfter": "PowerOn",
"schedules": [
{
"id": "Os2OswX",
"enabled": true,
"onTimeMinutes": 1320,
"offTimeMinutes": 1440,
"days": [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
],
"steamBoiler": true
},
{
"id": "aXFz5bJ",
"enabled": true,
"onTimeMinutes": 420,
"offTimeMinutes": 450,
"days": ["Sunday"],
"steamBoiler": false
}
]
},
"smartWakeUpSleepTutorialUrl": "https://www.lamarzocco.com/it/en/app/support/scheduling/#gs3-linea-micra-linea-mini-home",
"weeklySupported": false,
"weekly": null,
"weeklyTutorialUrl": "https://www.lamarzocco.com/it/en/app/support/scheduling/#linea-classic-s",
"autoOnOffSupported": false,
"autoOnOff": null,
"autoOnOffTutorialUrl": "https://www.lamarzocco.com/it/en/app/support/scheduling/#gb5-s-x-kb90-linea-pb-pbx-strada-s-x-commercial",
"autoStandBySupported": false,
"autoStandBy": null,
"autoStandByTutorialUrl": null
}

View File

@ -0,0 +1,50 @@
{
"serialNumber": "MR123456",
"type": "CoffeeMachine",
"name": "MR123456",
"location": null,
"modelCode": "LINEAMICRA",
"modelName": "LINEA MICRA",
"connected": true,
"connectionDate": 1742526019892,
"offlineMode": false,
"requireFirmwareUpdate": false,
"availableFirmwareUpdate": false,
"coffeeStation": null,
"imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png",
"bleAuthToken": null,
"actualFirmwares": [
{
"type": "Gateway",
"buildVersion": "v5.0.9",
"changeLog": "Whats new in this version:\n\n* New La Marzocco compatibility\n* Improved connectivity\n* Improved pairing process\n* Improved statistics\n* Boilers heating time\n* Last backflush date (GS3 MP excluded)\n* Automatic gateway updates option",
"thingModelCode": "LineaMicra",
"status": "ToUpdate",
"availableUpdate": {
"type": "Gateway",
"buildVersion": "v5.0.10",
"changeLog": "Whats new in this version:\n\n* fixed an issue that could cause the machine powers up outside scheduled time\n* minor improvements",
"thingModelCode": "LineaMicra"
}
},
{
"type": "Machine",
"buildVersion": "v1.17",
"changeLog": null,
"thingModelCode": "LineaMicra",
"status": "Updated",
"availableUpdate": null
}
],
"wifiSsid": "MyWifi",
"wifiRssi": -51,
"plumbInSupported": true,
"isPlumbedIn": true,
"cropsterSupported": false,
"cropsterActive": null,
"hemroSupported": false,
"hemroActive": null,
"factoryResetSupported": true,
"autoUpdateSupported": true,
"autoUpdate": false
}

View File

@ -1,26 +0,0 @@
[
{
"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,16 @@
{
"serialNumber": "GS012345",
"type": "CoffeeMachine",
"name": "GS012345",
"location": "HOME",
"modelCode": "GS3AV",
"modelName": "GS3AV",
"connected": true,
"connectionDate": 1742489087479,
"offlineMode": false,
"requireFirmwareUpdate": false,
"availableFirmwareUpdate": false,
"coffeeStation": null,
"imageUrl": "https://lion.lamarzocco.io/img/thing-model/detail/gs3av/gs3av-1.png",
"bleAuthToken": null
}

View File

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

View File

@ -1,135 +1,766 @@
# serializer version: 1
# name: test_diagnostics
dict({
'config': dict({
'backflush_enabled': False,
'bbw_settings': None,
'boilers': dict({
'CoffeeBoiler1': dict({
'current_temperature': 96.5,
'enabled': True,
'target_temperature': 95,
'dashboard': dict({
'available_firmware_update': False,
'ble_auth_token': None,
'coffee_station': None,
'config': dict({
'CMBackFlush': dict({
'last_cleaning_start_time': None,
'status': 'Off',
}),
'SteamBoiler': dict({
'current_temperature': 123.80000305175781,
'CMCoffeeBoiler': dict({
'enabled': True,
'target_temperature': 123.9000015258789,
'enabled_supported': False,
'ready_start_time': None,
'status': 'Ready',
'target_temperature': 95.0,
'target_temperature_max': 110,
'target_temperature_min': 80,
'target_temperature_step': 0.1,
}),
}),
'brew_active': False,
'brew_active_duration': 0,
'dose_hot_water': 8,
'doses': dict({
'1': 135,
'2': 97,
'3': 108,
'4': 121,
}),
'plumbed_in': True,
'prebrew_configuration': dict({
'1': list([
dict({
'off_time': 1,
'on_time': 0.5,
'CMGroupDoses': dict({
'available_modes': list([
'PulsesType',
]),
'brewing_pressure': None,
'brewing_pressure_supported': False,
'continuous_dose': None,
'continuous_dose_supported': False,
'doses': dict({
'pulses_type': list([
dict({
'dose': 126.0,
'dose_index': 'DoseA',
'dose_max': 9999.0,
'dose_min': 0.0,
'dose_step': 1,
}),
dict({
'dose': 126.0,
'dose_index': 'DoseB',
'dose_max': 9999.0,
'dose_min': 0.0,
'dose_step': 1,
}),
dict({
'dose': 160.0,
'dose_index': 'DoseC',
'dose_max': 9999.0,
'dose_min': 0.0,
'dose_step': 1,
}),
dict({
'dose': 77.0,
'dose_index': 'DoseD',
'dose_max': 9999.0,
'dose_min': 0.0,
'dose_step': 1,
}),
]),
}),
dict({
'off_time': 4,
'on_time': 0,
}),
]),
'2': list([
dict({
'off_time': 1,
'on_time': 0.5,
}),
dict({
'off_time': 4,
'on_time': 0,
}),
]),
'3': list([
dict({
'off_time': 3.3,
'on_time': 3.3,
}),
dict({
'off_time': 4,
'on_time': 0,
}),
]),
'4': list([
dict({
'off_time': 2,
'on_time': 2,
}),
dict({
'off_time': 4,
'on_time': 0,
}),
]),
}),
'prebrew_mode': 'TypeB',
'scale': None,
'smart_standby': dict({
'enabled': True,
'minutes': 10,
'mode': 'LastBrewing',
}),
'turned_on': True,
'wake_up_sleep_entries': dict({
'Os2OswX': dict({
'days': list([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
'mirror_with_group_1': None,
'mirror_with_group_1_not_effective': False,
'mirror_with_group_1_supported': False,
'mode': 'PulsesType',
'profile': None,
}),
'CMHotWaterDose': dict({
'doses': list([
dict({
'dose': 8.0,
'dose_index': 'DoseA',
'dose_max': 90.0,
'dose_min': 0.0,
'dose_step': 1,
}),
]),
'enabled': True,
'entry_id': 'Os2OswX',
'steam': True,
'time_off': '24:0',
'time_on': '22:0',
'enabled_supported': False,
}),
'aXFz5bJ': dict({
'days': list([
'sunday',
'CMMachineStatus': dict({
'available_modes': list([
'BrewingMode',
'StandBy',
]),
'brewing_start_time': None,
'mode': 'BrewingMode',
'next_status': dict({
'start_time': '2025-03-24T22:59:55.332000+00:00',
'status': 'StandBy',
}),
'status': 'PoweredOn',
}),
'CMPreBrewing': dict({
'available_modes': list([
'PreBrewing',
'PreInfusion',
'Disabled',
]),
'dose_index_supported': True,
'mode': 'PreInfusion',
'times': dict({
'pre_brewing': list([
dict({
'dose_index': 'DoseA',
'seconds': dict({
'In': 0.5,
'Out': 1.0,
}),
'seconds_max': dict({
'In': 10.0,
'Out': 10.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseB',
'seconds': dict({
'In': 0.5,
'Out': 1.0,
}),
'seconds_max': dict({
'In': 10.0,
'Out': 10.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseC',
'seconds': dict({
'In': 3.3,
'Out': 3.3,
}),
'seconds_max': dict({
'In': 10.0,
'Out': 10.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseD',
'seconds': dict({
'In': 2.0,
'Out': 2.0,
}),
'seconds_max': dict({
'In': 10.0,
'Out': 10.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
]),
'pre_infusion': list([
dict({
'dose_index': 'DoseA',
'seconds': dict({
'In': 0.0,
'Out': 4.0,
}),
'seconds_max': dict({
'In': 25.0,
'Out': 25.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseB',
'seconds': dict({
'In': 0.0,
'Out': 4.0,
}),
'seconds_max': dict({
'In': 25.0,
'Out': 25.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseC',
'seconds': dict({
'In': 0.0,
'Out': 4.0,
}),
'seconds_max': dict({
'In': 25.0,
'Out': 25.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseD',
'seconds': dict({
'In': 0.0,
'Out': 4.0,
}),
'seconds_max': dict({
'In': 25.0,
'Out': 25.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
]),
}),
}),
'CMSteamBoilerTemperature': dict({
'enabled': True,
'entry_id': 'aXFz5bJ',
'steam': True,
'time_off': '7:30',
'time_on': '7:0',
'enabled_supported': True,
'ready_start_time': None,
'status': 'Off',
'target_temperature': 123.9,
'target_temperature_max': 140,
'target_temperature_min': 95,
'target_temperature_step': 0.1,
'target_temperature_supported': True,
}),
}),
'water_contact': True,
'connected': True,
'connection_date': '2025-03-20T16:44:47.479000+00:00',
'image_url': 'https://lion.lamarzocco.io/img/thing-model/detail/gs3av/gs3av-1.png',
'location': 'HOME',
'model_code': 'GS3AV',
'model_name': 'GS3 AV',
'name': 'GS012345',
'offline_mode': False,
'require_firmware_update': False,
'serial_number': '**REDACTED**',
'type': 'CoffeeMachine',
'widgets': list([
dict({
'code': 'CMMachineStatus',
'index': 1,
'output': dict({
'available_modes': list([
'BrewingMode',
'StandBy',
]),
'brewing_start_time': None,
'mode': 'BrewingMode',
'next_status': dict({
'start_time': '2025-03-24T22:59:55.332000+00:00',
'status': 'StandBy',
}),
'status': 'PoweredOn',
}),
}),
dict({
'code': 'CMCoffeeBoiler',
'index': 1,
'output': dict({
'enabled': True,
'enabled_supported': False,
'ready_start_time': None,
'status': 'Ready',
'target_temperature': 95.0,
'target_temperature_max': 110,
'target_temperature_min': 80,
'target_temperature_step': 0.1,
}),
}),
dict({
'code': 'CMSteamBoilerTemperature',
'index': 1,
'output': dict({
'enabled': True,
'enabled_supported': True,
'ready_start_time': None,
'status': 'Off',
'target_temperature': 123.9,
'target_temperature_max': 140,
'target_temperature_min': 95,
'target_temperature_step': 0.1,
'target_temperature_supported': True,
}),
}),
dict({
'code': 'CMGroupDoses',
'index': 1,
'output': dict({
'available_modes': list([
'PulsesType',
]),
'brewing_pressure': None,
'brewing_pressure_supported': False,
'continuous_dose': None,
'continuous_dose_supported': False,
'doses': dict({
'pulses_type': list([
dict({
'dose': 126.0,
'dose_index': 'DoseA',
'dose_max': 9999.0,
'dose_min': 0.0,
'dose_step': 1,
}),
dict({
'dose': 126.0,
'dose_index': 'DoseB',
'dose_max': 9999.0,
'dose_min': 0.0,
'dose_step': 1,
}),
dict({
'dose': 160.0,
'dose_index': 'DoseC',
'dose_max': 9999.0,
'dose_min': 0.0,
'dose_step': 1,
}),
dict({
'dose': 77.0,
'dose_index': 'DoseD',
'dose_max': 9999.0,
'dose_min': 0.0,
'dose_step': 1,
}),
]),
}),
'mirror_with_group_1': None,
'mirror_with_group_1_not_effective': False,
'mirror_with_group_1_supported': False,
'mode': 'PulsesType',
'profile': None,
}),
}),
dict({
'code': 'CMPreBrewing',
'index': 1,
'output': dict({
'available_modes': list([
'PreBrewing',
'PreInfusion',
'Disabled',
]),
'dose_index_supported': True,
'mode': 'PreInfusion',
'times': dict({
'pre_brewing': list([
dict({
'dose_index': 'DoseA',
'seconds': dict({
'In': 0.5,
'Out': 1.0,
}),
'seconds_max': dict({
'In': 10.0,
'Out': 10.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseB',
'seconds': dict({
'In': 0.5,
'Out': 1.0,
}),
'seconds_max': dict({
'In': 10.0,
'Out': 10.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseC',
'seconds': dict({
'In': 3.3,
'Out': 3.3,
}),
'seconds_max': dict({
'In': 10.0,
'Out': 10.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseD',
'seconds': dict({
'In': 2.0,
'Out': 2.0,
}),
'seconds_max': dict({
'In': 10.0,
'Out': 10.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
]),
'pre_infusion': list([
dict({
'dose_index': 'DoseA',
'seconds': dict({
'In': 0.0,
'Out': 4.0,
}),
'seconds_max': dict({
'In': 25.0,
'Out': 25.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseB',
'seconds': dict({
'In': 0.0,
'Out': 4.0,
}),
'seconds_max': dict({
'In': 25.0,
'Out': 25.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseC',
'seconds': dict({
'In': 0.0,
'Out': 4.0,
}),
'seconds_max': dict({
'In': 25.0,
'Out': 25.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
dict({
'dose_index': 'DoseD',
'seconds': dict({
'In': 0.0,
'Out': 4.0,
}),
'seconds_max': dict({
'In': 25.0,
'Out': 25.0,
}),
'seconds_min': dict({
'In': 0.0,
'Out': 0.0,
}),
'seconds_step': dict({
'In': 0.1,
'Out': 0.1,
}),
}),
]),
}),
}),
}),
dict({
'code': 'CMHotWaterDose',
'index': 1,
'output': dict({
'doses': list([
dict({
'dose': 8.0,
'dose_index': 'DoseA',
'dose_max': 90.0,
'dose_min': 0.0,
'dose_step': 1,
}),
]),
'enabled': True,
'enabled_supported': False,
}),
}),
dict({
'code': 'CMBackFlush',
'index': 1,
'output': dict({
'last_cleaning_start_time': None,
'status': 'Off',
}),
}),
]),
}),
'firmware': list([
dict({
'machine': dict({
'current_version': '1.40',
'latest_version': '1.55',
'schedule': dict({
'available_firmware_update': False,
'ble_auth_token': None,
'coffee_station': None,
'connected': True,
'connection_date': '2025-03-21T03:00:19.892000+00:00',
'image_url': 'https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png',
'location': None,
'model_code': 'LINEAMICRA',
'model_name': 'Linea Micra',
'name': 'MR123456',
'offline_mode': False,
'require_firmware_update': False,
'serial_number': '**REDACTED**',
'smart_wake_up_sleep': dict({
'schedules': list([
dict({
'days': list([
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
]),
'enabled': True,
'id': 'Os2OswX',
'offTimeMinutes': 1440,
'onTimeMinutes': 1320,
'steamBoiler': True,
}),
dict({
'days': list([
'Sunday',
]),
'enabled': True,
'id': 'aXFz5bJ',
'offTimeMinutes': 450,
'onTimeMinutes': 420,
'steamBoiler': False,
}),
]),
'schedules_dict': dict({
'Os2OswX': dict({
'days': list([
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
]),
'enabled': True,
'id': 'Os2OswX',
'offTimeMinutes': 1440,
'onTimeMinutes': 1320,
'steamBoiler': True,
}),
'aXFz5bJ': dict({
'days': list([
'Sunday',
]),
'enabled': True,
'id': 'aXFz5bJ',
'offTimeMinutes': 450,
'onTimeMinutes': 420,
'steamBoiler': False,
}),
}),
'smart_stand_by_after': 'PowerOn',
'smart_stand_by_enabled': True,
'smart_stand_by_minutes': 10,
'smart_stand_by_minutes_max': 30,
'smart_stand_by_minutes_min': 1,
'smart_stand_by_minutes_step': 1,
}),
'smart_wake_up_sleep_supported': True,
'type': 'CoffeeMachine',
}),
'serial_number': '**REDACTED**',
'settings': dict({
'actual_firmwares': list([
dict({
'available_update': dict({
'build_version': 'v5.0.10',
'change_log': '''
Whats new in this version:
* fixed an issue that could cause the machine powers up outside scheduled time
* minor improvements
''',
'thing_model_code': 'LineaMicra',
'type': 'Gateway',
}),
'build_version': 'v5.0.9',
'change_log': '''
Whats new in this version:
* New La Marzocco compatibility
* Improved connectivity
* Improved pairing process
* Improved statistics
* Boilers heating time
* Last backflush date (GS3 MP excluded)
* Automatic gateway updates option
''',
'status': 'ToUpdate',
'thing_model_code': 'LineaMicra',
'type': 'Gateway',
}),
dict({
'available_update': None,
'build_version': 'v1.17',
'change_log': 'None',
'status': 'Updated',
'thing_model_code': 'LineaMicra',
'type': 'Machine',
}),
]),
'auto_update': False,
'auto_update_supported': True,
'available_firmware_update': False,
'ble_auth_token': None,
'coffee_station': None,
'connected': True,
'connection_date': '2025-03-21T03:00:19.892000+00:00',
'cropster_active': False,
'cropster_supported': False,
'factory_reset_supported': True,
'firmwares': dict({
'Gateway': dict({
'available_update': dict({
'build_version': 'v5.0.10',
'change_log': '''
Whats new in this version:
* fixed an issue that could cause the machine powers up outside scheduled time
* minor improvements
''',
'thing_model_code': 'LineaMicra',
'type': 'Gateway',
}),
'build_version': 'v5.0.9',
'change_log': '''
Whats new in this version:
* New La Marzocco compatibility
* Improved connectivity
* Improved pairing process
* Improved statistics
* Boilers heating time
* Last backflush date (GS3 MP excluded)
* Automatic gateway updates option
''',
'status': 'ToUpdate',
'thing_model_code': 'LineaMicra',
'type': 'Gateway',
}),
'Machine': dict({
'available_update': None,
'build_version': 'v1.17',
'change_log': 'None',
'status': 'Updated',
'thing_model_code': 'LineaMicra',
'type': 'Machine',
}),
}),
dict({
'gateway': dict({
'current_version': 'v3.1-rc4',
'latest_version': 'v3.5-rc3',
}),
}),
]),
'model': 'GS3 AV',
'statistics': dict({
'continous': 2252,
'drink_stats': dict({
'1': 1047,
'2': 560,
'3': 468,
'4': 312,
}),
'total_flushes': 1740,
'hemro_active': False,
'hemro_supported': False,
'image_url': 'https://lion.lamarzocco.io/img/thing-model/detail/lineamicra/lineamicra-1-c-bianco.png',
'is_plumbed_in': True,
'location': None,
'model_code': 'LINEAMICRA',
'model_name': 'Linea Micra',
'name': 'MR123456',
'offline_mode': False,
'plumb_in_supported': True,
'require_firmware_update': False,
'serial_number': '**REDACTED**',
'type': 'CoffeeMachine',
'wifi_rssi': -51,
'wifi_ssid': 'MyWifi',
}),
})
# ---

View File

@ -29,47 +29,14 @@
'labels': set({
}),
'manufacturer': 'La Marzocco',
'model': <MachineModel.GS3_AV: 'GS3 AV'>,
'model_id': <MachineModel.GS3_AV: 'GS3 AV'>,
'model': 'GS3 AV',
'model_id': 'GS3AV',
'name': 'GS012345',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'GS012345',
'suggested_area': None,
'sw_version': '1.40',
'sw_version': 'v1.17',
'via_device_id': None,
})
# ---
# name: test_scale_device[Linea Mini]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'lamarzocco',
'44:b7:d0:74:5f:90',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Acaia',
'model': 'Lunar',
'model_id': 'Y.301',
'name': 'LMZ-123A45',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': <ANY>,
})
# ---

File diff suppressed because it is too large Load Diff

View File

@ -1,60 +1,4 @@
# serializer version: 1
# name: test_active_bbw_recipe[Linea Mini]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'LMZ-123A45 Active brew by weight recipe',
'options': list([
'a',
'b',
]),
}),
'context': <ANY>,
'entity_id': 'select.lmz_123a45_active_brew_by_weight_recipe',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'a',
})
# ---
# name: test_active_bbw_recipe[Linea Mini].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'a',
'b',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.lmz_123a45_active_brew_by_weight_recipe',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Active brew by weight recipe',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'active_bbw',
'unique_id': 'LM012345_active_bbw',
'unit_of_measurement': None,
})
# ---
# name: test_pre_brew_infusion_select[GS3 AV]
StateSnapshot({
'attributes': ReadOnlyDict({
@ -113,6 +57,64 @@
'unit_of_measurement': None,
})
# ---
# name: test_pre_brew_infusion_select[Linea Micra]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'MR012345 Prebrew/-infusion mode',
'options': list([
'disabled',
'prebrew',
'preinfusion',
]),
}),
'context': <ANY>,
'entity_id': 'select.mr012345_prebrew_infusion_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'preinfusion',
})
# ---
# name: test_pre_brew_infusion_select[Linea Micra].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'disabled',
'prebrew',
'preinfusion',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.mr012345_prebrew_infusion_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Prebrew/-infusion mode',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'prebrew_infusion_select',
'unique_id': 'MR012345_prebrew_infusion_select',
'unit_of_measurement': None,
})
# ---
# name: test_pre_brew_infusion_select[Linea Mini]
StateSnapshot({
'attributes': ReadOnlyDict({
@ -128,7 +130,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'preinfusion',
'state': 'disabled',
})
# ---
# name: test_pre_brew_infusion_select[Linea Mini].1
@ -171,64 +173,6 @@
'unit_of_measurement': None,
})
# ---
# name: test_pre_brew_infusion_select[Micra]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'MR012345 Prebrew/-infusion mode',
'options': list([
'disabled',
'prebrew',
'preinfusion',
]),
}),
'context': <ANY>,
'entity_id': 'select.mr012345_prebrew_infusion_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'preinfusion',
})
# ---
# name: test_pre_brew_infusion_select[Micra].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'disabled',
'prebrew',
'preinfusion',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.mr012345_prebrew_infusion_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Prebrew/-infusion mode',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'prebrew_infusion_select',
'unique_id': 'MR012345_prebrew_infusion_select',
'unit_of_measurement': None,
})
# ---
# name: test_smart_standby_mode
StateSnapshot({
'attributes': ReadOnlyDict({
@ -243,7 +187,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'last_brewing',
'state': 'power_on',
})
# ---
# name: test_smart_standby_mode.1
@ -285,7 +229,7 @@
'unit_of_measurement': None,
})
# ---
# name: test_steam_boiler_level[Micra]
# name: test_steam_boiler_level[Linea Micra]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'MR012345 Steam level',
@ -300,10 +244,10 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
'state': '3',
})
# ---
# name: test_steam_boiler_level[Micra].1
# name: test_steam_boiler_level[Linea Micra].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),

View File

@ -1,521 +0,0 @@
# serializer version: 1
# name: test_scale_battery[Linea Mini]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'LMZ-123A45 Battery',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.lmz_123a45_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '64',
})
# ---
# name: test_scale_battery[Linea Mini].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.lmz_123a45_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'Battery',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'LM012345_scale_battery',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[sensor.gs012345_coffees_made_key_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs012345_coffees_made_key_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Coffees made Key 1',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'drink_stats_coffee_key',
'unique_id': 'GS012345_drink_stats_coffee_key_key1',
'unit_of_measurement': 'coffees',
})
# ---
# name: test_sensors[sensor.gs012345_coffees_made_key_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GS012345 Coffees made Key 1',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': 'coffees',
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_coffees_made_key_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1047',
})
# ---
# name: test_sensors[sensor.gs012345_coffees_made_key_2-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs012345_coffees_made_key_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Coffees made Key 2',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'drink_stats_coffee_key',
'unique_id': 'GS012345_drink_stats_coffee_key_key2',
'unit_of_measurement': 'coffees',
})
# ---
# name: test_sensors[sensor.gs012345_coffees_made_key_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GS012345 Coffees made Key 2',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': 'coffees',
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_coffees_made_key_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '560',
})
# ---
# name: test_sensors[sensor.gs012345_coffees_made_key_3-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs012345_coffees_made_key_3',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Coffees made Key 3',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'drink_stats_coffee_key',
'unique_id': 'GS012345_drink_stats_coffee_key_key3',
'unit_of_measurement': 'coffees',
})
# ---
# name: test_sensors[sensor.gs012345_coffees_made_key_3-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GS012345 Coffees made Key 3',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': 'coffees',
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_coffees_made_key_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '468',
})
# ---
# name: test_sensors[sensor.gs012345_coffees_made_key_4-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs012345_coffees_made_key_4',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Coffees made Key 4',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'drink_stats_coffee_key',
'unique_id': 'GS012345_drink_stats_coffee_key_key4',
'unit_of_measurement': 'coffees',
})
# ---
# name: test_sensors[sensor.gs012345_coffees_made_key_4-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GS012345 Coffees made Key 4',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': 'coffees',
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_coffees_made_key_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '312',
})
# ---
# name: test_sensors[sensor.gs012345_current_coffee_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gs012345_current_coffee_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Current coffee temperature',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'current_temp_coffee',
'unique_id': 'GS012345_current_temp_coffee',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.gs012345_current_coffee_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'GS012345 Current coffee temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_current_coffee_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '96.5',
})
# ---
# name: test_sensors[sensor.gs012345_current_steam_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gs012345_current_steam_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Current steam temperature',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'current_temp_steam',
'unique_id': 'GS012345_current_temp_steam',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.gs012345_current_steam_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'GS012345 Current steam temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_current_steam_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '123.800003051758',
})
# ---
# name: test_sensors[sensor.gs012345_shot_timer-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs012345_shot_timer',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Shot timer',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'shot_timer',
'unique_id': 'GS012345_shot_timer',
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_sensors[sensor.gs012345_shot_timer-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'GS012345 Shot timer',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_shot_timer',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.gs012345_total_coffees_made-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs012345_total_coffees_made',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Total coffees made',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'drink_stats_coffee',
'unique_id': 'GS012345_drink_stats_coffee',
'unit_of_measurement': 'coffees',
})
# ---
# name: test_sensors[sensor.gs012345_total_coffees_made-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GS012345 Total coffees made',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': 'coffees',
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_total_coffees_made',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2387',
})
# ---
# name: test_sensors[sensor.gs012345_total_flushes_made-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs012345_total_flushes_made',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Total flushes made',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'drink_stats_flushing',
'unique_id': 'GS012345_drink_stats_flushing',
'unit_of_measurement': 'flushes',
})
# ---
# name: test_sensors[sensor.gs012345_total_flushes_made-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'GS012345 Total flushes made',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': 'flushes',
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_total_flushes_made',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1740',
})
# ---

View File

@ -42,8 +42,8 @@
'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png',
'friendly_name': 'GS012345 Gateway firmware',
'in_progress': False,
'installed_version': 'v3.1-rc4',
'latest_version': 'v3.5-rc3',
'installed_version': 'v5.0.9',
'latest_version': 'v5.0.10',
'release_summary': None,
'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/',
'skipped_version': None,
@ -102,8 +102,8 @@
'entity_picture': 'https://brands.home-assistant.io/_/lamarzocco/icon.png',
'friendly_name': 'GS012345 Machine firmware',
'in_progress': False,
'installed_version': '1.40',
'latest_version': '1.55',
'installed_version': 'v1.17',
'latest_version': 'v1.17',
'release_summary': None,
'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/',
'skipped_version': None,
@ -116,6 +116,6 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
'state': 'off',
})
# ---

View File

@ -4,10 +4,7 @@ from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import MachineModel
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoScale
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
@ -21,7 +18,6 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat
async def test_binary_sensors(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
@ -35,26 +31,14 @@ async def test_binary_sensors(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_brew_active_does_not_exists(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry_no_local_connection: MockConfigEntry,
) -> None:
"""Test the La Marzocco currently_making_coffee doesn't exist if host not set."""
await async_init_integration(hass, mock_config_entry_no_local_connection)
state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_brewing_active")
assert state is None
async def test_brew_active_unavailable(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the La Marzocco currently_making_coffee becomes unavailable."""
"""Test the La Marzocco brew active becomes unavailable."""
mock_lamarzocco.websocket_connected = False
mock_lamarzocco.websocket.connected = False
await async_init_integration(hass, mock_config_entry)
state = hass.states.get(
f"binary_sensor.{mock_lamarzocco.serial_number}_brewing_active"
@ -79,7 +63,7 @@ async def test_sensor_going_unavailable(
assert state
assert state.state != STATE_UNAVAILABLE
mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("")
mock_lamarzocco.get_dashboard.side_effect = RequestNotSuccessful("")
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
@ -87,68 +71,3 @@ async def test_sensor_going_unavailable(
state = hass.states.get(brewing_active_sensor)
assert state
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_scale_connectivity(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the scale binary sensors."""
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("binary_sensor.lmz_123a45_connectivity")
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry.device_id
assert entry == snapshot
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA],
)
async def test_other_models_no_scale_connectivity(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Ensure the other models don't have a connectivity sensor."""
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("binary_sensor.lmz_123a45_connectivity")
assert state is None
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_connectivity_on_new_scale_added(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure the connectivity binary sensor for a new scale is added automatically."""
mock_lamarzocco.config.scale = None
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("binary_sensor.scale_123a45_connectivity")
assert state is None
mock_lamarzocco.config.scale = LaMarzoccoScale(
connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50
)
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("binary_sensor.scale_123a45_connectivity")
assert state

View File

@ -127,7 +127,12 @@ async def test_no_calendar_events_global_disable(
wake_up_sleep_entry_id = WAKE_UP_SLEEP_ENTRY_IDS[0]
mock_lamarzocco.config.wake_up_sleep_entries[wake_up_sleep_entry_id].enabled = False
wake_up_sleep_entry = mock_lamarzocco.schedule.smart_wake_up_sleep.schedules_dict[
wake_up_sleep_entry_id
]
assert wake_up_sleep_entry
wake_up_sleep_entry.enabled = False
test_time = datetime(2024, 1, 12, 11, tzinfo=dt_util.get_default_time_zone())
freezer.move_to(test_time)

View File

@ -1,11 +1,11 @@
"""Test the La Marzocco config flow."""
from collections.abc import Generator
from copy import deepcopy
from unittest.mock import AsyncMock, MagicMock, patch
from pylamarzocco.const import MachineModel
from pylamarzocco.const import ModelName
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoDeviceInfo
import pytest
from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE
@ -15,18 +15,11 @@ from homeassistant.config_entries import (
SOURCE_DHCP,
SOURCE_USER,
ConfigEntryState,
ConfigFlowResult,
)
from homeassistant.const import (
CONF_ADDRESS,
CONF_HOST,
CONF_MAC,
CONF_MODEL,
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
)
from homeassistant.const import CONF_ADDRESS, CONF_MAC, CONF_PASSWORD, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult, FlowResultType
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import USER_INPUT, async_init_integration, get_bluetooth_service_info
@ -34,9 +27,18 @@ from . import USER_INPUT, async_init_integration, get_bluetooth_service_info
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.lamarzocco.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
async def __do_successful_user_step(
hass: HomeAssistant, result: FlowResult, mock_cloud_client: MagicMock
) -> FlowResult:
hass: HomeAssistant, result: ConfigFlowResult, mock_cloud_client: MagicMock
) -> ConfigFlowResult:
"""Successfully configure the user step."""
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -50,40 +52,28 @@ async def __do_successful_user_step(
async def __do_sucessful_machine_selection_step(
hass: HomeAssistant, result2: FlowResult, mock_device_info: LaMarzoccoDeviceInfo
hass: HomeAssistant, result2: ConfigFlowResult
) -> None:
"""Successfully configure the machine selection step."""
with patch(
"homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection",
return_value=True,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_device_info.serial_number,
},
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{CONF_MACHINE: "GS012345"},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "GS3"
assert result3["data"] == {
assert result["title"] == "GS012345"
assert result["data"] == {
**USER_INPUT,
CONF_HOST: "192.168.1.1",
CONF_MODEL: mock_device_info.model,
CONF_NAME: mock_device_info.name,
CONF_TOKEN: mock_device_info.communication_key,
CONF_TOKEN: None,
}
assert result["result"].unique_id == "GS012345"
async def test_form(
hass: HomeAssistant,
mock_cloud_client: MagicMock,
mock_device_info: LaMarzoccoDeviceInfo,
mock_setup_entry: Generator[AsyncMock],
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
@ -93,14 +83,12 @@ async def test_form(
assert result["errors"] == {}
assert result["step_id"] == "user"
result2 = await __do_successful_user_step(hass, result, mock_cloud_client)
await __do_sucessful_machine_selection_step(hass, result2, mock_device_info)
result = await __do_successful_user_step(hass, result, mock_cloud_client)
await __do_sucessful_machine_selection_step(hass, result)
async def test_form_abort_already_configured(
hass: HomeAssistant,
mock_cloud_client: MagicMock,
mock_device_info: LaMarzoccoDeviceInfo,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test we abort if already configured."""
@ -112,139 +100,89 @@ async def test_form_abort_already_configured(
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "machine_selection"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "machine_selection"
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_device_info.serial_number,
CONF_MACHINE: "GS012345",
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "already_configured"
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("side_effect", "error"),
[
(AuthFail(""), "invalid_auth"),
(RequestNotSuccessful(""), "cannot_connect"),
],
)
async def test_form_invalid_auth(
hass: HomeAssistant,
mock_device_info: LaMarzoccoDeviceInfo,
mock_cloud_client: MagicMock,
mock_setup_entry: Generator[AsyncMock],
side_effect: Exception,
error: str,
) -> None:
"""Test invalid auth error."""
mock_cloud_client.get_customer_fleet.side_effect = AuthFail("")
mock_cloud_client.list_things.side_effect = side_effect
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1
# test recovery from failure
mock_cloud_client.get_customer_fleet.side_effect = None
result2 = await __do_successful_user_step(hass, result, mock_cloud_client)
await __do_sucessful_machine_selection_step(hass, result2, mock_device_info)
async def test_form_invalid_host(
hass: HomeAssistant,
mock_cloud_client: MagicMock,
mock_device_info: LaMarzoccoDeviceInfo,
mock_setup_entry: Generator[AsyncMock],
) -> None:
"""Test invalid auth error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is 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"] is FlowResultType.FORM
assert result2["step_id"] == "machine_selection"
with patch(
"homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection",
return_value=False,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_device_info.serial_number,
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.FORM
assert result3["errors"] == {"host": "cannot_connect"}
assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1
assert result["errors"] == {"base": error}
assert len(mock_cloud_client.list_things.mock_calls) == 1
# test recovery from failure
await __do_sucessful_machine_selection_step(hass, result2, mock_device_info)
mock_cloud_client.list_things.side_effect = None
result = await __do_successful_user_step(hass, result, mock_cloud_client)
await __do_sucessful_machine_selection_step(hass, result)
async def test_form_cannot_connect(
async def test_form_no_machines(
hass: HomeAssistant,
mock_cloud_client: MagicMock,
mock_device_info: LaMarzoccoDeviceInfo,
mock_setup_entry: Generator[AsyncMock],
) -> None:
"""Test cannot connect error."""
"""Test we don't have any devices."""
mock_cloud_client.get_customer_fleet.return_value = {}
original_return = mock_cloud_client.list_things.return_value
mock_cloud_client.list_things.return_value = []
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "no_machines"}
assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1
mock_cloud_client.get_customer_fleet.side_effect = RequestNotSuccessful("")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 2
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_machines"}
assert len(mock_cloud_client.list_things.mock_calls) == 1
# test recovery from failure
mock_cloud_client.get_customer_fleet.side_effect = None
mock_cloud_client.get_customer_fleet.return_value = {
mock_device_info.serial_number: mock_device_info
}
result2 = await __do_successful_user_step(hass, result, mock_cloud_client)
await __do_sucessful_machine_selection_step(hass, result2, mock_device_info)
mock_cloud_client.list_things.return_value = original_return
result = await __do_successful_user_step(hass, result, mock_cloud_client)
await __do_sucessful_machine_selection_step(hass, result)
async def test_reauth_flow(
@ -261,15 +199,15 @@ async def test_reauth_flow(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "new_password"},
)
assert result2["type"] is FlowResultType.ABORT
assert result["type"] is FlowResultType.ABORT
await hass.async_block_till_done()
assert result2["reason"] == "reauth_successful"
assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1
assert result["reason"] == "reauth_successful"
assert len(mock_cloud_client.list_things.mock_calls) == 1
assert mock_config_entry.data[CONF_PASSWORD] == "new_password"
@ -277,8 +215,6 @@ async def test_reconfigure_flow(
hass: HomeAssistant,
mock_cloud_client: MagicMock,
mock_config_entry: MockConfigEntry,
mock_device_info: LaMarzoccoDeviceInfo,
mock_setup_entry: Generator[AsyncMock],
) -> None:
"""Testing reconfgure flow."""
mock_config_entry.add_to_hass(hass)
@ -288,40 +224,33 @@ async def test_reconfigure_flow(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result2 = await __do_successful_user_step(hass, result, mock_cloud_client)
service_info = get_bluetooth_service_info(
mock_device_info.model, mock_device_info.serial_number
)
result = await __do_successful_user_step(hass, result, mock_cloud_client)
service_info = get_bluetooth_service_info(ModelName.GS3_MP, "GS012345")
with (
patch(
"homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection",
return_value=True,
),
patch(
"homeassistant.components.lamarzocco.config_flow.async_discovered_service_info",
return_value=[service_info],
),
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_device_info.serial_number,
CONF_MACHINE: "GS012345",
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "bluetooth_selection"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "bluetooth_selection"
result4 = await hass.config_entries.flow.async_configure(
result3["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_MAC: service_info.address},
)
assert result4["type"] is FlowResultType.ABORT
assert result4["reason"] == "reconfigure_successful"
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.title == "My LaMarzocco"
assert mock_config_entry.data == {
@ -334,12 +263,13 @@ async def test_bluetooth_discovery(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_cloud_client: MagicMock,
mock_setup_entry: Generator[AsyncMock],
) -> None:
"""Test bluetooth discovery."""
service_info = get_bluetooth_service_info(
mock_lamarzocco.model, mock_lamarzocco.serial_number
ModelName.GS3_MP, mock_lamarzocco.serial_number
)
mock_cloud_client.list_things.return_value[0].ble_auth_token = "dummyToken"
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info
)
@ -347,52 +277,30 @@ async def test_bluetooth_discovery(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "machine_selection"
assert result["type"] is FlowResultType.CREATE_ENTRY
assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1
with patch(
"homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection",
return_value=True,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_HOST: "192.168.1.1",
},
)
await hass.async_block_till_done()
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "GS3"
assert result3["data"] == {
assert result["title"] == "GS012345"
assert result["data"] == {
**USER_INPUT,
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_lamarzocco.serial_number,
CONF_NAME: "GS3",
CONF_MAC: "aa:bb:cc:dd:ee:ff",
CONF_MODEL: mock_lamarzocco.model,
CONF_TOKEN: "token",
CONF_TOKEN: "dummyToken",
}
async def test_bluetooth_discovery_already_configured(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_cloud_client: MagicMock,
mock_setup_entry: Generator[AsyncMock],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test bluetooth discovery."""
mock_config_entry.add_to_hass(hass)
service_info = get_bluetooth_service_info(
mock_lamarzocco.model, mock_lamarzocco.serial_number
ModelName.GS3_MP, mock_lamarzocco.serial_number
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info
@ -405,12 +313,10 @@ async def test_bluetooth_discovery_errors(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_cloud_client: MagicMock,
mock_device_info: LaMarzoccoDeviceInfo,
mock_setup_entry: Generator[AsyncMock],
) -> None:
"""Test bluetooth discovery errors."""
service_info = get_bluetooth_service_info(
mock_lamarzocco.model, mock_lamarzocco.serial_number
ModelName.GS3_MP, mock_lamarzocco.serial_number
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
@ -421,62 +327,36 @@ async def test_bluetooth_discovery_errors(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
mock_cloud_client.get_customer_fleet.return_value = {"GS98765", ""}
result2 = await hass.config_entries.flow.async_configure(
original_return = deepcopy(mock_cloud_client.list_things.return_value)
mock_cloud_client.list_things.return_value[0].serial_number = "GS98765"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "machine_not_found"}
assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "machine_not_found"}
assert len(mock_cloud_client.list_things.mock_calls) == 1
mock_cloud_client.get_customer_fleet.return_value = {
mock_device_info.serial_number: mock_device_info
}
result2 = await hass.config_entries.flow.async_configure(
mock_cloud_client.list_things.return_value = original_return
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "machine_selection"
assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 2
with patch(
"homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection",
return_value=True,
):
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_HOST: "192.168.1.1",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "GS3"
assert result3["data"] == {
assert result["title"] == "GS012345"
assert result["data"] == {
**USER_INPUT,
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_lamarzocco.serial_number,
CONF_NAME: "GS3",
CONF_MAC: "aa:bb:cc:dd:ee:ff",
CONF_MODEL: mock_lamarzocco.model,
CONF_TOKEN: "token",
CONF_TOKEN: None,
}
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI, MachineModel.GS3_AV],
)
async def test_dhcp_discovery(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_cloud_client: MagicMock,
mock_device_info: LaMarzoccoDeviceInfo,
mock_setup_entry: Generator[AsyncMock],
) -> None:
"""Test dhcp discovery."""
@ -493,30 +373,20 @@ async def test_dhcp_discovery(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
with patch(
"homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["data"] == {
**USER_INPUT,
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_HOST: "192.168.1.42",
CONF_MACHINE: mock_lamarzocco.serial_number,
CONF_MODEL: mock_device_info.model,
CONF_NAME: mock_device_info.name,
CONF_TOKEN: mock_device_info.communication_key,
}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
USER_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
**USER_INPUT,
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_TOKEN: None,
}
async def test_dhcp_discovery_abort_on_hostname_changed(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_cloud_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test dhcp discovery aborts when hostname was changed manually."""
@ -537,11 +407,9 @@ async def test_dhcp_discovery_abort_on_hostname_changed(
async def test_dhcp_already_configured_and_update(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_cloud_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test discovered IP address change."""
old_ip = mock_config_entry.data[CONF_HOST]
old_address = mock_config_entry.data[CONF_ADDRESS]
mock_config_entry.add_to_hass(hass)
@ -557,18 +425,13 @@ async def test_dhcp_already_configured_and_update(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.data[CONF_HOST] != old_ip
assert mock_config_entry.data[CONF_HOST] == "192.168.1.42"
assert mock_config_entry.data[CONF_ADDRESS] != old_address
assert mock_config_entry.data[CONF_ADDRESS] == "aa:bb:cc:dd:ee:ff"
async def test_options_flow(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: Generator[AsyncMock],
) -> None:
"""Test options flow."""
await async_init_integration(hass, mock_config_entry)
@ -579,7 +442,7 @@ async def test_options_flow(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
result2 = await hass.config_entries.options.async_configure(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_USE_BLUETOOTH: False,
@ -587,7 +450,7 @@ async def test_options_flow(
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["data"] == {
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_USE_BLUETOOTH: False,
}

View File

@ -1,11 +1,10 @@
"""Test initialization of lamarzocco."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import FirmwareType, MachineModel
from pylamarzocco.const import FirmwareType, ModelName
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.models import WebSocketDetails
import pytest
from syrupy import SnapshotAssertion
@ -13,6 +12,7 @@ from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE
from homeassistant.components.lamarzocco.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import (
CONF_ADDRESS,
CONF_HOST,
CONF_MAC,
CONF_MODEL,
@ -29,13 +29,12 @@ from homeassistant.helpers import (
from . import USER_INPUT, async_init_integration, get_bluetooth_service_info
from tests.common import MockConfigEntry, async_fire_time_changed
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."""
await async_init_integration(hass, mock_config_entry)
@ -54,25 +53,48 @@ async def test_config_entry_not_ready(
mock_lamarzocco: MagicMock,
) -> None:
"""Test the La Marzocco configuration entry not ready."""
mock_lamarzocco.get_config.side_effect = RequestNotSuccessful("")
mock_lamarzocco.get_dashboard.side_effect = RequestNotSuccessful("")
await async_init_integration(hass, mock_config_entry)
assert len(mock_lamarzocco.get_config.mock_calls) == 1
assert len(mock_lamarzocco.get_dashboard.mock_calls) == 1
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
("side_effect", "expected_state"),
[
(AuthFail(""), ConfigEntryState.SETUP_ERROR),
(RequestNotSuccessful(""), ConfigEntryState.SETUP_RETRY),
],
)
async def test_get_settings_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_cloud_client: MagicMock,
side_effect: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test error during initial settings get."""
mock_cloud_client.get_thing_settings.side_effect = side_effect
await async_init_integration(hass, mock_config_entry)
assert len(mock_cloud_client.get_thing_settings.mock_calls) == 1
assert mock_config_entry.state is expected_state
async def test_invalid_auth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lamarzocco: MagicMock,
) -> None:
"""Test auth error during setup."""
mock_lamarzocco.get_config.side_effect = AuthFail("")
mock_lamarzocco.get_dashboard.side_effect = AuthFail("")
await async_init_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert len(mock_lamarzocco.get_config.mock_calls) == 1
assert len(mock_lamarzocco.get_dashboard.mock_calls) == 1
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
@ -86,37 +108,52 @@ async def test_invalid_auth(
assert flow["context"].get("entry_id") == mock_config_entry.entry_id
async def test_v1_migration(
async def test_v1_migration_fails(
hass: HomeAssistant,
mock_cloud_client: MagicMock,
mock_lamarzocco: MagicMock,
) -> None:
"""Test v1 -> v2 Migration."""
common_data = {
**USER_INPUT,
CONF_HOST: "host",
CONF_MAC: "aa:bb:cc:dd:ee:ff",
}
entry_v1 = MockConfigEntry(
domain=DOMAIN,
version=1,
unique_id=mock_lamarzocco.serial_number,
data={
**common_data,
CONF_MACHINE: mock_lamarzocco.serial_number,
},
data={},
)
entry_v1.add_to_hass(hass)
await hass.config_entries.async_setup(entry_v1.entry_id)
await hass.async_block_till_done()
assert entry_v1.version == 2
assert dict(entry_v1.data) == {
**common_data,
CONF_NAME: "GS3",
CONF_MODEL: mock_lamarzocco.model,
CONF_TOKEN: "token",
assert entry_v1.state is ConfigEntryState.MIGRATION_ERROR
async def test_v2_migration(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
) -> None:
"""Test v2 -> v3 Migration."""
entry_v2 = MockConfigEntry(
domain=DOMAIN,
version=2,
unique_id=mock_lamarzocco.serial_number,
data={
**USER_INPUT,
CONF_HOST: "192.168.1.24",
CONF_NAME: "La Marzocco",
CONF_MODEL: ModelName.GS3_MP.value,
CONF_MAC: "aa:bb:cc:dd:ee:ff",
},
)
entry_v2.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry_v2.entry_id)
assert entry_v2.state is ConfigEntryState.LOADED
assert entry_v2.version == 3
assert dict(entry_v2.data) == {
**USER_INPUT,
CONF_MAC: "aa:bb:cc:dd:ee:ff",
CONF_TOKEN: None,
}
@ -128,28 +165,28 @@ async def test_migration_errors(
) -> None:
"""Test errors during migration."""
mock_cloud_client.get_customer_fleet.side_effect = RequestNotSuccessful("Error")
mock_cloud_client.list_things.side_effect = RequestNotSuccessful("Error")
entry_v1 = MockConfigEntry(
entry_v2 = MockConfigEntry(
domain=DOMAIN,
version=1,
version=2,
unique_id=mock_lamarzocco.serial_number,
data={
**USER_INPUT,
CONF_MACHINE: mock_lamarzocco.serial_number,
},
)
entry_v1.add_to_hass(hass)
entry_v2.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry_v1.entry_id)
assert entry_v1.state is ConfigEntryState.MIGRATION_ERROR
assert not await hass.config_entries.async_setup(entry_v2.entry_id)
assert entry_v2.state is ConfigEntryState.MIGRATION_ERROR
async def test_config_flow_entry_migration_downgrade(
hass: HomeAssistant,
) -> None:
"""Test that config entry fails setup if the version is from the future."""
entry = MockConfigEntry(domain=DOMAIN, version=3)
entry = MockConfigEntry(domain=DOMAIN, version=4)
entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry.entry_id)
@ -159,12 +196,14 @@ async def test_bluetooth_is_set_from_discovery(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lamarzocco: MagicMock,
mock_cloud_client: MagicMock,
) -> None:
"""Check we can fill a device from discovery info."""
service_info = get_bluetooth_service_info(
mock_lamarzocco.model, mock_lamarzocco.serial_number
ModelName.GS3_MP, mock_lamarzocco.serial_number
)
mock_cloud_client.get_thing_settings.return_value.ble_auth_token = "token"
with (
patch(
"homeassistant.components.lamarzocco.async_discovered_service_info",
@ -174,17 +213,15 @@ async def test_bluetooth_is_set_from_discovery(
"homeassistant.components.lamarzocco.LaMarzoccoMachine"
) as mock_machine_class,
):
mock_machine = MagicMock()
mock_machine.get_firmware = AsyncMock()
mock_machine.firmware = mock_lamarzocco.firmware
mock_machine_class.return_value = mock_machine
mock_machine_class.return_value = mock_lamarzocco
await async_init_integration(hass, mock_config_entry)
discovery.assert_called_once()
assert mock_machine_class.call_count == 2
assert mock_machine_class.call_count == 1
_, kwargs = mock_machine_class.call_args
assert kwargs["bluetooth_client"] is not None
assert mock_config_entry.data[CONF_NAME] == service_info.name
assert mock_config_entry.data[CONF_MAC] == service_info.address
assert mock_config_entry.data[CONF_TOKEN] == "token"
async def test_websocket_closed_on_unload(
@ -193,34 +230,37 @@ async def test_websocket_closed_on_unload(
mock_lamarzocco: MagicMock,
) -> None:
"""Test the websocket is closed on unload."""
with patch(
"homeassistant.components.lamarzocco.LaMarzoccoLocalClient",
autospec=True,
) as local_client:
client = local_client.return_value
client.websocket = AsyncMock()
mock_disconnect_callback = AsyncMock()
mock_websocket = MagicMock()
mock_websocket.closed = True
await async_init_integration(hass, mock_config_entry)
mock_lamarzocco.websocket_connect.assert_called_once()
mock_lamarzocco.websocket = WebSocketDetails(
mock_websocket, mock_disconnect_callback
)
client.websocket.closed = False
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
client.websocket.close.assert_called_once()
await async_init_integration(hass, mock_config_entry)
mock_lamarzocco.connect_dashboard_websocket.assert_called_once()
mock_websocket.closed = False
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
mock_disconnect_callback.assert_called_once()
@pytest.mark.parametrize(
("version", "issue_exists"), [("v3.5-rc6", False), ("v3.3-rc4", True)]
("version", "issue_exists"), [("v3.5-rc6", True), ("v5.0.9", False)]
)
async def test_gateway_version_issue(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lamarzocco: MagicMock,
mock_cloud_client: MagicMock,
version: str,
issue_exists: bool,
) -> None:
"""Make sure we get the issue for certain gateway firmware versions."""
mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = version
mock_cloud_client.get_thing_settings.return_value.firmwares[
FirmwareType.GATEWAY
].build_version = version
await async_init_integration(hass, mock_config_entry)
@ -229,34 +269,33 @@ async def test_gateway_version_issue(
assert (issue is not None) == issue_exists
async def test_conf_host_removed_for_new_gateway(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lamarzocco: MagicMock,
) -> None:
"""Make sure we get the issue for certain gateway firmware versions."""
mock_lamarzocco.firmware[FirmwareType.GATEWAY].current_version = "v5.0.9"
await async_init_integration(hass, mock_config_entry)
assert CONF_HOST not in mock_config_entry.data
async def test_device(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device."""
mock_config_entry = MockConfigEntry(
title="My LaMarzocco",
domain=DOMAIN,
version=3,
data=USER_INPUT
| {
CONF_ADDRESS: "00:00:00:00:00:00",
CONF_TOKEN: "token",
CONF_MAC: "aa:bb:cc:dd:ee:ff",
},
unique_id=mock_lamarzocco.serial_number,
)
await async_init_integration(hass, mock_config_entry)
hass.config_entries.async_update_entry(
mock_config_entry,
data={**mock_config_entry.data, CONF_MAC: "aa:bb:cc:dd:ee:ff"},
data={
**mock_config_entry.data,
},
)
state = hass.states.get(f"switch.{mock_lamarzocco.serial_number}")
@ -269,49 +308,3 @@ async def test_device(
device = device_registry.async_get(entry.device_id)
assert device
assert device == snapshot
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_scale_device(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device."""
await async_init_integration(hass, mock_config_entry)
device = device_registry.async_get_device(
identifiers={(DOMAIN, mock_lamarzocco.config.scale.address)}
)
assert device
assert device == snapshot
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_remove_stale_scale(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure stale scale is cleaned up."""
await async_init_integration(hass, mock_config_entry)
scale_address = mock_lamarzocco.config.scale.address
device = device_registry.async_get_device(identifiers={(DOMAIN, scale_address)})
assert device
mock_lamarzocco.config.scale = None
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, scale_address)})
assert device is None

View File

@ -1,19 +1,10 @@
"""Tests for the La Marzocco number entities."""
from datetime import timedelta
from typing import Any
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import (
KEYS_PER_MODEL,
BoilerType,
MachineModel,
PhysicalKey,
PrebrewMode,
)
from pylamarzocco.const import SmartStandByType
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoScale
import pytest
from syrupy import SnapshotAssertion
@ -22,14 +13,14 @@ from homeassistant.components.number import (
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import async_init_integration
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
@ -38,14 +29,14 @@ from tests.common import MockConfigEntry, async_fire_time_changed
(
"coffee_target_temperature",
94,
"set_temp",
{"boiler": BoilerType.COFFEE, "temperature": 94},
"set_coffee_target_temperature",
{"temperature": 94},
),
(
"smart_standby_time",
23,
"set_smart_standby",
{"enabled": True, "mode": "LastBrewing", "minutes": 23},
{"enabled": True, "mode": SmartStandByType.POWER_ON, "minutes": 23},
),
],
)
@ -94,318 +85,6 @@ async def test_general_numbers(
mock_func.assert_called_once_with(**kwargs)
@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV, MachineModel.GS3_MP])
@pytest.mark.parametrize(
("entity_name", "value", "func_name", "kwargs"),
[
(
"steam_target_temperature",
131,
"set_temp",
{"boiler": BoilerType.STEAM, "temperature": 131},
),
("tea_water_duration", 15, "set_dose_tea_water", {"dose": 15}),
],
)
async def test_gs3_exclusive(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
entity_name: str,
value: float,
func_name: str,
kwargs: dict[str, float],
) -> None:
"""Test exclusive entities for GS3 AV/MP."""
await async_init_integration(hass, mock_config_entry)
serial_number = mock_lamarzocco.serial_number
func = getattr(mock_lamarzocco, func_name)
state = hass.states.get(f"number.{serial_number}_{entity_name}")
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry.device_id
assert entry == snapshot
device = device_registry.async_get(entry.device_id)
assert device
# service call
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}",
ATTR_VALUE: value,
},
blocking=True,
)
assert len(func.mock_calls) == 1
func.assert_called_once_with(**kwargs)
@pytest.mark.parametrize(
"device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI]
)
async def test_gs3_exclusive_none(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Ensure GS3 exclusive is None for unsupported models."""
await async_init_integration(hass, mock_config_entry)
ENTITIES = ("steam_target_temperature", "tea_water_duration")
serial_number = mock_lamarzocco.serial_number
for entity in ENTITIES:
state = hass.states.get(f"number.{serial_number}_{entity}")
assert state is None
@pytest.mark.parametrize(
"device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI]
)
@pytest.mark.parametrize(
("entity_name", "function_name", "prebrew_mode", "value", "kwargs"),
[
(
"prebrew_off_time",
"set_prebrew_time",
PrebrewMode.PREBREW,
6,
{"prebrew_off_time": 6.0, "key": PhysicalKey.A},
),
(
"prebrew_on_time",
"set_prebrew_time",
PrebrewMode.PREBREW,
6,
{"prebrew_on_time": 6.0, "key": PhysicalKey.A},
),
(
"preinfusion_time",
"set_preinfusion_time",
PrebrewMode.PREINFUSION,
7,
{"preinfusion_time": 7.0, "key": PhysicalKey.A},
),
],
)
async def test_pre_brew_infusion_numbers(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
entity_name: str,
function_name: str,
prebrew_mode: PrebrewMode,
value: float,
kwargs: dict[str, float],
) -> None:
"""Test the La Marzocco prebrew/-infusion sensors."""
mock_lamarzocco.config.prebrew_mode = prebrew_mode
await async_init_integration(hass, mock_config_entry)
serial_number = mock_lamarzocco.serial_number
state = hass.states.get(f"number.{serial_number}_{entity_name}")
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry == snapshot
# service call
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}",
ATTR_VALUE: value,
},
blocking=True,
)
function = getattr(mock_lamarzocco, function_name)
function.assert_called_once_with(**kwargs)
@pytest.mark.parametrize(
"device_fixture", [MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI]
)
@pytest.mark.parametrize(
("prebrew_mode", "entity", "unavailable"),
[
(
PrebrewMode.PREBREW,
("prebrew_off_time", "prebrew_on_time"),
("preinfusion_time",),
),
(
PrebrewMode.PREINFUSION,
("preinfusion_time",),
("prebrew_off_time", "prebrew_on_time"),
),
],
)
async def test_pre_brew_infusion_numbers_unavailable(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
prebrew_mode: PrebrewMode,
entity: tuple[str, ...],
unavailable: tuple[str, ...],
) -> None:
"""Test entities are unavailable depending on selected state."""
mock_lamarzocco.config.prebrew_mode = prebrew_mode
await async_init_integration(hass, mock_config_entry)
serial_number = mock_lamarzocco.serial_number
for entity_name in entity:
state = hass.states.get(f"number.{serial_number}_{entity_name}")
assert state
assert state.state != STATE_UNAVAILABLE
for entity_name in unavailable:
state = hass.states.get(f"number.{serial_number}_{entity_name}")
assert state
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
("entity_name", "value", "prebrew_mode", "function_name", "kwargs"),
[
(
"prebrew_off_time",
6,
PrebrewMode.PREBREW,
"set_prebrew_time",
{"prebrew_off_time": 6.0},
),
(
"prebrew_on_time",
6,
PrebrewMode.PREBREW,
"set_prebrew_time",
{"prebrew_on_time": 6.0},
),
(
"preinfusion_time",
7,
PrebrewMode.PREINFUSION,
"set_preinfusion_time",
{"preinfusion_time": 7.0},
),
("dose", 6, PrebrewMode.DISABLED, "set_dose", {"dose": 6}),
],
)
async def test_pre_brew_infusion_key_numbers(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_name: str,
value: float,
prebrew_mode: PrebrewMode,
function_name: str,
kwargs: dict[str, float],
) -> None:
"""Test the La Marzocco number sensors for GS3AV model."""
mock_lamarzocco.config.prebrew_mode = prebrew_mode
await async_init_integration(hass, mock_config_entry)
serial_number = mock_lamarzocco.serial_number
func = getattr(mock_lamarzocco, function_name)
state = hass.states.get(f"number.{serial_number}_{entity_name}")
assert state is None
for key in PhysicalKey:
state = hass.states.get(f"number.{serial_number}_{entity_name}_key_{key}")
assert state
assert state == snapshot(name=f"{serial_number}_{entity_name}_key_{key}-state")
# service call
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: f"number.{serial_number}_{entity_name}_key_{key}",
ATTR_VALUE: value,
},
blocking=True,
)
kwargs["key"] = key
assert len(func.mock_calls) == key.value
func.assert_called_with(**kwargs)
@pytest.mark.parametrize("device_fixture", [MachineModel.GS3_AV])
async def test_disabled_entites(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the La Marzocco prebrew/-infusion sensors for GS3AV model."""
await async_init_integration(hass, mock_config_entry)
ENTITIES = (
"prebrew_off_time",
"prebrew_on_time",
"preinfusion_time",
"set_dose",
)
serial_number = mock_lamarzocco.serial_number
for entity_name in ENTITIES:
for key in PhysicalKey:
state = hass.states.get(f"number.{serial_number}_{entity_name}_key_{key}")
assert state is None
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_MP, MachineModel.LINEA_MICRA, MachineModel.LINEA_MINI],
)
async def test_not_existing_key_entities(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Assert not existing key entities."""
await async_init_integration(hass, mock_config_entry)
serial_number = mock_lamarzocco.serial_number
for entity in (
"prebrew_off_time",
"prebrew_on_time",
"preinfusion_time",
"set_dose",
):
for key in range(1, KEYS_PER_MODEL[MachineModel.GS3_AV] + 1):
state = hass.states.get(f"number.{serial_number}_{entity}_key_{key}")
assert state is None
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_number_error(
hass: HomeAssistant,
@ -419,7 +98,9 @@ async def test_number_error(
state = hass.states.get(f"number.{serial_number}_coffee_target_temperature")
assert state
mock_lamarzocco.set_temp.side_effect = RequestNotSuccessful("Boom")
mock_lamarzocco.set_coffee_target_temperature.side_effect = RequestNotSuccessful(
"Boom"
)
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
NUMBER_DOMAIN,
@ -431,107 +112,3 @@ async def test_number_error(
blocking=True,
)
assert exc_info.value.translation_key == "number_exception"
state = hass.states.get(f"number.{serial_number}_dose_key_1")
assert state
mock_lamarzocco.set_dose.side_effect = RequestNotSuccessful("Boom")
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: f"number.{serial_number}_dose_key_1",
ATTR_VALUE: 99,
},
blocking=True,
)
assert exc_info.value.translation_key == "number_exception_key"
@pytest.mark.parametrize("physical_key", [PhysicalKey.A, PhysicalKey.B])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_set_target(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
physical_key: PhysicalKey,
) -> None:
"""Test the La Marzocco set target sensors."""
await async_init_integration(hass, mock_config_entry)
entity_name = f"number.lmz_123a45_brew_by_weight_target_{int(physical_key)}"
state = hass.states.get(entity_name)
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry == snapshot
# service call
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: entity_name,
ATTR_VALUE: 42,
},
blocking=True,
)
mock_lamarzocco.set_bbw_recipe_target.assert_called_once_with(physical_key, 42)
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA],
)
async def test_other_models_no_scale_set_target(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Ensure the other models don't have a set target numbers."""
await async_init_integration(hass, mock_config_entry)
for i in range(1, 3):
state = hass.states.get(f"number.lmz_123a45_brew_by_weight_target_{i}")
assert state is None
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_set_target_on_new_scale_added(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure the set target numbers for a new scale are added automatically."""
mock_lamarzocco.config.scale = None
await async_init_integration(hass, mock_config_entry)
for i in range(1, 3):
state = hass.states.get(f"number.scale_123a45_brew_by_weight_target_{i}")
assert state is None
mock_lamarzocco.config.scale = LaMarzoccoScale(
connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50
)
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
for i in range(1, 3):
state = hass.states.get(f"number.scale_123a45_brew_by_weight_target_{i}")
assert state

View File

@ -1,18 +1,14 @@
"""Tests for the La Marzocco select entities."""
from datetime import timedelta
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import (
MachineModel,
PhysicalKey,
PrebrewMode,
SmartStandbyMode,
SteamLevel,
ModelName,
PreExtractionMode,
SmartStandByType,
SteamTargetLevel,
)
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoScale
import pytest
from syrupy import SnapshotAssertion
@ -26,15 +22,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import async_init_integration
from tests.common import MockConfigEntry, async_fire_time_changed
pytest.mark.usefixtures("init_integration")
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MICRA])
@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA])
async def test_steam_boiler_level(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
@ -65,12 +57,14 @@ async def test_steam_boiler_level(
blocking=True,
)
mock_lamarzocco.set_steam_level.assert_called_once_with(level=SteamLevel.LEVEL_2)
mock_lamarzocco.set_steam_level.assert_called_once_with(
level=SteamTargetLevel.LEVEL_2
)
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MINI],
[ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI],
)
async def test_steam_boiler_level_none(
hass: HomeAssistant,
@ -86,7 +80,7 @@ async def test_steam_boiler_level_none(
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.LINEA_MICRA, MachineModel.GS3_AV, MachineModel.LINEA_MINI],
[ModelName.LINEA_MICRA, ModelName.GS3_AV, ModelName.LINEA_MINI],
)
async def test_pre_brew_infusion_select(
hass: HomeAssistant,
@ -118,19 +112,21 @@ async def test_pre_brew_infusion_select(
blocking=True,
)
mock_lamarzocco.set_prebrew_mode.assert_called_once_with(mode=PrebrewMode.PREBREW)
mock_lamarzocco.set_pre_extraction_mode.assert_called_once_with(
mode=PreExtractionMode.PREBREWING
)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_MP],
[ModelName.GS3_MP],
)
async def test_pre_brew_infusion_select_none(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
) -> None:
"""Ensure the La Marzocco Steam Level Select is not created for non-Micra models."""
"""Ensure GS3 MP has no prebrew models."""
serial_number = mock_lamarzocco.serial_number
state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode")
@ -162,13 +158,13 @@ async def test_smart_standby_mode(
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: f"select.{serial_number}_smart_standby_mode",
ATTR_OPTION: "power_on",
ATTR_OPTION: "last_brewing",
},
blocking=True,
)
mock_lamarzocco.set_smart_standby.assert_called_once_with(
enabled=True, mode=SmartStandbyMode.POWER_ON, minutes=10
enabled=True, mode=SmartStandByType.LAST_BREW, minutes=10
)
@ -183,7 +179,7 @@ async def test_select_errors(
state = hass.states.get(f"select.{serial_number}_prebrew_infusion_mode")
assert state
mock_lamarzocco.set_prebrew_mode.side_effect = RequestNotSuccessful("Boom")
mock_lamarzocco.set_pre_extraction_mode.side_effect = RequestNotSuccessful("Boom")
# Test setting invalid option
with pytest.raises(HomeAssistantError) as exc_info:
@ -197,77 +193,3 @@ async def test_select_errors(
blocking=True,
)
assert exc_info.value.translation_key == "select_option_error"
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_active_bbw_recipe(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_lamarzocco: MagicMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test the La Marzocco active bbw recipe select."""
state = hass.states.get("select.lmz_123a45_active_brew_by_weight_recipe")
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry == snapshot
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.lmz_123a45_active_brew_by_weight_recipe",
ATTR_OPTION: "b",
},
blocking=True,
)
mock_lamarzocco.set_active_bbw_recipe.assert_called_once_with(PhysicalKey.B)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA],
)
async def test_other_models_no_active_bbw_select(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
) -> None:
"""Ensure the other models don't have a battery sensor."""
state = hass.states.get("select.lmz_123a45_active_brew_by_weight_recipe")
assert state is None
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_active_bbw_select_on_new_scale_added(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure the active bbw select for a new scale is added automatically."""
mock_lamarzocco.config.scale = None
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("select.scale_123a45_active_brew_by_weight_recipe")
assert state is None
mock_lamarzocco.config.scale = LaMarzoccoScale(
connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50
)
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("select.scale_123a45_active_brew_by_weight_recipe")
assert state

View File

@ -1,138 +0,0 @@
"""Tests for La Marzocco sensors."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import MachineModel
from pylamarzocco.models import LaMarzoccoScale
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import async_init_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the La Marzocco sensors."""
with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]):
await async_init_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_shot_timer_not_exists(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry_no_local_connection: MockConfigEntry,
) -> None:
"""Test the La Marzocco shot timer doesn't exist if host not set."""
await async_init_integration(hass, mock_config_entry_no_local_connection)
state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer")
assert state is None
async def test_shot_timer_unavailable(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the La Marzocco brew_active becomes unavailable."""
mock_lamarzocco.websocket_connected = False
await async_init_integration(hass, mock_config_entry)
state = hass.states.get(f"sensor.{mock_lamarzocco.serial_number}_shot_timer")
assert state
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_no_steam_linea_mini(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Ensure Linea Mini has no steam temp."""
await async_init_integration(hass, mock_config_entry)
serial_number = mock_lamarzocco.serial_number
state = hass.states.get(f"sensor.{serial_number}_current_temp_steam")
assert state is None
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_scale_battery(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the scale battery sensor."""
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("sensor.lmz_123a45_battery")
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry.device_id
assert entry == snapshot
@pytest.mark.parametrize(
"device_fixture",
[MachineModel.GS3_AV, MachineModel.GS3_MP, MachineModel.LINEA_MICRA],
)
async def test_other_models_no_scale_battery(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Ensure the other models don't have a battery sensor."""
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("sensor.lmz_123a45_battery")
assert state is None
@pytest.mark.parametrize("device_fixture", [MachineModel.LINEA_MINI])
async def test_battery_on_new_scale_added(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure the battery sensor for a new scale is added automatically."""
mock_lamarzocco.config.scale = None
await async_init_integration(hass, mock_config_entry)
state = hass.states.get("sensor.lmz_123a45_battery")
assert state is None
mock_lamarzocco.config.scale = LaMarzoccoScale(
connected=True, name="Scale-123A45", address="aa:bb:cc:dd:ee:ff", battery=50
)
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("sensor.scale_123a45_battery")
assert state

View File

@ -3,6 +3,7 @@
from typing import Any
from unittest.mock import MagicMock, patch
from pylamarzocco.const import SmartStandByType
from pylamarzocco.exceptions import RequestNotSuccessful
import pytest
from syrupy import SnapshotAssertion
@ -24,7 +25,6 @@ from tests.common import MockConfigEntry, snapshot_platform
async def test_switches(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
@ -47,7 +47,7 @@ async def test_switches(
(
"_smart_standby_enabled",
"set_smart_standby",
{"mode": "LastBrewing", "minutes": 10},
{"mode": SmartStandByType.POWER_ON, "minutes": 10},
),
],
)
@ -124,12 +124,15 @@ async def test_auto_on_off_switches(
blocking=True,
)
wake_up_sleep_entry = mock_lamarzocco.config.wake_up_sleep_entries[
wake_up_sleep_entry_id
]
wake_up_sleep_entry = (
mock_lamarzocco.schedule.smart_wake_up_sleep.schedules_dict[
wake_up_sleep_entry_id
]
)
assert wake_up_sleep_entry
wake_up_sleep_entry.enabled = False
mock_lamarzocco.set_wake_up_sleep.assert_called_with(wake_up_sleep_entry)
mock_lamarzocco.set_wakeup_schedule.assert_called_with(wake_up_sleep_entry)
await hass.services.async_call(
SWITCH_DOMAIN,
@ -140,7 +143,7 @@ async def test_auto_on_off_switches(
blocking=True,
)
wake_up_sleep_entry.enabled = True
mock_lamarzocco.set_wake_up_sleep.assert_called_with(wake_up_sleep_entry)
mock_lamarzocco.set_wakeup_schedule.assert_called_with(wake_up_sleep_entry)
async def test_switch_exceptions(
@ -183,7 +186,7 @@ async def test_switch_exceptions(
state = hass.states.get(f"switch.{serial_number}_auto_on_off_os2oswx")
assert state
mock_lamarzocco.set_wake_up_sleep.side_effect = RequestNotSuccessful("Boom")
mock_lamarzocco.set_wakeup_schedule.side_effect = RequestNotSuccessful("Boom")
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
SWITCH_DOMAIN,

View File

@ -2,7 +2,6 @@
from unittest.mock import MagicMock, patch
from pylamarzocco.const import FirmwareType
from pylamarzocco.exceptions import RequestNotSuccessful
import pytest
from syrupy import SnapshotAssertion
@ -20,7 +19,6 @@ from tests.common import MockConfigEntry, snapshot_platform
async def test_update(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
@ -31,19 +29,10 @@ async def test_update(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("entity_name", "component"),
[
("machine_firmware", FirmwareType.MACHINE),
("gateway_firmware", FirmwareType.GATEWAY),
],
)
async def test_update_entites(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_name: str,
component: FirmwareType,
) -> None:
"""Test the La Marzocco update entities."""
@ -55,43 +44,34 @@ async def test_update_entites(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: f"update.{serial_number}_{entity_name}",
ATTR_ENTITY_ID: f"update.{serial_number}_gateway_firmware",
},
blocking=True,
)
mock_lamarzocco.update_firmware.assert_called_once_with(component)
mock_lamarzocco.update_firmware.assert_called_once_with()
@pytest.mark.parametrize(
("attr", "value"),
[
("side_effect", RequestNotSuccessful("Boom")),
("return_value", False),
],
)
async def test_update_error(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
attr: str,
value: bool | Exception,
) -> None:
"""Test error during update."""
await async_init_integration(hass, mock_config_entry)
state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_machine_firmware")
state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_gateway_firmware")
assert state
setattr(mock_lamarzocco.update_firmware, attr, value)
mock_lamarzocco.update_firmware.side_effect = RequestNotSuccessful("Boom")
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_machine_firmware",
ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_gateway_firmware",
},
blocking=True,
)

View File

@ -86,7 +86,7 @@ async def test_update_entity(
matter_node: MatterNode,
) -> None:
"""Test update entity exists and update check got made."""
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_OFF
@ -101,7 +101,7 @@ async def test_update_check_service(
matter_node: MatterNode,
) -> None:
"""Test check device update through service call."""
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_OFF
assert state.attributes.get("installed_version") == "v1.0"
@ -124,14 +124,14 @@ async def test_update_check_service(
HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
{
ATTR_ENTITY_ID: "update.mock_dimmable_light",
ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware",
},
blocking=True,
)
assert matter_client.check_node_update.call_count == 2
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_ON
assert state.attributes.get("latest_version") == "v2.0"
@ -150,7 +150,7 @@ async def test_update_install(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test device update with Matter attribute changes influence progress."""
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_OFF
assert state.attributes.get("installed_version") == "v1.0"
@ -173,7 +173,7 @@ async def test_update_install(
assert matter_client.check_node_update.call_count == 2
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_ON
assert state.attributes.get("latest_version") == "v2.0"
@ -186,7 +186,7 @@ async def test_update_install(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: "update.mock_dimmable_light",
ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware",
},
blocking=True,
)
@ -199,7 +199,7 @@ async def test_update_install(
)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_ON
assert state.attributes["in_progress"] is True
@ -213,7 +213,7 @@ async def test_update_install(
)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_ON
assert state.attributes["in_progress"] is True
@ -239,7 +239,7 @@ async def test_update_install(
)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state.state == STATE_OFF
assert state.attributes.get("installed_version") == "v2.0"
@ -254,7 +254,7 @@ async def test_update_install_failure(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test update entity service call errors."""
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_OFF
assert state.attributes.get("installed_version") == "v1.0"
@ -277,7 +277,7 @@ async def test_update_install_failure(
assert matter_client.check_node_update.call_count == 2
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_ON
assert state.attributes.get("latest_version") == "v2.0"
@ -293,7 +293,7 @@ async def test_update_install_failure(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: "update.mock_dimmable_light",
ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware",
ATTR_VERSION: "v3.0",
},
blocking=True,
@ -306,7 +306,7 @@ async def test_update_install_failure(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: "update.mock_dimmable_light",
ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware",
ATTR_VERSION: "v3.0",
},
blocking=True,
@ -323,7 +323,7 @@ async def test_update_state_save_and_restore(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test latest update information is retained across reload/restart."""
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_OFF
assert state.attributes.get("installed_version") == "v1.0"
@ -336,7 +336,7 @@ async def test_update_state_save_and_restore(
assert matter_client.check_node_update.call_count == 2
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_ON
assert state.attributes.get("latest_version") == "v2.0"
@ -345,7 +345,7 @@ async def test_update_state_save_and_restore(
assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1
state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"]
assert state["entity_id"] == "update.mock_dimmable_light"
assert state["entity_id"] == "update.mock_dimmable_light_firmware"
extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"]
# Check that the extra data has the format we expect.
@ -376,7 +376,7 @@ async def test_update_state_restore(
(
(
State(
"update.mock_dimmable_light",
"update.mock_dimmable_light_firmware",
STATE_ON,
{
"auto_update": False,
@ -393,7 +393,7 @@ async def test_update_state_restore(
assert check_node_update.call_count == 0
state = hass.states.get("update.mock_dimmable_light")
state = hass.states.get("update.mock_dimmable_light_firmware")
assert state
assert state.state == STATE_ON
assert state.attributes.get("latest_version") == "v2.0"
@ -402,7 +402,7 @@ async def test_update_state_restore(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: "update.mock_dimmable_light",
ATTR_ENTITY_ID: "update.mock_dimmable_light_firmware",
},
blocking=True,
)

View File

@ -17,7 +17,7 @@ from homeassistant.setup import async_setup_component
from .const import CLIENT_ID, CLIENT_SECRET
from tests.common import MockConfigEntry, load_json_object_fixture
from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture
@pytest.fixture(name="expires_at")
@ -91,10 +91,23 @@ def action_fixture(load_action_file: str) -> MieleAction:
return load_json_object_fixture(load_action_file, DOMAIN)
@pytest.fixture(scope="package")
def load_programs_file() -> str:
"""Fixture for loading programs file."""
return "programs_washing_machine.json"
@pytest.fixture
def programs_fixture(load_programs_file: str) -> list[dict]:
"""Fixture for available programs."""
return load_fixture(load_programs_file, DOMAIN)
@pytest.fixture
def mock_miele_client(
device_fixture,
action_fixture,
programs_fixture,
) -> Generator[MagicMock]:
"""Mock a Miele client."""
@ -106,6 +119,7 @@ def mock_miele_client(
client.get_devices.return_value = device_fixture
client.get_actions.return_value = action_fixture
client.get_programs.return_value = programs_fixture
yield client

View File

@ -352,7 +352,18 @@
"key_localized": "Fan level"
},
"plateStep": [],
"ecoFeedback": null,
"ecoFeedback": {
"currentWaterConsumption": {
"unit": "l",
"value": 0.0
},
"currentEnergyConsumption": {
"unit": "kWh",
"value": 0.0
},
"waterForecast": 0.0,
"energyForecast": 0.1
},
"batteryLevel": null
}
}

View File

@ -0,0 +1,117 @@
[
{
"programId": 146,
"program": "QuickPowerWash",
"parameters": {}
},
{
"programId": 123,
"program": "Dark garments / Denim",
"parameters": {}
},
{
"programId": 190,
"program": "ECO 40-60 ",
"parameters": {}
},
{
"programId": 27,
"program": "Proofing",
"parameters": {}
},
{
"programId": 23,
"program": "Shirts",
"parameters": {}
},
{
"programId": 9,
"program": "Silks ",
"parameters": {}
},
{
"programId": 8,
"program": "Woollens ",
"parameters": {}
},
{
"programId": 4,
"program": "Delicates",
"parameters": {}
},
{
"programId": 3,
"program": "Minimum iron",
"parameters": {}
},
{
"programId": 1,
"program": "Cottons",
"parameters": {}
},
{
"programId": 69,
"program": "Cottons hygiene",
"parameters": {}
},
{
"programId": 37,
"program": "Outerwear",
"parameters": {}
},
{
"programId": 122,
"program": "Express 20",
"parameters": {}
},
{
"programId": 29,
"program": "Sportswear",
"parameters": {}
},
{
"programId": 31,
"program": "Automatic plus",
"parameters": {}
},
{
"programId": 39,
"program": "Pillows",
"parameters": {}
},
{
"programId": 22,
"program": "Curtains",
"parameters": {}
},
{
"programId": 129,
"program": "Down filled items",
"parameters": {}
},
{
"programId": 53,
"program": "First wash",
"parameters": {}
},
{
"programId": 95,
"program": "Down duvets",
"parameters": {}
},
{
"programId": 52,
"program": "Separate rinse / Starch",
"parameters": {}
},
{
"programId": 21,
"program": "Drain / Spin",
"parameters": {}
},
{
"programId": 91,
"program": "Clean machine",
"parameters": {}
}
]

View File

@ -0,0 +1,670 @@
# serializer version: 1
# name: test_diagnostics_config_entry
dict({
'config_entry_data': dict({
'auth_implementation': 'miele',
'token': dict({
'access_token': '**REDACTED**',
'expires_in': 86399,
'refresh_token': '**REDACTED**',
'token_type': 'Bearer',
}),
}),
'miele_data': dict({
'actions': dict({
'**REDACTED_019aa577ad1c330d': dict({
'ambientLight': list([
]),
'colors': list([
]),
'deviceName': True,
'light': list([
]),
'modes': list([
]),
'powerOff': False,
'powerOn': True,
'processAction': list([
]),
'programId': list([
]),
'runOnTime': list([
]),
'startTime': list([
]),
'targetTemperature': list([
]),
'ventilationStep': list([
]),
}),
'**REDACTED_57d53e72806e88b4': dict({
'ambientLight': list([
]),
'colors': list([
]),
'deviceName': True,
'light': list([
]),
'modes': list([
]),
'powerOff': False,
'powerOn': True,
'processAction': list([
]),
'programId': list([
]),
'runOnTime': list([
]),
'startTime': list([
]),
'targetTemperature': list([
]),
'ventilationStep': list([
]),
}),
'**REDACTED_c9fe55cdf70786ca': dict({
'ambientLight': list([
]),
'colors': list([
]),
'deviceName': True,
'light': list([
]),
'modes': list([
]),
'powerOff': False,
'powerOn': True,
'processAction': list([
]),
'programId': list([
]),
'runOnTime': list([
]),
'startTime': list([
]),
'targetTemperature': list([
]),
'ventilationStep': list([
]),
}),
}),
'devices': dict({
'**REDACTED_019aa577ad1c330d': dict({
'ident': dict({
'deviceIdentLabel': dict({
'fabIndex': '17',
'fabNumber': '**REDACTED**',
'matNumber': '10804770',
'swids': list([
'4497',
]),
'techType': 'KS 28423 D ed/c',
}),
'deviceName': '',
'protocolVersion': 201,
'type': dict({
'key_localized': 'Device type',
'value_localized': 'Refrigerator',
'value_raw': 19,
}),
'xkmIdentLabel': dict({
'releaseVersion': '31.17',
'techType': 'EK042',
}),
}),
'state': dict({
'ProgramID': dict({
'key_localized': 'Program name',
'value_localized': '',
'value_raw': 0,
}),
'ambientLight': None,
'batteryLevel': None,
'coreTargetTemperature': list([
]),
'coreTemperature': list([
]),
'dryingStep': dict({
'key_localized': 'Drying level',
'value_localized': '',
'value_raw': None,
}),
'ecoFeedback': None,
'elapsedTime': list([
]),
'light': None,
'plateStep': list([
]),
'programPhase': dict({
'key_localized': 'Program phase',
'value_localized': '',
'value_raw': 0,
}),
'programType': dict({
'key_localized': 'Program type',
'value_localized': '',
'value_raw': 0,
}),
'remainingTime': list([
0,
0,
]),
'remoteEnable': dict({
'fullRemoteControl': True,
'mobileStart': False,
'smartGrid': False,
}),
'signalDoor': False,
'signalFailure': False,
'signalInfo': False,
'spinningSpeed': dict({
'key_localized': 'Spin speed',
'unit': 'rpm',
'value_localized': None,
'value_raw': None,
}),
'startTime': list([
0,
0,
]),
'status': dict({
'key_localized': 'status',
'value_localized': 'In use',
'value_raw': 5,
}),
'targetTemperature': list([
dict({
'unit': 'Celsius',
'value_localized': 4,
'value_raw': 400,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
]),
'temperature': list([
dict({
'unit': 'Celsius',
'value_localized': 4,
'value_raw': 400,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
]),
'ventilationStep': dict({
'key_localized': 'Fan level',
'value_localized': '',
'value_raw': None,
}),
}),
}),
'**REDACTED_57d53e72806e88b4': dict({
'ident': dict({
'deviceIdentLabel': dict({
'fabIndex': '21',
'fabNumber': '**REDACTED**',
'matNumber': '10805070',
'swids': list([
'4497',
]),
'techType': 'FNS 28463 E ed/',
}),
'deviceName': '',
'protocolVersion': 201,
'type': dict({
'key_localized': 'Device type',
'value_localized': 'Freezer',
'value_raw': 20,
}),
'xkmIdentLabel': dict({
'releaseVersion': '31.17',
'techType': 'EK042',
}),
}),
'state': dict({
'ProgramID': dict({
'key_localized': 'Program name',
'value_localized': '',
'value_raw': 0,
}),
'ambientLight': None,
'batteryLevel': None,
'coreTargetTemperature': list([
]),
'coreTemperature': list([
]),
'dryingStep': dict({
'key_localized': 'Drying level',
'value_localized': '',
'value_raw': None,
}),
'ecoFeedback': None,
'elapsedTime': list([
]),
'light': None,
'plateStep': list([
]),
'programPhase': dict({
'key_localized': 'Program phase',
'value_localized': '',
'value_raw': 0,
}),
'programType': dict({
'key_localized': 'Program type',
'value_localized': '',
'value_raw': 0,
}),
'remainingTime': list([
0,
0,
]),
'remoteEnable': dict({
'fullRemoteControl': True,
'mobileStart': False,
'smartGrid': False,
}),
'signalDoor': False,
'signalFailure': False,
'signalInfo': False,
'spinningSpeed': dict({
'key_localized': 'Spin speed',
'unit': 'rpm',
'value_localized': None,
'value_raw': None,
}),
'startTime': list([
0,
0,
]),
'status': dict({
'key_localized': 'status',
'value_localized': 'In use',
'value_raw': 5,
}),
'targetTemperature': list([
dict({
'unit': 'Celsius',
'value_localized': -18,
'value_raw': -1800,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
]),
'temperature': list([
dict({
'unit': 'Celsius',
'value_localized': -18,
'value_raw': -1800,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
]),
'ventilationStep': dict({
'key_localized': 'Fan level',
'value_localized': '',
'value_raw': None,
}),
}),
}),
'**REDACTED_c9fe55cdf70786ca': dict({
'ident': dict({
'deviceIdentLabel': dict({
'fabIndex': '44',
'fabNumber': '**REDACTED**',
'matNumber': '11387290',
'swids': list([
'5975',
'20456',
'25213',
'25191',
'25446',
'25205',
'25447',
'25319',
]),
'techType': 'WCI870',
}),
'deviceName': '',
'protocolVersion': 4,
'type': dict({
'key_localized': 'Device type',
'value_localized': 'Washing machine',
'value_raw': 1,
}),
'xkmIdentLabel': dict({
'releaseVersion': '08.32',
'techType': 'EK057',
}),
}),
'state': dict({
'ProgramID': dict({
'key_localized': 'Program name',
'value_localized': '',
'value_raw': 0,
}),
'ambientLight': None,
'batteryLevel': None,
'coreTargetTemperature': list([
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
]),
'coreTemperature': list([
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
]),
'dryingStep': dict({
'key_localized': 'Drying level',
'value_localized': '',
'value_raw': None,
}),
'ecoFeedback': dict({
'currentEnergyConsumption': dict({
'unit': 'kWh',
'value': 0.0,
}),
'currentWaterConsumption': dict({
'unit': 'l',
'value': 0.0,
}),
'energyForecast': 0.1,
'waterForecast': 0.0,
}),
'elapsedTime': list([
0,
0,
]),
'light': None,
'plateStep': list([
]),
'programPhase': dict({
'key_localized': 'Program phase',
'value_localized': '',
'value_raw': 0,
}),
'programType': dict({
'key_localized': 'Program type',
'value_localized': '',
'value_raw': 0,
}),
'remainingTime': list([
0,
0,
]),
'remoteEnable': dict({
'fullRemoteControl': True,
'mobileStart': False,
'smartGrid': False,
}),
'signalDoor': True,
'signalFailure': False,
'signalInfo': False,
'spinningSpeed': dict({
'key_localized': 'Spin speed',
'unit': 'rpm',
'value_localized': None,
'value_raw': None,
}),
'startTime': list([
0,
0,
]),
'status': dict({
'key_localized': 'status',
'value_localized': 'Off',
'value_raw': 1,
}),
'targetTemperature': list([
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
]),
'temperature': list([
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
]),
'ventilationStep': dict({
'key_localized': 'Fan level',
'value_localized': '',
'value_raw': None,
}),
}),
}),
}),
}),
})
# ---
# name: test_diagnostics_device
dict({
'data': dict({
'auth_implementation': 'miele',
'token': dict({
'access_token': '**REDACTED**',
'expires_in': 86399,
'refresh_token': '**REDACTED**',
'token_type': 'Bearer',
}),
}),
'info': dict({
'manufacturer': 'Miele',
'model': 'FNS 28463 E ed/',
}),
'miele_data': dict({
'actions': dict({
'**REDACTED_57d53e72806e88b4': dict({
'ambientLight': list([
]),
'colors': list([
]),
'deviceName': True,
'light': list([
]),
'modes': list([
]),
'powerOff': False,
'powerOn': True,
'processAction': list([
]),
'programId': list([
]),
'runOnTime': list([
]),
'startTime': list([
]),
'targetTemperature': list([
]),
'ventilationStep': list([
]),
}),
}),
'devices': dict({
'**REDACTED_57d53e72806e88b4': dict({
'ident': dict({
'deviceIdentLabel': dict({
'fabIndex': '21',
'fabNumber': '**REDACTED**',
'matNumber': '10805070',
'swids': list([
'4497',
]),
'techType': 'FNS 28463 E ed/',
}),
'deviceName': '',
'protocolVersion': 201,
'type': dict({
'key_localized': 'Device type',
'value_localized': 'Freezer',
'value_raw': 20,
}),
'xkmIdentLabel': dict({
'releaseVersion': '31.17',
'techType': 'EK042',
}),
}),
'state': dict({
'ProgramID': dict({
'key_localized': 'Program name',
'value_localized': '',
'value_raw': 0,
}),
'ambientLight': None,
'batteryLevel': None,
'coreTargetTemperature': list([
]),
'coreTemperature': list([
]),
'dryingStep': dict({
'key_localized': 'Drying level',
'value_localized': '',
'value_raw': None,
}),
'ecoFeedback': None,
'elapsedTime': list([
]),
'light': None,
'plateStep': list([
]),
'programPhase': dict({
'key_localized': 'Program phase',
'value_localized': '',
'value_raw': 0,
}),
'programType': dict({
'key_localized': 'Program type',
'value_localized': '',
'value_raw': 0,
}),
'remainingTime': list([
0,
0,
]),
'remoteEnable': dict({
'fullRemoteControl': True,
'mobileStart': False,
'smartGrid': False,
}),
'signalDoor': False,
'signalFailure': False,
'signalInfo': False,
'spinningSpeed': dict({
'key_localized': 'Spin speed',
'unit': 'rpm',
'value_localized': None,
'value_raw': None,
}),
'startTime': list([
0,
0,
]),
'status': dict({
'key_localized': 'status',
'value_localized': 'In use',
'value_raw': 5,
}),
'targetTemperature': list([
dict({
'unit': 'Celsius',
'value_localized': -18,
'value_raw': -1800,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
]),
'temperature': list([
dict({
'unit': 'Celsius',
'value_localized': -18,
'value_raw': -1800,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
dict({
'unit': 'Celsius',
'value_localized': None,
'value_raw': -32768,
}),
]),
'ventilationStep': dict({
'key_localized': 'Fan level',
'value_localized': '',
'value_raw': None,
}),
}),
}),
}),
'programs': 'Not implemented',
}),
})
# ---

View File

@ -0,0 +1,69 @@
"""Tests for the diagnostics data provided by the miele integration."""
from collections.abc import Generator
from unittest.mock import MagicMock
from syrupy import SnapshotAssertion
from syrupy.filters import paths
from homeassistant.components.miele.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceRegistry
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import (
get_diagnostics_for_config_entry,
get_diagnostics_for_device,
)
from tests.typing import ClientSessionGenerator
async def test_diagnostics_config_entry(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_miele_client: Generator[MagicMock],
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics for config entry."""
await setup_integration(hass, mock_config_entry)
result = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
assert result == snapshot(
exclude=paths(
"config_entry_data.token.expires_at",
"miele_test.entry_id",
)
)
async def test_diagnostics_device(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
device_registry: DeviceRegistry,
mock_miele_client: Generator[MagicMock],
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics for device."""
TEST_DEVICE = "Dummy_Appliance_1"
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, TEST_DEVICE)})
assert device_entry is not None
result = await get_diagnostics_for_device(
hass, hass_client, mock_config_entry, device_entry
)
assert result == snapshot(
exclude=paths(
"data.token.expires_at",
"miele_test.entry_id",
)
)

View File

@ -65,21 +65,21 @@ async def test_async_browse_media_root(
assert response["success"]
result = response["result"]
for idx, item in enumerate(result["children"]):
assert item["title"] == LIBRARY[idx]
assert item["title"].lower() == LIBRARY[idx]
@pytest.mark.parametrize(
("category", "child_count"),
[
("Favorites", 4),
("Artists", 4),
("Albums", 4),
("Playlists", 4),
("Genres", 4),
("New Music", 4),
("Album Artists", 4),
("Apps", 3),
("Radios", 3),
("favorites", 4),
("artists", 4),
("albums", 4),
("playlists", 4),
("genres", 4),
("new music", 4),
("album artists", 4),
("apps", 3),
("radios", 3),
],
)
async def test_async_browse_media_with_subitems(

View File

@ -5,10 +5,12 @@ from unittest.mock import MagicMock, patch
import aiohttp
import pytest
from whirlpool.auth import AccountLockedError
from whirlpool.backendselector import Brand, Region
from homeassistant import config_entries
from homeassistant.components.whirlpool.const import CONF_BRAND, DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -20,6 +22,42 @@ CONFIG_INPUT = {
}
def assert_successful_user_flow(
mock_whirlpool_setup_entry: MagicMock,
result: ConfigFlowResult,
region: str,
brand: str,
) -> None:
"""Assert that the flow was successful."""
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-username"
assert result["data"] == {
CONF_USERNAME: CONFIG_INPUT[CONF_USERNAME],
CONF_PASSWORD: CONFIG_INPUT[CONF_PASSWORD],
CONF_REGION: region,
CONF_BRAND: brand,
}
assert result["result"].unique_id == CONFIG_INPUT[CONF_USERNAME]
assert len(mock_whirlpool_setup_entry.mock_calls) == 1
def assert_successful_reauth_flow(
mock_entry: MockConfigEntry,
result: ConfigFlowResult,
region: str,
brand: str,
) -> None:
"""Assert that the reauth flow was successful."""
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_entry.data == {
CONF_USERNAME: CONFIG_INPUT[CONF_USERNAME],
CONF_PASSWORD: "new-password",
CONF_REGION: region[0],
CONF_BRAND: brand[0],
}
@pytest.fixture(name="mock_whirlpool_setup_entry")
def fixture_mock_whirlpool_setup_entry():
"""Set up async_setup_entry fixture."""
@ -30,14 +68,14 @@ def fixture_mock_whirlpool_setup_entry():
@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api")
async def test_form(
async def test_user_flow(
hass: HomeAssistant,
region,
brand,
region: tuple[str, Region],
brand: tuple[str, Brand],
mock_backend_selector_api: MagicMock,
mock_whirlpool_setup_entry: MagicMock,
) -> None:
"""Test we get the form."""
"""Test successful flow initialized by the user."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -45,38 +83,39 @@ async def test_form(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == config_entries.SOURCE_USER
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
CONFIG_INPUT | {"region": region[0], "brand": brand[0]},
result = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "test-username"
assert result2["data"] == {
"username": "test-username",
"password": "test-password",
"region": region[0],
"brand": brand[0],
}
assert len(mock_whirlpool_setup_entry.mock_calls) == 1
assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0])
mock_backend_selector_api.assert_called_once_with(brand[1], region[1])
async def test_form_invalid_auth(
hass: HomeAssistant, region, brand, mock_auth_api: MagicMock
async def test_user_flow_invalid_auth(
hass: HomeAssistant,
region: tuple[str, Region],
brand: tuple[str, Brand],
mock_auth_api: MagicMock,
mock_whirlpool_setup_entry: MagicMock,
) -> None:
"""Test we handle invalid auth."""
"""Test invalid authentication in the flow initialized by the user."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_auth_api.return_value.is_access_token_valid.return_value = False
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
CONFIG_INPUT | {"region": region[0], "brand": brand[0]},
result = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
# Test that it succeeds if the authentication is valid
mock_auth_api.return_value.is_access_token_valid.return_value = True
result = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}
)
assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0])
@pytest.mark.usefixtures("mock_appliances_manager_api")
@ -89,16 +128,16 @@ async def test_form_invalid_auth(
(Exception, "unknown"),
],
)
async def test_form_auth_error(
async def test_user_flow_auth_error(
hass: HomeAssistant,
exception: Exception,
expected_error: str,
region,
brand,
region: tuple[str, Region],
brand: tuple[str, Brand],
mock_auth_api: MagicMock,
mock_whirlpool_setup_entry: MagicMock,
) -> None:
"""Test we handle cannot connect error."""
"""Test authentication exceptions in the flow initialized by the user."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -108,8 +147,8 @@ async def test_form_auth_error(
result["flow_id"],
CONFIG_INPUT
| {
"region": region[0],
"brand": brand[0],
CONF_REGION: region[0],
CONF_BRAND: brand[0],
},
)
assert result["type"] is FlowResultType.FORM
@ -118,27 +157,20 @@ async def test_form_auth_error(
# Test that it succeeds after the error is cleared
mock_auth_api.return_value.do_auth.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
CONFIG_INPUT | {"region": region[0], "brand": brand[0]},
result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "test-username"
assert result["data"] == {
"username": "test-username",
"password": "test-password",
"region": region[0],
"brand": brand[0],
}
assert len(mock_whirlpool_setup_entry.mock_calls) == 1
assert_successful_user_flow(mock_whirlpool_setup_entry, result, region[0], brand[0])
@pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api")
async def test_form_already_configured(hass: HomeAssistant, region, brand) -> None:
async def test_already_configured(
hass: HomeAssistant, region: tuple[str, Region], brand: tuple[str, Brand]
) -> None:
"""Test that configuring the integration twice with the same data fails."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]},
data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]},
unique_id="test-username",
)
mock_entry.add_to_hass(hass)
@ -150,22 +182,20 @@ async def test_form_already_configured(hass: HomeAssistant, region, brand) -> No
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == config_entries.SOURCE_USER
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
CONFIG_INPUT
| {
"region": region[0],
"brand": brand[0],
},
result = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_auth_api")
async def test_no_appliances_flow(
hass: HomeAssistant, region, brand, mock_appliances_manager_api: MagicMock
hass: HomeAssistant,
region: tuple[str, Region],
brand: tuple[str, Brand],
mock_appliances_manager_api: MagicMock,
) -> None:
"""Test we get an error with no appliances."""
result = await hass.config_entries.flow.async_init(
@ -177,23 +207,24 @@ async def test_no_appliances_flow(
mock_appliances_manager_api.return_value.aircons = []
mock_appliances_manager_api.return_value.washer_dryers = []
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
CONFIG_INPUT | {"region": region[0], "brand": brand[0]},
result = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]}
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "no_appliances"}
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_appliances"}
@pytest.mark.usefixtures(
"mock_auth_api", "mock_appliances_manager_api", "mock_whirlpool_setup_entry"
)
async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None:
async def test_reauth_flow(
hass: HomeAssistant, region: tuple[str, Region], brand: tuple[str, Brand]
) -> None:
"""Test a successful reauth flow."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]},
data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]},
unique_id="test-username",
)
mock_entry.add_to_hass(hass)
@ -204,30 +235,25 @@ async def test_reauth_flow(hass: HomeAssistant, region, brand) -> None:
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]},
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "reauth_successful"
assert mock_entry.data == {
CONF_USERNAME: "test-username",
CONF_PASSWORD: "new-password",
"region": region[0],
"brand": brand[0],
}
assert_successful_reauth_flow(mock_entry, result, region, brand)
@pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry")
async def test_reauth_flow_invalid_auth(
hass: HomeAssistant, region, brand, mock_auth_api: MagicMock
hass: HomeAssistant,
region: tuple[str, Region],
brand: tuple[str, Brand],
mock_auth_api: MagicMock,
) -> None:
"""Test an authorization error reauth flow."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]},
data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]},
unique_id="test-username",
)
mock_entry.add_to_hass(hass)
@ -238,13 +264,21 @@ async def test_reauth_flow_invalid_auth(
assert result["errors"] == {}
mock_auth_api.return_value.is_access_token_valid.return_value = False
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
# Test that it succeeds if the credentials are valid
mock_auth_api.return_value.is_access_token_valid.return_value = True
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}
)
assert_successful_reauth_flow(mock_entry, result, region, brand)
@pytest.mark.usefixtures("mock_appliances_manager_api", "mock_whirlpool_setup_entry")
@ -261,15 +295,15 @@ async def test_reauth_flow_auth_error(
hass: HomeAssistant,
exception: Exception,
expected_error: str,
region,
brand,
region: tuple[str, Region],
brand: tuple[str, Brand],
mock_auth_api: MagicMock,
) -> None:
"""Test a connection error reauth flow."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]},
data=CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]},
unique_id="test-username",
)
mock_entry.add_to_hass(hass)
@ -281,9 +315,16 @@ async def test_reauth_flow_auth_error(
assert result["errors"] == {}
mock_auth_api.return_value.do_auth.side_effect = exception
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]},
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": expected_error}
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
# Test that it succeeds if the exception is cleared
mock_auth_api.return_value.do_auth.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_PASSWORD: "new-password", CONF_BRAND: brand[0]}
)
assert_successful_reauth_flow(mock_entry, result, region, brand)

View File

@ -3,6 +3,7 @@
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from whirlpool.washerdryer import MachineState
@ -58,6 +59,7 @@ async def test_washer_dryer_time_sensor(
entity_id: str,
mock_fixture: str,
request: pytest.FixtureRequest,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test Washer/Dryer end time sensors."""
now = utcnow()
@ -113,7 +115,8 @@ async def test_washer_dryer_time_sensor(
# Test that periodic updates call the API to fetch data
mock_instance.fetch_data.reset_mock()
async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL)
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_instance.fetch_data.assert_called_once()

View File

@ -39,10 +39,12 @@ from zwave_js_server.model.value import ConfigurationValue, get_value_id_str
from homeassistant.components.websocket_api import ERR_INVALID_FORMAT, ERR_NOT_FOUND
from homeassistant.components.zwave_js.api import (
APPLICATION_VERSION,
AREA_ID,
CLIENT_SIDE_AUTH,
COMMAND_CLASS_ID,
CONFIG,
DEVICE_ID,
DEVICE_NAME,
DSK,
ENABLED,
ENDPOINT,
@ -67,6 +69,7 @@ from homeassistant.components.zwave_js.api import (
PRODUCT_TYPE,
PROPERTY,
PROPERTY_KEY,
PROTOCOL,
QR_CODE_STRING,
QR_PROVISIONING_INFORMATION,
REQUESTED_SECURITY_CLASSES,
@ -485,14 +488,14 @@ async def test_node_alerts(
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the node comments websocket command."""
entry = integration
ws_client = await hass_ws_client(hass)
device = device_registry.async_get_device(identifiers={(DOMAIN, "3245146787-35")})
assert device
await ws_client.send_json(
await ws_client.send_json_auto_id(
{
ID: 3,
TYPE: "zwave_js/node_alerts",
DEVICE_ID: device.id,
}
@ -502,6 +505,83 @@ async def test_node_alerts(
assert result["comments"] == [{"level": "info", "text": "test"}]
assert result["is_embedded"]
# Test with provisioned device
valid_qr_info = {
VERSION: 1,
SECURITY_CLASSES: [0],
DSK: "test",
GENERIC_DEVICE_CLASS: 1,
SPECIFIC_DEVICE_CLASS: 1,
INSTALLER_ICON_TYPE: 1,
MANUFACTURER_ID: 1,
PRODUCT_TYPE: 1,
PRODUCT_ID: 1,
APPLICATION_VERSION: "test",
}
# Test QR provisioning information
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/provision_smart_start_node",
ENTRY_ID: entry.entry_id,
QR_PROVISIONING_INFORMATION: valid_qr_info,
DEVICE_NAME: "test",
}
)
msg = await ws_client.receive_json()
assert msg["success"]
with patch(
f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries",
return_value=[
ProvisioningEntry.from_dict({**valid_qr_info, "device_id": msg["result"]})
],
):
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/node_alerts",
DEVICE_ID: msg["result"],
}
)
msg = await ws_client.receive_json()
assert msg["success"]
assert msg["result"]["comments"] == [
{
"level": "info",
"text": "This device has been provisioned but is not yet included in the network.",
}
]
# Test missing node with no provisioning entry
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, "3245146787-12")},
)
assert device
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/node_alerts",
DEVICE_ID: device.id,
}
)
msg = await ws_client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == ERR_NOT_FOUND
# Test integration not loaded error - need to unload the integration
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/node_alerts",
DEVICE_ID: device.id,
}
)
msg = await ws_client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == ERR_NOT_LOADED
async def test_add_node(
hass: HomeAssistant,
@ -1093,7 +1173,11 @@ async def test_validate_dsk_and_enter_pin(
async def test_provision_smart_start_node(
hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
integration,
client,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test provision_smart_start_node websocket command."""
entry = integration
@ -1131,20 +1215,9 @@ async def test_provision_smart_start_node(
assert len(client.async_send_command.call_args_list) == 1
assert client.async_send_command.call_args[0][0] == {
"command": "controller.provision_smart_start_node",
"entry": QRProvisioningInformation(
version=QRCodeVersion.SMART_START,
security_classes=[SecurityClass.S2_UNAUTHENTICATED],
"entry": ProvisioningEntry(
dsk="test",
generic_device_class=1,
specific_device_class=1,
installer_icon_type=1,
manufacturer_id=1,
product_type=1,
product_id=1,
application_version="test",
max_inclusion_request_interval=None,
uuid=None,
supported_protocols=None,
security_classes=[SecurityClass.S2_UNAUTHENTICATED],
additional_properties={"name": "test"},
).to_dict(),
}
@ -1152,6 +1225,51 @@ async def test_provision_smart_start_node(
client.async_send_command.reset_mock()
client.async_send_command.return_value = {"success": True}
# Test QR provisioning information with device name and area
await ws_client.send_json(
{
ID: 4,
TYPE: "zwave_js/provision_smart_start_node",
ENTRY_ID: entry.entry_id,
QR_PROVISIONING_INFORMATION: {
**valid_qr_info,
},
PROTOCOL: Protocols.ZWAVE_LONG_RANGE,
DEVICE_NAME: "test_name",
AREA_ID: "test_area",
}
)
msg = await ws_client.receive_json()
assert msg["success"]
# verify a device was created
device = device_registry.async_get_device(
identifiers={(DOMAIN, "provision_test")},
)
assert device is not None
assert device.name == "test_name"
assert device.area_id == "test_area"
assert len(client.async_send_command.call_args_list) == 2
assert client.async_send_command.call_args_list[0][0][0] == {
"command": "config_manager.lookup_device",
"manufacturerId": 1,
"productType": 1,
"productId": 1,
}
assert client.async_send_command.call_args_list[1][0][0] == {
"command": "controller.provision_smart_start_node",
"entry": ProvisioningEntry(
dsk="test",
security_classes=[SecurityClass.S2_UNAUTHENTICATED],
protocol=Protocols.ZWAVE_LONG_RANGE,
additional_properties={
"name": "test",
"device_id": device.id,
},
).to_dict(),
}
# Test QR provisioning information with S2 version throws error
await ws_client.send_json(
{
@ -1230,7 +1348,11 @@ async def test_provision_smart_start_node(
async def test_unprovision_smart_start_node(
hass: HomeAssistant, integration, client, hass_ws_client: WebSocketGenerator
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
integration,
client,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test unprovision_smart_start_node websocket command."""
entry = integration
@ -1239,9 +1361,8 @@ async def test_unprovision_smart_start_node(
client.async_send_command.return_value = {}
# Test node ID as input
await ws_client.send_json(
await ws_client.send_json_auto_id(
{
ID: 1,
TYPE: "zwave_js/unprovision_smart_start_node",
ENTRY_ID: entry.entry_id,
NODE_ID: 1,
@ -1251,8 +1372,12 @@ async def test_unprovision_smart_start_node(
msg = await ws_client.receive_json()
assert msg["success"]
assert len(client.async_send_command.call_args_list) == 1
assert client.async_send_command.call_args[0][0] == {
assert len(client.async_send_command.call_args_list) == 2
assert client.async_send_command.call_args_list[0][0][0] == {
"command": "controller.get_provisioning_entry",
"dskOrNodeId": 1,
}
assert client.async_send_command.call_args_list[1][0][0] == {
"command": "controller.unprovision_smart_start_node",
"dskOrNodeId": 1,
}
@ -1261,9 +1386,8 @@ async def test_unprovision_smart_start_node(
client.async_send_command.return_value = {}
# Test DSK as input
await ws_client.send_json(
await ws_client.send_json_auto_id(
{
ID: 2,
TYPE: "zwave_js/unprovision_smart_start_node",
ENTRY_ID: entry.entry_id,
DSK: "test",
@ -1273,8 +1397,12 @@ async def test_unprovision_smart_start_node(
msg = await ws_client.receive_json()
assert msg["success"]
assert len(client.async_send_command.call_args_list) == 1
assert client.async_send_command.call_args[0][0] == {
assert len(client.async_send_command.call_args_list) == 2
assert client.async_send_command.call_args_list[0][0][0] == {
"command": "controller.get_provisioning_entry",
"dskOrNodeId": "test",
}
assert client.async_send_command.call_args_list[1][0][0] == {
"command": "controller.unprovision_smart_start_node",
"dskOrNodeId": "test",
}
@ -1283,9 +1411,8 @@ async def test_unprovision_smart_start_node(
client.async_send_command.return_value = {}
# Test not including DSK or node ID as input fails
await ws_client.send_json(
await ws_client.send_json_auto_id(
{
ID: 3,
TYPE: "zwave_js/unprovision_smart_start_node",
ENTRY_ID: entry.entry_id,
}
@ -1296,14 +1423,78 @@ async def test_unprovision_smart_start_node(
assert len(client.async_send_command.call_args_list) == 0
# Test with pre provisioned device
# Create device registry entry for mock node
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, "provision_test"), ("other_domain", "test")},
name="Node 67",
)
provisioning_entry = ProvisioningEntry.from_dict(
{
"dsk": "test",
"securityClasses": [SecurityClass.S2_UNAUTHENTICATED],
"device_id": device.id,
}
)
with patch.object(
client.driver.controller,
"async_get_provisioning_entry",
return_value=provisioning_entry,
):
# Don't remove the device if it has additional identifiers
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/unprovision_smart_start_node",
ENTRY_ID: entry.entry_id,
DSK: "test",
}
)
msg = await ws_client.receive_json()
assert msg["success"]
assert len(client.async_send_command.call_args_list) == 1
assert client.async_send_command.call_args[0][0] == {
"command": "controller.unprovision_smart_start_node",
"dskOrNodeId": "test",
}
device = device_registry.async_get(device.id)
assert device is not None
client.async_send_command.reset_mock()
# Remove the device if it doesn't have additional identifiers
device_registry.async_update_device(
device.id, new_identifiers={(DOMAIN, "provision_test")}
)
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/unprovision_smart_start_node",
ENTRY_ID: entry.entry_id,
DSK: "test",
}
)
msg = await ws_client.receive_json()
assert msg["success"]
assert len(client.async_send_command.call_args_list) == 1
assert client.async_send_command.call_args[0][0] == {
"command": "controller.unprovision_smart_start_node",
"dskOrNodeId": "test",
}
# Verify device was removed from device registry
device = device_registry.async_get(device.id)
assert device is None
# Test FailedZWaveCommand is caught
with patch(
f"{CONTROLLER_PATCH_PREFIX}.async_unprovision_smart_start_node",
side_effect=FailedZWaveCommand("failed_command", 1, "error message"),
):
await ws_client.send_json(
await ws_client.send_json_auto_id(
{
ID: 6,
TYPE: "zwave_js/unprovision_smart_start_node",
ENTRY_ID: entry.entry_id,
DSK: "test",
@ -1319,9 +1510,8 @@ async def test_unprovision_smart_start_node(
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
await ws_client.send_json(
await ws_client.send_json_auto_id(
{
ID: 7,
TYPE: "zwave_js/unprovision_smart_start_node",
ENTRY_ID: entry.entry_id,
DSK: "test",
@ -5658,3 +5848,39 @@ async def test_lookup_device(
assert not msg["success"]
assert msg["error"]["code"] == error_message
assert msg["error"]["message"] == f"Command failed: {error_message}"
async def test_subscribe_new_devices(
hass: HomeAssistant,
integration,
client,
hass_ws_client: WebSocketGenerator,
multisensor_6_state,
) -> None:
"""Test the subscribe_new_devices websocket command."""
entry = integration
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id(
{
TYPE: "zwave_js/subscribe_new_devices",
ENTRY_ID: entry.entry_id,
}
)
msg = await ws_client.receive_json()
assert msg["success"]
assert msg["result"] is None
# Simulate a device being registered
node = Node(client, deepcopy(multisensor_6_state))
client.driver.controller.emit("node added", {"node": node})
await hass.async_block_till_done()
# Verify we receive the expected message
msg = await ws_client.receive_json()
assert msg["type"] == "event"
assert msg["event"]["event"] == "device registered"
assert msg["event"]["device"]["name"] == node.device_config.description
assert msg["event"]["device"]["manufacturer"] == node.device_config.manufacturer
assert msg["event"]["device"]["model"] == node.device_config.label

View File

@ -1,17 +1,27 @@
"""Test the Z-Wave JS helpers module."""
import voluptuous as vol
from unittest.mock import patch
import pytest
import voluptuous as vol
from zwave_js_server.const import SecurityClass
from zwave_js_server.model.controller import ProvisioningEntry
from homeassistant.components.zwave_js.const import DOMAIN
from homeassistant.components.zwave_js.helpers import (
async_get_node_status_sensor_entity_id,
async_get_nodes_from_area_id,
async_get_provisioning_entry_from_device_id,
get_value_state_schema,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar, device_registry as dr
from tests.common import MockConfigEntry
CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller"
async def test_async_get_node_status_sensor_entity_id(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
@ -43,3 +53,82 @@ async def test_get_value_state_schema_boolean_config_value(
)
assert isinstance(schema_validator, vol.Coerce)
assert schema_validator.type is bool
async def test_async_get_provisioning_entry_from_device_id(
hass: HomeAssistant, client, device_registry: dr.DeviceRegistry, integration
) -> None:
"""Test async_get_provisioning_entry_from_device_id function."""
device = device_registry.async_get_or_create(
config_entry_id=integration.entry_id,
identifiers={(DOMAIN, "test-device")},
)
provisioning_entry = ProvisioningEntry.from_dict(
{
"dsk": "test",
"securityClasses": [SecurityClass.S2_UNAUTHENTICATED],
"device_id": device.id,
}
)
with patch(
f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries",
return_value=[provisioning_entry],
):
result = await async_get_provisioning_entry_from_device_id(hass, device.id)
assert result == provisioning_entry
# Test invalid device
with pytest.raises(ValueError, match="Device ID not-a-real-device is not valid"):
await async_get_provisioning_entry_from_device_id(hass, "not-a-real-device")
# Test device exists but is not from a zwave_js config entry
non_zwave_config_entry = MockConfigEntry(domain="not_zwave_js")
non_zwave_config_entry.add_to_hass(hass)
non_zwave_device = device_registry.async_get_or_create(
config_entry_id=non_zwave_config_entry.entry_id,
identifiers={("not_zwave_js", "test-device")},
)
with pytest.raises(
ValueError,
match=f"Device {non_zwave_device.id} is not from an existing zwave_js config entry",
):
await async_get_provisioning_entry_from_device_id(hass, non_zwave_device.id)
# Test device exists but config entry is not loaded
not_loaded_config_entry = MockConfigEntry(
domain=DOMAIN, state=ConfigEntryState.NOT_LOADED
)
not_loaded_config_entry.add_to_hass(hass)
not_loaded_device = device_registry.async_get_or_create(
config_entry_id=not_loaded_config_entry.entry_id,
identifiers={(DOMAIN, "not-loaded-device")},
)
with pytest.raises(
ValueError, match=f"Device {not_loaded_device.id} config entry is not loaded"
):
await async_get_provisioning_entry_from_device_id(hass, not_loaded_device.id)
# Test no matching provisioning entry
with patch(
f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries",
return_value=[],
):
result = await async_get_provisioning_entry_from_device_id(hass, device.id)
assert result is None
# Test multiple provisioning entries but only one matches
other_provisioning_entry = ProvisioningEntry.from_dict(
{
"dsk": "other",
"securityClasses": [SecurityClass.S2_UNAUTHENTICATED],
"device_id": "other-id",
}
)
with patch(
f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries",
return_value=[other_provisioning_entry, provisioning_entry],
):
result = await async_get_provisioning_entry_from_device_id(hass, device.id)
assert result == provisioning_entry

View File

@ -11,12 +11,14 @@ from aiohasupervisor import SupervisorError
from aiohasupervisor.models import AddonsOptions
import pytest
from zwave_js_server.client import Client
from zwave_js_server.const import SecurityClass
from zwave_js_server.event import Event
from zwave_js_server.exceptions import (
BaseZwaveJSServerError,
InvalidServerVersion,
NotConnected,
)
from zwave_js_server.model.controller import ProvisioningEntry
from zwave_js_server.model.node import Node, NodeDataType
from zwave_js_server.model.version import VersionInfo
@ -24,7 +26,7 @@ from homeassistant.components.hassio import HassioAPIError
from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL
from homeassistant.components.persistent_notification import async_dismiss
from homeassistant.components.zwave_js import DOMAIN
from homeassistant.components.zwave_js.helpers import get_device_id
from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import CoreState, HomeAssistant
@ -45,6 +47,8 @@ from tests.common import (
)
from tests.typing import WebSocketGenerator
CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller"
@pytest.fixture(name="connect_timeout")
def connect_timeout_fixture() -> Generator[int]:
@ -277,10 +281,13 @@ async def test_listen_done_during_setup_after_forward_entry(
"""Test listen task finishing during setup after forward entry."""
assert hass.state is CoreState.running
original_send_command_side_effect = client.async_send_command.side_effect
async def send_command_side_effect(*args: Any, **kwargs: Any) -> None:
"""Mock send command."""
listen_block.set()
getattr(listen_result, listen_future_result_method)(listen_future_result)
client.async_send_command.side_effect = original_send_command_side_effect
# Yield to allow the listen task to run
await asyncio.sleep(0)
@ -427,6 +434,46 @@ async def test_on_node_added_ready(
)
async def test_on_node_added_preprovisioned(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
multisensor_6_state,
client,
integration,
) -> None:
"""Test node added event with a preprovisioned device."""
dsk = "test"
node = Node(client, deepcopy(multisensor_6_state))
device = device_registry.async_get_or_create(
config_entry_id=integration.entry_id,
identifiers={(DOMAIN, f"provision_{dsk}")},
)
provisioning_entry = ProvisioningEntry.from_dict(
{
"dsk": dsk,
"securityClasses": [SecurityClass.S2_UNAUTHENTICATED],
"device_id": device.id,
}
)
with patch(
f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entry",
side_effect=lambda id: provisioning_entry if id == node.node_id else None,
):
event = {"node": node}
client.driver.controller.emit("node added", event)
await hass.async_block_till_done()
device = device_registry.async_get(device.id)
assert device
assert device.identifiers == {
get_device_id(client.driver, node),
get_device_id_ext(client.driver, node),
}
assert device.sw_version == node.firmware_version
# There should only be the controller and the preprovisioned device
assert len(device_registry.devices) == 2
@pytest.mark.usefixtures("integration")
async def test_on_node_added_not_ready(
hass: HomeAssistant,
@ -2045,7 +2092,14 @@ async def test_server_logging(hass: HomeAssistant, client: MagicMock) -> None:
# is enabled
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(client.async_send_command.call_args_list) == 0
assert len(client.async_send_command.call_args_list) == 2
assert client.async_send_command.call_args_list[0][0][0] == {
"command": "controller.get_provisioning_entries",
}
assert client.async_send_command.call_args_list[1][0][0] == {
"command": "controller.get_provisioning_entry",
"dskOrNodeId": 1,
}
assert not client.enable_server_logging.called
assert not client.disable_server_logging.called

View File

@ -123,7 +123,7 @@ async def test_number_writeable(
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 2
assert len(client.async_send_command.call_args_list) == 5
args = client.async_send_command.call_args[0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == 4

View File

@ -324,12 +324,12 @@ async def test_update_entity_ha_not_running(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(client.async_send_command.call_args_list) == 1
assert len(client.async_send_command.call_args_list) == 4
await hass.async_start()
await hass.async_block_till_done()
assert len(client.async_send_command.call_args_list) == 1
assert len(client.async_send_command.call_args_list) == 4
# Update should be delayed by a day because HA is not running
hass.set_state(CoreState.starting)
@ -337,15 +337,15 @@ async def test_update_entity_ha_not_running(
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5))
await hass.async_block_till_done()
assert len(client.async_send_command.call_args_list) == 1
assert len(client.async_send_command.call_args_list) == 4
hass.set_state(CoreState.running)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1))
await hass.async_block_till_done()
assert len(client.async_send_command.call_args_list) == 2
args = client.async_send_command.call_args_list[1][0][0]
assert len(client.async_send_command.call_args_list) == 5
args = client.async_send_command.call_args_list[4][0][0]
assert args["command"] == "controller.get_available_firmware_updates"
assert args["nodeId"] == zen_31.node_id
@ -651,12 +651,12 @@ async def test_update_entity_delay(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(client.async_send_command.call_args_list) == 2
assert len(client.async_send_command.call_args_list) == 6
await hass.async_start()
await hass.async_block_till_done()
assert len(client.async_send_command.call_args_list) == 2
assert len(client.async_send_command.call_args_list) == 6
update_interval = timedelta(minutes=5)
freezer.tick(update_interval)
@ -665,8 +665,8 @@ async def test_update_entity_delay(
nodes: set[int] = set()
assert len(client.async_send_command.call_args_list) == 3
args = client.async_send_command.call_args_list[2][0][0]
assert len(client.async_send_command.call_args_list) == 7
args = client.async_send_command.call_args_list[6][0][0]
assert args["command"] == "controller.get_available_firmware_updates"
nodes.add(args["nodeId"])
@ -674,8 +674,8 @@ async def test_update_entity_delay(
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(client.async_send_command.call_args_list) == 4
args = client.async_send_command.call_args_list[3][0][0]
assert len(client.async_send_command.call_args_list) == 8
args = client.async_send_command.call_args_list[7][0][0]
assert args["command"] == "controller.get_available_firmware_updates"
nodes.add(args["nodeId"])
@ -846,8 +846,8 @@ async def test_update_entity_full_restore_data_update_available(
assert attrs[ATTR_IN_PROGRESS] is True
assert attrs[ATTR_UPDATE_PERCENTAGE] is None
assert len(client.async_send_command.call_args_list) == 2
assert client.async_send_command.call_args_list[1][0][0] == {
assert len(client.async_send_command.call_args_list) == 5
assert client.async_send_command.call_args_list[4][0][0] == {
"command": "controller.firmware_update_ota",
"nodeId": climate_radio_thermostat_ct100_plus_different_endpoints.node_id,
"updateInfo": {