Merge remote-tracking branch 'upstream/dev' into esphome_bronze

This commit is contained in:
J. Nick Koston 2025-04-17 22:48:32 -10:00
commit 0794bfbdcf
No known key found for this signature in database
163 changed files with 7066 additions and 5368 deletions

2
CODEOWNERS generated
View File

@ -432,7 +432,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/entur_public_transport/ @hfurubotten /homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50 /homeassistant/components/ephember/ @ttroy50 @roberty99
/homeassistant/components/epic_games_store/ @hacf-fr @Quentame /homeassistant/components/epic_games_store/ @hacf-fr @Quentame
/tests/components/epic_games_store/ @hacf-fr @Quentame /tests/components/epic_games_store/ @hacf-fr @Quentame
/homeassistant/components/epion/ @lhgravendeel /homeassistant/components/epion/ @lhgravendeel

View File

@ -120,6 +120,7 @@ class AppleTvMediaPlayer(
"""Initialize the Apple TV media player.""" """Initialize the Apple TV media player."""
super().__init__(name, identifier, manager) super().__init__(name, identifier, manager)
self._playing: Playing | None = None self._playing: Playing | None = None
self._playing_last_updated: datetime | None = None
self._app_list: dict[str, str] = {} self._app_list: dict[str, str] = {}
@callback @callback
@ -209,6 +210,7 @@ class AppleTvMediaPlayer(
This is a callback function from pyatv.interface.PushListener. This is a callback function from pyatv.interface.PushListener.
""" """
self._playing = playstatus self._playing = playstatus
self._playing_last_updated = dt_util.utcnow()
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
@ -316,7 +318,7 @@ class AppleTvMediaPlayer(
def media_position_updated_at(self) -> datetime | None: def media_position_updated_at(self) -> datetime | None:
"""Last valid time of media position.""" """Last valid time of media position."""
if self.state in {MediaPlayerState.PLAYING, MediaPlayerState.PAUSED}: if self.state in {MediaPlayerState.PLAYING, MediaPlayerState.PAUSED}:
return dt_util.utcnow() return self._playing_last_updated
return None return None
async def async_play_media( async def async_play_media(

View File

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

View File

@ -41,6 +41,7 @@ async def async_setup_entry(
DemoTVShowPlayer(), DemoTVShowPlayer(),
DemoBrowsePlayer("Browse"), DemoBrowsePlayer("Browse"),
DemoGroupPlayer("Group"), DemoGroupPlayer("Group"),
DemoSearchPlayer("Search"),
] ]
) )
@ -95,6 +96,8 @@ NETFLIX_PLAYER_SUPPORT = (
BROWSE_PLAYER_SUPPORT = MediaPlayerEntityFeature.BROWSE_MEDIA BROWSE_PLAYER_SUPPORT = MediaPlayerEntityFeature.BROWSE_MEDIA
SEARCH_PLAYER_SUPPORT = MediaPlayerEntityFeature.SEARCH_MEDIA
class AbstractDemoPlayer(MediaPlayerEntity): class AbstractDemoPlayer(MediaPlayerEntity):
"""A demo media players.""" """A demo media players."""
@ -398,3 +401,9 @@ class DemoGroupPlayer(AbstractDemoPlayer):
| MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.GROUPING
| MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_OFF
) )
class DemoSearchPlayer(AbstractDemoPlayer):
"""A Demo media player that supports searching."""
_attr_supported_features = SEARCH_PLAYER_SUPPORT

View File

@ -88,6 +88,8 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
): ):
"""Representation of a devolo device tracker.""" """Representation of a devolo device tracker."""
_attr_translation_key = "device_tracker"
def __init__( def __init__(
self, self,
coordinator: DevoloDataUpdateCoordinator[list[ConnectedStationInfo]], coordinator: DevoloDataUpdateCoordinator[list[ConnectedStationInfo]],
@ -123,13 +125,6 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
) )
return attrs return attrs
@property
def icon(self) -> str:
"""Return device icon."""
if self.is_connected:
return "mdi:lan-connect"
return "mdi:lan-disconnect"
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Return true if the device is connected to the network.""" """Return true if the device is connected to the network."""

View File

@ -13,6 +13,14 @@
"default": "mdi:wifi-plus" "default": "mdi:wifi-plus"
} }
}, },
"device_tracker": {
"device_tracker": {
"default": "mdi:lan-disconnect",
"state": {
"home": "mdi:lan-connect"
}
}
},
"sensor": { "sensor": {
"connected_plc_devices": { "connected_plc_devices": {
"default": "mdi:lan" "default": "mdi:lan"

View File

@ -114,9 +114,14 @@ class DevoloSwitchEntity[_DataT: _DataType](
translation_key="password_protected", translation_key="password_protected",
translation_placeholders={"title": self.entry.title}, translation_placeholders={"title": self.entry.title},
) from ex ) from ex
except DeviceUnavailable: except DeviceUnavailable as ex:
pass # The coordinator will handle this raise HomeAssistantError(
await self.coordinator.async_request_refresh() translation_domain=DOMAIN,
translation_key="no_response",
translation_placeholders={"title": self.entry.title},
) from ex
finally:
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off.""" """Turn the entity off."""
@ -129,6 +134,11 @@ class DevoloSwitchEntity[_DataT: _DataType](
translation_key="password_protected", translation_key="password_protected",
translation_placeholders={"title": self.entry.title}, translation_placeholders={"title": self.entry.title},
) from ex ) from ex
except DeviceUnavailable: except DeviceUnavailable as ex:
pass # The coordinator will handle this raise HomeAssistantError(
await self.coordinator.async_request_refresh() translation_domain=DOMAIN,
translation_key="no_response",
translation_placeholders={"title": self.entry.title},
) from ex
finally:
await self.coordinator.async_request_refresh()

View File

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

View File

@ -6,13 +6,13 @@ from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from pyephember.pyephember import ( from pyephember2.pyephember2 import (
EphEmber, EphEmber,
ZoneMode, ZoneMode,
zone_current_temperature, zone_current_temperature,
zone_is_active, zone_is_active,
zone_is_boost_active, zone_is_boost_active,
zone_is_hot_water, zone_is_hotwater,
zone_mode, zone_mode,
zone_name, zone_name,
zone_target_temperature, zone_target_temperature,
@ -69,14 +69,18 @@ def setup_platform(
try: try:
ember = EphEmber(username, password) ember = EphEmber(username, password)
zones = ember.get_zones()
for zone in zones:
add_entities([EphEmberThermostat(ember, zone)])
except RuntimeError: except RuntimeError:
_LOGGER.error("Cannot connect to EphEmber") _LOGGER.error("Cannot login to EphEmber")
try:
homes = ember.get_zones()
except RuntimeError:
_LOGGER.error("Fail to get zones")
return return
return add_entities(
EphEmberThermostat(ember, zone) for home in homes for zone in home["zones"]
)
class EphEmberThermostat(ClimateEntity): class EphEmberThermostat(ClimateEntity):
@ -85,33 +89,35 @@ class EphEmberThermostat(ClimateEntity):
_attr_hvac_modes = OPERATION_LIST _attr_hvac_modes = OPERATION_LIST
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(self, ember, zone): def __init__(self, ember, zone) -> None:
"""Initialize the thermostat.""" """Initialize the thermostat."""
self._ember = ember self._ember = ember
self._zone_name = zone_name(zone) self._zone_name = zone_name(zone)
self._zone = zone self._zone = zone
self._hot_water = zone_is_hot_water(zone)
# hot water = true, is immersive device without target temperature control.
self._hot_water = zone_is_hotwater(zone)
self._attr_name = self._zone_name self._attr_name = self._zone_name
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.AUX_HEAT
)
self._attr_target_temperature_step = 0.5
if self._hot_water: if self._hot_water:
self._attr_supported_features = ClimateEntityFeature.AUX_HEAT self._attr_supported_features = ClimateEntityFeature.AUX_HEAT
self._attr_target_temperature_step = None self._attr_target_temperature_step = None
self._attr_supported_features |= ( else:
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON self._attr_target_temperature_step = 0.5
) self._attr_supported_features = (
ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TARGET_TEMPERATURE
)
@property @property
def current_temperature(self): def current_temperature(self) -> float | None:
"""Return the current temperature.""" """Return the current temperature."""
return zone_current_temperature(self._zone) return zone_current_temperature(self._zone)
@property @property
def target_temperature(self): def target_temperature(self) -> float | None:
"""Return the temperature we try to reach.""" """Return the temperature we try to reach."""
return zone_target_temperature(self._zone) return zone_target_temperature(self._zone)
@ -133,12 +139,12 @@ class EphEmberThermostat(ClimateEntity):
"""Set the operation mode.""" """Set the operation mode."""
mode = self.map_mode_hass_eph(hvac_mode) mode = self.map_mode_hass_eph(hvac_mode)
if mode is not None: if mode is not None:
self._ember.set_mode_by_name(self._zone_name, mode) self._ember.set_zone_mode(self._zone["zoneid"], mode)
else: else:
_LOGGER.error("Invalid operation mode provided %s", hvac_mode) _LOGGER.error("Invalid operation mode provided %s", hvac_mode)
@property @property
def is_aux_heat(self): def is_aux_heat(self) -> bool:
"""Return true if aux heater.""" """Return true if aux heater."""
return zone_is_boost_active(self._zone) return zone_is_boost_active(self._zone)
@ -167,7 +173,7 @@ class EphEmberThermostat(ClimateEntity):
if temperature > self.max_temp or temperature < self.min_temp: if temperature > self.max_temp or temperature < self.min_temp:
return return
self._ember.set_target_temperture_by_name(self._zone_name, temperature) self._ember.set_zone_target_temperature(self._zone["zoneid"], temperature)
@property @property
def min_temp(self): def min_temp(self):
@ -188,7 +194,8 @@ class EphEmberThermostat(ClimateEntity):
def update(self) -> None: def update(self) -> None:
"""Get the latest data.""" """Get the latest data."""
self._zone = self._ember.get_zone(self._zone_name) self._ember.get_zones()
self._zone = self._ember.get_zone(self._zone["zoneid"])
@staticmethod @staticmethod
def map_mode_hass_eph(operation_mode): def map_mode_hass_eph(operation_mode):

View File

@ -1,10 +1,10 @@
{ {
"domain": "ephember", "domain": "ephember",
"name": "EPH Controls", "name": "EPH Controls",
"codeowners": ["@ttroy50"], "codeowners": ["@ttroy50", "@roberty99"],
"documentation": "https://www.home-assistant.io/integrations/ephember", "documentation": "https://www.home-assistant.io/integrations/ephember",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyephember"], "loggers": ["pyephember2"],
"quality_scale": "legacy", "quality_scale": "legacy",
"requirements": ["pyephember==0.3.1"] "requirements": ["pyephember2==0.4.12"]
} }

View File

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

View File

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

View File

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

View File

@ -28,6 +28,8 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
# Import config flow so that it's added to the registry # Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .enum_mapper import EsphomeEnumMapper from .enum_mapper import EsphomeEnumMapper
@ -167,7 +169,12 @@ def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeEntity[Any, Any]](
return await func(self, *args, **kwargs) return await func(self, *args, **kwargs)
except APIConnectionError as error: except APIConnectionError as error:
raise HomeAssistantError( raise HomeAssistantError(
f"Error communicating with device: {error}" translation_domain=DOMAIN,
translation_key="error_communicating_with_device",
translation_placeholders={
"device_name": self._device_info.name,
"error": str(error),
},
) from error ) from error
return handler return handler
@ -194,6 +201,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
_static_info: _InfoT _static_info: _InfoT
_state: _StateT _state: _StateT
_has_state: bool _has_state: bool
device_entry: dr.DeviceEntry
def __init__( def __init__(
self, self,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -184,6 +184,15 @@
"exceptions": { "exceptions": {
"action_call_failed": { "action_call_failed": {
"message": "Failed to execute the action call {call_name} on {device_name}: {error}" "message": "Failed to execute the action call {call_name} on {device_name}: {error}"
},
"error_communicating_with_device": {
"message": "Error communicating with the device {device_name}: {error}"
},
"error_compiling": {
"message": "Error compiling {configuration}; Try again in ESPHome dashboard for more information."
},
"error_uploading": {
"message": "Error during OTA of {configuration}; Try again in ESPHome dashboard for more information."
} }
} }
} }

View File

@ -18,7 +18,6 @@ from homeassistant.components.update import (
UpdateEntity, UpdateEntity,
UpdateEntityFeature, UpdateEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
@ -27,6 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.enum import try_parse_enum from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN
from .coordinator import ESPHomeDashboardCoordinator from .coordinator import ESPHomeDashboardCoordinator
from .dashboard import async_get_dashboard from .dashboard import async_get_dashboard
from .domain_data import DomainData from .domain_data import DomainData
@ -36,7 +36,7 @@ from .entity import (
esphome_state_property, esphome_state_property,
platform_async_setup_entry, platform_async_setup_entry,
) )
from .entry_data import RuntimeEntryData from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@ -47,7 +47,7 @@ NO_FEATURES = UpdateEntityFeature(0)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: ESPHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up ESPHome update based on a config entry.""" """Set up ESPHome update based on a config entry."""
@ -202,16 +202,23 @@ class ESPHomeDashboardUpdateEntity(
api = coordinator.api api = coordinator.api
device = coordinator.data.get(self._device_info.name) device = coordinator.data.get(self._device_info.name)
assert device is not None assert device is not None
configuration = device["configuration"]
try: try:
if not await api.compile(device["configuration"]): if not await api.compile(configuration):
raise HomeAssistantError( raise HomeAssistantError(
f"Error compiling {device['configuration']}; " translation_domain=DOMAIN,
"Try again in ESPHome dashboard for more information." translation_key="error_compiling",
translation_placeholders={
"configuration": configuration,
},
) )
if not await api.upload(device["configuration"], "OTA"): if not await api.upload(configuration, "OTA"):
raise HomeAssistantError( raise HomeAssistantError(
f"Error updating {device['configuration']} via OTA; " translation_domain=DOMAIN,
"Try again in ESPHome dashboard for more information." translation_key="error_uploading",
translation_placeholders={
"configuration": configuration,
},
) )
finally: finally:
await self.coordinator.async_request_refresh() await self.coordinator.async_request_refresh()

View File

@ -211,6 +211,10 @@ class FibaroController:
"""Return list of scenes.""" """Return list of scenes."""
return self._scenes return self._scenes
def get_all_devices(self) -> list[DeviceModel]:
"""Return list of all fibaro devices."""
return self._fibaro_device_manager.get_devices()
def read_fibaro_info(self) -> InfoModel: def read_fibaro_info(self) -> InfoModel:
"""Return the general info about the hub.""" """Return the general info about the hub."""
return self._fibaro_info return self._fibaro_info

View File

@ -0,0 +1,56 @@
"""Diagnostics support for fibaro integration."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from pyfibaro.fibaro_device import DeviceModel
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from . import CONF_IMPORT_PLUGINS, FibaroConfigEntry
TO_REDACT = {"password"}
def _create_diagnostics_data(
config_entry: FibaroConfigEntry, devices: list[DeviceModel]
) -> dict[str, Any]:
"""Combine diagnostics information and redact sensitive information."""
return {
"config": {CONF_IMPORT_PLUGINS: config_entry.data.get(CONF_IMPORT_PLUGINS)},
"fibaro_devices": async_redact_data([d.raw_data for d in devices], TO_REDACT),
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: FibaroConfigEntry
) -> Mapping[str, Any]:
"""Return diagnostics for a config entry."""
controller = config_entry.runtime_data
devices = controller.get_all_devices()
return _create_diagnostics_data(config_entry, devices)
async def async_get_device_diagnostics(
hass: HomeAssistant, config_entry: FibaroConfigEntry, device: DeviceEntry
) -> Mapping[str, Any]:
"""Return diagnostics for a device."""
controller = config_entry.runtime_data
devices = controller.get_all_devices()
ha_device_id = next(iter(device.identifiers))[1]
if ha_device_id == controller.hub_serial:
# special case where the device is representing the fibaro hub
return _create_diagnostics_data(config_entry, devices)
# normal devices are represented by a parent / child structure
filtered_devices = [
device
for device in devices
if ha_device_id in (device.fibaro_id, device.parent_fibaro_id)
]
return _create_diagnostics_data(config_entry, filtered_devices)

View File

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

View File

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

View File

@ -136,7 +136,7 @@
"state_attributes": { "state_attributes": {
"preset_mode": { "preset_mode": {
"state": { "state": {
"manual": "Manual" "manual": "[%key:common::state::manual%]"
} }
} }
} }

View File

@ -14,7 +14,7 @@
"title": "Pair with a device via HomeKit Accessory Protocol", "title": "Pair with a device via HomeKit Accessory Protocol",
"description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your eight digit HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging, often close to a HomeKit bar code, next to the image of a small house.", "description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your eight digit HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging, often close to a HomeKit bar code, next to the image of a small house.",
"data": { "data": {
"pairing_code": "Pairing Code", "pairing_code": "Pairing code",
"allow_insecure_setup_codes": "Allow pairing with insecure setup codes." "allow_insecure_setup_codes": "Allow pairing with insecure setup codes."
} }
}, },
@ -112,7 +112,7 @@
"air_purifier_state_target": { "air_purifier_state_target": {
"state": { "state": {
"automatic": "Automatic", "automatic": "Automatic",
"manual": "Manual" "manual": "[%key:common::state::manual%]"
} }
} }
}, },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,10 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import asdict from typing import Any
from typing import Any, TypedDict
from pylamarzocco.const import FirmwareType
from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant 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( async def async_get_config_entry_diagnostics(
hass: HomeAssistant, hass: HomeAssistant,
entry: LaMarzoccoConfigEntry, entry: LaMarzoccoConfigEntry,
@ -33,12 +21,4 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
coordinator = entry.runtime_data.config_coordinator coordinator = entry.runtime_data.config_coordinator
device = coordinator.device device = coordinator.device
# collect all data sources return async_redact_data(device.to_dict(), TO_REDACT)
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)

View File

@ -2,10 +2,9 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING
from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.const import FirmwareType from pylamarzocco.const import FirmwareType
from pylamarzocco.devices.machine import LaMarzoccoMachine
from homeassistant.const import CONF_ADDRESS, CONF_MAC from homeassistant.const import CONF_ADDRESS, CONF_MAC
from homeassistant.helpers.device_registry import ( from homeassistant.helpers.device_registry import (
@ -46,12 +45,12 @@ class LaMarzoccoBaseEntity(
self._attr_unique_id = f"{device.serial_number}_{key}" self._attr_unique_id = f"{device.serial_number}_{key}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.serial_number)}, identifiers={(DOMAIN, device.serial_number)},
name=device.name, name=device.dashboard.name,
manufacturer="La Marzocco", manufacturer="La Marzocco",
model=device.full_model_name, model=device.dashboard.model_name.value,
model_id=device.model, model_id=device.dashboard.model_code.value,
serial_number=device.serial_number, 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() connections: set[tuple[str, str]] = set()
if coordinator.config_entry.data.get(CONF_ADDRESS): if coordinator.config_entry.data.get(CONF_ADDRESS):
@ -86,26 +85,3 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity):
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(coordinator, entity_description.key) super().__init__(coordinator, entity_description.key)
self.entity_description = entity_description 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": { "dose": {
"default": "mdi:cup-water" "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": { "smart_standby_time": {
"default": "mdi:timer" "default": "mdi:timer"
},
"steam_temp": {
"default": "mdi:thermometer-water"
},
"tea_water_duration": {
"default": "mdi:timer-sand"
} }
}, },
"select": { "select": {
"active_bbw": {
"default": "mdi:alpha-u",
"state": {
"a": "mdi:alpha-a",
"b": "mdi:alpha-b"
}
},
"smart_standby_mode": { "smart_standby_mode": {
"default": "mdi:power", "default": "mdi:power",
"state": { "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": { "switch": {
"main": { "main": {
"default": "mdi:power", "default": "mdi:power",

View File

@ -34,8 +34,8 @@
], ],
"documentation": "https://www.home-assistant.io/integrations/lamarzocco", "documentation": "https://www.home-assistant.io/integrations/lamarzocco",
"integration_type": "device", "integration_type": "device",
"iot_class": "cloud_polling", "iot_class": "cloud_push",
"loggers": ["pylamarzocco"], "loggers": ["pylamarzocco"],
"quality_scale": "platinum", "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 collections.abc import Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any, cast
from pylamarzocco.const import ( from pylamarzocco import LaMarzoccoMachine
KEYS_PER_MODEL, from pylamarzocco.const import WidgetType
BoilerType,
MachineModel,
PhysicalKey,
PrebrewMode,
)
from pylamarzocco.devices.machine import LaMarzoccoMachine
from pylamarzocco.exceptions import RequestNotSuccessful from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import LaMarzoccoMachineConfig from pylamarzocco.models import CoffeeBoiler
from homeassistant.components.number import ( from homeassistant.components.number import (
NumberDeviceClass, NumberDeviceClass,
@ -32,8 +26,8 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@ -45,25 +39,10 @@ class LaMarzoccoNumberEntityDescription(
): ):
"""Description of a La Marzocco number entity.""" """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]] 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, ...] = ( ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
LaMarzoccoNumberEntityDescription( LaMarzoccoNumberEntityDescription(
key="coffee_temp", key="coffee_temp",
@ -73,43 +52,11 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
native_step=PRECISION_TENTHS, native_step=PRECISION_TENTHS,
native_min_value=85, native_min_value=85,
native_max_value=104, native_max_value=104,
set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.COFFEE, temp), set_value_fn=lambda machine, temp: machine.set_coffee_target_temperature(temp),
native_value_fn=lambda config: config.boilers[ native_value_fn=(
BoilerType.COFFEE lambda machine: cast(
].target_temperature, CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
), ).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,
), ),
), ),
LaMarzoccoNumberEntityDescription( LaMarzoccoNumberEntityDescription(
@ -117,119 +64,18 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
translation_key="smart_standby_time", translation_key="smart_standby_time",
device_class=NumberDeviceClass.DURATION, device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES, 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_step=PRECISION_WHOLE,
native_min_value=0, native_min_value=0,
native_max_value=999, native_max_value=240,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
set_value_fn=lambda machine, ticks, key: machine.set_dose( set_value_fn=(
dose=int(ticks), key=key lambda machine, value: machine.set_smart_standby(
), enabled=machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
native_value_fn=lambda config, key: config.doses[key], mode=machine.schedule.smart_wake_up_sleep.smart_stand_by_after,
supported_fn=lambda coordinator: coordinator.device.model minutes=int(value),
== 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
), ),
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) 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) async_add_entities(entities)
@ -286,7 +104,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity):
@property @property
def native_value(self) -> float: def native_value(self) -> float:
"""Return the current value.""" """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: async def async_set_native_value(self, value: float) -> None:
"""Set the value.""" """Set the value."""
@ -305,62 +123,3 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity):
}, },
) from exc ) from exc
self.async_write_ha_state() 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 collections.abc import Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any, cast
from pylamarzocco.const import ( from pylamarzocco.const import (
MachineModel, ModelName,
PhysicalKey, PreExtractionMode,
PrebrewMode, SmartStandByType,
SmartStandbyMode, SteamTargetLevel,
SteamLevel, WidgetType,
) )
from pylamarzocco.devices.machine import LaMarzoccoMachine from pylamarzocco.devices import LaMarzoccoMachine
from pylamarzocco.exceptions import RequestNotSuccessful 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.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@ -23,30 +23,29 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
STEAM_LEVEL_HA_TO_LM = { STEAM_LEVEL_HA_TO_LM = {
"1": SteamLevel.LEVEL_1, "1": SteamTargetLevel.LEVEL_1,
"2": SteamLevel.LEVEL_2, "2": SteamTargetLevel.LEVEL_2,
"3": SteamLevel.LEVEL_3, "3": SteamTargetLevel.LEVEL_3,
} }
STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items()} STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items()}
PREBREW_MODE_HA_TO_LM = { PREBREW_MODE_HA_TO_LM = {
"disabled": PrebrewMode.DISABLED, "disabled": PreExtractionMode.DISABLED,
"prebrew": PrebrewMode.PREBREW, "prebrew": PreExtractionMode.PREBREWING,
"prebrew_enabled": PrebrewMode.PREBREW_ENABLED, "preinfusion": PreExtractionMode.PREINFUSION,
"preinfusion": PrebrewMode.PREINFUSION,
} }
PREBREW_MODE_LM_TO_HA = {value: key for key, value in PREBREW_MODE_HA_TO_LM.items()} PREBREW_MODE_LM_TO_HA = {value: key for key, value in PREBREW_MODE_HA_TO_LM.items()}
STANDBY_MODE_HA_TO_LM = { STANDBY_MODE_HA_TO_LM = {
"power_on": SmartStandbyMode.POWER_ON, "power_on": SmartStandByType.POWER_ON,
"last_brewing": SmartStandbyMode.LAST_BREWING, "last_brewing": SmartStandByType.LAST_BREW,
} }
STANDBY_MODE_LM_TO_HA = {value: key for key, value in STANDBY_MODE_HA_TO_LM.items()} 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.""" """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]] 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( select_option_fn=lambda machine, option: machine.set_steam_level(
STEAM_LEVEL_HA_TO_LM[option] STEAM_LEVEL_HA_TO_LM[option]
), ),
current_option_fn=lambda config: STEAM_LEVEL_LM_TO_HA[config.steam_level], current_option_fn=lambda machine: STEAM_LEVEL_LM_TO_HA[
supported_fn=lambda coordinator: coordinator.device.model cast(
== MachineModel.LINEA_MICRA, 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( LaMarzoccoSelectEntityDescription(
key="prebrew_infusion_select", key="prebrew_infusion_select",
translation_key="prebrew_infusion_select", translation_key="prebrew_infusion_select",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
options=["disabled", "prebrew", "preinfusion"], 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] PREBREW_MODE_HA_TO_LM[option]
), ),
current_option_fn=lambda config: PREBREW_MODE_LM_TO_HA[config.prebrew_mode], current_option_fn=lambda machine: PREBREW_MODE_LM_TO_HA[
supported_fn=lambda coordinator: coordinator.device.model cast(PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]).mode
in ( ],
MachineModel.GS3_AV, supported_fn=(
MachineModel.LINEA_MICRA, lambda coordinator: coordinator.device.dashboard.model_name
MachineModel.LINEA_MINI, in (
MachineModel.LINEA_MINI_R, ModelName.LINEA_MICRA,
ModelName.LINEA_MINI,
ModelName.LINEA_MINI_R,
ModelName.GS3_AV,
)
), ),
), ),
LaMarzoccoSelectEntityDescription( LaMarzoccoSelectEntityDescription(
@ -98,32 +108,16 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
options=["power_on", "last_brewing"], options=["power_on", "last_brewing"],
select_option_fn=lambda machine, option: machine.set_smart_standby( 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], 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[ current_option_fn=lambda machine: STANDBY_MODE_LM_TO_HA[
config.smart_standby.mode 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -133,30 +127,11 @@ async def async_setup_entry(
"""Set up select entities.""" """Set up select entities."""
coordinator = entry.runtime_data.config_coordinator coordinator = entry.runtime_data.config_coordinator
entities = [ async_add_entities(
LaMarzoccoSelectEntity(coordinator, description) LaMarzoccoSelectEntity(coordinator, description)
for description in ENTITIES for description in ENTITIES
if description.supported_fn(coordinator) 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): class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
@ -167,9 +142,7 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
@property @property
def current_option(self) -> str | None: def current_option(self) -> str | None:
"""Return the current selected option.""" """Return the current selected option."""
return str( return self.entity_description.current_option_fn(self.coordinator.device)
self.entity_description.current_option_fn(self.coordinator.device.config)
)
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Change the selected option.""" """Change the selected option."""
@ -188,9 +161,3 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
}, },
) from exc ) from exc
self.async_write_ha_state() 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": { "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": { "data": {
"host": "[%key:common::config_flow::data::ip%]",
"machine": "Machine" "machine": "Machine"
}, },
"data_description": { "data_description": {
"host": "Local IP address of the machine",
"machine": "Select the machine you want to integrate" "machine": "Select the machine you want to integrate"
} }
}, },
@ -101,54 +99,16 @@
"coffee_temp": { "coffee_temp": {
"name": "Coffee target temperature" "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": { "smart_standby_time": {
"name": "Smart standby time" "name": "Smart standby time"
},
"steam_temp": {
"name": "Steam target temperature"
},
"tea_water_duration": {
"name": "Tea water duration"
} }
}, },
"select": { "select": {
"active_bbw": {
"name": "Active brew by weight recipe",
"state": {
"a": "Recipe A",
"b": "Recipe B"
}
},
"prebrew_infusion_select": { "prebrew_infusion_select": {
"name": "Prebrew/-infusion mode", "name": "Prebrew/-infusion mode",
"state": { "state": {
"disabled": "[%key:common::state::disabled%]", "disabled": "[%key:common::state::disabled%]",
"prebrew": "Prebrew", "prebrew": "Prebrew",
"prebrew_enabled": "Prebrew",
"preinfusion": "Preinfusion" "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": { "switch": {
"auto_on_off": { "auto_on_off": {
"name": "Auto on/off ({id})" "name": "Auto on/off ({id})"
@ -233,9 +170,6 @@
"number_exception": { "number_exception": {
"message": "Error while setting value {value} for number {key}" "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": { "select_option_error": {
"message": "Error while setting select option {option} for {key}" "message": "Error while setting select option {option} for {key}"
}, },

View File

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

View File

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

View File

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

View File

@ -68,7 +68,12 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from .browse_media import BrowseMedia, async_process_play_media_url # noqa: F401 from .browse_media import ( # noqa: F401
BrowseMedia,
SearchMedia,
SearchMediaQuery,
async_process_play_media_url,
)
from .const import ( # noqa: F401 from .const import ( # noqa: F401
_DEPRECATED_MEDIA_CLASS_DIRECTORY, _DEPRECATED_MEDIA_CLASS_DIRECTORY,
_DEPRECATED_SUPPORT_BROWSE_MEDIA, _DEPRECATED_SUPPORT_BROWSE_MEDIA,
@ -107,10 +112,12 @@ from .const import ( # noqa: F401
ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EPISODE, ATTR_MEDIA_EPISODE,
ATTR_MEDIA_EXTRA, ATTR_MEDIA_EXTRA,
ATTR_MEDIA_FILTER_CLASSES,
ATTR_MEDIA_PLAYLIST, ATTR_MEDIA_PLAYLIST,
ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION,
ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_POSITION_UPDATED_AT,
ATTR_MEDIA_REPEAT, ATTR_MEDIA_REPEAT,
ATTR_MEDIA_SEARCH_QUERY,
ATTR_MEDIA_SEASON, ATTR_MEDIA_SEASON,
ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SEEK_POSITION,
ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_SERIES_TITLE,
@ -128,6 +135,7 @@ from .const import ( # noqa: F401
SERVICE_CLEAR_PLAYLIST, SERVICE_CLEAR_PLAYLIST,
SERVICE_JOIN, SERVICE_JOIN,
SERVICE_PLAY_MEDIA, SERVICE_PLAY_MEDIA,
SERVICE_SEARCH_MEDIA,
SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOUND_MODE,
SERVICE_SELECT_SOURCE, SERVICE_SELECT_SOURCE,
SERVICE_UNJOIN, SERVICE_UNJOIN,
@ -137,7 +145,7 @@ from .const import ( # noqa: F401
MediaType, MediaType,
RepeatMode, RepeatMode,
) )
from .errors import BrowseError from .errors import BrowseError, SearchError
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -291,6 +299,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
) )
websocket_api.async_register_command(hass, websocket_browse_media) websocket_api.async_register_command(hass, websocket_browse_media)
websocket_api.async_register_command(hass, websocket_search_media)
hass.http.register_view(MediaPlayerImageView(component)) hass.http.register_view(MediaPlayerImageView(component))
await component.async_setup(config) await component.async_setup(config)
@ -447,6 +456,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_browse_media", "async_browse_media",
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,
) )
component.async_register_entity_service(
SERVICE_SEARCH_MEDIA,
{
vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string,
vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string,
vol.Required(ATTR_MEDIA_SEARCH_QUERY): cv.string,
vol.Optional(ATTR_MEDIA_FILTER_CLASSES): vol.All(
cv.ensure_list,
[vol.In([m.value for m in MediaClass])],
lambda x: {MediaClass(item) for item in x},
),
},
"async_internal_search_media",
[MediaPlayerEntityFeature.SEARCH_MEDIA],
SupportsResponse.ONLY,
)
component.async_register_entity_service( component.async_register_entity_service(
SERVICE_SHUFFLE_SET, SERVICE_SHUFFLE_SET,
{vol.Required(ATTR_MEDIA_SHUFFLE): cv.boolean}, {vol.Required(ATTR_MEDIA_SHUFFLE): cv.boolean},
@ -1157,6 +1182,29 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
""" """
raise NotImplementedError raise NotImplementedError
async def async_internal_search_media(
self,
search_query: str,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
media_filter_classes: list[MediaClass] | None = None,
) -> SearchMedia:
return await self.async_search_media(
query=SearchMediaQuery(
search_query=search_query,
media_content_type=media_content_type,
media_content_id=media_content_id,
media_filter_classes=media_filter_classes,
)
)
async def async_search_media(
self,
query: SearchMediaQuery,
) -> SearchMedia:
"""Search the media player."""
raise NotImplementedError
def join_players(self, group_members: list[str]) -> None: def join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player.""" """Join `group_members` as a player group with the current player."""
raise NotImplementedError raise NotImplementedError
@ -1360,6 +1408,75 @@ async def websocket_browse_media(
connection.send_result(msg["id"], result) connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): "media_player/search_media",
vol.Required("entity_id"): cv.entity_id,
vol.Inclusive(
ATTR_MEDIA_CONTENT_TYPE,
"media_ids",
"media_content_type and media_content_id must be provided together",
): str,
vol.Inclusive(
ATTR_MEDIA_CONTENT_ID,
"media_ids",
"media_content_type and media_content_id must be provided together",
): str,
vol.Required(ATTR_MEDIA_SEARCH_QUERY): str,
vol.Optional(ATTR_MEDIA_FILTER_CLASSES): vol.All(
cv.ensure_list,
[vol.In([m.value for m in MediaClass])],
lambda x: {MediaClass(item) for item in x},
),
}
)
@websocket_api.async_response
async def websocket_search_media(
hass: HomeAssistant,
connection: websocket_api.connection.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Search media available to the media_player entity.
To use, media_player integrations can implement
MediaPlayerEntity.async_search_media()
"""
player = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])
if player is None:
connection.send_error(msg["id"], "entity_not_found", "Entity not found")
return
if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features_compat:
connection.send_message(
websocket_api.error_message(
msg["id"], ERR_NOT_SUPPORTED, "Player does not support searching media"
)
)
return
media_content_type = msg.get(ATTR_MEDIA_CONTENT_TYPE)
media_content_id = msg.get(ATTR_MEDIA_CONTENT_ID)
query = str(msg.get(ATTR_MEDIA_SEARCH_QUERY))
media_filter_classes = msg.get(ATTR_MEDIA_FILTER_CLASSES, [])
try:
payload = await player.async_internal_search_media(
query,
media_content_type,
media_content_id,
media_filter_classes,
)
except SearchError as err:
connection.send_message(
websocket_api.error_message(msg["id"], ERR_UNKNOWN_ERROR, str(err))
)
return
result = payload.as_dict()
connection.send_result(msg["id"], result)
_FETCH_TIMEOUT = aiohttp.ClientTimeout(total=10) _FETCH_TIMEOUT = aiohttp.ClientTimeout(total=10)

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
@ -109,6 +110,7 @@ class BrowseMedia:
children_media_class: MediaClass | str | None = None, children_media_class: MediaClass | str | None = None,
thumbnail: str | None = None, thumbnail: str | None = None,
not_shown: int = 0, not_shown: int = 0,
can_search: bool = False,
) -> None: ) -> None:
"""Initialize browse media item.""" """Initialize browse media item."""
self.media_class = media_class self.media_class = media_class
@ -121,6 +123,7 @@ class BrowseMedia:
self.children_media_class = children_media_class self.children_media_class = children_media_class
self.thumbnail = thumbnail self.thumbnail = thumbnail
self.not_shown = not_shown self.not_shown = not_shown
self.can_search = can_search
def as_dict(self, *, parent: bool = True) -> dict[str, Any]: def as_dict(self, *, parent: bool = True) -> dict[str, Any]:
"""Convert Media class to browse media dictionary.""" """Convert Media class to browse media dictionary."""
@ -135,6 +138,7 @@ class BrowseMedia:
"children_media_class": self.children_media_class, "children_media_class": self.children_media_class,
"can_play": self.can_play, "can_play": self.can_play,
"can_expand": self.can_expand, "can_expand": self.can_expand,
"can_search": self.can_search,
"thumbnail": self.thumbnail, "thumbnail": self.thumbnail,
} }
@ -163,3 +167,27 @@ class BrowseMedia:
def __repr__(self) -> str: def __repr__(self) -> str:
"""Return representation of browse media.""" """Return representation of browse media."""
return f"<BrowseMedia {self.title} ({self.media_class})>" return f"<BrowseMedia {self.title} ({self.media_class})>"
@dataclass(kw_only=True, frozen=True)
class SearchMedia:
"""Represent search results."""
version: int = field(default=1)
result: list[BrowseMedia]
def as_dict(self, *, parent: bool = True) -> dict[str, Any]:
"""Convert SearchMedia class to browse media dictionary."""
return {
"result": [item.as_dict(parent=parent) for item in self.result],
}
@dataclass(kw_only=True, frozen=True)
class SearchMediaQuery:
"""Represent a search media file."""
search_query: str
media_content_type: MediaType | str | None = field(default=None)
media_content_id: str | None = None
media_filter_classes: list[MediaClass] | None = field(default=None)

View File

@ -26,6 +26,8 @@ ATTR_MEDIA_ARTIST = "media_artist"
ATTR_MEDIA_CHANNEL = "media_channel" ATTR_MEDIA_CHANNEL = "media_channel"
ATTR_MEDIA_CONTENT_ID = "media_content_id" ATTR_MEDIA_CONTENT_ID = "media_content_id"
ATTR_MEDIA_CONTENT_TYPE = "media_content_type" ATTR_MEDIA_CONTENT_TYPE = "media_content_type"
ATTR_MEDIA_SEARCH_QUERY = "search_query"
ATTR_MEDIA_FILTER_CLASSES = "media_filter_classes"
ATTR_MEDIA_DURATION = "media_duration" ATTR_MEDIA_DURATION = "media_duration"
ATTR_MEDIA_ENQUEUE = "enqueue" ATTR_MEDIA_ENQUEUE = "enqueue"
ATTR_MEDIA_EXTRA = "extra" ATTR_MEDIA_EXTRA = "extra"
@ -174,6 +176,7 @@ SERVICE_CLEAR_PLAYLIST = "clear_playlist"
SERVICE_JOIN = "join" SERVICE_JOIN = "join"
SERVICE_PLAY_MEDIA = "play_media" SERVICE_PLAY_MEDIA = "play_media"
SERVICE_BROWSE_MEDIA = "browse_media" SERVICE_BROWSE_MEDIA = "browse_media"
SERVICE_SEARCH_MEDIA = "search_media"
SERVICE_SELECT_SOUND_MODE = "select_sound_mode" SERVICE_SELECT_SOUND_MODE = "select_sound_mode"
SERVICE_SELECT_SOURCE = "select_source" SERVICE_SELECT_SOURCE = "select_source"
SERVICE_UNJOIN = "unjoin" SERVICE_UNJOIN = "unjoin"
@ -220,6 +223,7 @@ class MediaPlayerEntityFeature(IntFlag):
GROUPING = 524288 GROUPING = 524288
MEDIA_ANNOUNCE = 1048576 MEDIA_ANNOUNCE = 1048576
MEDIA_ENQUEUE = 2097152 MEDIA_ENQUEUE = 2097152
SEARCH_MEDIA = 4194304
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.

View File

@ -9,3 +9,7 @@ class MediaPlayerException(HomeAssistantError):
class BrowseError(MediaPlayerException): class BrowseError(MediaPlayerException):
"""Error while browsing.""" """Error while browsing."""
class SearchError(MediaPlayerException):
"""Error while searching."""

View File

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

@ -29,10 +29,10 @@
"public_weather": { "public_weather": {
"data": { "data": {
"area_name": "Name of the area", "area_name": "Name of the area",
"lat_ne": "North-East corner latitude", "lat_ne": "Northeast corner latitude",
"lon_ne": "North-East corner longitude", "lon_ne": "Northeast corner longitude",
"lat_sw": "South-West corner latitude", "lat_sw": "Southwest corner latitude",
"lon_sw": "South-West corner longitude", "lon_sw": "Southwest corner longitude",
"mode": "Calculation", "mode": "Calculation",
"show_on_map": "Show on map" "show_on_map": "Show on map"
}, },
@ -175,7 +175,7 @@
"state": { "state": {
"frost_guard": "Frost guard", "frost_guard": "Frost guard",
"schedule": "Schedule", "schedule": "Schedule",
"manual": "Manual" "manual": "[%key:common::state::manual%]"
} }
} }
} }
@ -206,13 +206,13 @@
"name": "Wind direction", "name": "Wind direction",
"state": { "state": {
"n": "North", "n": "North",
"ne": "North-east", "ne": "Northeast",
"e": "East", "e": "East",
"se": "South-east", "se": "Southeast",
"s": "South", "s": "South",
"sw": "South-west", "sw": "Southwest",
"w": "West", "w": "West",
"nw": "North-west" "nw": "Northwest"
} }
}, },
"wind_angle": { "wind_angle": {

View File

@ -63,6 +63,7 @@ from .const import (
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
RECOMMENDED_WEB_SEARCH_USER_LOCATION, RECOMMENDED_WEB_SEARCH_USER_LOCATION,
UNSUPPORTED_MODELS, UNSUPPORTED_MODELS,
WEB_SEARCH_MODELS,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -160,9 +161,10 @@ class OpenAIOptionsFlow(OptionsFlow):
errors[CONF_CHAT_MODEL] = "model_not_supported" errors[CONF_CHAT_MODEL] = "model_not_supported"
if user_input.get(CONF_WEB_SEARCH): if user_input.get(CONF_WEB_SEARCH):
if not user_input.get( if (
CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
).startswith("gpt-4o"): not in WEB_SEARCH_MODELS
):
errors[CONF_WEB_SEARCH] = "web_search_not_supported" errors[CONF_WEB_SEARCH] = "web_search_not_supported"
elif user_input.get(CONF_WEB_SEARCH_USER_LOCATION): elif user_input.get(CONF_WEB_SEARCH_USER_LOCATION):
user_input.update(await self.get_location_data()) user_input.update(await self.get_location_data())

View File

@ -41,3 +41,12 @@ UNSUPPORTED_MODELS: list[str] = [
"gpt-4o-mini-realtime-preview", "gpt-4o-mini-realtime-preview",
"gpt-4o-mini-realtime-preview-2024-12-17", "gpt-4o-mini-realtime-preview-2024-12-17",
] ]
WEB_SEARCH_MODELS: list[str] = [
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4o",
"gpt-4o-search-preview",
"gpt-4o-mini",
"gpt-4o-mini-search-preview",
]

View File

@ -40,7 +40,7 @@
}, },
"error": { "error": {
"model_not_supported": "This model is not supported, please select a different model", "model_not_supported": "This model is not supported, please select a different model",
"web_search_not_supported": "Web search is only supported for gpt-4o and gpt-4o-mini models" "web_search_not_supported": "Web search is not supported by this model"
} }
}, },
"selector": { "selector": {

View File

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

View File

@ -71,11 +71,11 @@ sequence:
title: !input dismiss_text title: !input dismiss_text
- alias: "Awaiting response" - alias: "Awaiting response"
wait_for_trigger: wait_for_trigger:
- platform: event - trigger: event
event_type: mobile_app_notification_action event_type: mobile_app_notification_action
event_data: event_data:
action: "{{ action_confirm }}" action: "{{ action_confirm }}"
- platform: event - trigger: event
event_type: mobile_app_notification_action event_type: mobile_app_notification_action
event_data: event_data:
action: "{{ action_dismiss }}" action: "{{ action_dismiss }}"

View File

@ -209,7 +209,7 @@ KELVIN_MIN_VALUE_COLOR: Final = 3000
BLOCK_WRONG_SLEEP_PERIOD = 21600 BLOCK_WRONG_SLEEP_PERIOD = 21600
BLOCK_EXPECTED_SLEEP_PERIOD = 43200 BLOCK_EXPECTED_SLEEP_PERIOD = 43200
UPTIME_DEVIATION: Final = 5 UPTIME_DEVIATION: Final = 60
# Time to wait before reloading entry upon device config change # Time to wait before reloading entry upon device config change
ENTRY_RELOAD_COOLDOWN = 60 ENTRY_RELOAD_COOLDOWN = 60

View File

@ -200,8 +200,18 @@ def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime:
if ( if (
not last_uptime not last_uptime
or abs((delta_uptime - last_uptime).total_seconds()) > UPTIME_DEVIATION or (diff := abs((delta_uptime - last_uptime).total_seconds()))
> UPTIME_DEVIATION
): ):
if last_uptime:
LOGGER.debug(
"Time deviation %s > %s: uptime=%s, last_uptime=%s, delta_uptime=%s",
diff,
UPTIME_DEVIATION,
uptime,
last_uptime,
delta_uptime,
)
return delta_uptime return delta_uptime
return last_uptime return last_uptime

View File

@ -354,11 +354,11 @@
"robot_cleaner_cleaning_mode": { "robot_cleaner_cleaning_mode": {
"name": "Cleaning mode", "name": "Cleaning mode",
"state": { "state": {
"auto": "Auto", "stop": "[%key:common::action::stop%]",
"auto": "[%key:common::state::auto%]",
"manual": "[%key:common::state::manual%]",
"part": "Partial", "part": "Partial",
"repeat": "Repeat", "repeat": "Repeat",
"manual": "Manual",
"stop": "[%key:common::action::stop%]",
"map": "Map" "map": "Map"
} }
}, },

View File

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

View File

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

View File

@ -61,6 +61,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription( SensorEntityDescription(
key="fuel", key="fuel",
translation_key="fuel", translation_key="fuel",
device_class=SensorDeviceClass.VOLUME,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
), ),
SensorEntityDescription( SensorEntityDescription(

View File

@ -113,7 +113,9 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
translation_key="last_boot_time", translation_key="last_boot_time",
device_class=SensorDeviceClass.TIMESTAMP, device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: now() - timedelta(seconds=data.status["uptime"]), value_fn=lambda data: (
now() - timedelta(seconds=data.status["uptime"])
).replace(microsecond=0),
), ),
StarlinkSensorEntityDescription( StarlinkSensorEntityDescription(
key="ping_drop_rate", key="ping_drop_rate",

View File

@ -0,0 +1,86 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: todo
config-flow: todo
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: todo
docs-installation-instructions: todo
docs-removal-instructions: todo
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to configure
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: todo
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: todo
comment: DHCP or zeroconf is still possible
discovery:
status: todo
comment: DHCP or zeroconf is still possible
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
This integration has a fixed single device.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration doesn't have any cases where raising an issue is needed.
stale-devices:
status: exempt
comment: |
This integration has a fixed single device.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@ -59,7 +59,7 @@
"name": "Lamp mode", "name": "Lamp mode",
"state": { "state": {
"automatic": "Automatic", "automatic": "Automatic",
"manual": "Manual" "manual": "[%key:common::state::manual%]"
} }
}, },
"aroma_therapy_slot": { "aroma_therapy_slot": {

View File

@ -75,7 +75,6 @@ class AirConEntity(WhirlpoolEntity, ClimateEntity):
_attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_hvac_modes = SUPPORTED_HVAC_MODES
_attr_max_temp = SUPPORTED_MAX_TEMP _attr_max_temp = SUPPORTED_MAX_TEMP
_attr_min_temp = SUPPORTED_MIN_TEMP _attr_min_temp = SUPPORTED_MIN_TEMP
_attr_should_poll = False
_attr_supported_features = ( _attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.FAN_MODE

View File

@ -12,6 +12,7 @@ class WhirlpoolEntity(Entity):
"""Base class for Whirlpool entities.""" """Base class for Whirlpool entities."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None: def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None:
"""Initialize the entity.""" """Initialize the entity."""

View File

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

View File

@ -174,8 +174,6 @@ async def async_setup_entry(
class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): class WhirlpoolSensor(WhirlpoolEntity, SensorEntity):
"""A class for the Whirlpool sensors.""" """A class for the Whirlpool sensors."""
_attr_should_poll = False
def __init__( def __init__(
self, appliance: Appliance, description: WhirlpoolSensorEntityDescription self, appliance: Appliance, description: WhirlpoolSensorEntityDescription
) -> None: ) -> None:

View File

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

View File

@ -91,6 +91,7 @@ from .const import (
from .helpers import ( from .helpers import (
async_enable_statistics, async_enable_statistics,
async_get_node_from_device_id, async_get_node_from_device_id,
async_get_provisioning_entry_from_device_id,
get_device_id, get_device_id,
) )
@ -171,6 +172,10 @@ ADDITIONAL_PROPERTIES = "additional_properties"
STATUS = "status" STATUS = "status"
REQUESTED_SECURITY_CLASSES = "requestedSecurityClasses" REQUESTED_SECURITY_CLASSES = "requestedSecurityClasses"
PROTOCOL = "protocol"
DEVICE_NAME = "device_name"
AREA_ID = "area_id"
FEATURE = "feature" FEATURE = "feature"
STRATEGY = "strategy" 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_subscribe_s2_inclusion)
websocket_api.async_register_command(hass, websocket_grant_security_classes) 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_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_provision_smart_start_node)
websocket_api.async_register_command(hass, websocket_unprovision_smart_start_node) websocket_api.async_register_command(hass, websocket_unprovision_smart_start_node)
websocket_api.async_register_command(hass, websocket_get_provisioning_entries) websocket_api.async_register_command(hass, websocket_get_provisioning_entries)
@ -631,14 +637,38 @@ async def websocket_node_metadata(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@async_get_node
async def websocket_node_alerts( async def websocket_node_alerts(
hass: HomeAssistant, hass: HomeAssistant,
connection: ActiveConnection, connection: ActiveConnection,
msg: dict[str, Any], msg: dict[str, Any],
node: Node,
) -> None: ) -> None:
"""Get the alerts for a Z-Wave JS node.""" """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( connection.send_result(
msg[ID], msg[ID],
{ {
@ -971,12 +1001,58 @@ async def websocket_validate_dsk_and_enter_pin(
connection.send_result(msg[ID]) 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.require_admin
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required(TYPE): "zwave_js/provision_smart_start_node", vol.Required(TYPE): "zwave_js/provision_smart_start_node",
vol.Required(ENTRY_ID): str, vol.Required(ENTRY_ID): str,
vol.Required(QR_PROVISIONING_INFORMATION): QR_PROVISIONING_INFORMATION_SCHEMA, 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 @websocket_api.async_response
@ -991,18 +1067,68 @@ async def websocket_provision_smart_start_node(
driver: Driver, driver: Driver,
) -> None: ) -> None:
"""Pre-provision a smart start node.""" """Pre-provision a smart start node."""
qr_info = msg[QR_PROVISIONING_INFORMATION]
provisioning_info = msg[QR_PROVISIONING_INFORMATION] if qr_info.version == QRCodeVersion.S2:
if provisioning_info.version == QRCodeVersion.S2:
connection.send_error( connection.send_error(
msg[ID], msg[ID],
ERR_INVALID_FORMAT, ERR_INVALID_FORMAT,
"QR code version S2 is not supported for this command", "QR code version S2 is not supported for this command",
) )
return 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) 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 @websocket_api.require_admin
@ -1036,7 +1162,24 @@ async def websocket_unprovision_smart_start_node(
) )
return return
dsk_or_node_id = msg.get(DSK) or msg[NODE_ID] 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) await driver.controller.async_unprovision_smart_start_node(dsk_or_node_id)
connection.send_result(msg[ID]) connection.send_result(msg[ID])

View File

@ -4,12 +4,17 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
from datetime import datetime
import logging import logging
from pathlib import Path
from typing import Any from typing import Any
import aiohttp import aiohttp
from serial.tools import list_ports from serial.tools import list_ports
import voluptuous as vol 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 zwave_js_server.version import VersionInfo, get_server_version
from homeassistant.components import usb from homeassistant.components import usb
@ -23,6 +28,7 @@ from homeassistant.config_entries import (
SOURCE_USB, SOURCE_USB,
ConfigEntry, ConfigEntry,
ConfigEntryBaseFlow, ConfigEntryBaseFlow,
ConfigEntryState,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
@ -60,6 +66,7 @@ from .const import (
CONF_S2_UNAUTHENTICATED_KEY, CONF_S2_UNAUTHENTICATED_KEY,
CONF_USB_PATH, CONF_USB_PATH,
CONF_USE_ADDON, CONF_USE_ADDON,
DATA_CLIENT,
DOMAIN, DOMAIN,
) )
@ -74,6 +81,9 @@ CONF_EMULATE_HARDWARE = "emulate_hardware"
CONF_LOG_LEVEL = "log_level" CONF_LOG_LEVEL = "log_level"
SERVER_VERSION_TIMEOUT = 10 SERVER_VERSION_TIMEOUT = 10
OPTIONS_INTENT_MIGRATE = "intent_migrate"
OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure"
ADDON_LOG_LEVELS = { ADDON_LOG_LEVELS = {
"error": "Error", "error": "Error",
"warn": "Warn", "warn": "Warn",
@ -636,7 +646,12 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
} }
if not self._usb_discovery: 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 = { schema = {
vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports),
**schema, **schema,
@ -717,6 +732,10 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
super().__init__() super().__init__()
self.original_addon_config: dict[str, Any] | None = None self.original_addon_config: dict[str, Any] | None = None
self.revert_reason: str | 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 @callback
def _async_update_entry(self, data: dict[str, Any]) -> None: def _async_update_entry(self, data: dict[str, Any]) -> None:
@ -725,6 +744,18 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None 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: ) -> ConfigFlowResult:
"""Manage the options.""" """Manage the options."""
if is_hassio(self.hass): if is_hassio(self.hass):
@ -732,6 +763,91 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
return await self.async_step_manual() 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( async def async_step_manual(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -881,7 +997,11 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info")
emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) 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( 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) 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( async def async_step_start_failed(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Add-on start failed.""" """Add-on start failed."""
return await self.async_revert_addon_config(reason="addon_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( async def async_step_finish_addon_setup(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -943,12 +1115,16 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
except CannotConnect: except CannotConnect:
return await self.async_revert_addon_config(reason="cannot_connect") 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") return await self.async_revert_addon_config(reason="different_device")
self._async_update_entry( self._async_update_entry(
{ {
**self.config_entry.data, **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_URL: self.ws_address,
CONF_USB_PATH: self.usb_path, CONF_USB_PATH: self.usb_path,
CONF_S0_LEGACY_KEY: self.s0_legacy_key, CONF_S0_LEGACY_KEY: self.s0_legacy_key,
@ -961,6 +1137,9 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon, 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. # Always reload entry since we may have disconnected the client.
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
return self.async_create_entry(title=TITLE, data={}) 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) _LOGGER.debug("Reverting add-on options, reason: %s", reason)
return await self.async_step_configure_addon(addon_config_input) 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): class CannotConnect(HomeAssistantError):
"""Indicate connection error.""" """Indicate connection error."""

View File

@ -15,7 +15,7 @@ from zwave_js_server.const import (
ConfigurationValueType, ConfigurationValueType,
LogLevel, 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.driver import Driver
from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_config import LogConfig
from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.node import Node as ZwaveNode
@ -233,7 +233,7 @@ def get_home_and_node_id_from_device_entry(
), ),
None, None,
) )
if device_id is None: if device_id is None or device_id.startswith("provision_"):
return None return None
id_ = device_id.split("-") id_ = device_id.split("-")
return (id_[0], int(id_[1])) return (id_[0], int(id_[1]))
@ -264,12 +264,12 @@ def async_get_node_from_device_id(
), ),
None, None,
) )
if entry and entry.state != ConfigEntryState.LOADED:
raise ValueError(f"Device {device_id} config entry is not loaded")
if entry is None: if entry is None:
raise ValueError( raise ValueError(
f"Device {device_id} is not from an existing zwave_js config entry" 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] client: ZwaveClient = entry.runtime_data[DATA_CLIENT]
driver = client.driver driver = client.driver
@ -289,6 +289,53 @@ def async_get_node_from_device_id(
return driver.controller.nodes[node_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 @callback
def async_get_node_from_entity_id( def async_get_node_from_entity_id(
hass: HomeAssistant, hass: HomeAssistant,

View File

@ -11,7 +11,11 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"discovery_requires_supervisor": "Discovery requires the supervisor.", "discovery_requires_supervisor": "Discovery requires the supervisor.",
"not_zwave_device": "Discovered device is not a Z-Wave device.", "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": { "error": {
"addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.", "addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.",
@ -22,7 +26,9 @@
"flow_title": "{name}", "flow_title": "{name}",
"progress": { "progress": {
"install_addon": "Please wait while the Z-Wave add-on installation finishes. This can take several minutes.", "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": { "step": {
"configure_addon": { "configure_addon": {
@ -217,7 +223,12 @@
"addon_stop_failed": "Failed to stop the Z-Wave add-on.", "addon_stop_failed": "Failed to stop the Z-Wave add-on.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@ -226,9 +237,27 @@
}, },
"progress": { "progress": {
"install_addon": "[%key:component::zwave_js::config::progress::install_addon%]", "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": { "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": { "configure_addon": {
"data": { "data": {
"emulate_hardware": "Emulate Hardware", "emulate_hardware": "Emulate Hardware",
@ -242,6 +271,12 @@
"description": "[%key:component::zwave_js::config::step::configure_addon::description%]", "description": "[%key:component::zwave_js::config::step::configure_addon::description%]",
"title": "[%key:component::zwave_js::config::step::configure_addon::title%]" "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": { "install_addon": {
"title": "[%key:component::zwave_js::config::step::install_addon::title%]" "title": "[%key:component::zwave_js::config::step::install_addon::title%]"
}, },

View File

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

View File

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

View File

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

View File

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

2
requirements.txt generated
View File

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

10
requirements_all.txt generated
View File

@ -829,7 +829,7 @@ ebusdpy==0.0.17
ecoaliface==0.4.0 ecoaliface==0.4.0
# homeassistant.components.eheimdigital # homeassistant.components.eheimdigital
eheimdigital==1.0.6 eheimdigital==1.1.0
# homeassistant.components.electric_kiwi # homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14 electrickiwi-api==0.9.14
@ -889,7 +889,7 @@ epson-projector==0.5.1
eq3btsmart==1.4.1 eq3btsmart==1.4.1
# homeassistant.components.esphome # homeassistant.components.esphome
esphome-dashboard-api==1.2.3 esphome-dashboard-api==1.3.0
# homeassistant.components.netgear_lte # homeassistant.components.netgear_lte
eternalegypt==0.0.16 eternalegypt==0.0.16
@ -1114,7 +1114,7 @@ ha-silabs-firmware-client==0.2.0
habiticalib==0.3.7 habiticalib==0.3.7
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
habluetooth==3.38.1 habluetooth==3.39.0
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.94.0 hass-nabucasa==0.94.0
@ -1957,7 +1957,7 @@ pyenphase==1.25.5
pyenvisalink==4.7 pyenvisalink==4.7
# homeassistant.components.ephember # homeassistant.components.ephember
pyephember==0.3.1 pyephember2==0.4.12
# homeassistant.components.everlights # homeassistant.components.everlights
pyeverlights==0.1.0 pyeverlights==0.1.0
@ -2089,7 +2089,7 @@ pykwb==0.0.8
pylacrosse==0.4 pylacrosse==0.4
# homeassistant.components.lamarzocco # homeassistant.components.lamarzocco
pylamarzocco==1.4.9 pylamarzocco==2.0.0b1
# homeassistant.components.lastfm # homeassistant.components.lastfm
pylast==5.1.0 pylast==5.1.0

View File

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

View File

@ -708,7 +708,7 @@ eagle100==0.1.1
easyenergy==2.1.2 easyenergy==2.1.2
# homeassistant.components.eheimdigital # homeassistant.components.eheimdigital
eheimdigital==1.0.6 eheimdigital==1.1.0
# homeassistant.components.electric_kiwi # homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14 electrickiwi-api==0.9.14
@ -759,7 +759,7 @@ epson-projector==0.5.1
eq3btsmart==1.4.1 eq3btsmart==1.4.1
# homeassistant.components.esphome # homeassistant.components.esphome
esphome-dashboard-api==1.2.3 esphome-dashboard-api==1.3.0
# homeassistant.components.netgear_lte # homeassistant.components.netgear_lte
eternalegypt==0.0.16 eternalegypt==0.0.16
@ -956,7 +956,7 @@ ha-silabs-firmware-client==0.2.0
habiticalib==0.3.7 habiticalib==0.3.7
# homeassistant.components.bluetooth # homeassistant.components.bluetooth
habluetooth==3.38.1 habluetooth==3.39.0
# homeassistant.components.cloud # homeassistant.components.cloud
hass-nabucasa==0.94.0 hass-nabucasa==0.94.0
@ -1704,7 +1704,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.8 pykulersky==0.5.8
# homeassistant.components.lamarzocco # homeassistant.components.lamarzocco
pylamarzocco==1.4.9 pylamarzocco==2.0.0b1
# homeassistant.components.lastfm # homeassistant.components.lastfm
pylast==5.1.0 pylast==5.1.0

View File

@ -969,7 +969,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"switcher_kis", "switcher_kis",
"switchmate", "switchmate",
"syncthing", "syncthing",
"syncthru",
"synology_chat", "synology_chat",
"synology_dsm", "synology_dsm",
"synology_srm", "synology_srm",
@ -1100,7 +1099,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"weatherkit", "weatherkit",
"webmin", "webmin",
"wemo", "wemo",
"whirlpool",
"whois", "whois",
"wiffi", "wiffi",
"wilight", "wilight",

View File

@ -355,6 +355,7 @@ async def test_browse_media(
"children_media_class": "app", "children_media_class": "app",
"can_play": False, "can_play": False,
"can_expand": True, "can_expand": True,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"not_shown": 0, "not_shown": 0,
"children": [ "children": [
@ -366,6 +367,7 @@ async def test_browse_media(
"children_media_class": None, "children_media_class": None,
"can_play": False, "can_play": False,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": "https://www.youtube.com/icon.png", "thumbnail": "https://www.youtube.com/icon.png",
}, },
{ {
@ -376,6 +378,7 @@ async def test_browse_media(
"children_media_class": None, "children_media_class": None,
"can_play": False, "can_play": False,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": "", "thumbnail": "",
}, },
], ],

View File

@ -1323,6 +1323,7 @@ async def test_async_play_media_url_m3u(
"media_content_id": "media-source://media_source/local/test.mp3", "media_content_id": "media-source://media_source/local/test.mp3",
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"children_media_class": None, "children_media_class": None,
}, },
@ -1337,6 +1338,7 @@ async def test_async_play_media_url_m3u(
"media_content_id": ("media-source://media_source/local/test.mp4"), "media_content_id": ("media-source://media_source/local/test.mp4"),
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"children_media_class": None, "children_media_class": None,
}, },

View File

@ -4,6 +4,7 @@
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children_media_class': None, 'children_media_class': None,
'media_class': 'directory', 'media_class': 'directory',
'media_content_id': '', 'media_content_id': '',
@ -18,6 +19,7 @@
dict({ dict({
'can_expand': False, 'can_expand': False,
'can_play': True, 'can_play': True,
'can_search': False,
'children_media_class': None, 'children_media_class': None,
'media_class': 'music', 'media_class': 'music',
'media_content_id': '1', 'media_content_id': '1',
@ -28,6 +30,7 @@
dict({ dict({
'can_expand': False, 'can_expand': False,
'can_play': True, 'can_play': True,
'can_search': False,
'children_media_class': None, 'children_media_class': None,
'media_class': 'music', 'media_class': 'music',
'media_content_id': '2', 'media_content_id': '2',

View File

@ -1037,6 +1037,7 @@ async def test_entity_browse_media(
), ),
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"children_media_class": None, "children_media_class": None,
} }
@ -1049,6 +1050,7 @@ async def test_entity_browse_media(
"media_content_id": "media-source://media_source/local/test.mp3", "media_content_id": "media-source://media_source/local/test.mp3",
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"children_media_class": None, "children_media_class": None,
} }
@ -1107,6 +1109,7 @@ async def test_entity_browse_media_audio_only(
"media_content_id": "media-source://media_source/local/test.mp3", "media_content_id": "media-source://media_source/local/test.mp3",
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"children_media_class": None, "children_media_class": None,
} }
@ -2208,6 +2211,7 @@ async def test_cast_platform_browse_media(
"media_content_id": "", "media_content_id": "",
"can_play": False, "can_play": False,
"can_expand": True, "can_expand": True,
"can_search": False,
"thumbnail": "https://brands.home-assistant.io/_/spotify/logo.png", "thumbnail": "https://brands.home-assistant.io/_/spotify/logo.png",
"children_media_class": None, "children_media_class": None,
} }
@ -2232,6 +2236,7 @@ async def test_cast_platform_browse_media(
"media_content_id": "", "media_content_id": "",
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"children_media_class": None, "children_media_class": None,
"thumbnail": None, "thumbnail": None,
"children": [], "children": [],

View File

@ -64,6 +64,7 @@ class MockDevice(Device):
return_value=FIRMWARE_UPDATE_AVAILABLE return_value=FIRMWARE_UPDATE_AVAILABLE
) )
self.device.async_get_led_setting = AsyncMock(return_value=False) self.device.async_get_led_setting = AsyncMock(return_value=False)
self.device.async_set_led_setting = AsyncMock(return_value=True)
self.device.async_restart = AsyncMock(return_value=True) self.device.async_restart = AsyncMock(return_value=True)
self.device.async_uptime = AsyncMock(return_value=UPTIME) self.device.async_uptime = AsyncMock(return_value=UPTIME)
self.device.async_start_wps = AsyncMock(return_value=True) self.device.async_start_wps = AsyncMock(return_value=True)
@ -71,6 +72,7 @@ class MockDevice(Device):
return_value=CONNECTED_STATIONS return_value=CONNECTED_STATIONS
) )
self.device.async_get_wifi_guest_access = AsyncMock(return_value=GUEST_WIFI) self.device.async_get_wifi_guest_access = AsyncMock(return_value=GUEST_WIFI)
self.device.async_set_wifi_guest_access = AsyncMock(return_value=True)
self.device.async_get_wifi_neighbor_access_points = AsyncMock( self.device.async_get_wifi_neighbor_access_points = AsyncMock(
return_value=NEIGHBOR_ACCESS_POINTS return_value=NEIGHBOR_ACCESS_POINTS
) )

View File

@ -3,7 +3,6 @@
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'band': '5 GHz', 'band': '5 GHz',
'icon': 'mdi:lan-connect',
'mac': 'AA:BB:CC:DD:EE:FF', 'mac': 'AA:BB:CC:DD:EE:FF',
'source_type': <SourceType.ROUTER: 'router'>, 'source_type': <SourceType.ROUTER: 'router'>,
'wifi': 'Main', 'wifi': 'Main',

View File

@ -4,6 +4,7 @@ from unittest.mock import AsyncMock
from devolo_plc_api.exceptions.device import DeviceUnavailable from devolo_plc_api.exceptions.device import DeviceUnavailable
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.device_tracker import DOMAIN as PLATFORM from homeassistant.components.device_tracker import DOMAIN as PLATFORM
@ -25,6 +26,7 @@ STATION = CONNECTED_STATIONS[0]
SERIAL = DISCOVERY_INFO.properties["SN"] SERIAL = DISCOVERY_INFO.properties["SN"]
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_device_tracker( async def test_device_tracker(
hass: HomeAssistant, hass: HomeAssistant,
mock_device: MockDevice, mock_device: MockDevice,
@ -42,14 +44,6 @@ async def test_device_tracker(
freezer.tick(LONG_UPDATE_INTERVAL) freezer.tick(LONG_UPDATE_INTERVAL)
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
# Enable entity
entity_registry.async_update_entity(state_key, disabled_by=None)
await hass.async_block_till_done()
freezer.tick(LONG_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(state_key) == snapshot assert hass.states.get(state_key) == snapshot
# Emulate state change # Emulate state change

View File

@ -15,7 +15,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR
from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.components.switch import DOMAIN as SWITCH
from homeassistant.components.update import DOMAIN as UPDATE from homeassistant.components.update import DOMAIN as UPDATE
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.entity_platform import async_get_platforms
@ -24,8 +24,6 @@ from . import configure_integration
from .const import IP from .const import IP
from .mock import MockDevice from .mock import MockDevice
from tests.common import MockConfigEntry
@pytest.mark.parametrize( @pytest.mark.parametrize(
"device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"]
@ -50,27 +48,6 @@ async def test_setup_entry(
assert device_info == snapshot assert device_info == snapshot
@pytest.mark.usefixtures("mock_device")
async def test_setup_without_password(hass: HomeAssistant) -> None:
"""Test setup entry without a device password set like used before HA Core 2022.06."""
config = {
CONF_IP_ADDRESS: IP,
}
entry = MockConfigEntry(domain=DOMAIN, data=config)
entry.add_to_hass(hass)
# Patching async_forward_entry_setup* is not advisable, and should be refactored
# in the future.
with (
patch(
"homeassistant.config_entries.ConfigEntries.async_forward_entry_setups",
return_value=True,
),
patch("homeassistant.core.EventBus.async_listen_once"),
):
assert await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
async def test_setup_device_not_found(hass: HomeAssistant) -> None: async def test_setup_device_not_found(hass: HomeAssistant) -> None:
"""Test setup entry.""" """Test setup entry."""
entry = configure_integration(hass) entry = configure_integration(hass)

View File

@ -1,7 +1,7 @@
"""Tests for the devolo Home Network switch.""" """Tests for the devolo Home Network switch."""
from datetime import timedelta from datetime import timedelta
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock
from devolo_plc_api.device_api import WifiGuestAccessGet from devolo_plc_api.device_api import WifiGuestAccessGet
from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable
@ -16,6 +16,7 @@ from homeassistant.components.devolo_home_network.const import (
from homeassistant.components.switch import DOMAIN as PLATFORM from homeassistant.components.switch import DOMAIN as PLATFORM
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
SERVICE_TURN_ON, SERVICE_TURN_ON,
STATE_OFF, STATE_OFF,
@ -106,18 +107,15 @@ async def test_update_enable_guest_wifi(
mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet(
enabled=False enabled=False
) )
with patch( await hass.services.async_call(
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access", PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True
new=AsyncMock(), )
) as turn_off:
await hass.services.async_call(
PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True
)
state = hass.states.get(state_key) state = hass.states.get(state_key)
assert state is not None assert state is not None
assert state.state == STATE_OFF assert state.state == STATE_OFF
turn_off.assert_called_once_with(False) mock_device.device.async_set_wifi_guest_access.assert_called_once_with(False)
mock_device.device.async_set_wifi_guest_access.reset_mock()
freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN)
async_fire_time_changed(hass) async_fire_time_changed(hass)
@ -127,18 +125,15 @@ async def test_update_enable_guest_wifi(
mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet( mock_device.device.async_get_wifi_guest_access.return_value = WifiGuestAccessGet(
enabled=True enabled=True
) )
with patch( await hass.services.async_call(
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access", PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True
new=AsyncMock(), )
) as turn_on:
await hass.services.async_call(
PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True
)
state = hass.states.get(state_key) state = hass.states.get(state_key)
assert state is not None assert state is not None
assert state.state == STATE_ON assert state.state == STATE_ON
turn_on.assert_called_once_with(True) mock_device.device.async_set_wifi_guest_access.assert_called_once_with(True)
mock_device.device.async_set_wifi_guest_access.reset_mock()
freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN)
async_fire_time_changed(hass) async_fire_time_changed(hass)
@ -146,17 +141,17 @@ async def test_update_enable_guest_wifi(
# Device unavailable # Device unavailable
mock_device.device.async_get_wifi_guest_access.side_effect = DeviceUnavailable() mock_device.device.async_get_wifi_guest_access.side_effect = DeviceUnavailable()
with patch( mock_device.device.async_set_wifi_guest_access.side_effect = DeviceUnavailable()
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access",
side_effect=DeviceUnavailable, with pytest.raises(
HomeAssistantError, match=f"Device {entry.title} did not respond"
): ):
await hass.services.async_call( await hass.services.async_call(
PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True
) )
state = hass.states.get(state_key)
state = hass.states.get(state_key) assert state is not None
assert state is not None assert state.state == STATE_UNAVAILABLE
assert state.state == STATE_UNAVAILABLE
await hass.config_entries.async_unload(entry.entry_id) await hass.config_entries.async_unload(entry.entry_id)
@ -191,18 +186,15 @@ async def test_update_enable_leds(
# Switch off # Switch off
mock_device.device.async_get_led_setting.return_value = False mock_device.device.async_get_led_setting.return_value = False
with patch( await hass.services.async_call(
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting", PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True
new=AsyncMock(), )
) as turn_off:
await hass.services.async_call(
PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True
)
state = hass.states.get(state_key) state = hass.states.get(state_key)
assert state is not None assert state is not None
assert state.state == STATE_OFF assert state.state == STATE_OFF
turn_off.assert_called_once_with(False) mock_device.device.async_set_led_setting.assert_called_once_with(False)
mock_device.device.async_set_led_setting.reset_mock()
freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN)
async_fire_time_changed(hass) async_fire_time_changed(hass)
@ -210,18 +202,15 @@ async def test_update_enable_leds(
# Switch on # Switch on
mock_device.device.async_get_led_setting.return_value = True mock_device.device.async_get_led_setting.return_value = True
with patch( await hass.services.async_call(
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting", PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True
new=AsyncMock(), )
) as turn_on:
await hass.services.async_call(
PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True
)
state = hass.states.get(state_key) state = hass.states.get(state_key)
assert state is not None assert state is not None
assert state.state == STATE_ON assert state.state == STATE_ON
turn_on.assert_called_once_with(True) mock_device.device.async_set_led_setting.assert_called_once_with(True)
mock_device.device.async_set_led_setting.reset_mock()
freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN) freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN)
async_fire_time_changed(hass) async_fire_time_changed(hass)
@ -229,17 +218,17 @@ async def test_update_enable_leds(
# Device unavailable # Device unavailable
mock_device.device.async_get_led_setting.side_effect = DeviceUnavailable() mock_device.device.async_get_led_setting.side_effect = DeviceUnavailable()
with patch( mock_device.device.async_set_led_setting.side_effect = DeviceUnavailable()
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting",
side_effect=DeviceUnavailable, with pytest.raises(
HomeAssistantError, match=f"Device {entry.title} did not respond"
): ):
await hass.services.async_call( await hass.services.async_call(
PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True
) )
state = hass.states.get(state_key)
state = hass.states.get(state_key) assert state is not None
assert state is not None assert state.state == STATE_UNAVAILABLE
assert state.state == STATE_UNAVAILABLE
await hass.config_entries.async_unload(entry.entry_id) await hass.config_entries.async_unload(entry.entry_id)
@ -308,7 +297,7 @@ async def test_auth_failed(
with pytest.raises(HomeAssistantError): with pytest.raises(HomeAssistantError):
await hass.services.async_call( await hass.services.async_call(
PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True
) )
await hass.async_block_till_done() await hass.async_block_till_done()

View File

@ -1058,6 +1058,7 @@ async def test_browse_media(
), ),
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"children_media_class": None, "children_media_class": None,
} }
@ -1070,6 +1071,7 @@ async def test_browse_media(
"media_content_id": "media-source://media_source/local/test.mp3", "media_content_id": "media-source://media_source/local/test.mp3",
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"children_media_class": None, "children_media_class": None,
} }
@ -1153,6 +1155,7 @@ async def test_browse_media_unfiltered(
), ),
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"children_media_class": None, "children_media_class": None,
} }
@ -1163,6 +1166,7 @@ async def test_browse_media_unfiltered(
"media_content_id": "media-source://media_source/local/test.mp3", "media_content_id": "media-source://media_source/local/test.mp3",
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"children_media_class": None, "children_media_class": None,
} }

View File

@ -103,6 +103,7 @@ ENTITY_IDS_BY_NUMBER = {
"26": "light.living_room_rgbww_lights", "26": "light.living_room_rgbww_lights",
"27": "media_player.group", "27": "media_player.group",
"28": "media_player.browse", "28": "media_player.browse",
"29": "media_player.search",
} }
ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()}

View File

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

View File

@ -70,6 +70,11 @@ def mock_power_sensor() -> Mock:
} }
sensor.actions = {} sensor.actions = {}
sensor.has_central_scene_event = False sensor.has_central_scene_event = False
sensor.raw_data = {
"fibaro_id": 1,
"name": "Test sensor",
"properties": {"power": 6.6, "password": "mysecret"},
}
value_mock = Mock() value_mock = Mock()
value_mock.has_value = False value_mock.has_value = False
value_mock.is_bool_value = False value_mock.is_bool_value = False
@ -123,6 +128,7 @@ def mock_light() -> Mock:
light.properties = {"manufacturer": ""} light.properties = {"manufacturer": ""}
light.actions = {"setValue": 1, "on": 0, "off": 0} light.actions = {"setValue": 1, "on": 0, "off": 0}
light.supported_features = {} light.supported_features = {}
light.raw_data = {"fibaro_id": 3, "name": "Test light", "properties": {"value": 20}}
value_mock = Mock() value_mock = Mock()
value_mock.has_value = True value_mock.has_value = True
value_mock.int_value.return_value = 20 value_mock.int_value.return_value = 20

View File

@ -0,0 +1,57 @@
# serializer version: 1
# name: test_config_entry_diagnostics
dict({
'config': dict({
'import_plugins': True,
}),
'fibaro_devices': list([
dict({
'fibaro_id': 3,
'name': 'Test light',
'properties': dict({
'value': 20,
}),
}),
]),
})
# ---
# name: test_device_diagnostics
dict({
'config': dict({
'import_plugins': True,
}),
'fibaro_devices': list([
dict({
'fibaro_id': 3,
'name': 'Test light',
'properties': dict({
'value': 20,
}),
}),
]),
})
# ---
# name: test_device_diagnostics_for_hub
dict({
'config': dict({
'import_plugins': True,
}),
'fibaro_devices': list([
dict({
'fibaro_id': 3,
'name': 'Test light',
'properties': dict({
'value': 20,
}),
}),
dict({
'fibaro_id': 1,
'name': 'Test sensor',
'properties': dict({
'password': '**REDACTED**',
'power': 6.6,
}),
}),
]),
})
# ---

View File

@ -0,0 +1,96 @@
"""Tests for the diagnostics data provided by the fibaro integration."""
from unittest.mock import Mock
from syrupy import SnapshotAssertion
from homeassistant.components.fibaro import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .conftest import TEST_SERIALNUMBER, init_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_config_entry_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_light: Mock,
mock_room: Mock,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_fibaro_client.read_devices.return_value = [mock_light]
# Act
await init_integration(hass, mock_config_entry)
# Assert
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)
async def test_device_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_light: Mock,
mock_room: Mock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_fibaro_client.read_devices.return_value = [mock_light]
# Act
await init_integration(hass, mock_config_entry)
entry = entity_registry.async_get("light.room_1_test_light_3")
device = device_registry.async_get(entry.device_id)
# Assert
assert device
assert (
await get_diagnostics_for_device(hass, hass_client, mock_config_entry, device)
== snapshot
)
async def test_device_diagnostics_for_hub(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_fibaro_client: Mock,
mock_config_entry: MockConfigEntry,
mock_light: Mock,
mock_power_sensor: Mock,
mock_room: Mock,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics for the hub."""
# Arrange
mock_fibaro_client.read_rooms.return_value = [mock_room]
mock_fibaro_client.read_devices.return_value = [mock_light, mock_power_sensor]
# Act
await init_integration(hass, mock_config_entry)
device = device_registry.async_get_device({(DOMAIN, TEST_SERIALNUMBER)})
# Assert
assert device
assert (
await get_diagnostics_for_device(hass, hass_client, mock_config_entry, device)
== snapshot
)

View File

@ -184,6 +184,7 @@ async def test_browse_media(
"media_content_id": "media-source://media_source/local/test.mp3", "media_content_id": "media-source://media_source/local/test.mp3",
"can_play": True, "can_play": True,
"can_expand": False, "can_expand": False,
"can_search": False,
"thumbnail": None, "thumbnail": None,
"children_media_class": None, "children_media_class": None,
} }

View File

@ -259,6 +259,13 @@ DEMO_DEVICES = [
"type": "action.devices.types.SETTOP", "type": "action.devices.types.SETTOP",
"willReportState": False, "willReportState": False,
}, },
{
"id": "media_player.search",
"name": {"name": "Search"},
"traits": ["action.devices.traits.MediaState", "action.devices.traits.OnOff"],
"type": "action.devices.types.SETTOP",
"willReportState": False,
},
{ {
"id": "fan.living_room_fan", "id": "fan.living_room_fan",
"name": {"name": "Living Room Fan"}, "name": {"name": "Living Room Fan"},

View File

@ -3,10 +3,12 @@
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children': list([ 'children': list([
dict({ dict({
'can_expand': False, 'can_expand': False,
'can_play': True, 'can_play': True,
'can_search': False,
'children_media_class': None, 'children_media_class': None,
'media_class': 'track', 'media_class': 'track',
'media_content_id': 'heos://media/1/station?name=Today%27s+Hits+Radio&image_url=&playable=True&browsable=False&media_id=123456789', 'media_content_id': 'heos://media/1/station?name=Today%27s+Hits+Radio&image_url=&playable=True&browsable=False&media_id=123456789',
@ -28,6 +30,7 @@
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children': list([ 'children': list([
]), ]),
'children_media_class': None, 'children_media_class': None,
@ -43,10 +46,12 @@
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children': list([ 'children': list([
dict({ dict({
'can_expand': False, 'can_expand': False,
'can_play': True, 'can_play': True,
'can_search': False,
'children_media_class': None, 'children_media_class': None,
'media_class': 'music', 'media_class': 'music',
'media_content_id': 'media-source://media_source/local/test.mp3', 'media_content_id': 'media-source://media_source/local/test.mp3',
@ -68,10 +73,12 @@
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children': list([ 'children': list([
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children_media_class': None, 'children_media_class': None,
'media_class': 'directory', 'media_class': 'directory',
'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user',
@ -82,6 +89,7 @@
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children_media_class': None, 'children_media_class': None,
'media_class': 'directory', 'media_class': 'directory',
'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False',
@ -92,6 +100,7 @@
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children_media_class': 'music', 'children_media_class': 'music',
'media_class': 'directory', 'media_class': 'directory',
'media_content_id': 'media-source://media_source/local/.', 'media_content_id': 'media-source://media_source/local/.',
@ -113,10 +122,12 @@
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children': list([ 'children': list([
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children_media_class': None, 'children_media_class': None,
'media_class': 'directory', 'media_class': 'directory',
'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user', 'media_content_id': 'heos://media/1/music_service?name=Pandora&image_url=&available=True&service_username=user',
@ -127,6 +138,7 @@
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children_media_class': None, 'children_media_class': None,
'media_class': 'directory', 'media_class': 'directory',
'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False', 'media_content_id': 'heos://media/3/music_service?name=TuneIn&image_url=&available=False',
@ -148,6 +160,7 @@
dict({ dict({
'can_expand': True, 'can_expand': True,
'can_play': False, 'can_play': False,
'can_search': False,
'children': list([ 'children': list([
]), ]),
'children_media_class': 'directory', 'children_media_class': 'directory',

Some files were not shown because too many files have changed in this diff Show More