mirror of
https://github.com/home-assistant/core.git
synced 2025-08-02 10:08:23 +00:00
Compare commits
No commits in common. "9b9c10b0c7df2b24b4fa292b206cf4cf61c177e8" and "e4978b467cc3d40168ad647fcf5aaf16de3f9e1e" have entirely different histories.
9b9c10b0c7
...
e4978b467c
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@ -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 @roberty99
|
/homeassistant/components/ephember/ @ttroy50
|
||||||
/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
|
||||||
|
@ -120,7 +120,6 @@ 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
|
||||||
@ -210,7 +209,6 @@ 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
|
||||||
@ -318,7 +316,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 self._playing_last_updated
|
return dt_util.utcnow()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def async_play_media(
|
async def async_play_media(
|
||||||
|
@ -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.39.0"
|
"habluetooth==3.38.1"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -41,7 +41,6 @@ async def async_setup_entry(
|
|||||||
DemoTVShowPlayer(),
|
DemoTVShowPlayer(),
|
||||||
DemoBrowsePlayer("Browse"),
|
DemoBrowsePlayer("Browse"),
|
||||||
DemoGroupPlayer("Group"),
|
DemoGroupPlayer("Group"),
|
||||||
DemoSearchPlayer("Search"),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -96,8 +95,6 @@ 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."""
|
||||||
@ -401,9 +398,3 @@ 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
|
|
||||||
|
@ -88,8 +88,6 @@ 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]],
|
||||||
@ -125,6 +123,13 @@ 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."""
|
||||||
|
@ -13,14 +13,6 @@
|
|||||||
"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"
|
||||||
|
@ -114,14 +114,9 @@ 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 as ex:
|
except DeviceUnavailable:
|
||||||
raise HomeAssistantError(
|
pass # The coordinator will handle this
|
||||||
translation_domain=DOMAIN,
|
await self.coordinator.async_request_refresh()
|
||||||
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."""
|
||||||
@ -134,11 +129,6 @@ 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 as ex:
|
except DeviceUnavailable:
|
||||||
raise HomeAssistantError(
|
pass # The coordinator will handle this
|
||||||
translation_domain=DOMAIN,
|
await self.coordinator.async_request_refresh()
|
||||||
translation_key="no_response",
|
|
||||||
translation_placeholders={"title": self.entry.title},
|
|
||||||
) from ex
|
|
||||||
finally:
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
@ -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.1.0"],
|
"requirements": ["eheimdigital==1.0.6"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
|
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
|
||||||
]
|
]
|
||||||
|
@ -6,13 +6,13 @@ from datetime import timedelta
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pyephember2.pyephember2 import (
|
from pyephember.pyephember 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_hotwater,
|
zone_is_hot_water,
|
||||||
zone_mode,
|
zone_mode,
|
||||||
zone_name,
|
zone_name,
|
||||||
zone_target_temperature,
|
zone_target_temperature,
|
||||||
@ -69,18 +69,14 @@ 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 login to EphEmber")
|
_LOGGER.error("Cannot connect to EphEmber")
|
||||||
|
|
||||||
try:
|
|
||||||
homes = ember.get_zones()
|
|
||||||
except RuntimeError:
|
|
||||||
_LOGGER.error("Fail to get zones")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
add_entities(
|
return
|
||||||
EphEmberThermostat(ember, zone) for home in homes for zone in home["zones"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EphEmberThermostat(ClimateEntity):
|
class EphEmberThermostat(ClimateEntity):
|
||||||
@ -89,35 +85,33 @@ 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) -> None:
|
def __init__(self, ember, zone):
|
||||||
"""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
|
||||||
else:
|
self._attr_supported_features |= (
|
||||||
self._attr_target_temperature_step = 0.5
|
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
|
||||||
self._attr_supported_features = (
|
)
|
||||||
ClimateEntityFeature.TURN_OFF
|
|
||||||
| ClimateEntityFeature.TURN_ON
|
|
||||||
| ClimateEntityFeature.TARGET_TEMPERATURE
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_temperature(self) -> float | None:
|
def current_temperature(self):
|
||||||
"""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) -> float | None:
|
def target_temperature(self):
|
||||||
"""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)
|
||||||
|
|
||||||
@ -139,12 +133,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_zone_mode(self._zone["zoneid"], mode)
|
self._ember.set_mode_by_name(self._zone_name, 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) -> bool:
|
def is_aux_heat(self):
|
||||||
"""Return true if aux heater."""
|
"""Return true if aux heater."""
|
||||||
|
|
||||||
return zone_is_boost_active(self._zone)
|
return zone_is_boost_active(self._zone)
|
||||||
@ -173,7 +167,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_zone_target_temperature(self._zone["zoneid"], temperature)
|
self._ember.set_target_temperture_by_name(self._zone_name, temperature)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def min_temp(self):
|
def min_temp(self):
|
||||||
@ -194,8 +188,7 @@ class EphEmberThermostat(ClimateEntity):
|
|||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
"""Get the latest data."""
|
"""Get the latest data."""
|
||||||
self._ember.get_zones()
|
self._zone = self._ember.get_zone(self._zone_name)
|
||||||
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):
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"domain": "ephember",
|
"domain": "ephember",
|
||||||
"name": "EPH Controls",
|
"name": "EPH Controls",
|
||||||
"codeowners": ["@ttroy50", "@roberty99"],
|
"codeowners": ["@ttroy50"],
|
||||||
"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": ["pyephember2"],
|
"loggers": ["pyephember"],
|
||||||
"quality_scale": "legacy",
|
"quality_scale": "legacy",
|
||||||
"requirements": ["pyephember2==0.4.12"]
|
"requirements": ["pyephember==0.3.1"]
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from aioesphomeapi import APIClient
|
from aioesphomeapi import APIClient
|
||||||
|
|
||||||
from homeassistant.components import zeroconf
|
from homeassistant.components import ffmpeg, 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,10 +17,13 @@ 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 . import dashboard, ffmpeg_proxy
|
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN
|
||||||
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
|
from .dashboard import async_setup as async_setup_dashboard
|
||||||
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)
|
||||||
@ -30,8 +33,12 @@ 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."""
|
||||||
ffmpeg_proxy.async_setup(hass)
|
proxy_data = hass.data[DATA_FFMPEG_PROXY] = FFmpegProxyData()
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
@ -47,7 +47,6 @@ 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"
|
||||||
@ -609,7 +608,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
@callback
|
@callback
|
||||||
def async_get_options_flow(
|
def async_get_options_flow(
|
||||||
config_entry: ESPHomeConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
) -> OptionsFlowHandler:
|
) -> OptionsFlowHandler:
|
||||||
"""Get the options flow for this handler."""
|
"""Get the options flow for this handler."""
|
||||||
return OptionsFlowHandler()
|
return OptionsFlowHandler()
|
||||||
|
@ -22,3 +22,5 @@ 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"
|
||||||
|
@ -28,8 +28,6 @@ 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
|
||||||
@ -169,12 +167,7 @@ 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(
|
||||||
translation_domain=DOMAIN,
|
f"Error communicating with device: {error}"
|
||||||
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
|
||||||
@ -201,7 +194,6 @@ 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,
|
||||||
|
@ -11,20 +11,17 @@ 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, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.util.hass_dict import HassKey
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DATA_FFMPEG_PROXY
|
||||||
|
|
||||||
_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,
|
||||||
@ -35,7 +32,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 = hass.data[DATA_FFMPEG_PROXY]
|
data: FFmpegProxyData = 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
|
||||||
)
|
)
|
||||||
@ -316,16 +313,3 @@ 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)
|
|
||||||
)
|
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -17,7 +17,7 @@
|
|||||||
"mqtt": ["esphome/discover/#"],
|
"mqtt": ["esphome/discover/#"],
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aioesphomeapi==30.0.1",
|
"aioesphomeapi==30.0.1",
|
||||||
"esphome-dashboard-api==1.3.0",
|
"esphome-dashboard-api==1.2.3",
|
||||||
"bleak-esphome==2.13.1"
|
"bleak-esphome==2.13.1"
|
||||||
],
|
],
|
||||||
"zeroconf": ["_esphomelib._tcp.local."]
|
"zeroconf": ["_esphomelib._tcp.local."]
|
||||||
|
@ -148,6 +148,10 @@ 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:
|
||||||
|
@ -3,11 +3,10 @@ rules:
|
|||||||
action-setup:
|
action-setup:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
Since actions are defined per device, rather than per integration,
|
Actions are per device and not per integration.
|
||||||
they are specific to the device's YAML configuration. Additionally,
|
ESPHome supports user defined actions which come
|
||||||
ESPHome allows for user-defined actions, making it impossible to
|
from the device YAML configuration.
|
||||||
set them up until the device is connected as they vary by device. For more
|
https://esphome.io/components/api.html#user-defined-actions
|
||||||
information, see: https://esphome.io/components/api.html#user-defined-actions
|
|
||||||
appropriate-polling: done
|
appropriate-polling: done
|
||||||
brands: done
|
brands: done
|
||||||
common-modules: done
|
common-modules: done
|
||||||
@ -17,11 +16,10 @@ rules:
|
|||||||
docs-actions:
|
docs-actions:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
Since actions are defined per device, rather than per integration,
|
Actions are per device and not per integration.
|
||||||
they are specific to the device's YAML configuration. Additionally,
|
ESPHome supports user defined actions which come
|
||||||
ESPHome allows for user-defined actions, making it difficult to provide
|
from the device YAML configuration.
|
||||||
standard documentation since these actions vary by device. For more
|
https://esphome.io/components/api.html#user-defined-actions
|
||||||
information, see: https://esphome.io/components/api.html#user-defined-actions
|
|
||||||
docs-high-level-description: done
|
docs-high-level-description: done
|
||||||
docs-installation-instructions: done
|
docs-installation-instructions: done
|
||||||
docs-removal-instructions: done
|
docs-removal-instructions: done
|
||||||
@ -33,16 +31,16 @@ rules:
|
|||||||
test-before-setup:
|
test-before-setup:
|
||||||
status: exempt
|
status: exempt
|
||||||
comment: |
|
comment: |
|
||||||
ESPHome relies on sleepy devices and fast reconnect logic, so we
|
ESPHome uses sleepy devices and fast reconnect logic
|
||||||
can't raise `ConfigEntryNotReady`. Instead, we need to utilize the
|
so we cannot raise ConfigEntryNotReady as we need to use
|
||||||
reconnect logic in `aioesphomeapi` to determine the right moment
|
the reconnect logic in aioesphomeapi to know when to trigger
|
||||||
to trigger the connection.
|
connections.
|
||||||
unique-config-entry: done
|
unique-config-entry: done
|
||||||
# Silver
|
# Silver
|
||||||
action-exceptions: done
|
action-exceptions: done
|
||||||
config-entry-unloading: done
|
config-entry-unloading: done
|
||||||
docs-configuration-parameters: done
|
docs-configuration-parameters: done
|
||||||
docs-installation-parameters: done
|
docs-installation-parameters: todo
|
||||||
entity-unavailable: done
|
entity-unavailable: done
|
||||||
integration-owner: done
|
integration-owner: done
|
||||||
log-when-unavailable: done
|
log-when-unavailable: done
|
||||||
@ -55,26 +53,28 @@ rules:
|
|||||||
diagnostics: done
|
diagnostics: done
|
||||||
discovery-update-info: done
|
discovery-update-info: done
|
||||||
discovery: done
|
discovery: done
|
||||||
docs-data-update: done
|
docs-data-update: todo
|
||||||
docs-examples:
|
docs-examples: todo
|
||||||
status: exempt
|
docs-known-limitations: todo
|
||||||
comment: |
|
docs-supported-devices: todo
|
||||||
Since ESPHome is a framework for creating custom devices, the
|
docs-supported-functions: todo
|
||||||
possibilities are virtually limitless. As a result, example
|
docs-troubleshooting: todo
|
||||||
automations would likely only be relevant to the specific user
|
docs-use-cases: todo
|
||||||
of the device and not generally useful to others.
|
|
||||||
docs-known-limitations: done
|
|
||||||
docs-supported-devices: done
|
|
||||||
docs-supported-functions: done
|
|
||||||
docs-troubleshooting: done
|
|
||||||
docs-use-cases: done
|
|
||||||
dynamic-devices: done
|
dynamic-devices: done
|
||||||
entity-category: done
|
entity-category: done
|
||||||
entity-device-class: done
|
entity-device-class: done
|
||||||
entity-disabled-by-default: done
|
entity-disabled-by-default: done
|
||||||
entity-translations: done
|
entity-translations:
|
||||||
exception-translations: done
|
status: exempt
|
||||||
icon-translations: done
|
comment: |
|
||||||
|
entities cannot be translated because they are
|
||||||
|
provided by the device YAML configuration.
|
||||||
|
exception-translations: todo
|
||||||
|
icon-translations:
|
||||||
|
status: exempt
|
||||||
|
comment: |
|
||||||
|
icons cannot be translated because they are
|
||||||
|
provided by the device YAML configuration.
|
||||||
reconfiguration-flow: todo
|
reconfiguration-flow: todo
|
||||||
repair-issues: done
|
repair-issues: done
|
||||||
stale-devices: done
|
stale-devices: done
|
||||||
@ -82,4 +82,4 @@ rules:
|
|||||||
# Platinum
|
# Platinum
|
||||||
async-dependency: done
|
async-dependency: done
|
||||||
inject-websession: done
|
inject-websession: done
|
||||||
strict-typing: done
|
strict-typing: todo
|
||||||
|
@ -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: ESPHomeConfigEntry,
|
entry: ConfigEntry,
|
||||||
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."""
|
||||||
|
@ -184,15 +184,6 @@
|
|||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ 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
|
||||||
@ -26,7 +27,6 @@ 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 ESPHomeConfigEntry, RuntimeEntryData
|
from .entry_data import 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: ESPHomeConfigEntry,
|
entry: ConfigEntry,
|
||||||
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,23 +202,16 @@ 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(configuration):
|
if not await api.compile(device["configuration"]):
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
f"Error compiling {device['configuration']}; "
|
||||||
translation_key="error_compiling",
|
"Try again in ESPHome dashboard for more information."
|
||||||
translation_placeholders={
|
|
||||||
"configuration": configuration,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if not await api.upload(configuration, "OTA"):
|
if not await api.upload(device["configuration"], "OTA"):
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
f"Error updating {device['configuration']} via OTA; "
|
||||||
translation_key="error_uploading",
|
"Try again in ESPHome dashboard for more information."
|
||||||
translation_placeholders={
|
|
||||||
"configuration": configuration,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await self.coordinator.async_request_refresh()
|
await self.coordinator.async_request_refresh()
|
||||||
|
@ -211,10 +211,6 @@ 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
|
||||||
|
@ -1,56 +0,0 @@
|
|||||||
"""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)
|
|
@ -252,7 +252,9 @@ 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 not in self.data:
|
if event_message_ha_id 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 (
|
||||||
|
@ -17,6 +17,7 @@ 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,
|
||||||
@ -312,7 +313,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.programs
|
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
|
||||||
else []
|
else []
|
||||||
),
|
),
|
||||||
*[
|
*[
|
||||||
|
@ -136,7 +136,7 @@
|
|||||||
"state_attributes": {
|
"state_attributes": {
|
||||||
"preset_mode": {
|
"preset_mode": {
|
||||||
"state": {
|
"state": {
|
||||||
"manual": "[%key:common::state::manual%]"
|
"manual": "Manual"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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": "[%key:common::state::manual%]"
|
"manual": "Manual"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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 import (
|
from pylamarzocco.clients.bluetooth import LaMarzoccoBluetoothClient
|
||||||
LaMarzoccoBluetoothClient,
|
from pylamarzocco.clients.cloud import LaMarzoccoCloudClient
|
||||||
LaMarzoccoCloudClient,
|
from pylamarzocco.clients.local import LaMarzoccoLocalClient
|
||||||
LaMarzoccoMachine,
|
from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType
|
||||||
)
|
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,
|
||||||
LaMarzoccoScheduleUpdateCoordinator,
|
LaMarzoccoStatisticsUpdateCoordinator,
|
||||||
LaMarzoccoSettingsUpdateCoordinator,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
@ -40,12 +40,11 @@ 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__)
|
||||||
|
|
||||||
|
|
||||||
@ -62,23 +61,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
|||||||
client=client,
|
client=client,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# initialize the firmware update coordinator early to check the firmware version
|
||||||
settings = await cloud_client.get_thing_settings(serial)
|
firmware_device = LaMarzoccoMachine(
|
||||||
except AuthFail as ex:
|
model=entry.data[CONF_MODEL],
|
||||||
raise ConfigEntryAuthFailed(
|
serial_number=entry.unique_id,
|
||||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
name=entry.data[CONF_NAME],
|
||||||
) from ex
|
cloud_client=cloud_client,
|
||||||
except RequestNotSuccessful as ex:
|
|
||||||
_LOGGER.debug(ex, exc_info=True)
|
|
||||||
raise ConfigEntryNotReady(
|
|
||||||
translation_domain=DOMAIN, translation_key="api_error"
|
|
||||||
) from ex
|
|
||||||
|
|
||||||
gateway_version = version.parse(
|
|
||||||
settings.firmwares[FirmwareType.GATEWAY].build_version
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if gateway_version < version.parse("v5.0.9"):
|
firmware_coordinator = LaMarzoccoFirmwareUpdateCoordinator(
|
||||||
|
hass, entry, firmware_device
|
||||||
|
)
|
||||||
|
await firmware_coordinator.async_config_entry_first_refresh()
|
||||||
|
gateway_version = version.parse(
|
||||||
|
firmware_device.firmware[FirmwareType.GATEWAY].current_version
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
@ -90,12 +97,24 @@ 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) and (
|
if entry.options.get(CONF_USE_BLUETOOTH, True):
|
||||||
token := settings.ble_auth_token
|
|
||||||
):
|
def bluetooth_configured() -> bool:
|
||||||
if CONF_MAC not in entry.data:
|
return entry.data.get(CONF_MAC, "") and entry.data.get(CONF_NAME, "")
|
||||||
|
|
||||||
|
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)
|
||||||
@ -109,43 +128,38 @@ 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 not entry.data[CONF_TOKEN]:
|
if bluetooth_configured():
|
||||||
# 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),
|
LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client),
|
||||||
LaMarzoccoSettingsUpdateCoordinator(hass, entry, device),
|
firmware_coordinator,
|
||||||
LaMarzoccoScheduleUpdateCoordinator(hass, entry, device),
|
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.gather(
|
# API does not like concurrent requests, so no asyncio.gather here
|
||||||
coordinators.config_coordinator.async_config_entry_first_refresh(),
|
await coordinators.config_coordinator.async_config_entry_first_refresh()
|
||||||
coordinators.settings_coordinator.async_config_entry_first_refresh(),
|
await coordinators.statistics_coordinator.async_config_entry_first_refresh()
|
||||||
coordinators.schedule_coordinator.async_config_entry_first_refresh(),
|
|
||||||
)
|
|
||||||
|
|
||||||
entry.runtime_data = coordinators
|
entry.runtime_data = coordinators
|
||||||
|
|
||||||
@ -170,45 +184,41 @@ async def async_migrate_entry(
|
|||||||
hass: HomeAssistant, entry: LaMarzoccoConfigEntry
|
hass: HomeAssistant, entry: LaMarzoccoConfigEntry
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Migrate config entry."""
|
"""Migrate config entry."""
|
||||||
if entry.version > 3:
|
if entry.version > 2:
|
||||||
# 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:
|
||||||
things = await cloud_client.list_things()
|
fleet = await cloud_client.get_customer_fleet()
|
||||||
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_TOKEN: next(
|
CONF_MODEL: device.model,
|
||||||
(
|
CONF_NAME: device.name,
|
||||||
thing.ble_auth_token
|
CONF_TOKEN: device.communication_key,
|
||||||
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:
|
||||||
v3_data[CONF_MAC] = entry.data[CONF_MAC]
|
v2_data[CONF_MAC] = entry.data[CONF_MAC]
|
||||||
|
|
||||||
hass.config_entries.async_update_entry(
|
hass.config_entries.async_update_entry(
|
||||||
entry,
|
entry,
|
||||||
data=v3_data,
|
data=v2_data,
|
||||||
version=3,
|
version=2,
|
||||||
)
|
)
|
||||||
_LOGGER.debug("Migrated La Marzocco config entry to version 2")
|
_LOGGER.debug("Migrated La Marzocco config entry to version 2")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
@ -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 cast
|
|
||||||
|
|
||||||
from pylamarzocco.const import BackFlushStatus, MachineState, WidgetType
|
from pylamarzocco.const import MachineModel
|
||||||
from pylamarzocco.models import BackFlush, BaseWidgetOutput, MachineStatus
|
from pylamarzocco.models import LaMarzoccoMachineConfig
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
BinarySensorDeviceClass,
|
BinarySensorDeviceClass,
|
||||||
@ -17,7 +16,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
|
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
|
||||||
|
|
||||||
# Coordinator is used to centralize the data updates
|
# Coordinator is used to centralize the data updates
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
@ -30,7 +29,7 @@ class LaMarzoccoBinarySensorEntityDescription(
|
|||||||
):
|
):
|
||||||
"""Description of a La Marzocco binary sensor."""
|
"""Description of a La Marzocco binary sensor."""
|
||||||
|
|
||||||
is_on_fn: Callable[[dict[WidgetType, BaseWidgetOutput]], bool | None]
|
is_on_fn: Callable[[LaMarzoccoMachineConfig], bool | None]
|
||||||
|
|
||||||
|
|
||||||
ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
|
ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
|
||||||
@ -38,30 +37,32 @@ 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: WidgetType.CM_NO_WATER in config,
|
is_on_fn=lambda config: not config.water_contact,
|
||||||
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=(
|
is_on_fn=lambda config: config.brew_active,
|
||||||
lambda config: cast(
|
available_fn=lambda device: device.websocket_connected,
|
||||||
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=(
|
is_on_fn=lambda config: config.backflush_enabled,
|
||||||
lambda config: cast(BackFlush, config[WidgetType.CM_BACK_FLUSH]).status
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -75,11 +76,30 @@ 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
|
||||||
|
|
||||||
async_add_entities(
|
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):
|
||||||
@ -90,6 +110,12 @@ 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(
|
return self.entity_description.is_on_fn(self.coordinator.device.config)
|
||||||
self.coordinator.device.dashboard.config
|
|
||||||
)
|
|
||||||
|
class LaMarzoccoScaleBinarySensorEntity(
|
||||||
|
LaMarzoccoBinarySensorEntity, LaMarzoccScaleEntity
|
||||||
|
):
|
||||||
|
"""Binary sensor for La Marzocco scales."""
|
||||||
|
|
||||||
|
entity_description: LaMarzoccoBinarySensorEntityDescription
|
||||||
|
@ -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.const import WeekDay
|
from pylamarzocco.models import LaMarzoccoWakeUpSleepEntry
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
WEEKDAY_TO_ENUM = {
|
DAY_OF_WEEK = [
|
||||||
0: WeekDay.MONDAY,
|
"monday",
|
||||||
1: WeekDay.TUESDAY,
|
"tuesday",
|
||||||
2: WeekDay.WEDNESDAY,
|
"wednesday",
|
||||||
3: WeekDay.THURSDAY,
|
"thursday",
|
||||||
4: WeekDay.FRIDAY,
|
"friday",
|
||||||
5: WeekDay.SATURDAY,
|
"saturday",
|
||||||
6: WeekDay.SUNDAY,
|
"sunday",
|
||||||
}
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@ -36,12 +36,10 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up switch entities and services."""
|
"""Set up switch entities and services."""
|
||||||
|
|
||||||
coordinator = entry.runtime_data.schedule_coordinator
|
coordinator = entry.runtime_data.config_coordinator
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, schedule.identifier)
|
LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, wake_up_sleep_entry)
|
||||||
for schedule in coordinator.device.schedule.smart_wake_up_sleep.schedules
|
for wake_up_sleep_entry in coordinator.device.config.wake_up_sleep_entries.values()
|
||||||
if schedule.identifier
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -54,12 +52,12 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity):
|
|||||||
self,
|
self,
|
||||||
coordinator: LaMarzoccoUpdateCoordinator,
|
coordinator: LaMarzoccoUpdateCoordinator,
|
||||||
key: str,
|
key: str,
|
||||||
identifier: str,
|
wake_up_sleep_entry: LaMarzoccoWakeUpSleepEntry,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up calendar."""
|
"""Set up calendar."""
|
||||||
super().__init__(coordinator, f"{key}_{identifier}")
|
super().__init__(coordinator, f"{key}_{wake_up_sleep_entry.entry_id}")
|
||||||
self._identifier = identifier
|
self.wake_up_sleep_entry = wake_up_sleep_entry
|
||||||
self._attr_translation_placeholders = {"id": identifier}
|
self._attr_translation_placeholders = {"id": wake_up_sleep_entry.entry_id}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def event(self) -> CalendarEvent | None:
|
def event(self) -> CalendarEvent | None:
|
||||||
@ -114,31 +112,24 @@ 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 schedule_entry.enabled:
|
if not self.wake_up_sleep_entry.enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# parse the schedule for the day
|
# parse the schedule for the day
|
||||||
|
|
||||||
if WEEKDAY_TO_ENUM[date.weekday()] not in schedule_entry.days:
|
if DAY_OF_WEEK[date.weekday()] not in self.wake_up_sleep_entry.days:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
hour_on = schedule_entry.on_time_minutes // 60
|
hour_on, minute_on = self.wake_up_sleep_entry.time_on.split(":")
|
||||||
minute_on = schedule_entry.on_time_minutes % 60
|
hour_off, minute_off = self.wake_up_sleep_entry.time_off.split(":")
|
||||||
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),
|
||||||
|
@ -7,9 +7,10 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
from pylamarzocco import LaMarzoccoCloudClient
|
from pylamarzocco.clients.cloud import LaMarzoccoCloudClient
|
||||||
|
from pylamarzocco.clients.local import LaMarzoccoLocalClient
|
||||||
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
||||||
from pylamarzocco.models import Thing
|
from pylamarzocco.models import LaMarzoccoDeviceInfo
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.bluetooth import (
|
from homeassistant.components.bluetooth import (
|
||||||
@ -25,7 +26,9 @@ 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,
|
||||||
@ -56,14 +59,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 = 3
|
VERSION = 2
|
||||||
|
|
||||||
_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._things: dict[str, Thing] = {}
|
self._fleet: dict[str, LaMarzoccoDeviceInfo] = {}
|
||||||
self._discovered: dict[str, str] = {}
|
self._discovered: dict[str, str] = {}
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
@ -80,6 +83,7 @@ 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)
|
||||||
@ -89,7 +93,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
client=self._client,
|
client=self._client,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
things = await cloud_client.list_things()
|
self._fleet = await cloud_client.get_customer_fleet()
|
||||||
except AuthFail:
|
except AuthFail:
|
||||||
_LOGGER.debug("Server rejected login credentials")
|
_LOGGER.debug("Server rejected login credentials")
|
||||||
errors["base"] = "invalid_auth"
|
errors["base"] = "invalid_auth"
|
||||||
@ -97,30 +101,37 @@ 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:
|
||||||
self._things = {thing.serial_number: thing for thing in things}
|
if not self._fleet:
|
||||||
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._things:
|
if self._discovered[CONF_MACHINE] not in self._fleet:
|
||||||
errors["base"] = "machine_not_found"
|
errors["base"] = "machine_not_found"
|
||||||
else:
|
else:
|
||||||
# store discovered connection address
|
self._config = data
|
||||||
if CONF_MAC in self._discovered:
|
# if DHCP discovery was used, auto fill machine selection
|
||||||
self._config[CONF_MAC] = self._discovered[CONF_MAC]
|
if CONF_HOST in self._discovered:
|
||||||
if CONF_ADDRESS in self._discovered:
|
return await self.async_step_machine_selection(
|
||||||
self._config[CONF_ADDRESS] = self._discovered[CONF_ADDRESS]
|
user_input={
|
||||||
|
CONF_HOST: self._discovered[CONF_HOST],
|
||||||
return await self.async_step_machine_selection(
|
CONF_MACHINE: self._discovered[CONF_MACHINE],
|
||||||
user_input={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}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
@ -164,7 +175,18 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
else:
|
else:
|
||||||
serial_number = self._discovered[CONF_MACHINE]
|
serial_number = self._discovered[CONF_MACHINE]
|
||||||
|
|
||||||
selected_device = self._things[serial_number]
|
selected_device = self._fleet[serial_number]
|
||||||
|
|
||||||
|
# validate local connection if host is provided
|
||||||
|
if user_input.get(CONF_HOST):
|
||||||
|
if not await LaMarzoccoLocalClient.validate_connection(
|
||||||
|
client=self._client,
|
||||||
|
host=user_input[CONF_HOST],
|
||||||
|
token=selected_device.communication_key,
|
||||||
|
):
|
||||||
|
errors[CONF_HOST] = "cannot_connect"
|
||||||
|
else:
|
||||||
|
self._config[CONF_HOST] = user_input[CONF_HOST]
|
||||||
|
|
||||||
if not errors:
|
if not errors:
|
||||||
if self.source == SOURCE_RECONFIGURE:
|
if self.source == SOURCE_RECONFIGURE:
|
||||||
@ -178,16 +200,18 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
title=selected_device.name,
|
title=selected_device.name,
|
||||||
data={
|
data={
|
||||||
**self._config,
|
**self._config,
|
||||||
CONF_TOKEN: self._things[serial_number].ble_auth_token,
|
CONF_NAME: selected_device.name,
|
||||||
|
CONF_MODEL: selected_device.model,
|
||||||
|
CONF_TOKEN: selected_device.communication_key,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
machine_options = [
|
machine_options = [
|
||||||
SelectOptionDict(
|
SelectOptionDict(
|
||||||
value=thing.serial_number,
|
value=device.serial_number,
|
||||||
label=f"{thing.name} ({thing.serial_number})",
|
label=f"{device.model} ({device.serial_number})",
|
||||||
)
|
)
|
||||||
for thing in self._things.values()
|
for device in self._fleet.values()
|
||||||
]
|
]
|
||||||
|
|
||||||
machine_selection_schema = vol.Schema(
|
machine_selection_schema = vol.Schema(
|
||||||
@ -200,6 +224,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
mode=SelectSelectorMode.DROPDOWN,
|
mode=SelectSelectorMode.DROPDOWN,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
vol.Optional(CONF_HOST): cv.string,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -279,6 +304,7 @@ 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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -290,8 +316,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()
|
||||||
|
@ -3,25 +3,28 @@
|
|||||||
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 import LaMarzoccoMachine
|
from pylamarzocco.clients.local import LaMarzoccoLocalClient
|
||||||
|
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
|
from homeassistant.core import HomeAssistant, callback
|
||||||
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)
|
||||||
SETTINGS_UPDATE_INTERVAL = timedelta(hours=1)
|
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=1)
|
||||||
SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5)
|
STATISTICS_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -30,8 +33,8 @@ class LaMarzoccoRuntimeData:
|
|||||||
"""Runtime data for La Marzocco."""
|
"""Runtime data for La Marzocco."""
|
||||||
|
|
||||||
config_coordinator: LaMarzoccoConfigUpdateCoordinator
|
config_coordinator: LaMarzoccoConfigUpdateCoordinator
|
||||||
settings_coordinator: LaMarzoccoSettingsUpdateCoordinator
|
firmware_coordinator: LaMarzoccoFirmwareUpdateCoordinator
|
||||||
schedule_coordinator: LaMarzoccoScheduleUpdateCoordinator
|
statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData]
|
type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData]
|
||||||
@ -48,6 +51,7 @@ 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__(
|
||||||
@ -58,6 +62,9 @@ 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."""
|
||||||
@ -82,22 +89,30 @@ 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 not self.device.websocket.connected:
|
if self._local_client is not None and (
|
||||||
|
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.connect_dashboard_websocket(
|
target=self.device.websocket_connect(
|
||||||
update_callback=lambda _: self.async_set_updated_data(None)
|
notify_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 self.device.websocket.connected:
|
if (
|
||||||
await self.device.websocket.disconnect()
|
self._local_client is not None
|
||||||
|
and self._local_client.websocket is not None
|
||||||
|
and not self._local_client.websocket.closed
|
||||||
|
):
|
||||||
|
await self._local_client.websocket.close()
|
||||||
|
|
||||||
self.config_entry.async_on_unload(
|
self.config_entry.async_on_unload(
|
||||||
self.hass.bus.async_listen_once(
|
self.hass.bus.async_listen_once(
|
||||||
@ -108,28 +123,47 @@ 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_dashboard()
|
await self.device.get_config()
|
||||||
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
|
_LOGGER.debug("Current status: %s", str(self.device.config))
|
||||||
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 LaMarzoccoSettingsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||||
"""Coordinator for La Marzocco settings."""
|
"""Coordinator for La Marzocco firmware."""
|
||||||
|
|
||||||
_default_update_interval = SETTINGS_UPDATE_INTERVAL
|
_default_update_interval = FIRMWARE_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_settings()
|
await self.device.get_firmware()
|
||||||
_LOGGER.debug("Current settings: %s", self.device.settings.to_dict())
|
_LOGGER.debug("Current firmware: %s", str(self.device.firmware))
|
||||||
|
|
||||||
|
|
||||||
class LaMarzoccoScheduleUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||||
"""Coordinator for La Marzocco schedule."""
|
"""Coordinator for La Marzocco statistics."""
|
||||||
|
|
||||||
_default_update_interval = SCHEDULE_UPDATE_INTERVAL
|
_default_update_interval = STATISTICS_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_schedule()
|
await self.device.get_statistics()
|
||||||
_LOGGER.debug("Current schedule: %s", self.device.schedule.to_dict())
|
_LOGGER.debug("Current statistics: %s", str(self.device.statistics))
|
||||||
|
@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from dataclasses import asdict
|
||||||
|
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
|
||||||
@ -14,6 +17,15 @@ 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,
|
||||||
@ -21,4 +33,12 @@ 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
|
||||||
return async_redact_data(device.to_dict(), TO_REDACT)
|
# collect all data sources
|
||||||
|
diagnostics_data = DiagnosticsData(
|
||||||
|
model=device.model,
|
||||||
|
config=asdict(device.config),
|
||||||
|
firmware=[{key: asdict(firmware)} for key, firmware in device.firmware.items()],
|
||||||
|
statistics=asdict(device.statistics),
|
||||||
|
)
|
||||||
|
|
||||||
|
return async_redact_data(diagnostics_data, TO_REDACT)
|
||||||
|
@ -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 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 (
|
||||||
@ -45,12 +46,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.dashboard.name,
|
name=device.name,
|
||||||
manufacturer="La Marzocco",
|
manufacturer="La Marzocco",
|
||||||
model=device.dashboard.model_name.value,
|
model=device.full_model_name,
|
||||||
model_id=device.dashboard.model_code.value,
|
model_id=device.model,
|
||||||
serial_number=device.serial_number,
|
serial_number=device.serial_number,
|
||||||
sw_version=device.settings.firmwares[FirmwareType.MACHINE].build_version,
|
sw_version=device.firmware[FirmwareType.MACHINE].current_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):
|
||||||
@ -85,3 +86,26 @@ 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),
|
||||||
|
)
|
||||||
|
@ -34,11 +34,36 @@
|
|||||||
"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": {
|
||||||
@ -63,6 +88,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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",
|
||||||
|
@ -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_push",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pylamarzocco"],
|
"loggers": ["pylamarzocco"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["pylamarzocco==2.0.0b1"]
|
"requirements": ["pylamarzocco==1.4.9"]
|
||||||
}
|
}
|
||||||
|
@ -2,12 +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, cast
|
from typing import Any
|
||||||
|
|
||||||
from pylamarzocco import LaMarzoccoMachine
|
from pylamarzocco.const import (
|
||||||
from pylamarzocco.const import WidgetType
|
KEYS_PER_MODEL,
|
||||||
|
BoilerType,
|
||||||
|
MachineModel,
|
||||||
|
PhysicalKey,
|
||||||
|
PrebrewMode,
|
||||||
|
)
|
||||||
|
from pylamarzocco.devices.machine import LaMarzoccoMachine
|
||||||
from pylamarzocco.exceptions import RequestNotSuccessful
|
from pylamarzocco.exceptions import RequestNotSuccessful
|
||||||
from pylamarzocco.models import CoffeeBoiler
|
from pylamarzocco.models import LaMarzoccoMachineConfig
|
||||||
|
|
||||||
from homeassistant.components.number import (
|
from homeassistant.components.number import (
|
||||||
NumberDeviceClass,
|
NumberDeviceClass,
|
||||||
@ -26,8 +32,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
|
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
|
||||||
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
|
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
@ -39,10 +45,25 @@ class LaMarzoccoNumberEntityDescription(
|
|||||||
):
|
):
|
||||||
"""Description of a La Marzocco number entity."""
|
"""Description of a La Marzocco number entity."""
|
||||||
|
|
||||||
native_value_fn: Callable[[LaMarzoccoMachine], float | int]
|
native_value_fn: Callable[[LaMarzoccoMachineConfig], 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",
|
||||||
@ -52,11 +73,43 @@ 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_coffee_target_temperature(temp),
|
set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.COFFEE, temp),
|
||||||
native_value_fn=(
|
native_value_fn=lambda config: config.boilers[
|
||||||
lambda machine: cast(
|
BoilerType.COFFEE
|
||||||
CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
|
].target_temperature,
|
||||||
).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(
|
||||||
@ -64,18 +117,119 @@ 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=PRECISION_WHOLE,
|
native_step=10,
|
||||||
native_min_value=0,
|
native_min_value=10,
|
||||||
native_max_value=240,
|
native_max_value=240,
|
||||||
entity_category=EntityCategory.CONFIG,
|
entity_category=EntityCategory.CONFIG,
|
||||||
set_value_fn=(
|
set_value_fn=lambda machine, value: machine.set_smart_standby(
|
||||||
lambda machine, value: machine.set_smart_standby(
|
enabled=machine.config.smart_standby.enabled,
|
||||||
enabled=machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
|
mode=machine.config.smart_standby.mode,
|
||||||
mode=machine.schedule.smart_wake_up_sleep.smart_stand_by_after,
|
minutes=int(value),
|
||||||
minutes=int(value),
|
),
|
||||||
)
|
native_value_fn=lambda config: config.smart_standby.minutes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
|
||||||
|
LaMarzoccoKeyNumberEntityDescription(
|
||||||
|
key="prebrew_off",
|
||||||
|
translation_key="prebrew_off",
|
||||||
|
device_class=NumberDeviceClass.DURATION,
|
||||||
|
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||||
|
native_step=PRECISION_TENTHS,
|
||||||
|
native_min_value=1,
|
||||||
|
native_max_value=10,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
set_value_fn=lambda machine, value, key: machine.set_prebrew_time(
|
||||||
|
prebrew_off_time=value, key=key
|
||||||
|
),
|
||||||
|
native_value_fn=lambda config, key: config.prebrew_configuration[key][
|
||||||
|
0
|
||||||
|
].off_time,
|
||||||
|
available_fn=lambda device: len(device.config.prebrew_configuration) > 0
|
||||||
|
and device.config.prebrew_mode
|
||||||
|
in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED),
|
||||||
|
supported_fn=lambda coordinator: coordinator.device.model
|
||||||
|
!= MachineModel.GS3_MP,
|
||||||
|
),
|
||||||
|
LaMarzoccoKeyNumberEntityDescription(
|
||||||
|
key="prebrew_on",
|
||||||
|
translation_key="prebrew_on",
|
||||||
|
device_class=NumberDeviceClass.DURATION,
|
||||||
|
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||||
|
native_step=PRECISION_TENTHS,
|
||||||
|
native_min_value=2,
|
||||||
|
native_max_value=10,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
set_value_fn=lambda machine, value, key: machine.set_prebrew_time(
|
||||||
|
prebrew_on_time=value, key=key
|
||||||
|
),
|
||||||
|
native_value_fn=lambda config, key: config.prebrew_configuration[key][
|
||||||
|
0
|
||||||
|
].off_time,
|
||||||
|
available_fn=lambda device: len(device.config.prebrew_configuration) > 0
|
||||||
|
and device.config.prebrew_mode
|
||||||
|
in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED),
|
||||||
|
supported_fn=lambda coordinator: coordinator.device.model
|
||||||
|
!= MachineModel.GS3_MP,
|
||||||
|
),
|
||||||
|
LaMarzoccoKeyNumberEntityDescription(
|
||||||
|
key="preinfusion_off",
|
||||||
|
translation_key="preinfusion_off",
|
||||||
|
device_class=NumberDeviceClass.DURATION,
|
||||||
|
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||||
|
native_step=PRECISION_TENTHS,
|
||||||
|
native_min_value=2,
|
||||||
|
native_max_value=29,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
set_value_fn=lambda machine, value, key: machine.set_preinfusion_time(
|
||||||
|
preinfusion_time=value, key=key
|
||||||
|
),
|
||||||
|
native_value_fn=lambda config, key: config.prebrew_configuration[key][
|
||||||
|
1
|
||||||
|
].preinfusion_time,
|
||||||
|
available_fn=lambda device: len(device.config.prebrew_configuration) > 0
|
||||||
|
and device.config.prebrew_mode == PrebrewMode.PREINFUSION,
|
||||||
|
supported_fn=lambda coordinator: coordinator.device.model
|
||||||
|
!= MachineModel.GS3_MP,
|
||||||
|
),
|
||||||
|
LaMarzoccoKeyNumberEntityDescription(
|
||||||
|
key="dose",
|
||||||
|
translation_key="dose",
|
||||||
|
native_unit_of_measurement="ticks",
|
||||||
|
native_step=PRECISION_WHOLE,
|
||||||
|
native_min_value=0,
|
||||||
|
native_max_value=999,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
set_value_fn=lambda machine, ticks, key: machine.set_dose(
|
||||||
|
dose=int(ticks), key=key
|
||||||
|
),
|
||||||
|
native_value_fn=lambda config, key: config.doses[key],
|
||||||
|
supported_fn=lambda coordinator: coordinator.device.model
|
||||||
|
== MachineModel.GS3_AV,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
|
||||||
|
LaMarzoccoKeyNumberEntityDescription(
|
||||||
|
key="scale_target",
|
||||||
|
translation_key="scale_target",
|
||||||
|
native_step=PRECISION_WHOLE,
|
||||||
|
native_min_value=1,
|
||||||
|
native_max_value=100,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
set_value_fn=lambda machine, weight, key: machine.set_bbw_recipe_target(
|
||||||
|
key, int(weight)
|
||||||
|
),
|
||||||
|
native_value_fn=lambda config, key: (
|
||||||
|
config.bbw_settings.doses[key] if config.bbw_settings else None
|
||||||
|
),
|
||||||
|
supported_fn=(
|
||||||
|
lambda coordinator: coordinator.device.model
|
||||||
|
in (MachineModel.LINEA_MINI, MachineModel.LINEA_MINI_R)
|
||||||
|
and coordinator.device.config.scale is not None
|
||||||
),
|
),
|
||||||
native_value_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -93,6 +247,34 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@ -104,7 +286,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)
|
return self.entity_description.native_value_fn(self.coordinator.device.config)
|
||||||
|
|
||||||
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."""
|
||||||
@ -123,3 +305,62 @@ 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
|
||||||
|
@ -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, cast
|
from typing import Any
|
||||||
|
|
||||||
from pylamarzocco.const import (
|
from pylamarzocco.const import (
|
||||||
ModelName,
|
MachineModel,
|
||||||
PreExtractionMode,
|
PhysicalKey,
|
||||||
SmartStandByType,
|
PrebrewMode,
|
||||||
SteamTargetLevel,
|
SmartStandbyMode,
|
||||||
WidgetType,
|
SteamLevel,
|
||||||
)
|
)
|
||||||
from pylamarzocco.devices import LaMarzoccoMachine
|
from pylamarzocco.devices.machine import LaMarzoccoMachine
|
||||||
from pylamarzocco.exceptions import RequestNotSuccessful
|
from pylamarzocco.exceptions import RequestNotSuccessful
|
||||||
from pylamarzocco.models import PreBrewing, SteamBoilerLevel
|
from pylamarzocco.models import LaMarzoccoMachineConfig
|
||||||
|
|
||||||
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,29 +23,30 @@ 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
|
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
STEAM_LEVEL_HA_TO_LM = {
|
STEAM_LEVEL_HA_TO_LM = {
|
||||||
"1": SteamTargetLevel.LEVEL_1,
|
"1": SteamLevel.LEVEL_1,
|
||||||
"2": SteamTargetLevel.LEVEL_2,
|
"2": SteamLevel.LEVEL_2,
|
||||||
"3": SteamTargetLevel.LEVEL_3,
|
"3": SteamLevel.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": PreExtractionMode.DISABLED,
|
"disabled": PrebrewMode.DISABLED,
|
||||||
"prebrew": PreExtractionMode.PREBREWING,
|
"prebrew": PrebrewMode.PREBREW,
|
||||||
"preinfusion": PreExtractionMode.PREINFUSION,
|
"prebrew_enabled": PrebrewMode.PREBREW_ENABLED,
|
||||||
|
"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": SmartStandByType.POWER_ON,
|
"power_on": SmartStandbyMode.POWER_ON,
|
||||||
"last_brewing": SmartStandByType.LAST_BREW,
|
"last_brewing": SmartStandbyMode.LAST_BREWING,
|
||||||
}
|
}
|
||||||
|
|
||||||
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()}
|
||||||
@ -58,7 +59,7 @@ class LaMarzoccoSelectEntityDescription(
|
|||||||
):
|
):
|
||||||
"""Description of a La Marzocco select entity."""
|
"""Description of a La Marzocco select entity."""
|
||||||
|
|
||||||
current_option_fn: Callable[[LaMarzoccoMachine], str | None]
|
current_option_fn: Callable[[LaMarzoccoMachineConfig], str | None]
|
||||||
select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]]
|
select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]]
|
||||||
|
|
||||||
|
|
||||||
@ -70,36 +71,25 @@ 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 machine: STEAM_LEVEL_LM_TO_HA[
|
current_option_fn=lambda config: STEAM_LEVEL_LM_TO_HA[config.steam_level],
|
||||||
cast(
|
supported_fn=lambda coordinator: coordinator.device.model
|
||||||
SteamBoilerLevel,
|
== MachineModel.LINEA_MICRA,
|
||||||
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_pre_extraction_mode(
|
select_option_fn=lambda machine, option: machine.set_prebrew_mode(
|
||||||
PREBREW_MODE_HA_TO_LM[option]
|
PREBREW_MODE_HA_TO_LM[option]
|
||||||
),
|
),
|
||||||
current_option_fn=lambda machine: PREBREW_MODE_LM_TO_HA[
|
current_option_fn=lambda config: PREBREW_MODE_LM_TO_HA[config.prebrew_mode],
|
||||||
cast(PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]).mode
|
supported_fn=lambda coordinator: coordinator.device.model
|
||||||
],
|
in (
|
||||||
supported_fn=(
|
MachineModel.GS3_AV,
|
||||||
lambda coordinator: coordinator.device.dashboard.model_name
|
MachineModel.LINEA_MICRA,
|
||||||
in (
|
MachineModel.LINEA_MINI,
|
||||||
ModelName.LINEA_MICRA,
|
MachineModel.LINEA_MINI_R,
|
||||||
ModelName.LINEA_MINI,
|
|
||||||
ModelName.LINEA_MINI_R,
|
|
||||||
ModelName.GS3_AV,
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
LaMarzoccoSelectEntityDescription(
|
LaMarzoccoSelectEntityDescription(
|
||||||
@ -108,16 +98,32 @@ 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.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
|
enabled=machine.config.smart_standby.enabled,
|
||||||
mode=STANDBY_MODE_HA_TO_LM[option],
|
mode=STANDBY_MODE_HA_TO_LM[option],
|
||||||
minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
|
minutes=machine.config.smart_standby.minutes,
|
||||||
),
|
),
|
||||||
current_option_fn=lambda machine: STANDBY_MODE_LM_TO_HA[
|
current_option_fn=lambda config: STANDBY_MODE_LM_TO_HA[
|
||||||
machine.schedule.smart_wake_up_sleep.smart_stand_by_after
|
config.smart_standby.mode
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
@ -127,11 +133,30 @@ 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
|
||||||
|
|
||||||
async_add_entities(
|
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):
|
||||||
@ -142,7 +167,9 @@ 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 self.entity_description.current_option_fn(self.coordinator.device)
|
return str(
|
||||||
|
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."""
|
||||||
@ -161,3 +188,9 @@ 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
|
||||||
|
226
homeassistant/components/lamarzocco/sensor.py
Normal file
226
homeassistant/components/lamarzocco/sensor.py
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
"""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
|
@ -32,11 +32,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"machine_selection": {
|
"machine_selection": {
|
||||||
"description": "Select the machine you want to integrate.",
|
"description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -99,16 +101,54 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -128,6 +168,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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})"
|
||||||
@ -170,6 +233,9 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
|
@ -2,17 +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, cast
|
from typing import Any
|
||||||
|
|
||||||
from pylamarzocco import LaMarzoccoMachine
|
from pylamarzocco.const import BoilerType
|
||||||
from pylamarzocco.const import MachineMode, ModelName, WidgetType
|
from pylamarzocco.devices.machine import LaMarzoccoMachine
|
||||||
from pylamarzocco.exceptions import RequestNotSuccessful
|
from pylamarzocco.exceptions import RequestNotSuccessful
|
||||||
from pylamarzocco.models import (
|
from pylamarzocco.models import LaMarzoccoMachineConfig
|
||||||
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
|
||||||
@ -35,7 +30,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[[LaMarzoccoMachine], bool]
|
is_on_fn: Callable[[LaMarzoccoMachineConfig], bool]
|
||||||
|
|
||||||
|
|
||||||
ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
|
ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
|
||||||
@ -44,42 +39,13 @@ 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=(
|
is_on_fn=lambda config: config.turned_on,
|
||||||
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=(
|
is_on_fn=lambda config: config.boilers[BoilerType.STEAM].enabled,
|
||||||
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",
|
||||||
@ -87,10 +53,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.schedule.smart_wake_up_sleep.smart_stand_by_after,
|
mode=machine.config.smart_standby.mode,
|
||||||
minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
|
minutes=machine.config.smart_standby.minutes,
|
||||||
),
|
),
|
||||||
is_on_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
|
is_on_fn=lambda config: config.smart_standby.enabled,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -112,8 +78,8 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
entities.extend(
|
entities.extend(
|
||||||
LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry)
|
LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry_id)
|
||||||
for wake_up_sleep_entry in coordinator.device.schedule.smart_wake_up_sleep.schedules
|
for wake_up_sleep_entry_id in coordinator.device.config.wake_up_sleep_entries
|
||||||
)
|
)
|
||||||
|
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
@ -151,7 +117,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)
|
return self.entity_description.is_on_fn(self.coordinator.device.config)
|
||||||
|
|
||||||
|
|
||||||
class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity):
|
class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity):
|
||||||
@ -163,21 +129,22 @@ class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: LaMarzoccoUpdateCoordinator,
|
coordinator: LaMarzoccoUpdateCoordinator,
|
||||||
schedule_entry: WakeUpScheduleSettings,
|
identifier: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the switch."""
|
"""Initialize the switch."""
|
||||||
super().__init__(coordinator, f"auto_on_off_{schedule_entry.identifier}")
|
super().__init__(coordinator, f"auto_on_off_{identifier}")
|
||||||
assert schedule_entry.identifier
|
self._identifier = identifier
|
||||||
self._schedule_entry = schedule_entry
|
self._attr_translation_placeholders = {"id": identifier}
|
||||||
self._identifier = schedule_entry.identifier
|
self.entity_category = EntityCategory.CONFIG
|
||||||
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."""
|
||||||
self._schedule_entry.enabled = state
|
wake_up_sleep_entry = self.coordinator.device.config.wake_up_sleep_entries[
|
||||||
|
self._identifier
|
||||||
|
]
|
||||||
|
wake_up_sleep_entry.enabled = state
|
||||||
try:
|
try:
|
||||||
await self.coordinator.device.set_wakeup_schedule(self._schedule_entry)
|
await self.coordinator.device.set_wake_up_sleep(wake_up_sleep_entry)
|
||||||
except RequestNotSuccessful as exc:
|
except RequestNotSuccessful as exc:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
@ -197,4 +164,6 @@ 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._schedule_entry.enabled
|
return self.coordinator.device.config.wake_up_sleep_entries[
|
||||||
|
self._identifier
|
||||||
|
].enabled
|
||||||
|
@ -59,7 +59,7 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Create update entities."""
|
"""Create update entities."""
|
||||||
|
|
||||||
coordinator = entry.runtime_data.settings_coordinator
|
coordinator = entry.runtime_data.firmware_coordinator
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
LaMarzoccoUpdateEntity(coordinator, description)
|
LaMarzoccoUpdateEntity(coordinator, description)
|
||||||
for description in ENTITIES
|
for description in ENTITIES
|
||||||
@ -74,20 +74,18 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
|
|||||||
_attr_supported_features = UpdateEntityFeature.INSTALL
|
_attr_supported_features = UpdateEntityFeature.INSTALL
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def installed_version(self) -> str:
|
def installed_version(self) -> str | None:
|
||||||
"""Return the current firmware version."""
|
"""Return the current firmware version."""
|
||||||
return self.coordinator.device.settings.firmwares[
|
return self.coordinator.device.firmware[
|
||||||
self.entity_description.component
|
self.entity_description.component
|
||||||
].build_version
|
].current_version
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def latest_version(self) -> str:
|
def latest_version(self) -> str:
|
||||||
"""Return the latest firmware version."""
|
"""Return the latest firmware version."""
|
||||||
if available_update := self.coordinator.device.settings.firmwares[
|
return self.coordinator.device.firmware[
|
||||||
self.entity_description.component
|
self.entity_description.component
|
||||||
].available_update:
|
].latest_version
|
||||||
return available_update.build_version
|
|
||||||
return self.installed_version
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def release_url(self) -> str | None:
|
def release_url(self) -> str | None:
|
||||||
@ -101,7 +99,9 @@ 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:
|
||||||
await self.coordinator.device.update_firmware()
|
success = 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,5 +110,13 @@ 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()
|
||||||
|
@ -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
|
key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE, name=None
|
||||||
),
|
),
|
||||||
entity_class=MatterUpdate,
|
entity_class=MatterUpdate,
|
||||||
required_attributes=(
|
required_attributes=(
|
||||||
|
@ -68,12 +68,7 @@ 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 ( # noqa: F401
|
from .browse_media import BrowseMedia, async_process_play_media_url # 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,
|
||||||
@ -112,12 +107,10 @@ 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,
|
||||||
@ -135,7 +128,6 @@ 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,
|
||||||
@ -145,7 +137,7 @@ from .const import ( # noqa: F401
|
|||||||
MediaType,
|
MediaType,
|
||||||
RepeatMode,
|
RepeatMode,
|
||||||
)
|
)
|
||||||
from .errors import BrowseError, SearchError
|
from .errors import BrowseError
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -299,7 +291,6 @@ 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)
|
||||||
@ -456,22 +447,6 @@ 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},
|
||||||
@ -1182,29 +1157,6 @@ 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
|
||||||
@ -1408,75 +1360,6 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,7 +3,6 @@
|
|||||||
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
|
||||||
@ -110,7 +109,6 @@ 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
|
||||||
@ -123,7 +121,6 @@ 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."""
|
||||||
@ -138,7 +135,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,27 +163,3 @@ 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)
|
|
||||||
|
@ -26,8 +26,6 @@ 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"
|
||||||
@ -176,7 +174,6 @@ 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"
|
||||||
@ -223,7 +220,6 @@ 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.
|
||||||
|
@ -9,7 +9,3 @@ class MediaPlayerException(HomeAssistantError):
|
|||||||
|
|
||||||
class BrowseError(MediaPlayerException):
|
class BrowseError(MediaPlayerException):
|
||||||
"""Error while browsing."""
|
"""Error while browsing."""
|
||||||
|
|
||||||
|
|
||||||
class SearchError(MediaPlayerException):
|
|
||||||
"""Error while searching."""
|
|
||||||
|
@ -68,9 +68,6 @@
|
|||||||
"repeat_set": {
|
"repeat_set": {
|
||||||
"service": "mdi:repeat"
|
"service": "mdi:repeat"
|
||||||
},
|
},
|
||||||
"search_media": {
|
|
||||||
"service": "mdi:text-search"
|
|
||||||
},
|
|
||||||
"select_sound_mode": {
|
"select_sound_mode": {
|
||||||
"service": "mdi:surround-sound"
|
"service": "mdi:surround-sound"
|
||||||
},
|
},
|
||||||
|
@ -181,35 +181,6 @@ browse_media:
|
|||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
|
|
||||||
search_media:
|
|
||||||
target:
|
|
||||||
entity:
|
|
||||||
domain: media_player
|
|
||||||
supported_features:
|
|
||||||
- media_player.MediaPlayerEntityFeature.SEARCH_MEDIA
|
|
||||||
fields:
|
|
||||||
search_query:
|
|
||||||
required: true
|
|
||||||
example: "Beatles"
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
media_content_type:
|
|
||||||
required: false
|
|
||||||
example: "music"
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
media_content_id:
|
|
||||||
required: false
|
|
||||||
example: "A:ALBUMARTIST/Beatles"
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
media_filter_classes:
|
|
||||||
required: false
|
|
||||||
example: ["album", "artist"]
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
multiple: true
|
|
||||||
|
|
||||||
select_source:
|
select_source:
|
||||||
target:
|
target:
|
||||||
entity:
|
entity:
|
||||||
|
@ -274,28 +274,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"search_media": {
|
|
||||||
"name": "Search media",
|
|
||||||
"description": "Searches the available media.",
|
|
||||||
"fields": {
|
|
||||||
"media_content_id": {
|
|
||||||
"name": "[%key:component::media_player::services::browse_media::fields::media_content_id::name%]",
|
|
||||||
"description": "[%key:component::media_player::services::browse_media::fields::media_content_id::description%]"
|
|
||||||
},
|
|
||||||
"media_content_type": {
|
|
||||||
"name": "[%key:component::media_player::services::browse_media::fields::media_content_type::name%]",
|
|
||||||
"description": "[%key:component::media_player::services::browse_media::fields::media_content_type::description%]"
|
|
||||||
},
|
|
||||||
"search_query": {
|
|
||||||
"name": "Search query",
|
|
||||||
"description": "The term to search for."
|
|
||||||
},
|
|
||||||
"media_filter_classes": {
|
|
||||||
"name": "Media filter classes",
|
|
||||||
"description": "List of media classes to filter the search results by."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"select_source": {
|
"select_source": {
|
||||||
"name": "Select source",
|
"name": "Select source",
|
||||||
"description": "Sends the media player the command to change input source.",
|
"description": "Sends the media player the command to change input source.",
|
||||||
|
@ -33,7 +33,7 @@ from .const import (
|
|||||||
URI_SCHEME,
|
URI_SCHEME,
|
||||||
URI_SCHEME_REGEX,
|
URI_SCHEME_REGEX,
|
||||||
)
|
)
|
||||||
from .error import MediaSourceError, UnknownMediaSource, Unresolvable
|
from .error import MediaSourceError, Unresolvable
|
||||||
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
|
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@ -113,11 +113,7 @@ def _get_media_item(
|
|||||||
return MediaSourceItem(hass, domain, "", target_media_player)
|
return MediaSourceItem(hass, domain, "", target_media_player)
|
||||||
|
|
||||||
if item.domain is not None and item.domain not in hass.data[DOMAIN]:
|
if item.domain is not None and item.domain not in hass.data[DOMAIN]:
|
||||||
raise UnknownMediaSource(
|
raise ValueError("Unknown media source")
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="unknown_media_source",
|
|
||||||
translation_placeholders={"domain": item.domain},
|
|
||||||
)
|
|
||||||
|
|
||||||
return item
|
return item
|
||||||
|
|
||||||
@ -136,14 +132,7 @@ async def async_browse_media(
|
|||||||
try:
|
try:
|
||||||
item = await _get_media_item(hass, media_content_id, None).async_browse()
|
item = await _get_media_item(hass, media_content_id, None).async_browse()
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
raise BrowseError(
|
raise BrowseError(str(err)) from err
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="browse_media_failed",
|
|
||||||
translation_placeholders={
|
|
||||||
"media_content_id": str(media_content_id),
|
|
||||||
"error": str(err),
|
|
||||||
},
|
|
||||||
) from err
|
|
||||||
|
|
||||||
if content_filter is None or item.children is None:
|
if content_filter is None or item.children is None:
|
||||||
return item
|
return item
|
||||||
@ -176,14 +165,7 @@ async def async_resolve_media(
|
|||||||
try:
|
try:
|
||||||
item = _get_media_item(hass, media_content_id, target_media_player)
|
item = _get_media_item(hass, media_content_id, target_media_player)
|
||||||
except ValueError as err:
|
except ValueError as err:
|
||||||
raise Unresolvable(
|
raise Unresolvable(str(err)) from err
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="resolve_media_failed",
|
|
||||||
translation_placeholders={
|
|
||||||
"media_content_id": str(media_content_id),
|
|
||||||
"error": str(err),
|
|
||||||
},
|
|
||||||
) from err
|
|
||||||
|
|
||||||
return await item.async_resolve()
|
return await item.async_resolve()
|
||||||
|
|
||||||
|
@ -9,7 +9,3 @@ class MediaSourceError(HomeAssistantError):
|
|||||||
|
|
||||||
class Unresolvable(MediaSourceError):
|
class Unresolvable(MediaSourceError):
|
||||||
"""When media ID is not resolvable."""
|
"""When media ID is not resolvable."""
|
||||||
|
|
||||||
|
|
||||||
class UnknownMediaSource(MediaSourceError, ValueError):
|
|
||||||
"""When media source is unknown."""
|
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"exceptions": {
|
|
||||||
"browse_media_failed": {
|
|
||||||
"message": "Failed to browse media with content id {media_content_id}: {error}"
|
|
||||||
},
|
|
||||||
"resolve_media_failed": {
|
|
||||||
"message": "Failed to resolve media with content id {media_content_id}: {error}"
|
|
||||||
},
|
|
||||||
"unknown_media_source": {
|
|
||||||
"message": "Unknown media source: {domain}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -74,7 +74,6 @@ 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: [
|
||||||
@ -82,11 +81,10 @@ 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é", "Ciel clair"],
|
ATTR_CONDITION_SUNNY: ["Ensoleillé"],
|
||||||
ATTR_CONDITION_WINDY: [],
|
ATTR_CONDITION_WINDY: [],
|
||||||
ATTR_CONDITION_WINDY_VARIANT: [],
|
ATTR_CONDITION_WINDY_VARIANT: [],
|
||||||
ATTR_CONDITION_EXCEPTIONAL: [],
|
ATTR_CONDITION_EXCEPTIONAL: [],
|
||||||
|
@ -1,80 +0,0 @@
|
|||||||
"""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),
|
|
||||||
}
|
|
@ -29,10 +29,10 @@
|
|||||||
"public_weather": {
|
"public_weather": {
|
||||||
"data": {
|
"data": {
|
||||||
"area_name": "Name of the area",
|
"area_name": "Name of the area",
|
||||||
"lat_ne": "Northeast corner latitude",
|
"lat_ne": "North-East corner latitude",
|
||||||
"lon_ne": "Northeast corner longitude",
|
"lon_ne": "North-East corner longitude",
|
||||||
"lat_sw": "Southwest corner latitude",
|
"lat_sw": "South-West corner latitude",
|
||||||
"lon_sw": "Southwest corner longitude",
|
"lon_sw": "South-West 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": "[%key:common::state::manual%]"
|
"manual": "Manual"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -206,13 +206,13 @@
|
|||||||
"name": "Wind direction",
|
"name": "Wind direction",
|
||||||
"state": {
|
"state": {
|
||||||
"n": "North",
|
"n": "North",
|
||||||
"ne": "Northeast",
|
"ne": "North-east",
|
||||||
"e": "East",
|
"e": "East",
|
||||||
"se": "Southeast",
|
"se": "South-east",
|
||||||
"s": "South",
|
"s": "South",
|
||||||
"sw": "Southwest",
|
"sw": "South-west",
|
||||||
"w": "West",
|
"w": "West",
|
||||||
"nw": "Northwest"
|
"nw": "North-west"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wind_angle": {
|
"wind_angle": {
|
||||||
|
@ -63,7 +63,6 @@ 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__)
|
||||||
@ -161,10 +160,9 @@ 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 (
|
if not user_input.get(
|
||||||
user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL
|
||||||
not in WEB_SEARCH_MODELS
|
).startswith("gpt-4o"):
|
||||||
):
|
|
||||||
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())
|
||||||
|
@ -41,12 +41,3 @@ 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",
|
|
||||||
]
|
|
||||||
|
@ -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 not supported by this model"
|
"web_search_not_supported": "Web search is only supported for gpt-4o and gpt-4o-mini models"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"selector": {
|
"selector": {
|
||||||
|
@ -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:
|
def last_changed_timestamp(self) -> float: # type: ignore[override]
|
||||||
"""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:
|
def last_reported_timestamp(self) -> float: # type: ignore[override]
|
||||||
"""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:
|
||||||
|
@ -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:
|
||||||
- trigger: event
|
- platform: event
|
||||||
event_type: mobile_app_notification_action
|
event_type: mobile_app_notification_action
|
||||||
event_data:
|
event_data:
|
||||||
action: "{{ action_confirm }}"
|
action: "{{ action_confirm }}"
|
||||||
- trigger: event
|
- platform: event
|
||||||
event_type: mobile_app_notification_action
|
event_type: mobile_app_notification_action
|
||||||
event_data:
|
event_data:
|
||||||
action: "{{ action_dismiss }}"
|
action: "{{ action_dismiss }}"
|
||||||
|
@ -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 = 60
|
UPTIME_DEVIATION: Final = 5
|
||||||
|
|
||||||
# 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
|
||||||
|
@ -200,18 +200,8 @@ def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime:
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
not last_uptime
|
not last_uptime
|
||||||
or (diff := abs((delta_uptime - last_uptime).total_seconds()))
|
or abs((delta_uptime - last_uptime).total_seconds()) > UPTIME_DEVIATION
|
||||||
> 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
|
||||||
|
@ -354,11 +354,11 @@
|
|||||||
"robot_cleaner_cleaning_mode": {
|
"robot_cleaner_cleaning_mode": {
|
||||||
"name": "Cleaning mode",
|
"name": "Cleaning mode",
|
||||||
"state": {
|
"state": {
|
||||||
"stop": "[%key:common::action::stop%]",
|
"auto": "Auto",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -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",
|
||||||
MediaType.APPS: "apps",
|
"Apps": "apps",
|
||||||
"radios": "radios",
|
"Radios": "radios",
|
||||||
}
|
}
|
||||||
|
|
||||||
SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = {
|
SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = {
|
||||||
@ -58,20 +58,22 @@ 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},
|
||||||
"radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP},
|
"Apps": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP},
|
||||||
"artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
|
"Radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP},
|
||||||
"albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
|
"App": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
|
||||||
"tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
|
"Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
|
||||||
"playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST},
|
"Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
|
||||||
"genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE},
|
"Tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
|
||||||
"new music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
|
"Playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST},
|
||||||
"album artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
|
"Genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE},
|
||||||
|
"New Music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
|
||||||
|
"Album Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
|
||||||
MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK},
|
MediaType.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": ""},
|
||||||
@ -89,15 +91,17 @@ 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
|
||||||
"radios": MediaClass.APP,
|
"Apps": MediaClass.APP,
|
||||||
"new music": MediaType.ALBUM,
|
"Radios": MediaClass.APP,
|
||||||
"album artists": MediaType.ARTIST,
|
"App": None, # can only be determined after inspecting the item
|
||||||
|
"New Music": MediaType.ALBUM,
|
||||||
|
"Album Artists": MediaType.ARTIST,
|
||||||
MediaType.APPS: MediaType.APP,
|
MediaType.APPS: MediaType.APP,
|
||||||
MediaType.APP: MediaType.TRACK,
|
MediaType.APP: MediaType.TRACK,
|
||||||
}
|
}
|
||||||
@ -169,7 +173,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"]),
|
||||||
@ -179,21 +183,21 @@ def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia:
|
|||||||
can_expand=True,
|
can_expand=True,
|
||||||
can_play=True,
|
can_play=True,
|
||||||
)
|
)
|
||||||
if item.get("hasitems") and not item.get("isaudio"):
|
if item["hasitems"] and not item["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=bool(item.get("hasitems")),
|
can_expand=item["hasitems"],
|
||||||
can_play=bool(item["isaudio"] and item.get("url")),
|
can_play=bool(item["isaudio"] and item.get("url")),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -216,7 +220,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
|
||||||
@ -261,10 +265,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:
|
||||||
@ -360,11 +364,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(),
|
title=item,
|
||||||
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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -446,9 +446,6 @@ 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:
|
||||||
@ -620,9 +617,6 @@ 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)
|
||||||
|
|
||||||
|
@ -61,7 +61,6 @@ 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(
|
||||||
|
@ -113,9 +113,7 @@ 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: (
|
value_fn=lambda data: now() - timedelta(seconds=data.status["uptime"]),
|
||||||
now() - timedelta(seconds=data.status["uptime"])
|
|
||||||
).replace(microsecond=0),
|
|
||||||
),
|
),
|
||||||
StarlinkSensorEntityDescription(
|
StarlinkSensorEntityDescription(
|
||||||
key="ping_drop_rate",
|
key="ping_drop_rate",
|
||||||
|
@ -1,86 +0,0 @@
|
|||||||
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
|
|
@ -59,7 +59,7 @@
|
|||||||
"name": "Lamp mode",
|
"name": "Lamp mode",
|
||||||
"state": {
|
"state": {
|
||||||
"automatic": "Automatic",
|
"automatic": "Automatic",
|
||||||
"manual": "[%key:common::state::manual%]"
|
"manual": "Manual"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"aroma_therapy_slot": {
|
"aroma_therapy_slot": {
|
||||||
|
@ -75,6 +75,7 @@ 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
|
||||||
|
@ -12,7 +12,6 @@ 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."""
|
||||||
|
@ -1,92 +0,0 @@
|
|||||||
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
|
|
@ -174,6 +174,8 @@ 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:
|
||||||
|
@ -363,17 +363,11 @@ 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 and device not in provisioned_devices:
|
if device not in known_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
|
||||||
@ -454,8 +448,6 @@ 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(
|
||||||
@ -505,7 +497,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
|
||||||
await self.async_register_node_in_dev_reg(node)
|
self.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:
|
||||||
@ -582,52 +574,18 @@ class ControllerEvents:
|
|||||||
f"{DOMAIN}.identify_controller.{dev_id[1]}",
|
f"{DOMAIN}.identify_controller.{dev_id[1]}",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None:
|
@callback
|
||||||
"""Check if the node was preprovisioned and update the device registry."""
|
def register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry:
|
||||||
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_identifier = None
|
via_device_id = 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_identifier = get_device_id(driver, controller.own_node)
|
via_device_id = 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
|
||||||
@ -674,7 +632,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_identifier,
|
via_device=via_device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async_dispatcher_send(self.hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device)
|
async_dispatcher_send(self.hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device)
|
||||||
@ -708,7 +666,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 = await self.controller_events.async_register_node_in_dev_reg(node)
|
device = self.controller_events.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)
|
||||||
|
@ -91,7 +91,6 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -172,10 +171,6 @@ 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"
|
||||||
|
|
||||||
@ -403,7 +398,6 @@ 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)
|
||||||
@ -637,38 +631,14 @@ 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],
|
||||||
{
|
{
|
||||||
@ -1001,58 +971,12 @@ 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
|
||||||
@ -1067,68 +991,18 @@ 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]
|
|
||||||
|
|
||||||
if qr_info.version == QRCodeVersion.S2:
|
provisioning_info = msg[QR_PROVISIONING_INFORMATION]
|
||||||
|
|
||||||
|
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)
|
||||||
if device:
|
connection.send_result(msg[ID])
|
||||||
connection.send_result(msg[ID], device.id)
|
|
||||||
else:
|
|
||||||
connection.send_result(msg[ID])
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.require_admin
|
@websocket_api.require_admin
|
||||||
@ -1162,24 +1036,7 @@ 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])
|
||||||
|
|
||||||
|
|
||||||
|
@ -4,17 +4,12 @@ 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
|
||||||
@ -28,7 +23,6 @@ from homeassistant.config_entries import (
|
|||||||
SOURCE_USB,
|
SOURCE_USB,
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
ConfigEntryBaseFlow,
|
ConfigEntryBaseFlow,
|
||||||
ConfigEntryState,
|
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
OptionsFlow,
|
OptionsFlow,
|
||||||
@ -66,7 +60,6 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -81,9 +74,6 @@ 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",
|
||||||
@ -646,12 +636,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if not self._usb_discovery:
|
if not self._usb_discovery:
|
||||||
try:
|
ports = await async_get_usb_ports(self.hass)
|
||||||
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,
|
||||||
@ -732,10 +717,6 @@ 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:
|
||||||
@ -744,18 +725,6 @@ 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):
|
||||||
@ -763,91 +732,6 @@ 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:
|
||||||
@ -997,11 +881,7 @@ 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)
|
||||||
|
|
||||||
try:
|
ports = await async_get_usb_ports(self.hass)
|
||||||
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(
|
||||||
{
|
{
|
||||||
@ -1031,64 +911,12 @@ 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:
|
||||||
@ -1115,16 +943,12 @@ 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.backup_data is None and self.config_entry.unique_id != str(
|
if self.config_entry.unique_id != str(self.version_info.home_id):
|
||||||
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,
|
||||||
@ -1137,9 +961,6 @@ 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={})
|
||||||
@ -1169,74 +990,6 @@ 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."""
|
||||||
|
@ -15,7 +15,7 @@ from zwave_js_server.const import (
|
|||||||
ConfigurationValueType,
|
ConfigurationValueType,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
)
|
)
|
||||||
from zwave_js_server.model.controller import Controller, ProvisioningEntry
|
from zwave_js_server.model.controller import Controller
|
||||||
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 or device_id.startswith("provision_"):
|
if device_id is None:
|
||||||
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,53 +289,6 @@ 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,
|
||||||
|
@ -11,11 +11,7 @@
|
|||||||
"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.",
|
||||||
@ -26,9 +22,7 @@
|
|||||||
"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": {
|
||||||
@ -223,12 +217,7 @@
|
|||||||
"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%]",
|
||||||
@ -237,27 +226,9 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@ -271,12 +242,6 @@
|
|||||||
"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%]"
|
||||||
},
|
},
|
||||||
|
@ -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_push"
|
"iot_class": "cloud_polling"
|
||||||
},
|
},
|
||||||
"lametric": {
|
"lametric": {
|
||||||
"name": "LaMetric",
|
"name": "LaMetric",
|
||||||
|
@ -1072,7 +1072,7 @@ class TemplateStateBase(State):
|
|||||||
raise KeyError
|
raise KeyError
|
||||||
|
|
||||||
@under_cached_property
|
@under_cached_property
|
||||||
def entity_id(self) -> str:
|
def entity_id(self) -> str: # type: ignore[override]
|
||||||
"""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:
|
def name(self) -> str: # type: ignore[override]
|
||||||
"""Wrap State.name."""
|
"""Wrap State.name."""
|
||||||
self._collect_state()
|
self._collect_state()
|
||||||
return self._state.name
|
return self._state.name
|
||||||
|
@ -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.39.0
|
habluetooth==3.38.1
|
||||||
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.20.0
|
yarl==1.19.0
|
||||||
zeroconf==0.146.5
|
zeroconf==0.146.5
|
||||||
|
|
||||||
# Constrain pycryptodome to avoid vulnerability
|
# Constrain pycryptodome to avoid vulnerability
|
||||||
|
@ -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.20.0",
|
"yarl==1.19.0",
|
||||||
"webrtc-models==0.3.0",
|
"webrtc-models==0.3.0",
|
||||||
"zeroconf==0.146.5",
|
"zeroconf==0.146.5",
|
||||||
]
|
]
|
||||||
|
2
requirements.txt
generated
2
requirements.txt
generated
@ -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.20.0
|
yarl==1.19.0
|
||||||
webrtc-models==0.3.0
|
webrtc-models==0.3.0
|
||||||
zeroconf==0.146.5
|
zeroconf==0.146.5
|
||||||
|
10
requirements_all.txt
generated
10
requirements_all.txt
generated
@ -829,7 +829,7 @@ ebusdpy==0.0.17
|
|||||||
ecoaliface==0.4.0
|
ecoaliface==0.4.0
|
||||||
|
|
||||||
# homeassistant.components.eheimdigital
|
# homeassistant.components.eheimdigital
|
||||||
eheimdigital==1.1.0
|
eheimdigital==1.0.6
|
||||||
|
|
||||||
# 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.3.0
|
esphome-dashboard-api==1.2.3
|
||||||
|
|
||||||
# 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.39.0
|
habluetooth==3.38.1
|
||||||
|
|
||||||
# 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
|
||||||
pyephember2==0.4.12
|
pyephember==0.3.1
|
||||||
|
|
||||||
# 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==2.0.0b1
|
pylamarzocco==1.4.9
|
||||||
|
|
||||||
# homeassistant.components.lastfm
|
# homeassistant.components.lastfm
|
||||||
pylast==5.1.0
|
pylast==5.1.0
|
||||||
|
@ -10,10 +10,9 @@
|
|||||||
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.0a8
|
mypy-dev==1.16.0a7
|
||||||
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
|
||||||
|
8
requirements_test_all.txt
generated
8
requirements_test_all.txt
generated
@ -708,7 +708,7 @@ eagle100==0.1.1
|
|||||||
easyenergy==2.1.2
|
easyenergy==2.1.2
|
||||||
|
|
||||||
# homeassistant.components.eheimdigital
|
# homeassistant.components.eheimdigital
|
||||||
eheimdigital==1.1.0
|
eheimdigital==1.0.6
|
||||||
|
|
||||||
# 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.3.0
|
esphome-dashboard-api==1.2.3
|
||||||
|
|
||||||
# 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.39.0
|
habluetooth==3.38.1
|
||||||
|
|
||||||
# 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==2.0.0b1
|
pylamarzocco==1.4.9
|
||||||
|
|
||||||
# homeassistant.components.lastfm
|
# homeassistant.components.lastfm
|
||||||
pylast==5.1.0
|
pylast==5.1.0
|
||||||
|
@ -969,6 +969,7 @@ 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",
|
||||||
@ -1099,6 +1100,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
|||||||
"weatherkit",
|
"weatherkit",
|
||||||
"webmin",
|
"webmin",
|
||||||
"wemo",
|
"wemo",
|
||||||
|
"whirlpool",
|
||||||
"whois",
|
"whois",
|
||||||
"wiffi",
|
"wiffi",
|
||||||
"wilight",
|
"wilight",
|
||||||
|
@ -355,7 +355,6 @@ 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": [
|
||||||
@ -367,7 +366,6 @@ 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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -378,7 +376,6 @@ 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": "",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -1323,7 +1323,6 @@ 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,
|
||||||
},
|
},
|
||||||
@ -1338,7 +1337,6 @@ 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,
|
||||||
},
|
},
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
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': '',
|
||||||
@ -19,7 +18,6 @@
|
|||||||
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',
|
||||||
@ -30,7 +28,6 @@
|
|||||||
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',
|
||||||
|
@ -1037,7 +1037,6 @@ 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,
|
||||||
}
|
}
|
||||||
@ -1050,7 +1049,6 @@ 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,
|
||||||
}
|
}
|
||||||
@ -1109,7 +1107,6 @@ 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,
|
||||||
}
|
}
|
||||||
@ -2211,7 +2208,6 @@ 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,
|
||||||
}
|
}
|
||||||
@ -2236,7 +2232,6 @@ 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": [],
|
||||||
|
@ -64,7 +64,6 @@ 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)
|
||||||
@ -72,7 +71,6 @@ 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
|
||||||
)
|
)
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
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',
|
||||||
|
@ -4,7 +4,6 @@ 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
|
||||||
@ -26,7 +25,6 @@ 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,
|
||||||
@ -44,6 +42,14 @@ 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
|
||||||
|
@ -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 EVENT_HOMEASSISTANT_STOP
|
from homeassistant.const import CONF_IP_ADDRESS, 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,6 +24,8 @@ 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"]
|
||||||
@ -48,6 +50,27 @@ 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)
|
||||||
|
@ -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
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
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,7 +16,6 @@ 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,
|
||||||
@ -107,15 +106,18 @@ 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
|
||||||
)
|
)
|
||||||
await hass.services.async_call(
|
with patch(
|
||||||
PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True
|
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access",
|
||||||
)
|
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
|
||||||
mock_device.device.async_set_wifi_guest_access.assert_called_once_with(False)
|
turn_off.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)
|
||||||
@ -125,15 +127,18 @@ 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
|
||||||
)
|
)
|
||||||
await hass.services.async_call(
|
with patch(
|
||||||
PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True
|
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access",
|
||||||
)
|
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
|
||||||
mock_device.device.async_set_wifi_guest_access.assert_called_once_with(True)
|
turn_on.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)
|
||||||
@ -141,17 +146,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()
|
||||||
mock_device.device.async_set_wifi_guest_access.side_effect = DeviceUnavailable()
|
with patch(
|
||||||
|
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_wifi_guest_access",
|
||||||
with pytest.raises(
|
side_effect=DeviceUnavailable,
|
||||||
HomeAssistantError, match=f"Device {entry.title} did not respond"
|
|
||||||
):
|
):
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True
|
PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True
|
||||||
)
|
)
|
||||||
state = hass.states.get(state_key)
|
|
||||||
assert state is not None
|
state = hass.states.get(state_key)
|
||||||
assert state.state == STATE_UNAVAILABLE
|
assert state is not None
|
||||||
|
assert state.state == STATE_UNAVAILABLE
|
||||||
|
|
||||||
await hass.config_entries.async_unload(entry.entry_id)
|
await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
@ -186,15 +191,18 @@ 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
|
||||||
await hass.services.async_call(
|
with patch(
|
||||||
PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True
|
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting",
|
||||||
)
|
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
|
||||||
mock_device.device.async_set_led_setting.assert_called_once_with(False)
|
turn_off.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)
|
||||||
@ -202,15 +210,18 @@ 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
|
||||||
await hass.services.async_call(
|
with patch(
|
||||||
PLATFORM, SERVICE_TURN_ON, {ATTR_ENTITY_ID: state_key}, blocking=True
|
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting",
|
||||||
)
|
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
|
||||||
mock_device.device.async_set_led_setting.assert_called_once_with(True)
|
turn_on.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)
|
||||||
@ -218,17 +229,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()
|
||||||
mock_device.device.async_set_led_setting.side_effect = DeviceUnavailable()
|
with patch(
|
||||||
|
"devolo_plc_api.device_api.deviceapi.DeviceApi.async_set_led_setting",
|
||||||
with pytest.raises(
|
side_effect=DeviceUnavailable,
|
||||||
HomeAssistantError, match=f"Device {entry.title} did not respond"
|
|
||||||
):
|
):
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
PLATFORM, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: state_key}, blocking=True
|
PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True
|
||||||
)
|
)
|
||||||
state = hass.states.get(state_key)
|
|
||||||
assert state is not None
|
state = hass.states.get(state_key)
|
||||||
assert state.state == STATE_UNAVAILABLE
|
assert state is not None
|
||||||
|
assert state.state == STATE_UNAVAILABLE
|
||||||
|
|
||||||
await hass.config_entries.async_unload(entry.entry_id)
|
await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|
||||||
@ -297,7 +308,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, {ATTR_ENTITY_ID: state_key}, blocking=True
|
PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True
|
||||||
)
|
)
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
@ -1058,7 +1058,6 @@ 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,
|
||||||
}
|
}
|
||||||
@ -1071,7 +1070,6 @@ 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,
|
||||||
}
|
}
|
||||||
@ -1155,7 +1153,6 @@ 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,
|
||||||
}
|
}
|
||||||
@ -1166,7 +1163,6 @@ 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,
|
||||||
}
|
}
|
||||||
|
@ -103,7 +103,6 @@ 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()}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user