Compare commits

...

12 Commits

Author SHA1 Message Date
Abílio Costa
f9bb7e404e
Improve Whirlpool config flow test completeness and naming (#143118) 2025-04-17 13:40:57 +01:00
Josef Zweck
bbb8a1bacc
Migrate lamarzocco to pylamarzocco 2.0.0 (#142098)
* Migrate lamarzocco to pylamarzocco 2.0.0

* bump manifest

* Remove CONF_TOKEN

* remove icons

* Rename coordiantor

* use none for token

* Bump version

* Move first get settings

* remove sensor snapshots

* Change iot_class from cloud_polling to cloud_push

* Update integrations.json

* Re-add release url

* Remove extra icon, fix native step

* fomat

* Rename const

* review comments

* Update tests/components/lamarzocco/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* add unique id check

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-04-17 13:34:06 +02:00
Petar Petrov
0aaa4fa79b
Create empty Z-Wave JS device on smart start provisioning (#140872)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-04-17 13:18:48 +02:00
Paul Bottein
4ed81fb03f
Use firmware name from device class for matter update entity (#143140)
* Use firmware name from device class for matter update entity

* Update tests
2025-04-17 12:50:10 +02:00
Åke Strandberg
7d13c2d854
Add miele diagnostics platform (#142900) 2025-04-17 11:42:07 +02:00
Petar Petrov
cadbb623c7
New ZWave-JS migration flow (#142717)
* ZwaveJS radio migration flow

* Partial migration flow

* basic migration flow

* report exact progress to frontend

* Display backup file path

* string tweak

* update tests

* improve exception handling

* radio -> controller

* test tweak

* test tweak

* clean up and test error handling

* more tests

* test progress

* PR comments

* fix tests

* test restore progress

* more coverage

* coverage

* coverage

* make mypy happy

* PR comments

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* ruff

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-04-17 10:14:47 +02:00
J. Nick Koston
4d959fb91c
Bump esphome-dashboard-api to 1.3.0 (#143128) 2025-04-17 09:57:55 +02:00
J. Nick Koston
1fb3d8d601
Bump habluetooth to 3.39.0 (#143125) 2025-04-17 09:56:38 +02:00
J. Nick Koston
dd4334e3ba
Bump yarl to 1.20.0 (#143124) 2025-04-17 09:55:30 +02:00
Sid
5eee47d1e4
Bump eheimdigital to 1.1.0 (#143138) 2025-04-17 09:44:40 +02:00
Arjan
54def1ae0e
Meteofrance: adding new states provided by MF API since mid April (#143137) 2025-04-17 08:47:37 +02:00
J. Nick Koston
6a36fc75cf
Fix flakey ESPHome dashboard tests (attempt 2) (#143123)
These tests do not need a config entry, only the integration
to be set up. Since I cannot replicate the issue locally after
1000 runs, I switched it to use async_setup_component to minimize
the potential problem area and hopefully fix the flakey test

I also modified the test to explictly set up hassio to ensure
the patch is effective since we have to patch a late import

last observed flake: https://github.com/home-assistant/core/actions/runs/14503715101/job/40689452294?pr=143106
2025-04-17 08:36:34 +02:00
72 changed files with 5165 additions and 4636 deletions

View File

@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.4.5",
"bluetooth-data-tools==1.27.0",
"dbus-fast==2.43.0",
"habluetooth==3.38.1"
"habluetooth==3.39.0"
]
}

View File

@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "bronze",
"requirements": ["eheimdigital==1.0.6"],
"requirements": ["eheimdigital==1.1.0"],
"zeroconf": [
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
]

View File

@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"requirements": [
"aioesphomeapi==30.0.1",
"esphome-dashboard-api==1.2.3",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==2.13.1"
],
"zeroconf": ["_esphomelib._tcp.local."]

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

@ -74,6 +74,7 @@ CONDITION_CLASSES: dict[str, list[str]] = {
"Pluie modérée",
"Pluie / Averses",
"Averses",
"Averses faibles",
"Pluie",
],
ATTR_CONDITION_SNOWY: [
@ -81,10 +82,11 @@ CONDITION_CLASSES: dict[str, list[str]] = {
"Neige",
"Averses de neige",
"Neige forte",
"Neige faible",
"Quelques flocons",
],
ATTR_CONDITION_SNOWY_RAINY: ["Pluie et neige", "Pluie verglaçante"],
ATTR_CONDITION_SUNNY: ["Ensoleillé"],
ATTR_CONDITION_SUNNY: ["Ensoleillé", "Ciel clair"],
ATTR_CONDITION_WINDY: [],
ATTR_CONDITION_WINDY_VARIANT: [],
ATTR_CONDITION_EXCEPTIONAL: [],

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

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

@ -4,12 +4,17 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from datetime import datetime
import logging
from pathlib import Path
from typing import Any
import aiohttp
from serial.tools import list_ports
import voluptuous as vol
from zwave_js_server.client import Client
from zwave_js_server.exceptions import FailedCommand
from zwave_js_server.model.driver import Driver
from zwave_js_server.version import VersionInfo, get_server_version
from homeassistant.components import usb
@ -23,6 +28,7 @@ from homeassistant.config_entries import (
SOURCE_USB,
ConfigEntry,
ConfigEntryBaseFlow,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@ -60,6 +66,7 @@ from .const import (
CONF_S2_UNAUTHENTICATED_KEY,
CONF_USB_PATH,
CONF_USE_ADDON,
DATA_CLIENT,
DOMAIN,
)
@ -74,6 +81,9 @@ CONF_EMULATE_HARDWARE = "emulate_hardware"
CONF_LOG_LEVEL = "log_level"
SERVER_VERSION_TIMEOUT = 10
OPTIONS_INTENT_MIGRATE = "intent_migrate"
OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure"
ADDON_LOG_LEVELS = {
"error": "Error",
"warn": "Warn",
@ -636,7 +646,12 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
}
if not self._usb_discovery:
ports = await async_get_usb_ports(self.hass)
try:
ports = await async_get_usb_ports(self.hass)
except OSError as err:
_LOGGER.error("Failed to get USB ports: %s", err)
return self.async_abort(reason="usb_ports_failed")
schema = {
vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports),
**schema,
@ -717,6 +732,10 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
super().__init__()
self.original_addon_config: dict[str, Any] | None = None
self.revert_reason: str | None = None
self.backup_task: asyncio.Task | None = None
self.restore_backup_task: asyncio.Task | None = None
self.backup_data: bytes | None = None
self.backup_filepath: str | None = None
@callback
def _async_update_entry(self, data: dict[str, Any]) -> None:
@ -725,6 +744,18 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm if we are migrating adapters or just re-configuring."""
return self.async_show_menu(
step_id="init",
menu_options=[
OPTIONS_INTENT_RECONFIGURE,
OPTIONS_INTENT_MIGRATE,
],
)
async def async_step_intent_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if is_hassio(self.hass):
@ -732,6 +763,91 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
return await self.async_step_manual()
async def async_step_intent_migrate(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the user wants to reset their current controller."""
if not self.config_entry.data.get(CONF_USE_ADDON):
return self.async_abort(reason="addon_required")
if user_input is not None:
return await self.async_step_backup_nvm()
return self.async_show_form(step_id="intent_migrate")
async def async_step_backup_nvm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Backup the current network."""
if self.backup_task is None:
self.backup_task = self.hass.async_create_task(self._async_backup_network())
if not self.backup_task.done():
return self.async_show_progress(
step_id="backup_nvm",
progress_action="backup_nvm",
progress_task=self.backup_task,
)
try:
await self.backup_task
except AbortFlow as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="backup_failed")
finally:
self.backup_task = None
return self.async_show_progress_done(next_step_id="instruct_unplug")
async def async_step_restore_nvm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Restore the backup."""
if self.restore_backup_task is None:
self.restore_backup_task = self.hass.async_create_task(
self._async_restore_network_backup()
)
if not self.restore_backup_task.done():
return self.async_show_progress(
step_id="restore_nvm",
progress_action="restore_nvm",
progress_task=self.restore_backup_task,
)
try:
await self.restore_backup_task
except AbortFlow as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="restore_failed")
finally:
self.restore_backup_task = None
return self.async_show_progress_done(next_step_id="migration_done")
async def async_step_instruct_unplug(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reset the current controller, and instruct the user to unplug it."""
if user_input is not None:
# Now that the old controller is gone, we can scan for serial ports again
return await self.async_step_choose_serial_port()
# reset the old controller
try:
await self._get_driver().async_hard_reset()
except FailedCommand as err:
_LOGGER.error("Failed to reset controller: %s", err)
return self.async_abort(reason="reset_failed")
return self.async_show_form(
step_id="instruct_unplug",
description_placeholders={
"file_path": str(self.backup_filepath),
},
)
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -881,7 +997,11 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info")
emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False)
ports = await async_get_usb_ports(self.hass)
try:
ports = await async_get_usb_ports(self.hass)
except OSError as err:
_LOGGER.error("Failed to get USB ports: %s", err)
return self.async_abort(reason="usb_ports_failed")
data_schema = vol.Schema(
{
@ -911,12 +1031,64 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
return self.async_show_form(step_id="configure_addon", data_schema=data_schema)
async def async_step_choose_serial_port(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Choose a serial port."""
if user_input is not None:
addon_info = await self._async_get_addon_info()
addon_config = addon_info.options
self.usb_path = user_input[CONF_USB_PATH]
new_addon_config = {
**addon_config,
CONF_ADDON_DEVICE: self.usb_path,
}
if addon_info.state == AddonState.RUNNING:
self.restart_addon = True
# Copy the add-on config to keep the objects separate.
self.original_addon_config = dict(addon_config)
await self._async_set_addon_config(new_addon_config)
return await self.async_step_start_addon()
try:
ports = await async_get_usb_ports(self.hass)
except OSError as err:
_LOGGER.error("Failed to get USB ports: %s", err)
return self.async_abort(reason="usb_ports_failed")
data_schema = vol.Schema(
{
vol.Required(CONF_USB_PATH): vol.In(ports),
}
)
return self.async_show_form(
step_id="choose_serial_port", data_schema=data_schema
)
async def async_step_start_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Add-on start failed."""
return await self.async_revert_addon_config(reason="addon_start_failed")
async def async_step_backup_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Backup failed."""
return self.async_abort(reason="backup_failed")
async def async_step_restore_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Restore failed."""
return self.async_abort(reason="restore_failed")
async def async_step_migration_done(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Migration done."""
return self.async_create_entry(title=TITLE, data={})
async def async_step_finish_addon_setup(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@ -943,12 +1115,16 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
except CannotConnect:
return await self.async_revert_addon_config(reason="cannot_connect")
if self.config_entry.unique_id != str(self.version_info.home_id):
if self.backup_data is None and self.config_entry.unique_id != str(
self.version_info.home_id
):
return await self.async_revert_addon_config(reason="different_device")
self._async_update_entry(
{
**self.config_entry.data,
# this will only be different in a migration flow
"unique_id": str(self.version_info.home_id),
CONF_URL: self.ws_address,
CONF_USB_PATH: self.usb_path,
CONF_S0_LEGACY_KEY: self.s0_legacy_key,
@ -961,6 +1137,9 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon,
}
)
if self.backup_data:
return await self.async_step_restore_nvm()
# Always reload entry since we may have disconnected the client.
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
return self.async_create_entry(title=TITLE, data={})
@ -990,6 +1169,74 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
_LOGGER.debug("Reverting add-on options, reason: %s", reason)
return await self.async_step_configure_addon(addon_config_input)
async def _async_backup_network(self) -> None:
"""Backup the current network."""
@callback
def forward_progress(event: dict) -> None:
"""Forward progress events to frontend."""
self.async_update_progress(event["bytesRead"] / event["total"])
controller = self._get_driver().controller
unsub = controller.on("nvm backup progress", forward_progress)
try:
self.backup_data = await controller.async_backup_nvm_raw()
except FailedCommand as err:
raise AbortFlow(f"Failed to backup network: {err}") from err
finally:
unsub()
# save the backup to a file just in case
self.backup_filepath = self.hass.config.path(
f"zwavejs_nvm_backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.bin"
)
try:
await self.hass.async_add_executor_job(
Path(self.backup_filepath).write_bytes,
self.backup_data,
)
except OSError as err:
raise AbortFlow(f"Failed to save backup file: {err}") from err
async def _async_restore_network_backup(self) -> None:
"""Restore the backup."""
assert self.backup_data is not None
# Reload the config entry to reconnect the client after the addon restart
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
@callback
def forward_progress(event: dict) -> None:
"""Forward progress events to frontend."""
if event["event"] == "nvm convert progress":
# assume convert is 50% of the total progress
self.async_update_progress(event["bytesRead"] / event["total"] * 0.5)
elif event["event"] == "nvm restore progress":
# assume restore is the rest of the progress
self.async_update_progress(
event["bytesWritten"] / event["total"] * 0.5 + 0.5
)
controller = self._get_driver().controller
unsubs = [
controller.on("nvm convert progress", forward_progress),
controller.on("nvm restore progress", forward_progress),
]
try:
await controller.async_restore_nvm(self.backup_data)
except FailedCommand as err:
raise AbortFlow(f"Failed to restore network: {err}") from err
finally:
for unsub in unsubs:
unsub()
def _get_driver(self) -> Driver:
if self.config_entry.state != ConfigEntryState.LOADED:
raise AbortFlow("Configuration entry is not loaded")
client: Client = self.config_entry.runtime_data[DATA_CLIENT]
assert client.driver is not None
return client.driver
class CannotConnect(HomeAssistantError):
"""Indicate connection error."""

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

@ -11,7 +11,11 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"discovery_requires_supervisor": "Discovery requires the supervisor.",
"not_zwave_device": "Discovered device is not a Z-Wave device.",
"not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on."
"not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.",
"backup_failed": "Failed to backup network.",
"restore_failed": "Failed to restore network.",
"reset_failed": "Failed to reset controller.",
"usb_ports_failed": "Failed to get USB devices."
},
"error": {
"addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.",
@ -22,7 +26,9 @@
"flow_title": "{name}",
"progress": {
"install_addon": "Please wait while the Z-Wave add-on installation finishes. This can take several minutes.",
"start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds."
"start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds.",
"backup_nvm": "Please wait while the network backup completes.",
"restore_nvm": "Please wait while the network restore completes."
},
"step": {
"configure_addon": {
@ -217,7 +223,12 @@
"addon_stop_failed": "Failed to stop the Z-Wave add-on.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device."
"different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.",
"addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor add-on. You can still use the Backup and Restore buttons to migrate your network manually.",
"backup_failed": "[%key:component::zwave_js::config::abort::backup_failed%]",
"restore_failed": "[%key:component::zwave_js::config::abort::restore_failed%]",
"reset_failed": "[%key:component::zwave_js::config::abort::reset_failed%]",
"usb_ports_failed": "[%key:component::zwave_js::config::abort::usb_ports_failed%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@ -226,9 +237,27 @@
},
"progress": {
"install_addon": "[%key:component::zwave_js::config::progress::install_addon%]",
"start_addon": "[%key:component::zwave_js::config::progress::start_addon%]"
"start_addon": "[%key:component::zwave_js::config::progress::start_addon%]",
"backup_nvm": "[%key:component::zwave_js::config::progress::backup_nvm%]",
"restore_nvm": "[%key:component::zwave_js::config::progress::restore_nvm%]"
},
"step": {
"init": {
"title": "Migrate or re-configure",
"description": "Are you migrating to a new controller or re-configuring the current controller?",
"menu_options": {
"intent_migrate": "Migrate to a new controller",
"intent_reconfigure": "Re-configure the current controller"
}
},
"intent_migrate": {
"title": "[%key:component::zwave_js::options::step::init::menu_options::intent_migrate%]",
"description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?"
},
"instruct_unplug": {
"title": "Unplug your old controller",
"description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing."
},
"configure_addon": {
"data": {
"emulate_hardware": "Emulate Hardware",
@ -242,6 +271,12 @@
"description": "[%key:component::zwave_js::config::step::configure_addon::description%]",
"title": "[%key:component::zwave_js::config::step::configure_addon::title%]"
},
"choose_serial_port": {
"data": {
"usb_path": "[%key:common::config_flow::data::usb_path%]"
},
"title": "Select your Z-Wave device"
},
"install_addon": {
"title": "[%key:component::zwave_js::config::step::install_addon::title%]"
},

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

@ -34,7 +34,7 @@ dbus-fast==2.43.0
fnv-hash-fast==1.4.0
go2rtc-client==0.1.2
ha-ffmpeg==3.2.2
habluetooth==3.38.1
habluetooth==3.39.0
hass-nabucasa==0.94.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
@ -74,7 +74,7 @@ voluptuous-openapi==0.0.6
voluptuous-serialize==2.6.0
voluptuous==0.15.2
webrtc-models==0.3.0
yarl==1.19.0
yarl==1.20.0
zeroconf==0.146.5
# Constrain pycryptodome to avoid vulnerability

View File

@ -121,7 +121,7 @@ dependencies = [
"voluptuous==0.15.2",
"voluptuous-serialize==2.6.0",
"voluptuous-openapi==0.0.6",
"yarl==1.19.0",
"yarl==1.20.0",
"webrtc-models==0.3.0",
"zeroconf==0.146.5",
]

2
requirements.txt generated
View File

@ -58,6 +58,6 @@ uv==0.6.10
voluptuous==0.15.2
voluptuous-serialize==2.6.0
voluptuous-openapi==0.0.6
yarl==1.19.0
yarl==1.20.0
webrtc-models==0.3.0
zeroconf==0.146.5

8
requirements_all.txt generated
View File

@ -829,7 +829,7 @@ ebusdpy==0.0.17
ecoaliface==0.4.0
# homeassistant.components.eheimdigital
eheimdigital==1.0.6
eheimdigital==1.1.0
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
@ -889,7 +889,7 @@ epson-projector==0.5.1
eq3btsmart==1.4.1
# homeassistant.components.esphome
esphome-dashboard-api==1.2.3
esphome-dashboard-api==1.3.0
# homeassistant.components.netgear_lte
eternalegypt==0.0.16
@ -1114,7 +1114,7 @@ ha-silabs-firmware-client==0.2.0
habiticalib==0.3.7
# homeassistant.components.bluetooth
habluetooth==3.38.1
habluetooth==3.39.0
# homeassistant.components.cloud
hass-nabucasa==0.94.0
@ -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

@ -708,7 +708,7 @@ eagle100==0.1.1
easyenergy==2.1.2
# homeassistant.components.eheimdigital
eheimdigital==1.0.6
eheimdigital==1.1.0
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
@ -759,7 +759,7 @@ epson-projector==0.5.1
eq3btsmart==1.4.1
# homeassistant.components.esphome
esphome-dashboard-api==1.2.3
esphome-dashboard-api==1.3.0
# homeassistant.components.netgear_lte
eternalegypt==0.0.16
@ -956,7 +956,7 @@ ha-silabs-firmware-client==0.2.0
habiticalib==0.3.7
# homeassistant.components.bluetooth
habluetooth==3.38.1
habluetooth==3.39.0
# homeassistant.components.cloud
hass-nabucasa==0.94.0
@ -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

@ -6,10 +6,16 @@ from unittest.mock import patch
from aioesphomeapi import DeviceInfo, InvalidAuthAPIError
import pytest
from homeassistant.components.esphome import CONF_NOISE_PSK, coordinator, dashboard
from homeassistant.components.esphome import (
CONF_NOISE_PSK,
DOMAIN,
coordinator,
dashboard,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.setup import async_setup_component
from . import VALID_NOISE_PSK
@ -34,7 +40,6 @@ async def test_dashboard_storage(
async def test_restore_dashboard_storage(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_storage: dict[str, Any],
) -> None:
"""Restore dashboard url and slug from storage."""
@ -47,14 +52,13 @@ async def test_restore_dashboard_storage(
with patch.object(
dashboard, "async_get_or_create_dashboard_manager"
) as mock_get_or_create:
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert mock_get_or_create.call_count == 1
async def test_restore_dashboard_storage_end_to_end(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_storage: dict[str, Any],
) -> None:
"""Restore dashboard url and slug from storage."""
@ -72,15 +76,13 @@ async def test_restore_dashboard_storage_end_to_end(
"homeassistant.components.esphome.coordinator.ESPHomeDashboardAPI"
) as mock_dashboard_api,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_dashboard_api.mock_calls[0][1][0] == "http://new-host:6052"
async def test_restore_dashboard_storage_skipped_if_addon_uninstalled(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_storage: dict[str, Any],
caplog: pytest.LogCaptureFixture,
) -> None:
@ -103,27 +105,25 @@ async def test_restore_dashboard_storage_skipped_if_addon_uninstalled(
return_value={},
),
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await async_setup_component(hass, "hassio", {})
await hass.async_block_till_done()
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.async_block_till_done() # wait for dashboard setup
assert "test-slug is no longer installed" in caplog.text
assert not mock_dashboard_api.called
async def test_setup_dashboard_fails(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_storage: dict[str, Any],
) -> None:
"""Test that nothing is stored on failed dashboard setup when there was no dashboard before."""
with patch.object(
coordinator.ESPHomeDashboardAPI, "get_devices", side_effect=TimeoutError
) as mock_get_devices:
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await dashboard.async_set_dashboard_info(hass, "test-slug", "test-host", 6052)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_get_devices.call_count == 1
# The dashboard addon might recover later so we still

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,28 +1,25 @@
"""Lamarzocco session fixtures."""
from collections.abc import Generator
import json
from unittest.mock import AsyncMock, 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
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture
@ -42,33 +39,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 +60,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 +79,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
@ -35,26 +32,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 +64,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 +72,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
@ -35,8 +28,8 @@ from tests.common import MockConfigEntry
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,39 +43,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()
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{CONF_MACHINE: "GS012345"},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "GS3"
assert result3["title"] == "GS012345"
assert result3["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 result3["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."""
@ -94,13 +76,12 @@ async def test_form(
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)
await __do_sucessful_machine_selection_step(hass, result2)
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."""
@ -124,8 +105,7 @@ async def test_form_abort_already_configured(
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_device_info.serial_number,
CONF_MACHINE: "GS012345",
},
)
await hass.async_block_till_done()
@ -134,15 +114,23 @@ async def test_form_abort_already_configured(
assert result3["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}
)
@ -153,67 +141,24 @@ async def test_form_invalid_auth(
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
assert len(mock_cloud_client.get_customer_fleet.mock_calls) == 1
assert result2["errors"] == {"base": error}
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.list_things.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)
await __do_sucessful_machine_selection_step(hass, result2)
async def test_form_invalid_host(
async def test_form_no_machines(
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"] == {}
"""Test we don't have any devices."""
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
# test recovery from failure
await __do_sucessful_machine_selection_step(hass, result2, mock_device_info)
async def test_form_cannot_connect(
hass: HomeAssistant,
mock_cloud_client: MagicMock,
mock_device_info: LaMarzoccoDeviceInfo,
mock_setup_entry: Generator[AsyncMock],
) -> None:
"""Test cannot connect error."""
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}
@ -226,25 +171,13 @@ async def test_form_cannot_connect(
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 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
}
mock_cloud_client.list_things.return_value = original_return
result2 = await __do_successful_user_step(hass, result, mock_cloud_client)
await __do_sucessful_machine_selection_step(hass, result2, mock_device_info)
await __do_sucessful_machine_selection_step(hass, result2)
async def test_reauth_flow(
@ -269,7 +202,7 @@ async def test_reauth_flow(
assert result2["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 len(mock_cloud_client.list_things.mock_calls) == 1
assert mock_config_entry.data[CONF_PASSWORD] == "new_password"
@ -277,7 +210,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."""
@ -289,15 +221,9 @@ async def test_reconfigure_flow(
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
)
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],
@ -306,8 +232,7 @@ async def test_reconfigure_flow(
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_HOST: "192.168.1.1",
CONF_MACHINE: mock_device_info.serial_number,
CONF_MACHINE: "GS012345",
},
)
await hass.async_block_till_done()
@ -338,8 +263,10 @@ async def test_bluetooth_discovery(
) -> 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
)
@ -351,33 +278,13 @@ async def test_bluetooth_discovery(
result["flow_id"],
USER_INPUT,
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "machine_selection"
assert result2["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 result2["title"] == "GS012345"
assert result2["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",
}
@ -392,7 +299,7 @@ async def test_bluetooth_discovery_already_configured(
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 +312,11 @@ 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,61 +327,37 @@ 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", ""}
original_return = deepcopy(mock_cloud_client.list_things.return_value)
mock_cloud_client.list_things.return_value[0].serial_number = "GS98765"
result2 = 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 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
}
mock_cloud_client.list_things.return_value = original_return
result2 = 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 result2["type"] is FlowResultType.CREATE_ENTRY
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "GS3"
assert result3["data"] == {
assert result2["title"] == "GS012345"
assert result2["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,24 +375,16 @@ 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,
}
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_TOKEN: None,
}
async def test_dhcp_discovery_abort_on_hostname_changed(
@ -541,7 +415,6 @@ async def test_dhcp_already_configured_and_update(
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,9 +430,6 @@ 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"

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,7 +29,7 @@ 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(
@ -54,25 +54,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 +109,54 @@ 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_cloud_client: MagicMock,
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 +168,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 +199,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 +216,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 +233,38 @@ 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 +273,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 +312,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
@ -47,7 +48,7 @@ async def test_switches(
(
"_smart_standby_enabled",
"set_smart_standby",
{"mode": "LastBrewing", "minutes": 10},
{"mode": SmartStandByType.POWER_ON, "minutes": 10},
),
],
)
@ -124,12 +125,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 +144,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 +187,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
@ -31,19 +30,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 +45,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

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

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

@ -13,18 +13,23 @@ from aiohasupervisor.models import AddonsOptions, Discovery
import aiohttp
import pytest
from serial.tools.list_ports_common import ListPortInfo
from zwave_js_server.exceptions import FailedCommand
from zwave_js_server.version import VersionInfo
from homeassistant import config_entries
from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE
from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.zwave_js.config_flow import (
SERVER_VERSION_TIMEOUT,
TITLE,
OptionsFlowHandler,
)
from homeassistant.components.zwave_js.const import ADDON_SLUG, CONF_USB_PATH, DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_capture_events
ADDON_DISCOVERY_INFO = {
"addon": "Z-Wave JS",
@ -229,18 +234,48 @@ async def slow_server_version(*args):
@pytest.mark.parametrize(
("flow", "flow_params"),
("url", "server_version_side_effect", "server_version_timeout", "error"),
[
(
"flow",
lambda entry: {
"handler": DOMAIN,
"context": {"source": config_entries.SOURCE_USER},
},
"not-ws-url",
None,
SERVER_VERSION_TIMEOUT,
"invalid_ws_url",
),
(
"ws://localhost:3000",
slow_server_version,
0,
"cannot_connect",
),
(
"ws://localhost:3000",
Exception("Boom"),
SERVER_VERSION_TIMEOUT,
"unknown",
),
("options", lambda entry: {"handler": entry.entry_id}),
],
)
async def test_manual_errors(hass: HomeAssistant, integration, url, error) -> None:
"""Test all errors with a manual set up."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"url": url,
},
)
assert result["step_id"] == "manual"
assert result["errors"] == {"base": error}
@pytest.mark.parametrize(
("url", "server_version_side_effect", "server_version_timeout", "error"),
[
@ -264,24 +299,28 @@ async def slow_server_version(*args):
),
],
)
async def test_manual_errors(
hass: HomeAssistant, integration, url, error, flow, flow_params
async def test_manual_errors_options_flow(
hass: HomeAssistant, integration, url, error
) -> None:
"""Test all errors with a manual set up."""
entry = integration
result = await getattr(hass.config_entries, flow).async_init(**flow_params(entry))
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
result = await getattr(hass.config_entries, flow).async_configure(
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{
"url": url,
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
assert result["errors"] == {"base": error}
@ -1717,6 +1756,32 @@ async def test_addon_installed_set_options_failure(
assert start_addon.call_count == 0
async def test_addon_installed_usb_ports_failure(
hass: HomeAssistant,
supervisor,
addon_installed,
) -> None:
"""Test usb ports failure when add-on is installed."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
with patch(
"homeassistant.components.zwave_js.config_flow.async_get_usb_ports",
side_effect=OSError("test_error"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"use_addon": True}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "usb_ports_failed"
@pytest.mark.parametrize(
"discovery_info",
[
@ -1972,6 +2037,13 @@ async def test_options_manual(hass: HomeAssistant, client, integration) -> None:
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
@ -1997,6 +2069,13 @@ async def test_options_manual_different_device(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
@ -2021,6 +2100,13 @@ async def test_options_not_addon(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2069,6 +2155,13 @@ async def test_options_not_addon_with_addon(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2129,6 +2222,13 @@ async def test_options_not_addon_with_addon_stop_fail(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2259,6 +2359,13 @@ async def test_options_addon_running(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2386,6 +2493,13 @@ async def test_options_addon_running_no_changes(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2559,6 +2673,13 @@ async def test_options_different_device(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2735,6 +2856,13 @@ async def test_options_addon_restart_failed(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2869,6 +2997,13 @@ async def test_options_addon_running_server_info_failure(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -2999,6 +3134,13 @@ async def test_options_addon_not_installed(
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
@ -3100,3 +3242,472 @@ async def test_zeroconf(hass: HomeAssistant) -> None:
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_options_migrate_no_addon(hass: HomeAssistant, integration) -> None:
"""Test migration flow fails when not using add-on."""
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="1234", data={**entry.data, "use_addon": False}
)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "addon_required"
@pytest.mark.parametrize(
"discovery_info",
[
[
Discovery(
addon="core_zwave_js",
service="zwave_js",
uuid=uuid4(),
config=ADDON_DISCOVERY_INFO,
)
]
],
)
async def test_options_migrate_with_addon(
hass: HomeAssistant,
client,
supervisor,
integration,
addon_running,
restart_addon,
set_addon_options,
get_addon_discovery_info,
) -> None:
"""Test migration flow with add-on."""
hass.config_entries.async_update_entry(
integration,
unique_id="1234",
data={
"url": "ws://localhost:3000",
"use_addon": True,
"usb_path": "/dev/ttyUSB0",
},
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm backup progress", {"bytesRead": 100, "total": 200}
)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
async def mock_restore_nvm(data: bytes):
client.driver.controller.emit(
"nvm convert progress",
{"event": "nvm convert progress", "bytesRead": 100, "total": 200},
)
await asyncio.sleep(0)
client.driver.controller.emit(
"nvm restore progress",
{"event": "nvm restore progress", "bytesWritten": 100, "total": 200},
)
client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm)
hass.config_entries.async_reload = AsyncMock()
events = async_capture_events(
hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE
)
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
assert len(events) == 1
assert events[0].data["progress"] == 0.5
events.clear()
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "choose_serial_port"
assert result["data_schema"].schema[CONF_USB_PATH]
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_USB_PATH: "/test",
},
)
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
assert set_addon_options.call_args == call(
"core_zwave_js", AddonsOptions(config={"device": "/test"})
)
await hass.async_block_till_done()
assert restart_addon.call_args == call("core_zwave_js")
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "restore_nvm"
await hass.async_block_till_done()
assert hass.config_entries.async_reload.called
assert client.driver.controller.async_restore_nvm.call_count == 1
assert len(events) == 2
assert events[0].data["progress"] == 0.25
assert events[1].data["progress"] == 0.75
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.CREATE_ENTRY
assert integration.data["url"] == "ws://host1:3001"
assert integration.data["usb_path"] == "/test"
assert integration.data["use_addon"] is True
async def test_options_migrate_backup_failure(
hass: HomeAssistant, integration, client
) -> None:
"""Test backup failure."""
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="1234", data={**entry.data, "use_addon": True}
)
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=FailedCommand("test_error", "unknown_error")
)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "backup_failed"
async def test_options_migrate_backup_file_failure(
hass: HomeAssistant, integration, client
) -> None:
"""Test backup file failure."""
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="1234", data={**entry.data, "use_addon": True}
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch(
"pathlib.Path.write_bytes", MagicMock(side_effect=OSError("test_error"))
):
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "backup_failed"
@pytest.mark.parametrize(
"discovery_info",
[
[
Discovery(
addon="core_zwave_js",
service="zwave_js",
uuid=uuid4(),
config=ADDON_DISCOVERY_INFO,
)
]
],
)
async def test_options_migrate_restore_failure(
hass: HomeAssistant,
client,
supervisor,
integration,
addon_running,
restart_addon,
set_addon_options,
get_addon_discovery_info,
) -> None:
"""Test restore failure."""
hass.config_entries.async_update_entry(
integration, unique_id="1234", data={**integration.data, "use_addon": True}
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
client.driver.controller.async_restore_nvm = AsyncMock(
side_effect=FailedCommand("test_error", "unknown_error")
)
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "choose_serial_port"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_USB_PATH: "/test",
},
)
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
await hass.async_block_till_done()
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "restore_nvm"
await hass.async_block_till_done()
assert client.driver.controller.async_restore_nvm.call_count == 1
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "restore_failed"
async def test_get_driver_failure(hass: HomeAssistant, integration, client) -> None:
"""Test get driver failure."""
handler = OptionsFlowHandler()
handler.hass = hass
handler._config_entry = integration
await hass.config_entries.async_unload(integration.entry_id)
with pytest.raises(data_entry_flow.AbortFlow):
await handler._get_driver()
async def test_hard_reset_failure(hass: HomeAssistant, integration, client) -> None:
"""Test hard reset failure."""
hass.config_entries.async_update_entry(
integration, unique_id="1234", data={**integration.data, "use_addon": True}
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
client.driver.async_hard_reset = AsyncMock(
side_effect=FailedCommand("test_error", "unknown_error")
)
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "reset_failed"
async def test_choose_serial_port_usb_ports_failure(
hass: HomeAssistant, integration, client
) -> None:
"""Test choose serial port usb ports failure."""
hass.config_entries.async_update_entry(
integration, unique_id="1234", data={**integration.data, "use_addon": True}
)
async def mock_backup_nvm_raw():
await asyncio.sleep(0)
return b"test_nvm_data"
client.driver.controller.async_backup_nvm_raw = AsyncMock(
side_effect=mock_backup_nvm_raw
)
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "intent_migrate"
result = await hass.config_entries.options.async_configure(result["flow_id"], {})
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "backup_nvm"
with patch("pathlib.Path.write_bytes", MagicMock()) as mock_file:
await hass.async_block_till_done()
assert client.driver.controller.async_backup_nvm_raw.call_count == 1
assert mock_file.call_count == 1
result = await hass.config_entries.options.async_configure(result["flow_id"])
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
with patch(
"homeassistant.components.zwave_js.config_flow.async_get_usb_ports",
side_effect=OSError("test_error"),
):
result = await hass.config_entries.options.async_configure(
result["flow_id"], {}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "usb_ports_failed"
async def test_configure_addon_usb_ports_failure(
hass: HomeAssistant, integration, addon_installed, supervisor
) -> None:
"""Test configure addon usb ports failure."""
result = await hass.config_entries.options.async_init(integration.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "on_supervisor"
with patch(
"homeassistant.components.zwave_js.config_flow.async_get_usb_ports",
side_effect=OSError("test_error"),
):
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"use_addon": True}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "usb_ports_failed"

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