forked from home-assistant/core
Compare commits
129 Commits
2025.3.0b5
...
2025.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f244b2b66 | |||
| 1b7e53fd01 | |||
| bfabf972a8 | |||
| c0c997eed8 | |||
| 14b07087dc | |||
| f54a634563 | |||
| e98d518b0b | |||
| 121ee27105 | |||
| 5681f4f2ea | |||
| 8a63fa3bb7 | |||
| 983a2f513d | |||
| aab349e787 | |||
| 21ced23c3c | |||
| a453e9d4c2 | |||
| 3f493dce06 | |||
| 403fe36489 | |||
| 66fd7d9e8a | |||
| c9ceade10d | |||
| 85b6b3a360 | |||
| a2102f9b98 | |||
| 28cad1d085 | |||
| 9d8dbfbf3f | |||
| 1382a001e3 | |||
| 88e3dcccda | |||
| 43e24cf833 | |||
| 65aef40a3f | |||
| 4d1c89f0d1 | |||
| 831f2dc30e | |||
| 1566ab3b28 | |||
| c852e1398c | |||
| 761be9342e | |||
| 54ad44a574 | |||
| fed4015bab | |||
| 019a0ebf9b | |||
| 7607b7d494 | |||
| 8b96a9606d | |||
| 6349821037 | |||
| db26a42734 | |||
| 74fe35f44e | |||
| e648716ddf | |||
| 2e20245cdf | |||
| a12915fc14 | |||
| 3d5e4b980f | |||
| f2f653efcf | |||
| b5c7bdd98f | |||
| 38e6133202 | |||
| 8541dc5bde | |||
| 5327996bad | |||
| 4ddc43a9d9 | |||
| e6dea4179b | |||
| 0318b85517 | |||
| 29987d443e | |||
| cbfd8707b9 | |||
| 5f158f5c87 | |||
| d67ccd2fce | |||
| 29c9d3804b | |||
| 76d478c84f | |||
| 5d9d6f099c | |||
| e4b31640b3 | |||
| c43f6a67d0 | |||
| 0bbab63193 | |||
| 06188b8fbd | |||
| bbbb5cadd4 | |||
| 52fcdda429 | |||
| 7d93ceb0f0 | |||
| 873e4b77eb | |||
| 61f0eabcbb | |||
| 134b5319e1 | |||
| ee78e21950 | |||
| 323bc54efc | |||
| fd2dee3c11 | |||
| fc53322c07 | |||
| faf9977abb | |||
| 7336c8fc07 | |||
| 5cfaeda95b | |||
| a78e9039c6 | |||
| 227f3cea25 | |||
| cab4890246 | |||
| 95fd096bdd | |||
| 91cf8cb547 | |||
| 3ce4f3f918 | |||
| 4e89948b5c | |||
| 9f95383201 | |||
| 7e452521c8 | |||
| 991de6f1d0 | |||
| be32e3fe8f | |||
| d6eb61e9ec | |||
| e74fe69d65 | |||
| 208406123e | |||
| 8bcd135f3d | |||
| e7ea0e435e | |||
| b15b680cfe | |||
| 5e26d98bdf | |||
| 9f94ee280a | |||
| efa98539fa | |||
| 113cd4bfcc | |||
| ccbaf76e44 | |||
| 5d9d93d3a1 | |||
| c2c5274aac | |||
| 89756394c9 | |||
| 352aa88e79 | |||
| 714962bd7a | |||
| fb4c50b5dc | |||
| b4794b2029 | |||
| 3a8c8accfe | |||
| 844adfc590 | |||
| a279e23fb5 | |||
| af9bbd0585 | |||
| 1304194f09 | |||
| e909417a3f | |||
| 02706c116d | |||
| 3af6b5cb4c | |||
| 35c1bb1ec5 | |||
| 97cc3984c5 | |||
| 98e317dd55 | |||
| ed088aa72f | |||
| 51162320cb | |||
| b88eab8ba3 | |||
| 6c080ee650 | |||
| 8056b0df2b | |||
| 3f94b7a61c | |||
| 1484e46317 | |||
| 2812c8a993 | |||
| 5043e2ad10 | |||
| 2c2fd76270 | |||
| 7001f8daaf | |||
| b41fc932c5 | |||
| 0872243297 | |||
| bba889975a |
@@ -118,6 +118,7 @@ class BackupManagerState(StrEnum):
|
||||
|
||||
IDLE = "idle"
|
||||
CREATE_BACKUP = "create_backup"
|
||||
BLOCKED = "blocked"
|
||||
RECEIVE_BACKUP = "receive_backup"
|
||||
RESTORE_BACKUP = "restore_backup"
|
||||
|
||||
@@ -226,6 +227,13 @@ class RestoreBackupEvent(ManagerStateEvent):
|
||||
state: RestoreBackupState
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class BlockedEvent(ManagerStateEvent):
|
||||
"""Backup manager blocked, Home Assistant is starting."""
|
||||
|
||||
manager_state: BackupManagerState = BackupManagerState.BLOCKED
|
||||
|
||||
|
||||
class BackupPlatformProtocol(Protocol):
|
||||
"""Define the format that backup platforms can have."""
|
||||
|
||||
@@ -340,7 +348,7 @@ class BackupManager:
|
||||
self.remove_next_delete_event: Callable[[], None] | None = None
|
||||
|
||||
# Latest backup event and backup event subscribers
|
||||
self.last_event: ManagerStateEvent = IdleEvent()
|
||||
self.last_event: ManagerStateEvent = BlockedEvent()
|
||||
self.last_non_idle_event: ManagerStateEvent | None = None
|
||||
self._backup_event_subscriptions = hass.data[
|
||||
DATA_BACKUP
|
||||
@@ -354,10 +362,19 @@ class BackupManager:
|
||||
self.known_backups.load(stored["backups"])
|
||||
|
||||
await self._reader_writer.async_validate_config(config=self.config)
|
||||
|
||||
await self._reader_writer.async_resume_restore_progress_after_restart(
|
||||
on_progress=self.async_on_backup_event
|
||||
)
|
||||
|
||||
async def set_manager_idle_after_start(hass: HomeAssistant) -> None:
|
||||
"""Set manager to idle after start."""
|
||||
self.async_on_backup_event(IdleEvent())
|
||||
|
||||
if self.state == BackupManagerState.BLOCKED:
|
||||
# If we're not finishing a restore job, set the manager to idle after start
|
||||
start.async_at_started(self.hass, set_manager_idle_after_start)
|
||||
|
||||
await self.load_platforms()
|
||||
|
||||
@property
|
||||
@@ -1293,7 +1310,7 @@ class BackupManager:
|
||||
if (current_state := self.state) != (new_state := event.manager_state):
|
||||
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
|
||||
self.last_event = event
|
||||
if not isinstance(event, IdleEvent):
|
||||
if not isinstance(event, (BlockedEvent, IdleEvent)):
|
||||
self.last_non_idle_event = event
|
||||
for subscription in self._backup_event_subscriptions:
|
||||
subscription(event)
|
||||
|
||||
@@ -75,6 +75,9 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by zeroconf discovery."""
|
||||
# the player can have an ipv6 address, but the api is only available on ipv4
|
||||
if discovery_info.ip_address.version != 4:
|
||||
return self.async_abort(reason="no_ipv4_address")
|
||||
if discovery_info.port is not None:
|
||||
self._port = discovery_info.port
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"no_ipv4_address": "No IPv4 address found."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
|
||||
@@ -77,9 +77,12 @@ class BringEventEntity(BringBaseEntity, EventEntity):
|
||||
attributes = asdict(activity.content)
|
||||
|
||||
attributes["last_activity_by"] = next(
|
||||
x.name
|
||||
for x in bring_list.users.users
|
||||
if x.publicUuid == activity.content.publicUserUuid
|
||||
(
|
||||
x.name
|
||||
for x in bring_list.users.users
|
||||
if x.publicUuid == activity.content.publicUserUuid
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
self._trigger_event(
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aioelectricitymaps import (
|
||||
ElectricityMaps,
|
||||
ElectricityMapsError,
|
||||
ElectricityMapsInvalidTokenError,
|
||||
ElectricityMapsNoDataError,
|
||||
)
|
||||
@@ -36,6 +36,8 @@ TYPE_USE_HOME = "use_home_location"
|
||||
TYPE_SPECIFY_COORDINATES = "specify_coordinates"
|
||||
TYPE_SPECIFY_COUNTRY = "specify_country_code"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Co2signal."""
|
||||
@@ -158,7 +160,8 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "invalid_auth"
|
||||
except ElectricityMapsNoDataError:
|
||||
errors["base"] = "no_data"
|
||||
except ElectricityMapsError:
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error occurred while checking API key")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if self.source == SOURCE_REAUTH:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"]
|
||||
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.5"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aioecowitt==2024.2.1"]
|
||||
"requirements": ["aioecowitt==2025.3.1"]
|
||||
}
|
||||
|
||||
@@ -101,9 +101,11 @@ def hostname_from_url(url: str) -> str:
|
||||
|
||||
def _host_validator(config: dict[str, str]) -> dict[str, str]:
|
||||
"""Validate that a host is properly configured."""
|
||||
if config[CONF_HOST].startswith("elks://"):
|
||||
if config[CONF_HOST].startswith(("elks://", "elksv1_2://")):
|
||||
if CONF_USERNAME not in config or CONF_PASSWORD not in config:
|
||||
raise vol.Invalid("Specify username and password for elks://")
|
||||
raise vol.Invalid(
|
||||
"Specify username and password for elks:// or elksv1_2://"
|
||||
)
|
||||
elif not config[CONF_HOST].startswith("elk://") and not config[
|
||||
CONF_HOST
|
||||
].startswith("serial://"):
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sense_energy"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["sense-energy==0.13.6"]
|
||||
"requirements": ["sense-energy==0.13.7"]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any
|
||||
import evohomeasync as ec1
|
||||
import evohomeasync2 as ec2
|
||||
from evohomeasync2.const import (
|
||||
SZ_DHW,
|
||||
SZ_GATEWAY_ID,
|
||||
SZ_GATEWAY_INFO,
|
||||
SZ_GATEWAYS,
|
||||
@@ -19,8 +20,9 @@ from evohomeasync2.const import (
|
||||
SZ_TEMPERATURE_CONTROL_SYSTEMS,
|
||||
SZ_TIME_ZONE,
|
||||
SZ_USE_DAYLIGHT_SAVE_SWITCHING,
|
||||
SZ_ZONES,
|
||||
)
|
||||
from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT
|
||||
from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT
|
||||
|
||||
from homeassistant.const import CONF_SCAN_INTERVAL
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -113,17 +115,19 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
SZ_USE_DAYLIGHT_SAVE_SWITCHING
|
||||
],
|
||||
}
|
||||
tcs_info: EvoTcsConfigResponseT = self.tcs.config # type: ignore[assignment]
|
||||
tcs_info[SZ_ZONES] = [zone.config for zone in self.tcs.zones]
|
||||
if self.tcs.hotwater:
|
||||
tcs_info[SZ_DHW] = self.tcs.hotwater.config
|
||||
gwy_info = {
|
||||
SZ_GATEWAY_ID: self.loc.gateways[0].id,
|
||||
SZ_TEMPERATURE_CONTROL_SYSTEMS: [
|
||||
self.loc.gateways[0].systems[0].config
|
||||
],
|
||||
SZ_TEMPERATURE_CONTROL_SYSTEMS: [tcs_info],
|
||||
}
|
||||
config = {
|
||||
SZ_LOCATION_INFO: loc_info,
|
||||
SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}],
|
||||
}
|
||||
self.logger.debug("Config = %s", config)
|
||||
self.logger.debug("Config = %s", [config])
|
||||
|
||||
async def call_client_api(
|
||||
self,
|
||||
@@ -203,10 +207,18 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
async def _update_v2_schedules(self) -> None:
|
||||
for zone in self.tcs.zones:
|
||||
await zone.get_schedule()
|
||||
try:
|
||||
await zone.get_schedule()
|
||||
except ec2.InvalidScheduleError as err:
|
||||
self.logger.warning(
|
||||
"Zone '%s' has an invalid/missing schedule: %r", zone.name, err
|
||||
)
|
||||
|
||||
if dhw := self.tcs.hotwater:
|
||||
await dhw.get_schedule()
|
||||
try:
|
||||
await dhw.get_schedule()
|
||||
except ec2.InvalidScheduleError as err:
|
||||
self.logger.warning("DHW has an invalid/missing schedule: %r", err)
|
||||
|
||||
async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override]
|
||||
"""Fetch the latest state of an entire TCC Location.
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import evohomeasync2 as evo
|
||||
from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -102,7 +103,7 @@ class EvoChild(EvoEntity):
|
||||
|
||||
self._evo_tcs = evo_device.tcs
|
||||
|
||||
self._schedule: dict[str, Any] | None = None
|
||||
self._schedule: list[DayOfWeekDhwT] | None = None
|
||||
self._setpoints: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
@@ -123,6 +124,9 @@ class EvoChild(EvoEntity):
|
||||
Only Zones & DHW controllers (but not the TCS) can have schedules.
|
||||
"""
|
||||
|
||||
if not self._schedule:
|
||||
return self._setpoints
|
||||
|
||||
this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint
|
||||
next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint
|
||||
|
||||
@@ -152,10 +156,10 @@ class EvoChild(EvoEntity):
|
||||
self._evo_device,
|
||||
err,
|
||||
)
|
||||
self._schedule = {}
|
||||
self._schedule = []
|
||||
return
|
||||
else:
|
||||
self._schedule = schedule or {} # mypy hint
|
||||
self._schedule = schedule # type: ignore[assignment]
|
||||
|
||||
_LOGGER.debug("Schedule['%s'] = %s", self.name, schedule)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["evohome-async==1.0.2"]
|
||||
"requirements": ["evohome-async==1.0.4"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250228.0"]
|
||||
"requirements": ["home-assistant-frontend==20250306.0"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["ayla-iot-unofficial==1.4.5"]
|
||||
"requirements": ["ayla-iot-unofficial==1.4.7"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from google import genai # type: ignore[attr-defined]
|
||||
@@ -65,9 +66,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
prompt_parts = [call.data[CONF_PROMPT]]
|
||||
|
||||
config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries(
|
||||
DOMAIN
|
||||
)[0]
|
||||
config_entry: GoogleGenerativeAIConfigEntry = (
|
||||
hass.config_entries.async_loaded_entries(DOMAIN)[0]
|
||||
)
|
||||
|
||||
client = config_entry.runtime_data
|
||||
|
||||
@@ -83,7 +84,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
if not Path(filename).exists():
|
||||
raise HomeAssistantError(f"`{filename}` does not exist")
|
||||
prompt_parts.append(client.files.upload(file=filename))
|
||||
mimetype = mimetypes.guess_type(filename)[0]
|
||||
with open(filename, "rb") as file:
|
||||
uploaded_file = client.files.upload(
|
||||
file=file, config={"mime_type": mimetype}
|
||||
)
|
||||
prompt_parts.append(uploaded_file)
|
||||
|
||||
await hass.async_add_executor_job(append_files_to_prompt)
|
||||
|
||||
|
||||
@@ -64,28 +64,18 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_KEYS = {
|
||||
"min_items",
|
||||
"example",
|
||||
"property_ordering",
|
||||
"pattern",
|
||||
"minimum",
|
||||
"default",
|
||||
"any_of",
|
||||
"max_length",
|
||||
"title",
|
||||
"min_properties",
|
||||
"min_length",
|
||||
"max_items",
|
||||
"maximum",
|
||||
"nullable",
|
||||
"max_properties",
|
||||
# Gemini API does not support all of the OpenAPI schema
|
||||
# SoT: https://ai.google.dev/api/caching#Schema
|
||||
"type",
|
||||
"description",
|
||||
"enum",
|
||||
"format",
|
||||
"items",
|
||||
"description",
|
||||
"nullable",
|
||||
"enum",
|
||||
"max_items",
|
||||
"min_items",
|
||||
"properties",
|
||||
"required",
|
||||
"items",
|
||||
}
|
||||
|
||||
|
||||
@@ -109,9 +99,7 @@ def _format_schema(schema: dict[str, Any]) -> Schema:
|
||||
key = _camel_to_snake(key)
|
||||
if key not in SUPPORTED_SCHEMA_KEYS:
|
||||
continue
|
||||
if key == "any_of":
|
||||
val = [_format_schema(subschema) for subschema in val]
|
||||
elif key == "type":
|
||||
if key == "type":
|
||||
val = val.upper()
|
||||
elif key == "format":
|
||||
# Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema
|
||||
@@ -288,6 +276,13 @@ class GoogleGenerativeAIConversationEntity(
|
||||
):
|
||||
return await self._async_handle_message(user_input, chat_log)
|
||||
|
||||
def _fix_tool_name(self, tool_name: str) -> str:
|
||||
"""Fix tool name if needed."""
|
||||
# The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool
|
||||
# name. This makes sure when it incorrectly changes the name, that we change it
|
||||
# back for HA to call.
|
||||
return tool_name if tool_name != "HasListAddItem" else "HassListAddItem"
|
||||
|
||||
async def _async_handle_message(
|
||||
self,
|
||||
user_input: conversation.ConversationInput,
|
||||
@@ -447,7 +442,10 @@ class GoogleGenerativeAIConversationEntity(
|
||||
tool_name = tool_call.name
|
||||
tool_args = _escape_decode(tool_call.args)
|
||||
tool_calls.append(
|
||||
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
|
||||
llm.ToolInput(
|
||||
tool_name=self._fix_tool_name(tool_name),
|
||||
tool_args=tool_args,
|
||||
)
|
||||
)
|
||||
|
||||
chat_request = _create_google_tool_response_content(
|
||||
|
||||
@@ -135,5 +135,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["govee-ble==0.43.0"]
|
||||
"requirements": ["govee-ble==0.43.1"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ from pyheos import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector
|
||||
@@ -141,8 +146,10 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
hostname = urlparse(discovery_info.ssdp_location).hostname
|
||||
assert hostname is not None
|
||||
|
||||
# Abort early when discovered host is part of the current system
|
||||
if entry and hostname in _get_current_hosts(entry):
|
||||
# Abort early when discovery is ignored or host is part of the current system
|
||||
if entry and (
|
||||
entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry)
|
||||
):
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
# Connect to discovered host and get system information
|
||||
@@ -198,7 +205,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Obtain host and validate connection."""
|
||||
await self.async_set_unique_id(DOMAIN)
|
||||
await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured(error="single_instance_allowed")
|
||||
# Try connecting to host if provided
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
@@ -159,13 +159,12 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
async def _async_on_reconnected(self) -> None:
|
||||
"""Handle when reconnected so resources are updated and entities marked available."""
|
||||
await self._async_update_players()
|
||||
await self._async_update_sources()
|
||||
_LOGGER.warning("Successfully reconnected to HEOS host %s", self.host)
|
||||
self.async_update_listeners()
|
||||
|
||||
async def _async_on_controller_event(
|
||||
self, event: str, data: PlayerUpdateResult | None
|
||||
self, event: str, data: PlayerUpdateResult | None = None
|
||||
) -> None:
|
||||
"""Handle a controller event, such as players or groups changed."""
|
||||
if event == const.EVENT_PLAYERS_CHANGED:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyheos"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyheos==1.0.2"],
|
||||
"requirements": ["pyheos==1.0.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
||||
|
||||
@@ -16,11 +16,17 @@ from aiohomeconnect.model import (
|
||||
SettingKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
@@ -611,18 +617,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
config_entry_auth = AsyncConfigEntryAuth(hass, session)
|
||||
try:
|
||||
await config_entry_auth.async_get_access_token()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
home_connect_client = HomeConnectClient(config_entry_auth)
|
||||
|
||||
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
await coordinator.async_setup()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.runtime_data.start_event_listener()
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
coordinator.async_refresh(),
|
||||
f"home_connect-initial-full-refresh-{entry.entry_id}",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -137,41 +137,6 @@ def setup_home_connect_entry(
|
||||
defaultdict(list)
|
||||
)
|
||||
|
||||
entities: list[HomeConnectEntity] = []
|
||||
for appliance in entry.runtime_data.data.values():
|
||||
entities_to_add = get_entities_for_appliance(entry, appliance)
|
||||
if get_option_entities_for_appliance:
|
||||
entities_to_add.extend(get_option_entities_for_appliance(entry, appliance))
|
||||
for event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
):
|
||||
changed_options_listener_remove_callback = (
|
||||
entry.runtime_data.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entry,
|
||||
appliance,
|
||||
known_entity_unique_ids,
|
||||
get_option_entities_for_appliance,
|
||||
async_add_entities,
|
||||
),
|
||||
(appliance.info.ha_id, event_key),
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(changed_options_listener_remove_callback)
|
||||
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
|
||||
changed_options_listener_remove_callback
|
||||
)
|
||||
known_entity_unique_ids.update(
|
||||
{
|
||||
cast(str, entity.unique_id): appliance.info.ha_id
|
||||
for entity in entities_to_add
|
||||
}
|
||||
)
|
||||
entities.extend(entities_to_add)
|
||||
async_add_entities(entities)
|
||||
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_add_special_listener(
|
||||
partial(
|
||||
|
||||
@@ -10,6 +10,7 @@ from .utils import bsh_key_to_translation_key
|
||||
|
||||
DOMAIN = "home_connect"
|
||||
|
||||
API_DEFAULT_RETRY_AFTER = 60
|
||||
|
||||
APPLIANCES_WITH_PROGRAMS = (
|
||||
"CleaningRobot",
|
||||
@@ -284,7 +285,9 @@ SPIN_SPEED_OPTIONS = {
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.Off",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM400",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM600",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM700",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM800",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM900",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1000",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1200",
|
||||
"LaundryCare.Washer.EnumType.SpinSpeed.RPM1400",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from asyncio import sleep as asyncio_sleep
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
@@ -29,6 +29,7 @@ from aiohomeconnect.model.error import (
|
||||
HomeConnectApiError,
|
||||
HomeConnectError,
|
||||
HomeConnectRequestError,
|
||||
TooManyRequestsError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
|
||||
@@ -36,11 +37,11 @@ from propcache.api import cached_property
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
|
||||
from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -154,7 +155,7 @@ class HomeConnectCoordinator(
|
||||
f"home_connect-events_listener_task-{self.config_entry.entry_id}",
|
||||
)
|
||||
|
||||
async def _event_listener(self) -> None:
|
||||
async def _event_listener(self) -> None: # noqa: C901
|
||||
"""Match event with listener for event type."""
|
||||
retry_time = 10
|
||||
while True:
|
||||
@@ -269,7 +270,7 @@ class HomeConnectCoordinator(
|
||||
type(error).__name__,
|
||||
retry_time,
|
||||
)
|
||||
await asyncio.sleep(retry_time)
|
||||
await asyncio_sleep(retry_time)
|
||||
retry_time = min(retry_time * 2, 3600)
|
||||
except HomeConnectApiError as error:
|
||||
_LOGGER.error("Error while listening for events: %s", error)
|
||||
@@ -278,6 +279,13 @@ class HomeConnectCoordinator(
|
||||
)
|
||||
break
|
||||
|
||||
# Trigger to delete the possible depaired device entities
|
||||
# from known_entities variable at common.py
|
||||
for listener, context in self._special_listeners.values():
|
||||
assert isinstance(context, tuple)
|
||||
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
|
||||
listener()
|
||||
|
||||
@callback
|
||||
def _call_event_listener(self, event_message: EventMessage) -> None:
|
||||
"""Call listener for event."""
|
||||
@@ -295,6 +303,42 @@ class HomeConnectCoordinator(
|
||||
|
||||
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
|
||||
"""Fetch data from Home Connect."""
|
||||
await self._async_setup()
|
||||
|
||||
for appliance_data in self.data.values():
|
||||
appliance = appliance_data.info
|
||||
ha_id = appliance.ha_id
|
||||
while True:
|
||||
try:
|
||||
self.data[ha_id] = await self._get_appliance_data(
|
||||
appliance, self.data.get(ha_id)
|
||||
)
|
||||
except TooManyRequestsError as err:
|
||||
_LOGGER.debug(
|
||||
"Rate limit exceeded on initial fetch: %s",
|
||||
err,
|
||||
)
|
||||
await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER)
|
||||
else:
|
||||
break
|
||||
|
||||
for listener, context in self._special_listeners.values():
|
||||
assert isinstance(context, tuple)
|
||||
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
|
||||
listener()
|
||||
|
||||
return self.data
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the devices."""
|
||||
try:
|
||||
await self._async_setup()
|
||||
except UpdateFailed as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the devices."""
|
||||
old_appliances = set(self.data.keys())
|
||||
try:
|
||||
appliances = await self.client.get_home_appliances()
|
||||
except UnauthorizedError as error:
|
||||
@@ -312,12 +356,38 @@ class HomeConnectCoordinator(
|
||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||
) from error
|
||||
|
||||
return {
|
||||
appliance.ha_id: await self._get_appliance_data(
|
||||
appliance, self.data.get(appliance.ha_id)
|
||||
for appliance in appliances.homeappliances:
|
||||
self.device_registry.async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
identifiers={(DOMAIN, appliance.ha_id)},
|
||||
manufacturer=appliance.brand,
|
||||
name=appliance.name,
|
||||
model=appliance.vib,
|
||||
)
|
||||
for appliance in appliances.homeappliances
|
||||
}
|
||||
if appliance.ha_id not in self.data:
|
||||
self.data[appliance.ha_id] = HomeConnectApplianceData(
|
||||
commands=set(),
|
||||
events={},
|
||||
info=appliance,
|
||||
options={},
|
||||
programs=[],
|
||||
settings={},
|
||||
status={},
|
||||
)
|
||||
else:
|
||||
self.data[appliance.ha_id].info.connected = appliance.connected
|
||||
old_appliances.remove(appliance.ha_id)
|
||||
|
||||
for ha_id in old_appliances:
|
||||
self.data.pop(ha_id, None)
|
||||
device = self.device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, ha_id)}
|
||||
)
|
||||
if device:
|
||||
self.device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
async def _get_appliance_data(
|
||||
self,
|
||||
@@ -339,6 +409,8 @@ class HomeConnectCoordinator(
|
||||
await self.client.get_settings(appliance.ha_id)
|
||||
).settings
|
||||
}
|
||||
except TooManyRequestsError:
|
||||
raise
|
||||
except HomeConnectError as error:
|
||||
_LOGGER.debug(
|
||||
"Error fetching settings for %s: %s",
|
||||
@@ -353,6 +425,8 @@ class HomeConnectCoordinator(
|
||||
status.key: status
|
||||
for status in (await self.client.get_status(appliance.ha_id)).status
|
||||
}
|
||||
except TooManyRequestsError:
|
||||
raise
|
||||
except HomeConnectError as error:
|
||||
_LOGGER.debug(
|
||||
"Error fetching status for %s: %s",
|
||||
@@ -369,6 +443,8 @@ class HomeConnectCoordinator(
|
||||
if appliance.type in APPLIANCES_WITH_PROGRAMS:
|
||||
try:
|
||||
all_programs = await self.client.get_all_programs(appliance.ha_id)
|
||||
except TooManyRequestsError:
|
||||
raise
|
||||
except HomeConnectError as error:
|
||||
_LOGGER.debug(
|
||||
"Error fetching programs for %s: %s",
|
||||
@@ -427,6 +503,8 @@ class HomeConnectCoordinator(
|
||||
await self.client.get_available_commands(appliance.ha_id)
|
||||
).commands
|
||||
}
|
||||
except TooManyRequestsError:
|
||||
raise
|
||||
except HomeConnectError:
|
||||
commands = set()
|
||||
|
||||
@@ -461,6 +539,8 @@ class HomeConnectCoordinator(
|
||||
).options
|
||||
or []
|
||||
}
|
||||
except TooManyRequestsError:
|
||||
raise
|
||||
except HomeConnectError as error:
|
||||
_LOGGER.debug(
|
||||
"Error fetching options for %s: %s",
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
"""Home Connect entity base class."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable, Coroutine
|
||||
import contextlib
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import cast
|
||||
from typing import Any, Concatenate, cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
from aiohomeconnect.model.error import (
|
||||
ActiveProgramNotSetError,
|
||||
HomeConnectError,
|
||||
TooManyRequestsError,
|
||||
)
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import API_DEFAULT_RETRY_AFTER, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
@@ -127,3 +134,34 @@ class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
def bsh_key(self) -> OptionKey:
|
||||
"""Return the BSH key."""
|
||||
return cast(OptionKey, self.entity_description.key)
|
||||
|
||||
|
||||
def constraint_fetcher[_EntityT: HomeConnectEntity, **_P](
|
||||
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate the function to catch Home Connect too many requests error and retry later.
|
||||
|
||||
If it needs to be called later, it will call async_write_ha_state function
|
||||
"""
|
||||
|
||||
async def handler_to_return(
|
||||
self: _EntityT, *args: _P.args, **kwargs: _P.kwargs
|
||||
) -> None:
|
||||
async def handler(_datetime: datetime | None = None) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except TooManyRequestsError as err:
|
||||
if (retry_after := err.retry_after) is None:
|
||||
retry_after = API_DEFAULT_RETRY_AFTER
|
||||
async_call_later(self.hass, retry_after, handler)
|
||||
except HomeConnectError as err:
|
||||
_LOGGER.error(
|
||||
"Error fetching constraints for %s: %s", self.entity_id, err
|
||||
)
|
||||
else:
|
||||
if _datetime is not None:
|
||||
self.async_write_ha_state()
|
||||
|
||||
await handler()
|
||||
|
||||
return handler_to_return
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"requirements": ["aiohomeconnect==0.16.2"],
|
||||
"requirements": ["aiohomeconnect==0.16.3"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ from .const import (
|
||||
UNIT_MAP,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -189,19 +189,25 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
|
||||
},
|
||||
) from err
|
||||
|
||||
@constraint_fetcher
|
||||
async def async_fetch_constraints(self) -> None:
|
||||
"""Fetch the max and min values and step for the number entity."""
|
||||
try:
|
||||
setting_key = cast(SettingKey, self.bsh_key)
|
||||
data = self.appliance.settings.get(setting_key)
|
||||
if not data or not data.unit or not data.constraints:
|
||||
data = await self.coordinator.client.get_setting(
|
||||
self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key)
|
||||
self.appliance.info.ha_id, setting_key=setting_key
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
_LOGGER.error("An error occurred: %s", err)
|
||||
else:
|
||||
if data.unit:
|
||||
self._attr_native_unit_of_measurement = data.unit
|
||||
self.set_constraints(data)
|
||||
|
||||
def set_constraints(self, setting: GetSetting) -> None:
|
||||
"""Set constraints for the number entity."""
|
||||
if setting.unit:
|
||||
self._attr_native_unit_of_measurement = UNIT_MAP.get(
|
||||
setting.unit, setting.unit
|
||||
)
|
||||
if not (constraints := setting.constraints):
|
||||
return
|
||||
if constraints.max:
|
||||
@@ -222,10 +228,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
|
||||
self._attr_native_unit_of_measurement = data.unit
|
||||
self.set_constraints(data)
|
||||
if (
|
||||
not hasattr(self, "_attr_native_min_value")
|
||||
not hasattr(self, "_attr_native_unit_of_measurement")
|
||||
or not hasattr(self, "_attr_native_min_value")
|
||||
or not hasattr(self, "_attr_native_max_value")
|
||||
or not hasattr(self, "_attr_native_step")
|
||||
):
|
||||
@@ -253,7 +259,6 @@ class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity):
|
||||
or candidate_unit != self._attr_native_unit_of_measurement
|
||||
):
|
||||
self._attr_native_unit_of_measurement = candidate_unit
|
||||
self.__dict__.pop("unit_of_measurement", None)
|
||||
option_constraints = option_definition.constraints
|
||||
if option_constraints:
|
||||
if (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Provides a select platform for Home Connect."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
@@ -47,9 +47,11 @@ from .coordinator import (
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
|
||||
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = {
|
||||
@@ -413,6 +415,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
"""Select setting class for Home Connect."""
|
||||
|
||||
entity_description: HomeConnectSelectEntityDescription
|
||||
_original_option_keys: set[str | None]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -421,6 +424,7 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
desc: HomeConnectSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._original_option_keys = set(desc.values_translation_key)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
@@ -458,23 +462,29 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
await self.async_fetch_options()
|
||||
|
||||
@constraint_fetcher
|
||||
async def async_fetch_options(self) -> None:
|
||||
"""Fetch options from the API."""
|
||||
setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key))
|
||||
if (
|
||||
not setting
|
||||
or not setting.constraints
|
||||
or not setting.constraints.allowed_values
|
||||
):
|
||||
with contextlib.suppress(HomeConnectError):
|
||||
setting = await self.coordinator.client.get_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=cast(SettingKey, self.bsh_key),
|
||||
)
|
||||
setting = await self.coordinator.client.get_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=cast(SettingKey, self.bsh_key),
|
||||
)
|
||||
|
||||
if setting and setting.constraints and setting.constraints.allowed_values:
|
||||
self._original_option_keys = set(setting.constraints.allowed_values)
|
||||
self._attr_options = [
|
||||
self.entity_description.values_translation_key[option]
|
||||
for option in setting.constraints.allowed_values
|
||||
if option in self.entity_description.values_translation_key
|
||||
for option in self._original_option_keys
|
||||
if option is not None
|
||||
and option in self.entity_description.values_translation_key
|
||||
]
|
||||
|
||||
|
||||
@@ -491,7 +501,7 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
|
||||
desc: HomeConnectSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._original_option_keys = set(desc.values_translation_key.keys())
|
||||
self._original_option_keys = set(desc.values_translation_key)
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
@@ -524,5 +534,5 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
|
||||
self.entity_description.values_translation_key[option]
|
||||
for option in self._original_option_keys
|
||||
if option is not None
|
||||
and option in self.entity_description.values_translation_key
|
||||
]
|
||||
self.__dict__.pop("options", None)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Provides a sensor for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, StatusKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -28,7 +27,9 @@ from .const import (
|
||||
UNIT_MAP,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .entity import HomeConnectEntity, constraint_fetcher
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -335,16 +336,14 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||
else:
|
||||
await self.fetch_unit()
|
||||
|
||||
@constraint_fetcher
|
||||
async def fetch_unit(self) -> None:
|
||||
"""Fetch the unit of measurement."""
|
||||
with contextlib.suppress(HomeConnectError):
|
||||
data = await self.coordinator.client.get_status_value(
|
||||
self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key)
|
||||
)
|
||||
if data.unit:
|
||||
self._attr_native_unit_of_measurement = UNIT_MAP.get(
|
||||
data.unit, data.unit
|
||||
)
|
||||
data = await self.coordinator.client.get_status_value(
|
||||
self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key)
|
||||
)
|
||||
if data.unit:
|
||||
self._attr_native_unit_of_measurement = UNIT_MAP.get(data.unit, data.unit)
|
||||
|
||||
|
||||
class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
@@ -386,6 +385,13 @@ class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Update the program sensor's status."""
|
||||
self.program_running = (
|
||||
status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE)
|
||||
) is not None and status.value in [
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
]
|
||||
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
||||
if event:
|
||||
self._update_native_value(event.value)
|
||||
|
||||
@@ -468,11 +468,11 @@ set_program_and_options:
|
||||
translation_key: venting_level
|
||||
options:
|
||||
- cooking_hood_enum_type_stage_fan_off
|
||||
- cooking_hood_enum_type_stage_fan_stage01
|
||||
- cooking_hood_enum_type_stage_fan_stage02
|
||||
- cooking_hood_enum_type_stage_fan_stage03
|
||||
- cooking_hood_enum_type_stage_fan_stage04
|
||||
- cooking_hood_enum_type_stage_fan_stage05
|
||||
- cooking_hood_enum_type_stage_fan_stage_01
|
||||
- cooking_hood_enum_type_stage_fan_stage_02
|
||||
- cooking_hood_enum_type_stage_fan_stage_03
|
||||
- cooking_hood_enum_type_stage_fan_stage_04
|
||||
- cooking_hood_enum_type_stage_fan_stage_05
|
||||
cooking_hood_option_intensive_level:
|
||||
example: cooking_hood_enum_type_intensive_stage_intensive_stage1
|
||||
required: false
|
||||
@@ -528,7 +528,7 @@ set_program_and_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
laundry_care_washer_option_temperature:
|
||||
example: laundry_care_washer_enum_type_temperature_g_c40
|
||||
example: laundry_care_washer_enum_type_temperature_g_c_40
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -536,14 +536,14 @@ set_program_and_options:
|
||||
translation_key: washer_temperature
|
||||
options:
|
||||
- laundry_care_washer_enum_type_temperature_cold
|
||||
- laundry_care_washer_enum_type_temperature_g_c20
|
||||
- laundry_care_washer_enum_type_temperature_g_c30
|
||||
- laundry_care_washer_enum_type_temperature_g_c40
|
||||
- laundry_care_washer_enum_type_temperature_g_c50
|
||||
- laundry_care_washer_enum_type_temperature_g_c60
|
||||
- laundry_care_washer_enum_type_temperature_g_c70
|
||||
- laundry_care_washer_enum_type_temperature_g_c80
|
||||
- laundry_care_washer_enum_type_temperature_g_c90
|
||||
- laundry_care_washer_enum_type_temperature_g_c_20
|
||||
- laundry_care_washer_enum_type_temperature_g_c_30
|
||||
- laundry_care_washer_enum_type_temperature_g_c_40
|
||||
- laundry_care_washer_enum_type_temperature_g_c_50
|
||||
- laundry_care_washer_enum_type_temperature_g_c_60
|
||||
- laundry_care_washer_enum_type_temperature_g_c_70
|
||||
- laundry_care_washer_enum_type_temperature_g_c_80
|
||||
- laundry_care_washer_enum_type_temperature_g_c_90
|
||||
- laundry_care_washer_enum_type_temperature_ul_cold
|
||||
- laundry_care_washer_enum_type_temperature_ul_warm
|
||||
- laundry_care_washer_enum_type_temperature_ul_hot
|
||||
@@ -557,13 +557,15 @@ set_program_and_options:
|
||||
translation_key: spin_speed
|
||||
options:
|
||||
- laundry_care_washer_enum_type_spin_speed_off
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m400
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m600
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m800
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m1000
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m1200
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m1400
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m1600
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_400
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_600
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_700
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_800
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_900
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_1000
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_1200
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_1400
|
||||
- laundry_care_washer_enum_type_spin_speed_r_p_m_1600
|
||||
- laundry_care_washer_enum_type_spin_speed_ul_off
|
||||
- laundry_care_washer_enum_type_spin_speed_ul_low
|
||||
- laundry_care_washer_enum_type_spin_speed_ul_medium
|
||||
|
||||
@@ -417,11 +417,11 @@
|
||||
"venting_level": {
|
||||
"options": {
|
||||
"cooking_hood_enum_type_stage_fan_off": "Fan off",
|
||||
"cooking_hood_enum_type_stage_fan_stage01": "Fan stage 1",
|
||||
"cooking_hood_enum_type_stage_fan_stage02": "Fan stage 2",
|
||||
"cooking_hood_enum_type_stage_fan_stage03": "Fan stage 3",
|
||||
"cooking_hood_enum_type_stage_fan_stage04": "Fan stage 4",
|
||||
"cooking_hood_enum_type_stage_fan_stage05": "Fan stage 5"
|
||||
"cooking_hood_enum_type_stage_fan_stage_01": "Fan stage 1",
|
||||
"cooking_hood_enum_type_stage_fan_stage_02": "Fan stage 2",
|
||||
"cooking_hood_enum_type_stage_fan_stage_03": "Fan stage 3",
|
||||
"cooking_hood_enum_type_stage_fan_stage_04": "Fan stage 4",
|
||||
"cooking_hood_enum_type_stage_fan_stage_05": "Fan stage 5"
|
||||
}
|
||||
},
|
||||
"intensive_level": {
|
||||
@@ -441,14 +441,14 @@
|
||||
"washer_temperature": {
|
||||
"options": {
|
||||
"laundry_care_washer_enum_type_temperature_cold": "Cold",
|
||||
"laundry_care_washer_enum_type_temperature_g_c20": "20ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c30": "30ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c40": "40ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c50": "50ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c60": "60ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c70": "70ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c80": "80ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c90": "90ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_20": "20ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_30": "30ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_40": "40ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_50": "50ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_60": "60ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_70": "70ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_80": "80ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_90": "90ºC clothes",
|
||||
"laundry_care_washer_enum_type_temperature_ul_cold": "Cold",
|
||||
"laundry_care_washer_enum_type_temperature_ul_warm": "Warm",
|
||||
"laundry_care_washer_enum_type_temperature_ul_hot": "Hot",
|
||||
@@ -458,13 +458,15 @@
|
||||
"spin_speed": {
|
||||
"options": {
|
||||
"laundry_care_washer_enum_type_spin_speed_off": "Off",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1600": "1600 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_800": "800 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_900": "900 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "1000 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_off": "Off",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_low": "Low",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium",
|
||||
@@ -1382,11 +1384,11 @@
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]",
|
||||
"state": {
|
||||
"cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]"
|
||||
"cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage_02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_02%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage_03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_03%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage_04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_04%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage_05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_05%]"
|
||||
}
|
||||
},
|
||||
"intensive_level": {
|
||||
@@ -1409,14 +1411,14 @@
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]",
|
||||
"state": {
|
||||
"laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_20%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_30%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_40%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_50%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_60%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_70%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_80%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c_90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_90%]",
|
||||
"laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]",
|
||||
"laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]",
|
||||
"laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]",
|
||||
@@ -1427,13 +1429,15 @@
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]",
|
||||
"state": {
|
||||
"laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_800%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_900%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1000%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Pick Homematic IP access point",
|
||||
"description": "If you are about to register a **Homematic IP HCU1**, please press the button on top of the device before you continue.\n\nThe registration process must be completed within 5 minutes.",
|
||||
"data": {
|
||||
"hapid": "Access point ID (SGTIN)",
|
||||
"pin": "[%key:common::config_flow::data::pin%]",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pydrawise"],
|
||||
"requirements": ["pydrawise==2025.2.0"]
|
||||
"requirements": ["pydrawise==2025.3.0"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"loggers": ["xknx", "xknxproject"],
|
||||
"requirements": [
|
||||
"xknx==3.6.0",
|
||||
"xknxproject==3.8.1",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.1.30.194235"
|
||||
],
|
||||
"single_config_entry": true
|
||||
|
||||
@@ -61,6 +61,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
client=client,
|
||||
)
|
||||
|
||||
# initialize the firmware update coordinator early to check the firmware version
|
||||
firmware_device = LaMarzoccoMachine(
|
||||
model=entry.data[CONF_MODEL],
|
||||
serial_number=entry.unique_id,
|
||||
name=entry.data[CONF_NAME],
|
||||
cloud_client=cloud_client,
|
||||
)
|
||||
|
||||
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
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"unsupported_gateway_firmware",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="unsupported_gateway_firmware",
|
||||
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:
|
||||
@@ -117,30 +153,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
|
||||
coordinators = LaMarzoccoRuntimeData(
|
||||
LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client),
|
||||
LaMarzoccoFirmwareUpdateCoordinator(hass, entry, device),
|
||||
firmware_coordinator,
|
||||
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
|
||||
)
|
||||
|
||||
# API does not like concurrent requests, so no asyncio.gather here
|
||||
await coordinators.config_coordinator.async_config_entry_first_refresh()
|
||||
await coordinators.firmware_coordinator.async_config_entry_first_refresh()
|
||||
await coordinators.statistics_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
gateway_version = device.firmware[FirmwareType.GATEWAY].current_version
|
||||
if version.parse(gateway_version) < version.parse("v3.4-rc5"):
|
||||
# incompatible gateway firmware, create an issue
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"unsupported_gateway_firmware",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="unsupported_gateway_firmware",
|
||||
translation_placeholders={"gateway_version": gateway_version},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def update_listener(
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==1.4.7"]
|
||||
"requirements": ["pylamarzocco==1.4.9"]
|
||||
}
|
||||
|
||||
@@ -144,9 +144,12 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
|
||||
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].off_time,
|
||||
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 == PrebrewMode.PREBREW,
|
||||
and device.config.prebrew_mode
|
||||
in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED),
|
||||
supported_fn=lambda coordinator: coordinator.device.model
|
||||
!= MachineModel.GS3_MP,
|
||||
),
|
||||
@@ -162,9 +165,12 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
|
||||
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].off_time,
|
||||
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 == PrebrewMode.PREBREW,
|
||||
and device.config.prebrew_mode
|
||||
in (PrebrewMode.PREBREW, PrebrewMode.PREBREW_ENABLED),
|
||||
supported_fn=lambda coordinator: coordinator.device.model
|
||||
!= MachineModel.GS3_MP,
|
||||
),
|
||||
@@ -180,8 +186,8 @@ KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
|
||||
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
|
||||
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,
|
||||
|
||||
@@ -38,6 +38,7 @@ STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items(
|
||||
PREBREW_MODE_HA_TO_LM = {
|
||||
"disabled": PrebrewMode.DISABLED,
|
||||
"prebrew": PrebrewMode.PREBREW,
|
||||
"prebrew_enabled": PrebrewMode.PREBREW_ENABLED,
|
||||
"preinfusion": PrebrewMode.PREINFUSION,
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"state": {
|
||||
"disabled": "Disabled",
|
||||
"prebrew": "Prebrew",
|
||||
"prebrew_enabled": "Prebrew",
|
||||
"preinfusion": "Preinfusion"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -110,7 +110,9 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
self._attr_hvac_modes = [HVACMode.OFF]
|
||||
self._attr_hvac_mode = HVACMode.OFF
|
||||
self._attr_preset_modes = []
|
||||
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
self._attr_temperature_unit = (
|
||||
self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
|
||||
)
|
||||
self._requested_hvac_mode: str | None = None
|
||||
|
||||
# Set up HVAC modes.
|
||||
@@ -182,6 +184,11 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
|
||||
self._attr_target_temperature_high = self.data.target_temp_high
|
||||
self._attr_target_temperature_low = self.data.target_temp_low
|
||||
|
||||
# Update unit.
|
||||
self._attr_temperature_unit = (
|
||||
self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s",
|
||||
self.coordinator.device_name,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
|
||||
# Config flow
|
||||
DOMAIN = "lg_thinq"
|
||||
COMPANY = "LGE"
|
||||
@@ -18,3 +20,10 @@ MQTT_SUBSCRIPTION_INTERVAL: Final = timedelta(days=1)
|
||||
# MQTT: Message types
|
||||
DEVICE_PUSH_MESSAGE: Final = "DEVICE_PUSH"
|
||||
DEVICE_STATUS_MESSAGE: Final = "DEVICE_STATUS"
|
||||
|
||||
# Unit conversion map
|
||||
DEVICE_UNIT_TO_HA: dict[str, str] = {
|
||||
"F": UnitOfTemperature.FAHRENHEIT,
|
||||
"C": UnitOfTemperature.CELSIUS,
|
||||
}
|
||||
REVERSE_DEVICE_UNIT_TO_HA = {v: k for k, v in DEVICE_UNIT_TO_HA.items()}
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from thinqconnect import ThinQAPIException
|
||||
from thinqconnect.integration import HABridge
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import EVENT_CORE_CONFIG_UPDATE
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import ThinqConfigEntry
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, REVERSE_DEVICE_UNIT_TO_HA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,6 +56,40 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id
|
||||
)
|
||||
|
||||
# Set your preferred temperature unit. This will allow us to retrieve
|
||||
# temperature values from the API in a converted value corresponding to
|
||||
# preferred unit.
|
||||
self._update_preferred_temperature_unit()
|
||||
|
||||
# Add a callback to handle core config update.
|
||||
self.unit_system: str | None = None
|
||||
self.hass.bus.async_listen(
|
||||
event_type=EVENT_CORE_CONFIG_UPDATE,
|
||||
listener=self._handle_update_config,
|
||||
event_filter=self.async_config_update_filter,
|
||||
)
|
||||
|
||||
async def _handle_update_config(self, _: Event) -> None:
|
||||
"""Handle update core config."""
|
||||
self._update_preferred_temperature_unit()
|
||||
|
||||
await self.async_refresh()
|
||||
|
||||
@callback
|
||||
def async_config_update_filter(self, event_data: Mapping[str, Any]) -> bool:
|
||||
"""Filter out unwanted events."""
|
||||
if (unit_system := event_data.get("unit_system")) != self.unit_system:
|
||||
self.unit_system = unit_system
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _update_preferred_temperature_unit(self) -> None:
|
||||
"""Update preferred temperature unit."""
|
||||
self.api.set_preferred_temperature_unit(
|
||||
REVERSE_DEVICE_UNIT_TO_HA.get(self.hass.config.units.temperature_unit)
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Request to the server to update the status from full response data."""
|
||||
try:
|
||||
|
||||
@@ -10,25 +10,19 @@ from thinqconnect import ThinQAPIException
|
||||
from thinqconnect.devices.const import Location
|
||||
from thinqconnect.integration import PropertyState
|
||||
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import COMPANY, DOMAIN
|
||||
from .const import COMPANY, DEVICE_UNIT_TO_HA, DOMAIN
|
||||
from .coordinator import DeviceDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
EMPTY_STATE = PropertyState()
|
||||
|
||||
UNIT_CONVERSION_MAP: dict[str, str] = {
|
||||
"F": UnitOfTemperature.FAHRENHEIT,
|
||||
"C": UnitOfTemperature.CELSIUS,
|
||||
}
|
||||
|
||||
|
||||
class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
|
||||
"""The base implementation of all lg thinq entities."""
|
||||
@@ -75,7 +69,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
|
||||
if unit is None:
|
||||
return None
|
||||
|
||||
return UNIT_CONVERSION_MAP.get(unit)
|
||||
return DEVICE_UNIT_TO_HA.get(unit)
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update status itself.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "matter",
|
||||
"name": "Matter (BETA)",
|
||||
"name": "Matter",
|
||||
"after_dependencies": ["hassio"],
|
||||
"codeowners": ["@home-assistant/matter"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -15,6 +15,7 @@ import socket
|
||||
import ssl
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
import certifi
|
||||
|
||||
@@ -292,7 +293,7 @@ class MqttClientSetup:
|
||||
"""
|
||||
# We don't import on the top because some integrations
|
||||
# should be able to optionally rely on MQTT.
|
||||
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
|
||||
from paho.mqtt import client as mqtt # pylint: disable=import-outside-toplevel
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from .async_client import AsyncMQTTClient
|
||||
@@ -309,9 +310,10 @@ class MqttClientSetup:
|
||||
clean_session = True
|
||||
|
||||
if (client_id := config.get(CONF_CLIENT_ID)) is None:
|
||||
# PAHO MQTT relies on the MQTT server to generate random client IDs.
|
||||
# However, that feature is not mandatory so we generate our own.
|
||||
client_id = None
|
||||
# PAHO MQTT relies on the MQTT server to generate random client ID
|
||||
# for protocol version 3.1, however, that feature is not mandatory
|
||||
# so we generate our own.
|
||||
client_id = mqtt._base62(uuid4().int, padding=22) # noqa: SLF001
|
||||
transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
|
||||
self._client = AsyncMQTTClient(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
|
||||
@@ -31,7 +31,6 @@ from homeassistant.components.light import (
|
||||
LightEntity,
|
||||
LightEntityFeature,
|
||||
brightness_supported,
|
||||
color_supported,
|
||||
valid_supported_color_modes,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
@@ -293,7 +292,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
|
||||
elif values["state"] is None:
|
||||
self._attr_is_on = None
|
||||
|
||||
if color_supported(self.supported_color_modes) and "color_mode" in values:
|
||||
if "color_mode" in values:
|
||||
self._update_color(values)
|
||||
|
||||
if brightness_supported(self.supported_color_modes):
|
||||
|
||||
@@ -276,22 +276,26 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
self._attr_state = MediaPlayerState(player.state.value)
|
||||
else:
|
||||
self._attr_state = MediaPlayerState(STATE_OFF)
|
||||
group_members_entity_ids: list[str] = []
|
||||
|
||||
group_members: list[str] = []
|
||||
if player.group_childs:
|
||||
# translate MA group_childs to HA group_members as entity id's
|
||||
entity_registry = er.async_get(self.hass)
|
||||
group_members_entity_ids = [
|
||||
entity_id
|
||||
for child_id in player.group_childs
|
||||
if (
|
||||
entity_id := entity_registry.async_get_entity_id(
|
||||
self.platform.domain, DOMAIN, child_id
|
||||
)
|
||||
group_members = player.group_childs
|
||||
elif player.synced_to and (parent := self.mass.players.get(player.synced_to)):
|
||||
group_members = parent.group_childs
|
||||
|
||||
# translate MA group_childs to HA group_members as entity id's
|
||||
entity_registry = er.async_get(self.hass)
|
||||
group_members_entity_ids: list[str] = [
|
||||
entity_id
|
||||
for child_id in group_members
|
||||
if (
|
||||
entity_id := entity_registry.async_get_entity_id(
|
||||
self.platform.domain, DOMAIN, child_id
|
||||
)
|
||||
]
|
||||
# NOTE: we sort the group_members for now,
|
||||
# until the MA API returns them sorted (group_childs is now a set)
|
||||
self._attr_group_members = sorted(group_members_entity_ids)
|
||||
)
|
||||
]
|
||||
|
||||
self._attr_group_members = group_members_entity_ids
|
||||
self._attr_volume_level = (
|
||||
player.volume_level / 100 if player.volume_level is not None else None
|
||||
)
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nexia",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["nexia"],
|
||||
"requirements": ["nexia==2.1.1"]
|
||||
"requirements": ["nexia==2.2.2"]
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
"switch": {
|
||||
"hold": {
|
||||
"name": "Hold"
|
||||
},
|
||||
"emergency_heat": {
|
||||
"name": "Emergency heat"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -486,6 +486,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
|
||||
NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
|
||||
NumberDeviceClass.POWER: {
|
||||
UnitOfPower.MILLIWATT,
|
||||
UnitOfPower.WATT,
|
||||
UnitOfPower.KILO_WATT,
|
||||
UnitOfPower.MEGA_WATT,
|
||||
|
||||
@@ -7,5 +7,6 @@ set_value:
|
||||
fields:
|
||||
value:
|
||||
example: 42
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -97,11 +97,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None:
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.0.12"]
|
||||
"requirements": ["onedrive-personal-sdk==0.0.13"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
@@ -58,7 +59,7 @@ class OneWireHub:
|
||||
|
||||
owproxy: protocol._Proxy
|
||||
devices: list[OWDeviceDescription]
|
||||
_version: str
|
||||
_version: str | None = None
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
@@ -74,7 +75,9 @@ class OneWireHub:
|
||||
port = self._config_entry.data[CONF_PORT]
|
||||
_LOGGER.debug("Initializing connection to %s:%s", host, port)
|
||||
self.owproxy = protocol.proxy(host, port)
|
||||
self._version = self.owproxy.read(protocol.PTH_VERSION).decode()
|
||||
with contextlib.suppress(protocol.OwnetError):
|
||||
# Version is not available on all servers
|
||||
self._version = self.owproxy.read(protocol.PTH_VERSION).decode()
|
||||
self.devices = _discover_devices(self.owproxy)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
|
||||
@@ -83,7 +83,16 @@ class PlaybackProxyView(HomeAssistantView):
|
||||
_LOGGER.warning("Reolink playback proxy error: %s", str(err))
|
||||
return web.Response(body=str(err), status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
headers = dict(request.headers)
|
||||
headers.pop("Host", None)
|
||||
headers.pop("Referer", None)
|
||||
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug(
|
||||
"Requested Playback Proxy Method %s, Headers: %s",
|
||||
request.method,
|
||||
headers,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Opening VOD stream from %s: %s",
|
||||
host.api.camera_name(ch),
|
||||
@@ -93,6 +102,7 @@ class PlaybackProxyView(HomeAssistantView):
|
||||
try:
|
||||
reolink_response = await self.session.get(
|
||||
reolink_url,
|
||||
headers=headers,
|
||||
timeout=ClientTimeout(
|
||||
connect=15, sock_connect=15, sock_read=5, total=None
|
||||
),
|
||||
@@ -118,18 +128,25 @@ class PlaybackProxyView(HomeAssistantView):
|
||||
]:
|
||||
err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}"
|
||||
_LOGGER.error(err_str)
|
||||
if reolink_response.content_type == "text/html":
|
||||
text = await reolink_response.text()
|
||||
_LOGGER.debug(text)
|
||||
return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={
|
||||
"Content-Type": "video/mp4",
|
||||
},
|
||||
response_headers = dict(reolink_response.headers)
|
||||
_LOGGER.debug(
|
||||
"Response Playback Proxy Status %s:%s, Headers: %s",
|
||||
reolink_response.status,
|
||||
reolink_response.reason,
|
||||
response_headers,
|
||||
)
|
||||
response_headers["Content-Type"] = "video/mp4"
|
||||
|
||||
if reolink_response.content_length is not None:
|
||||
response.content_length = reolink_response.content_length
|
||||
response = web.StreamResponse(
|
||||
status=reolink_response.status,
|
||||
reason=reolink_response.reason,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
||||
await response.prepare(request)
|
||||
|
||||
@@ -141,7 +158,8 @@ class PlaybackProxyView(HomeAssistantView):
|
||||
"Timeout while reading Reolink playback from %s, writing EOF",
|
||||
host.api.nvr_name,
|
||||
)
|
||||
finally:
|
||||
reolink_response.release()
|
||||
|
||||
reolink_response.release()
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
@@ -83,13 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
# Get a Coordinator if the device is available or if we have connected to the device before
|
||||
coordinators = await asyncio.gather(
|
||||
*build_setup_functions(
|
||||
hass,
|
||||
entry,
|
||||
device_map,
|
||||
user_data,
|
||||
product_info,
|
||||
home_data.rooms,
|
||||
api_client,
|
||||
hass, entry, device_map, user_data, product_info, home_data.rooms
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
@@ -141,7 +135,6 @@ def build_setup_functions(
|
||||
user_data: UserData,
|
||||
product_info: dict[str, HomeDataProduct],
|
||||
home_data_rooms: list[HomeDataRoom],
|
||||
api_client: RoborockApiClient,
|
||||
) -> list[
|
||||
Coroutine[
|
||||
Any,
|
||||
@@ -158,7 +151,6 @@ def build_setup_functions(
|
||||
device,
|
||||
product_info[device.product_id],
|
||||
home_data_rooms,
|
||||
api_client,
|
||||
)
|
||||
for device in device_map.values()
|
||||
]
|
||||
@@ -171,12 +163,11 @@ async def setup_device(
|
||||
device: HomeDataDevice,
|
||||
product_info: HomeDataProduct,
|
||||
home_data_rooms: list[HomeDataRoom],
|
||||
api_client: RoborockApiClient,
|
||||
) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None:
|
||||
"""Set up a coordinator for a given device."""
|
||||
if device.pv == "1.0":
|
||||
return await setup_device_v1(
|
||||
hass, entry, user_data, device, product_info, home_data_rooms, api_client
|
||||
hass, entry, user_data, device, product_info, home_data_rooms
|
||||
)
|
||||
if device.pv == "A01":
|
||||
return await setup_device_a01(hass, entry, user_data, device, product_info)
|
||||
@@ -196,7 +187,6 @@ async def setup_device_v1(
|
||||
device: HomeDataDevice,
|
||||
product_info: HomeDataProduct,
|
||||
home_data_rooms: list[HomeDataRoom],
|
||||
api_client: RoborockApiClient,
|
||||
) -> RoborockDataUpdateCoordinator | None:
|
||||
"""Set up a device Coordinator."""
|
||||
mqtt_client = await hass.async_add_executor_job(
|
||||
@@ -218,15 +208,7 @@ async def setup_device_v1(
|
||||
await mqtt_client.async_release()
|
||||
raise
|
||||
coordinator = RoborockDataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
device,
|
||||
networking,
|
||||
product_info,
|
||||
mqtt_client,
|
||||
home_data_rooms,
|
||||
api_client,
|
||||
user_data,
|
||||
hass, entry, device, networking, product_info, mqtt_client, home_data_rooms
|
||||
)
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -36,7 +36,6 @@ PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.IMAGE,
|
||||
Platform.NUMBER,
|
||||
Platform.SCENE,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
|
||||
@@ -10,26 +10,17 @@ import logging
|
||||
from propcache.api import cached_property
|
||||
from roborock import HomeDataRoom
|
||||
from roborock.code_mappings import RoborockCategory
|
||||
from roborock.containers import (
|
||||
DeviceData,
|
||||
HomeDataDevice,
|
||||
HomeDataProduct,
|
||||
HomeDataScene,
|
||||
NetworkInfo,
|
||||
UserData,
|
||||
)
|
||||
from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo
|
||||
from roborock.exceptions import RoborockException
|
||||
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
|
||||
from roborock.roborock_typing import DeviceProp
|
||||
from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1
|
||||
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
|
||||
from roborock.version_a01_apis import RoborockClientA01
|
||||
from roborock.web_api import RoborockApiClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_CONNECTIONS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -76,8 +67,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
product_info: HomeDataProduct,
|
||||
cloud_api: RoborockMqttClientV1,
|
||||
home_data_rooms: list[HomeDataRoom],
|
||||
api_client: RoborockApiClient,
|
||||
user_data: UserData,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
@@ -100,7 +89,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
self.cloud_api = cloud_api
|
||||
self.device_info = DeviceInfo(
|
||||
name=self.roborock_device_info.device.name,
|
||||
identifiers={(DOMAIN, self.duid)},
|
||||
identifiers={(DOMAIN, self.roborock_device_info.device.duid)},
|
||||
manufacturer="Roborock",
|
||||
model=self.roborock_device_info.product.model,
|
||||
model_id=self.roborock_device_info.product.model,
|
||||
@@ -114,10 +103,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
self.maps: dict[int, RoborockMapInfo] = {}
|
||||
self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms}
|
||||
self.map_storage = RoborockMapStorage(
|
||||
hass, self.config_entry.entry_id, self.duid_slug
|
||||
hass, self.config_entry.entry_id, slugify(self.duid)
|
||||
)
|
||||
self._user_data = user_data
|
||||
self._api_client = api_client
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
@@ -147,7 +134,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
except RoborockException:
|
||||
_LOGGER.warning(
|
||||
"Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance",
|
||||
self.duid,
|
||||
self.roborock_device_info.device.duid,
|
||||
)
|
||||
await self.api.async_disconnect()
|
||||
# We use the cloud api if the local api fails to connect.
|
||||
@@ -207,34 +194,6 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
for room in room_mapping or ()
|
||||
}
|
||||
|
||||
async def get_scenes(self) -> list[HomeDataScene]:
|
||||
"""Get scenes."""
|
||||
try:
|
||||
return await self._api_client.get_scenes(self._user_data, self.duid)
|
||||
except RoborockException as err:
|
||||
_LOGGER.error("Failed to get scenes %s", err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={
|
||||
"command": "get_scenes",
|
||||
},
|
||||
) from err
|
||||
|
||||
async def execute_scene(self, scene_id: int) -> None:
|
||||
"""Execute scene."""
|
||||
try:
|
||||
await self._api_client.execute_scene(self._user_data, scene_id)
|
||||
except RoborockException as err:
|
||||
_LOGGER.error("Failed to execute scene %s %s", scene_id, err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={
|
||||
"command": "execute_scene",
|
||||
},
|
||||
) from err
|
||||
|
||||
@cached_property
|
||||
def duid(self) -> str:
|
||||
"""Get the unique id of the device as specified by Roborock."""
|
||||
|
||||
@@ -112,19 +112,6 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
|
||||
"""Return if this map is the currently selected map."""
|
||||
return self.map_flag == self.coordinator.current_map
|
||||
|
||||
def is_map_valid(self) -> bool:
|
||||
"""Update the map if it is valid.
|
||||
|
||||
Update this map if it is the currently active map, and the
|
||||
vacuum is cleaning, or if it has never been set at all.
|
||||
"""
|
||||
return self.cached_map == b"" or (
|
||||
self.is_selected
|
||||
and self.image_last_updated is not None
|
||||
and self.coordinator.roborock_device_info.props.status is not None
|
||||
and bool(self.coordinator.roborock_device_info.props.status.in_cleaning)
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass load any previously cached maps from disk."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -137,15 +124,22 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
|
||||
# Bump last updated every third time the coordinator runs, so that async_image
|
||||
# will be called and we will evaluate on the new coordinator data if we should
|
||||
# update the cache.
|
||||
if (
|
||||
dt_util.utcnow() - self.image_last_updated
|
||||
).total_seconds() > IMAGE_CACHE_INTERVAL and self.is_map_valid():
|
||||
if self.is_selected and (
|
||||
(
|
||||
(dt_util.utcnow() - self.image_last_updated).total_seconds()
|
||||
> IMAGE_CACHE_INTERVAL
|
||||
and self.coordinator.roborock_device_info.props.status is not None
|
||||
and bool(self.coordinator.roborock_device_info.props.status.in_cleaning)
|
||||
)
|
||||
or self.cached_map == b""
|
||||
):
|
||||
# This will tell async_image it should update.
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_image(self) -> bytes | None:
|
||||
"""Update the image if it is not cached."""
|
||||
if self.is_map_valid():
|
||||
if self.is_selected:
|
||||
response = await asyncio.gather(
|
||||
*(
|
||||
self.cloud_api.get_map_v1(),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["roborock"],
|
||||
"requirements": [
|
||||
"python-roborock==2.11.1",
|
||||
"python-roborock==2.12.2",
|
||||
"vacuum-map-parser-roborock==0.1.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Support for Roborock scene."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.scene import Scene as SceneEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import RoborockConfigEntry
|
||||
from .coordinator import RoborockDataUpdateCoordinator
|
||||
from .entity import RoborockEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: RoborockConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up scene platform."""
|
||||
scene_lists = await asyncio.gather(
|
||||
*[coordinator.get_scenes() for coordinator in config_entry.runtime_data.v1],
|
||||
)
|
||||
async_add_entities(
|
||||
RoborockSceneEntity(
|
||||
coordinator,
|
||||
EntityDescription(
|
||||
key=str(scene.id),
|
||||
name=scene.name,
|
||||
),
|
||||
)
|
||||
for coordinator, scenes in zip(
|
||||
config_entry.runtime_data.v1, scene_lists, strict=True
|
||||
)
|
||||
for scene in scenes
|
||||
)
|
||||
|
||||
|
||||
class RoborockSceneEntity(RoborockEntity, SceneEntity):
|
||||
"""A class to define Roborock scene entities."""
|
||||
|
||||
entity_description: EntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RoborockDataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
) -> None:
|
||||
"""Create a scene entity."""
|
||||
super().__init__(
|
||||
f"{entity_description.key}_{coordinator.duid_slug}",
|
||||
coordinator.device_info,
|
||||
coordinator.api,
|
||||
)
|
||||
self._scene_id = int(entity_description.key)
|
||||
self._coordinator = coordinator
|
||||
self.entity_description = entity_description
|
||||
|
||||
async def async_activate(self, **kwargs: Any) -> None:
|
||||
"""Activate the scene."""
|
||||
await self._coordinator.execute_scene(self._scene_id)
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sense",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["sense_energy"],
|
||||
"requirements": ["sense-energy==0.13.6"]
|
||||
"requirements": ["sense-energy==0.13.7"]
|
||||
}
|
||||
|
||||
@@ -582,6 +582,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
|
||||
SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
|
||||
SensorDeviceClass.POWER: {
|
||||
UnitOfPower.MILLIWATT,
|
||||
UnitOfPower.WATT,
|
||||
UnitOfPower.KILO_WATT,
|
||||
UnitOfPower.MEGA_WATT,
|
||||
|
||||
@@ -74,12 +74,14 @@ async def async_get_config_entry_diagnostics(
|
||||
device_settings = {
|
||||
k: v for k, v in rpc_coordinator.device.config.items() if k in ["cloud"]
|
||||
}
|
||||
ws_config = rpc_coordinator.device.config["ws"]
|
||||
device_settings["ws_outbound_enabled"] = ws_config["enable"]
|
||||
if ws_config["enable"]:
|
||||
device_settings["ws_outbound_server_valid"] = bool(
|
||||
ws_config["server"] == get_rpc_ws_url(hass)
|
||||
)
|
||||
if not (ws_config := rpc_coordinator.device.config.get("ws", {})):
|
||||
device_settings["ws_outbound"] = "not supported"
|
||||
if (ws_outbound_enabled := ws_config.get("enable")) is not None:
|
||||
device_settings["ws_outbound_enabled"] = ws_outbound_enabled
|
||||
if ws_outbound_enabled:
|
||||
device_settings["ws_outbound_server_valid"] = bool(
|
||||
ws_config["server"] == get_rpc_ws_url(hass)
|
||||
)
|
||||
device_status = {
|
||||
k: v
|
||||
for k, v in rpc_coordinator.device.status.items()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
import ssl
|
||||
|
||||
from smart_meter_texas import Account, Client, ClientSSLContext
|
||||
from smart_meter_texas import Account, Client
|
||||
from smart_meter_texas.exceptions import (
|
||||
SmartMeterTexasAPIError,
|
||||
SmartMeterTexasAuthError,
|
||||
@@ -16,6 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import (
|
||||
DATA_COORDINATOR,
|
||||
@@ -38,8 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
account = Account(username, password)
|
||||
|
||||
client_ssl_context = ClientSSLContext()
|
||||
ssl_context = await client_ssl_context.get_ssl_context()
|
||||
ssl_context = get_default_context()
|
||||
|
||||
smart_meter_texas_data = SmartMeterTexasData(hass, entry, account, ssl_context)
|
||||
try:
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from smart_meter_texas import Account, Client, ClientSSLContext
|
||||
from smart_meter_texas import Account, Client
|
||||
from smart_meter_texas.exceptions import (
|
||||
SmartMeterTexasAPIError,
|
||||
SmartMeterTexasAuthError,
|
||||
@@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.util.ssl import get_default_context
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -31,8 +32,7 @@ async def validate_input(hass: HomeAssistant, data):
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
client_ssl_context = ClientSSLContext()
|
||||
ssl_context = await client_ssl_context.get_ssl_context()
|
||||
ssl_context = get_default_context()
|
||||
client_session = aiohttp_client.async_get_clientsession(hass)
|
||||
account = Account(data["username"], data["password"])
|
||||
client = Client(client_session, account, ssl_context)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, cast
|
||||
@@ -11,15 +12,22 @@ from pysmartthings import (
|
||||
Attribute,
|
||||
Capability,
|
||||
Device,
|
||||
DeviceEvent,
|
||||
Scene,
|
||||
SmartThings,
|
||||
SmartThingsAuthenticationFailedError,
|
||||
SmartThingsSinkError,
|
||||
Status,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_TOKEN,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -28,7 +36,15 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from .const import CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, DOMAIN, MAIN, OLD_DATA
|
||||
from .const import (
|
||||
CONF_INSTALLED_APP_ID,
|
||||
CONF_LOCATION_ID,
|
||||
CONF_SUBSCRIPTION_ID,
|
||||
DOMAIN,
|
||||
EVENT_BUTTON,
|
||||
MAIN,
|
||||
OLD_DATA,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -90,6 +106,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry)
|
||||
|
||||
client.refresh_token_function = _refresh_token
|
||||
|
||||
def _handle_max_connections() -> None:
|
||||
_LOGGER.debug("We hit the limit of max connections")
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
client.max_connections_reached_callback = _handle_max_connections
|
||||
|
||||
def _handle_new_subscription_identifier(identifier: str | None) -> None:
|
||||
"""Handle a new subscription identifier."""
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_SUBSCRIPTION_ID: identifier,
|
||||
},
|
||||
)
|
||||
if identifier is not None:
|
||||
_LOGGER.debug("Updating subscription ID to %s", identifier)
|
||||
else:
|
||||
_LOGGER.debug("Removing subscription ID")
|
||||
|
||||
client.new_subscription_id_callback = _handle_new_subscription_identifier
|
||||
|
||||
if (old_identifier := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None:
|
||||
_LOGGER.debug("Trying to delete old subscription %s", old_identifier)
|
||||
await client.delete_subscription(old_identifier)
|
||||
|
||||
_LOGGER.debug("Trying to create a new subscription")
|
||||
try:
|
||||
subscription = await client.create_subscription(
|
||||
entry.data[CONF_LOCATION_ID],
|
||||
entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID],
|
||||
)
|
||||
except SmartThingsSinkError as err:
|
||||
_LOGGER.exception("Couldn't create a new subscription")
|
||||
raise ConfigEntryNotReady from err
|
||||
subscription_id = subscription.subscription_id
|
||||
_handle_new_subscription_identifier(subscription_id)
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
client.subscribe(
|
||||
entry.data[CONF_LOCATION_ID],
|
||||
entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID],
|
||||
subscription,
|
||||
),
|
||||
"smartthings_socket",
|
||||
)
|
||||
|
||||
device_status: dict[str, FullDevice] = {}
|
||||
try:
|
||||
devices = await client.get_devices()
|
||||
@@ -114,12 +178,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry)
|
||||
scenes=scenes,
|
||||
)
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass,
|
||||
client.subscribe(
|
||||
entry.data[CONF_LOCATION_ID], entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID]
|
||||
),
|
||||
"smartthings_webhook",
|
||||
def handle_button_press(event: DeviceEvent) -> None:
|
||||
"""Handle a button press."""
|
||||
if (
|
||||
event.capability is Capability.BUTTON
|
||||
and event.attribute is Attribute.BUTTON
|
||||
):
|
||||
hass.bus.async_fire(
|
||||
EVENT_BUTTON,
|
||||
{
|
||||
"component_id": event.component_id,
|
||||
"device_id": event.device_id,
|
||||
"location_id": event.location_id,
|
||||
"value": event.value,
|
||||
"name": entry.runtime_data.devices[event.device_id].device.label,
|
||||
"data": event.data,
|
||||
},
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
client.add_unspecified_device_event_listener(handle_button_press)
|
||||
)
|
||||
|
||||
async def _handle_shutdown(_: Event) -> None:
|
||||
"""Handle shutdown."""
|
||||
await client.delete_subscription(subscription_id)
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_shutdown)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
@@ -145,6 +231,9 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: SmartThingsConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
client = entry.runtime_data.client
|
||||
if (subscription_id := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None:
|
||||
await client.delete_subscription(subscription_id)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -160,25 +249,39 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
KEEP_CAPABILITY_QUIRK: dict[
|
||||
Capability | str, Callable[[dict[Attribute | str, Status]], bool]
|
||||
] = {
|
||||
Capability.DRYER_OPERATING_STATE: (
|
||||
lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None
|
||||
),
|
||||
Capability.WASHER_OPERATING_STATE: (
|
||||
lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None
|
||||
),
|
||||
Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True,
|
||||
}
|
||||
|
||||
|
||||
def process_status(
|
||||
status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]],
|
||||
) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]:
|
||||
"""Remove disabled capabilities from status."""
|
||||
if (main_component := status.get("main")) is None or (
|
||||
if (main_component := status.get(MAIN)) is None:
|
||||
return status
|
||||
if (
|
||||
disabled_capabilities_capability := main_component.get(
|
||||
Capability.CUSTOM_DISABLED_CAPABILITIES
|
||||
)
|
||||
) is None:
|
||||
return status
|
||||
disabled_capabilities = cast(
|
||||
list[Capability | str],
|
||||
disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value,
|
||||
)
|
||||
for capability in disabled_capabilities:
|
||||
# We still need to make sure the climate entity can work without this capability
|
||||
if (
|
||||
capability in main_component
|
||||
and capability != Capability.DEMAND_RESPONSE_LOAD_CONTROL
|
||||
):
|
||||
del main_component[capability]
|
||||
) is not None:
|
||||
disabled_capabilities = cast(
|
||||
list[Capability | str],
|
||||
disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value,
|
||||
)
|
||||
if disabled_capabilities is not None:
|
||||
for capability in disabled_capabilities:
|
||||
if capability in main_component and (
|
||||
capability not in KEEP_CAPABILITY_QUIRK
|
||||
or not KEEP_CAPABILITY_QUIRK[capability](main_component[capability])
|
||||
):
|
||||
del main_component[capability]
|
||||
return status
|
||||
|
||||
@@ -161,9 +161,7 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
if self.get_attribute_value(
|
||||
Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE
|
||||
):
|
||||
if self.supports_capability(Capability.THERMOSTAT_FAN_MODE):
|
||||
flags |= ClimateEntityFeature.FAN_MODE
|
||||
return flags
|
||||
|
||||
@@ -253,6 +251,8 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the current running hvac operation if supported."""
|
||||
if not self.supports_capability(Capability.THERMOSTAT_OPERATING_STATE):
|
||||
return None
|
||||
return OPERATING_STATE_TO_ACTION.get(
|
||||
self.get_attribute_value(
|
||||
Capability.THERMOSTAT_OPERATING_STATE,
|
||||
@@ -272,11 +272,15 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available operation modes."""
|
||||
return [
|
||||
state
|
||||
for mode in self.get_attribute_value(
|
||||
if (
|
||||
supported_thermostat_modes := self.get_attribute_value(
|
||||
Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES
|
||||
)
|
||||
) is None:
|
||||
return []
|
||||
return [
|
||||
state
|
||||
for mode in supported_thermostat_modes
|
||||
if (state := AC_MODE_TO_STATE.get(mode)) is not None
|
||||
]
|
||||
|
||||
@@ -314,10 +318,14 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement."""
|
||||
unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][
|
||||
Attribute.TEMPERATURE
|
||||
].unit
|
||||
assert unit
|
||||
# Offline third party thermostats may not have a unit
|
||||
# Since climate always requires a unit, default to Celsius
|
||||
if (
|
||||
unit := self._internal_state[Capability.TEMPERATURE_MEASUREMENT][
|
||||
Attribute.TEMPERATURE
|
||||
].unit
|
||||
) is None:
|
||||
return UnitOfTemperature.CELSIUS
|
||||
return UNIT_MAP[unit]
|
||||
|
||||
|
||||
@@ -445,12 +453,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return device specific state attributes.
|
||||
|
||||
Include attributes from the Demand Response Load Control (drlc)
|
||||
and Power Consumption capabilities.
|
||||
"""
|
||||
if not self.supports_capability(Capability.DEMAND_RESPONSE_LOAD_CONTROL):
|
||||
return None
|
||||
|
||||
drlc_status = self.get_attribute_value(
|
||||
Capability.DEMAND_RESPONSE_LOAD_CONTROL,
|
||||
Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS,
|
||||
@@ -554,11 +565,15 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
|
||||
def _determine_hvac_modes(self) -> list[HVACMode]:
|
||||
"""Determine the supported HVAC modes."""
|
||||
modes = [HVACMode.OFF]
|
||||
modes.extend(
|
||||
state
|
||||
for mode in self.get_attribute_value(
|
||||
if (
|
||||
ac_modes := self.get_attribute_value(
|
||||
Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES
|
||||
)
|
||||
if (state := AC_MODE_TO_STATE.get(mode)) is not None
|
||||
)
|
||||
) is not None:
|
||||
modes.extend(
|
||||
state
|
||||
for mode in ac_modes
|
||||
if (state := AC_MODE_TO_STATE.get(mode)) is not None
|
||||
if state not in modes
|
||||
)
|
||||
return modes
|
||||
|
||||
@@ -32,3 +32,6 @@ CONF_REFRESH_TOKEN = "refresh_token"
|
||||
|
||||
MAIN = "main"
|
||||
OLD_DATA = "old_data"
|
||||
|
||||
CONF_SUBSCRIPTION_ID = "subscription_id"
|
||||
EVENT_BUTTON = "smartthings.button"
|
||||
|
||||
@@ -118,6 +118,10 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity):
|
||||
self._attr_current_cover_position = self.get_attribute_value(
|
||||
Capability.SWITCH_LEVEL, Attribute.LEVEL
|
||||
)
|
||||
elif self.supports_capability(Capability.WINDOW_SHADE_LEVEL):
|
||||
self._attr_current_cover_position = self.get_attribute_value(
|
||||
Capability.WINDOW_SHADE_LEVEL, Attribute.SHADE_LEVEL
|
||||
)
|
||||
|
||||
self._attr_extra_state_attributes = {}
|
||||
if self.supports_capability(Capability.BATTERY):
|
||||
|
||||
@@ -17,6 +17,15 @@ from .const import DOMAIN
|
||||
EVENT_WAIT_TIME = 5
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
entry: SmartThingsConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
client = entry.runtime_data.client
|
||||
return {"devices": await client.get_raw_devices()}
|
||||
|
||||
|
||||
async def async_get_device_diagnostics(
|
||||
hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry
|
||||
) -> dict[str, Any]:
|
||||
@@ -26,7 +35,8 @@ async def async_get_device_diagnostics(
|
||||
identifier for identifier in device.identifiers if identifier[0] == DOMAIN
|
||||
)[1]
|
||||
|
||||
device_status = await client.get_device_status(device_id)
|
||||
device_status = await client.get_raw_device_status(device_id)
|
||||
device_info = await client.get_raw_device(device_id)
|
||||
|
||||
events: list[DeviceEvent] = []
|
||||
|
||||
@@ -39,11 +49,8 @@ async def async_get_device_diagnostics(
|
||||
|
||||
listener()
|
||||
|
||||
status: dict[str, Any] = {}
|
||||
for component, capabilities in device_status.items():
|
||||
status[component] = {}
|
||||
for capability, attributes in capabilities.items():
|
||||
status[component][capability] = {}
|
||||
for attribute, value in attributes.items():
|
||||
status[component][capability][attribute] = asdict(value)
|
||||
return {"events": [asdict(event) for event in events], "status": status}
|
||||
return {
|
||||
"events": [asdict(event) for event in events],
|
||||
"status": device_status,
|
||||
"info": device_info,
|
||||
}
|
||||
|
||||
@@ -48,7 +48,9 @@ class SmartThingsEntity(Entity):
|
||||
self._attr_device_info.update(
|
||||
{
|
||||
"manufacturer": ocf.manufacturer_name,
|
||||
"model": ocf.model_number.split("|")[0],
|
||||
"model": (
|
||||
(ocf.model_number.split("|")[0]) if ocf.model_number else None
|
||||
),
|
||||
"hw_version": ocf.hardware_version,
|
||||
"sw_version": ocf.firmware_version,
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if fan is on."""
|
||||
return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
|
||||
return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on"
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
@@ -132,6 +132,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
|
||||
|
||||
Requires FanEntityFeature.PRESET_MODE.
|
||||
"""
|
||||
if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE):
|
||||
return None
|
||||
return self.get_attribute_value(
|
||||
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE
|
||||
)
|
||||
@@ -142,6 +144,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
|
||||
|
||||
Requires FanEntityFeature.PRESET_MODE.
|
||||
"""
|
||||
if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE):
|
||||
return None
|
||||
return self.get_attribute_value(
|
||||
Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES
|
||||
)
|
||||
|
||||
@@ -147,14 +147,21 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity):
|
||||
"""Update entity attributes when the device status has changed."""
|
||||
# Brightness and transition
|
||||
if brightness_supported(self._attr_supported_color_modes):
|
||||
self._attr_brightness = int(
|
||||
convert_scale(
|
||||
self.get_attribute_value(Capability.SWITCH_LEVEL, Attribute.LEVEL),
|
||||
100,
|
||||
255,
|
||||
0,
|
||||
if (
|
||||
brightness := self.get_attribute_value(
|
||||
Capability.SWITCH_LEVEL, Attribute.LEVEL
|
||||
)
|
||||
) is None:
|
||||
self._attr_brightness = None
|
||||
else:
|
||||
self._attr_brightness = int(
|
||||
convert_scale(
|
||||
brightness,
|
||||
100,
|
||||
255,
|
||||
0,
|
||||
)
|
||||
)
|
||||
)
|
||||
# Color Temperature
|
||||
if ColorMode.COLOR_TEMP in self._attr_supported_color_modes:
|
||||
self._attr_color_temp_kelvin = self.get_attribute_value(
|
||||
@@ -162,16 +169,21 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity):
|
||||
)
|
||||
# Color
|
||||
if ColorMode.HS in self._attr_supported_color_modes:
|
||||
self._attr_hs_color = (
|
||||
convert_scale(
|
||||
self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE),
|
||||
100,
|
||||
360,
|
||||
),
|
||||
self.get_attribute_value(
|
||||
Capability.COLOR_CONTROL, Attribute.SATURATION
|
||||
),
|
||||
)
|
||||
if (
|
||||
hue := self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE)
|
||||
) is None:
|
||||
self._attr_hs_color = None
|
||||
else:
|
||||
self._attr_hs_color = (
|
||||
convert_scale(
|
||||
hue,
|
||||
100,
|
||||
360,
|
||||
),
|
||||
self.get_attribute_value(
|
||||
Capability.COLOR_CONTROL, Attribute.SATURATION
|
||||
),
|
||||
)
|
||||
|
||||
async def async_set_color(self, hs_color):
|
||||
"""Set the color of the device."""
|
||||
@@ -217,6 +229,10 @@ class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity):
|
||||
super()._update_handler(event)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if light is on."""
|
||||
return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on"
|
||||
if (
|
||||
state := self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
|
||||
) is None:
|
||||
return None
|
||||
return state == "on"
|
||||
|
||||
@@ -29,5 +29,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smartthings",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"requirements": ["pysmartthings==2.5.0"]
|
||||
"requirements": ["pysmartthings==2.7.4"]
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from pysmartthings import Attribute, Capability, SmartThings
|
||||
from pysmartthings import Attribute, Capability, SmartThings, Status
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -57,6 +57,7 @@ JOB_STATE_MAP = {
|
||||
"freezeProtection": "freeze_protection",
|
||||
"preDrain": "pre_drain",
|
||||
"preWash": "pre_wash",
|
||||
"prewash": "pre_wash",
|
||||
"wrinklePrevent": "wrinkle_prevent",
|
||||
"unknown": None,
|
||||
}
|
||||
@@ -130,7 +131,8 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription):
|
||||
unique_id_separator: str = "."
|
||||
capability_ignore_list: list[set[Capability]] | None = None
|
||||
options_attribute: Attribute | None = None
|
||||
except_if_state_none: bool = False
|
||||
exists_fn: Callable[[Status], bool] | None = None
|
||||
use_temperature_unit: bool = False
|
||||
|
||||
|
||||
CAPABILITY_TO_SENSORS: dict[
|
||||
@@ -561,6 +563,8 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
SmartThingsSensorEntityDescription(
|
||||
key=Attribute.COMPLETION_TIME,
|
||||
translation_key="completion_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=dt_util.parse_datetime,
|
||||
)
|
||||
],
|
||||
},
|
||||
@@ -569,6 +573,10 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
SmartThingsSensorEntityDescription(
|
||||
key=Attribute.OVEN_SETPOINT,
|
||||
translation_key="oven_setpoint",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
use_temperature_unit=True,
|
||||
# Set the value to None if it is 0 F (-17 C)
|
||||
value_fn=lambda value: None if value in {0, -17} else value,
|
||||
)
|
||||
]
|
||||
},
|
||||
@@ -581,7 +589,10 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value_fn=lambda value: value["energy"] / 1000,
|
||||
suggested_display_precision=2,
|
||||
except_if_state_none=True,
|
||||
exists_fn=lambda status: (
|
||||
(value := cast(dict | None, status.value)) is not None
|
||||
and "energy" in value
|
||||
),
|
||||
),
|
||||
SmartThingsSensorEntityDescription(
|
||||
key="power_meter",
|
||||
@@ -591,7 +602,10 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
value_fn=lambda value: value["power"],
|
||||
extra_state_attributes_fn=power_attributes,
|
||||
suggested_display_precision=2,
|
||||
except_if_state_none=True,
|
||||
exists_fn=lambda status: (
|
||||
(value := cast(dict | None, status.value)) is not None
|
||||
and "power" in value
|
||||
),
|
||||
),
|
||||
SmartThingsSensorEntityDescription(
|
||||
key="deltaEnergy_meter",
|
||||
@@ -601,7 +615,10 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value_fn=lambda value: value["deltaEnergy"] / 1000,
|
||||
suggested_display_precision=2,
|
||||
except_if_state_none=True,
|
||||
exists_fn=lambda status: (
|
||||
(value := cast(dict | None, status.value)) is not None
|
||||
and "deltaEnergy" in value
|
||||
),
|
||||
),
|
||||
SmartThingsSensorEntityDescription(
|
||||
key="powerEnergy_meter",
|
||||
@@ -611,7 +628,10 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value_fn=lambda value: value["powerEnergy"] / 1000,
|
||||
suggested_display_precision=2,
|
||||
except_if_state_none=True,
|
||||
exists_fn=lambda status: (
|
||||
(value := cast(dict | None, status.value)) is not None
|
||||
and "powerEnergy" in value
|
||||
),
|
||||
),
|
||||
SmartThingsSensorEntityDescription(
|
||||
key="energySaved_meter",
|
||||
@@ -621,7 +641,10 @@ CAPABILITY_TO_SENSORS: dict[
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value_fn=lambda value: value["energySaved"] / 1000,
|
||||
suggested_display_precision=2,
|
||||
except_if_state_none=True,
|
||||
exists_fn=lambda status: (
|
||||
(value := cast(dict | None, status.value)) is not None
|
||||
and "energySaved" in value
|
||||
),
|
||||
),
|
||||
]
|
||||
},
|
||||
@@ -951,6 +974,7 @@ UNITS = {
|
||||
"F": UnitOfTemperature.FAHRENHEIT,
|
||||
"lux": LIGHT_LUX,
|
||||
"mG": None,
|
||||
"μg/m^3": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
}
|
||||
|
||||
|
||||
@@ -976,8 +1000,8 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
and (
|
||||
not description.except_if_state_none
|
||||
or device.status[MAIN][capability][attribute].value is not None
|
||||
not description.exists_fn
|
||||
or description.exists_fn(device.status[MAIN][capability][attribute])
|
||||
)
|
||||
)
|
||||
|
||||
@@ -996,7 +1020,10 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity):
|
||||
attribute: Attribute,
|
||||
) -> None:
|
||||
"""Init the class."""
|
||||
super().__init__(client, device, {capability})
|
||||
capabilities_to_subscribe = {capability}
|
||||
if entity_description.use_temperature_unit:
|
||||
capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT)
|
||||
super().__init__(client, device, capabilities_to_subscribe)
|
||||
self._attr_unique_id = f"{device.device.device_id}{entity_description.unique_id_separator}{entity_description.key}"
|
||||
self._attribute = attribute
|
||||
self.capability = capability
|
||||
@@ -1011,7 +1038,12 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity):
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit this state is expressed in."""
|
||||
unit = self._internal_state[self.capability][self._attribute].unit
|
||||
if self.entity_description.use_temperature_unit:
|
||||
unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][
|
||||
Attribute.TEMPERATURE
|
||||
].unit
|
||||
else:
|
||||
unit = self._internal_state[self.capability][self._attribute].unit
|
||||
return (
|
||||
UNITS.get(unit, unit)
|
||||
if unit
|
||||
@@ -1031,8 +1063,11 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity):
|
||||
def options(self) -> list[str] | None:
|
||||
"""Return the options for this sensor."""
|
||||
if self.entity_description.options_attribute:
|
||||
options = self.get_attribute_value(
|
||||
self.capability, self.entity_description.options_attribute
|
||||
)
|
||||
if (
|
||||
options := self.get_attribute_value(
|
||||
self.capability, self.entity_description.options_attribute
|
||||
)
|
||||
) is None:
|
||||
return []
|
||||
return [option.lower() for option in options]
|
||||
return super().options
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["snoo"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-snoo==0.6.0"]
|
||||
"requirements": ["python-snoo==0.6.4"]
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ SONOS_TRACKS = "tracks"
|
||||
SONOS_COMPOSER = "composers"
|
||||
SONOS_RADIO = "radio"
|
||||
SONOS_OTHER_ITEM = "other items"
|
||||
SONOS_AUDIO_BOOK = "audio book"
|
||||
|
||||
SONOS_STATE_PLAYING = "PLAYING"
|
||||
SONOS_STATE_TRANSITIONING = "TRANSITIONING"
|
||||
@@ -67,6 +68,7 @@ SONOS_TO_MEDIA_CLASSES = {
|
||||
"object.item": MediaClass.TRACK,
|
||||
"object.item.audioItem.musicTrack": MediaClass.TRACK,
|
||||
"object.item.audioItem.audioBroadcast": MediaClass.GENRE,
|
||||
"object.item.audioItem.audioBook": MediaClass.TRACK,
|
||||
}
|
||||
|
||||
SONOS_TO_MEDIA_TYPES = {
|
||||
@@ -84,6 +86,7 @@ SONOS_TO_MEDIA_TYPES = {
|
||||
"object.container.playlistContainer.sameArtist": MediaType.ARTIST,
|
||||
"object.container.playlistContainer": MediaType.PLAYLIST,
|
||||
"object.item.audioItem.musicTrack": MediaType.TRACK,
|
||||
"object.item.audioItem.audioBook": MediaType.TRACK,
|
||||
}
|
||||
|
||||
MEDIA_TYPES_TO_SONOS: dict[MediaType | str, str] = {
|
||||
@@ -113,6 +116,7 @@ SONOS_TYPES_MAPPING = {
|
||||
"object.item": SONOS_OTHER_ITEM,
|
||||
"object.item.audioItem.musicTrack": SONOS_TRACKS,
|
||||
"object.item.audioItem.audioBroadcast": SONOS_RADIO,
|
||||
"object.item.audioItem.audioBook": SONOS_AUDIO_BOOK,
|
||||
}
|
||||
|
||||
LIBRARY_TITLES_MAPPING = {
|
||||
|
||||
@@ -105,7 +105,7 @@ class SonosFavorites(SonosHouseholdCoordinator):
|
||||
@soco_error()
|
||||
def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool:
|
||||
"""Update cache of known favorites and return if cache has changed."""
|
||||
new_favorites = soco.music_library.get_sonos_favorites()
|
||||
new_favorites = soco.music_library.get_sonos_favorites(full_album_art_uri=True)
|
||||
|
||||
# Polled update_id values do not match event_id values
|
||||
# Each speaker can return a different polled update_id
|
||||
|
||||
@@ -165,6 +165,8 @@ async def async_browse_media(
|
||||
favorites_folder_payload,
|
||||
speaker.favorites,
|
||||
media_content_id,
|
||||
media,
|
||||
get_browse_image_url,
|
||||
)
|
||||
|
||||
payload = {
|
||||
@@ -443,7 +445,10 @@ def favorites_payload(favorites: SonosFavorites) -> BrowseMedia:
|
||||
|
||||
|
||||
def favorites_folder_payload(
|
||||
favorites: SonosFavorites, media_content_id: str
|
||||
favorites: SonosFavorites,
|
||||
media_content_id: str,
|
||||
media: SonosMedia,
|
||||
get_browse_image_url: GetBrowseImageUrlType,
|
||||
) -> BrowseMedia:
|
||||
"""Create response payload to describe all items of a type of favorite.
|
||||
|
||||
@@ -463,7 +468,14 @@ def favorites_folder_payload(
|
||||
media_content_type="favorite_item_id",
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
thumbnail=getattr(favorite, "album_art_uri", None),
|
||||
thumbnail=get_thumbnail_url_full(
|
||||
media=media,
|
||||
is_internal=True,
|
||||
media_content_type="favorite_item_id",
|
||||
media_content_id=favorite.item_id,
|
||||
get_browse_image_url=get_browse_image_url,
|
||||
item=favorite,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
suggested_unit_of_measurement=UnitOfTime.HOURS,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=STATUS_SENSOR_INFO_TOTAL_GENRES,
|
||||
|
||||
@@ -20,8 +20,8 @@ class SuezWaterAggregatedAttributes:
|
||||
|
||||
this_month_consumption: dict[str, float]
|
||||
previous_month_consumption: dict[str, float]
|
||||
last_year_overall: dict[str, float]
|
||||
this_year_overall: dict[str, float]
|
||||
last_year_overall: int
|
||||
this_year_overall: int
|
||||
history: dict[str, float]
|
||||
highest_monthly_consumption: float
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pysuez", "regex"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pysuezV2==2.0.3"]
|
||||
"requirements": ["pysuezV2==2.0.4"]
|
||||
}
|
||||
|
||||
@@ -39,5 +39,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/switchbot",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["switchbot"],
|
||||
"requirements": ["PySwitchbot==0.56.1"]
|
||||
"requirements": ["PySwitchbot==0.57.1"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/synology_dsm",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["synology_dsm"],
|
||||
"requirements": ["py-synologydsm-api==2.7.0"],
|
||||
"requirements": ["py-synologydsm-api==2.7.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Synology",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tesla-fleet-api"],
|
||||
"requirements": ["tesla-fleet-api==0.9.12"]
|
||||
"requirements": ["tesla-fleet-api==0.9.13"]
|
||||
}
|
||||
|
||||
@@ -466,6 +466,7 @@ async def async_setup_entry(
|
||||
for energysite in entry.runtime_data.energysites
|
||||
for description in ENERGY_LIVE_DESCRIPTIONS
|
||||
if description.key in energysite.live_coordinator.data
|
||||
or description.key == "percentage_charged"
|
||||
),
|
||||
( # Add energy site history
|
||||
TeslaFleetEnergyHistorySensorEntity(energysite, description)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tesla-fleet-api"],
|
||||
"requirements": ["tesla-fleet-api==0.9.12", "teslemetry-stream==0.6.10"]
|
||||
"requirements": ["tesla-fleet-api==0.9.13", "teslemetry-stream==0.6.12"]
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
polling: bool = False
|
||||
polling_value_fn: Callable[[StateType], StateType] = lambda x: x
|
||||
polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None
|
||||
nullable: bool = False
|
||||
streaming_key: Signal | None = None
|
||||
streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x
|
||||
streaming_firmware: str = "2024.26"
|
||||
@@ -210,7 +210,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
|
||||
TeslemetryVehicleSensorEntityDescription(
|
||||
key="drive_state_shift_state",
|
||||
polling=True,
|
||||
polling_available_fn=lambda x: True,
|
||||
nullable=True,
|
||||
polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"),
|
||||
streaming_key=Signal.GEAR,
|
||||
streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(),
|
||||
@@ -622,10 +622,10 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor)
|
||||
|
||||
def _async_value_from_stream(self, value) -> None:
|
||||
"""Update the value of the entity."""
|
||||
if value is None:
|
||||
self._attr_native_value = None
|
||||
else:
|
||||
if self.entity_description.nullable or value is not None:
|
||||
self._attr_native_value = self.entity_description.streaming_value_fn(value)
|
||||
else:
|
||||
self._attr_native_value = None
|
||||
|
||||
|
||||
class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity):
|
||||
@@ -644,7 +644,7 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity):
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update the attributes of the sensor."""
|
||||
if self.entity_description.polling_available_fn(self._value):
|
||||
if self.entity_description.nullable or self._value is not None:
|
||||
self._attr_available = True
|
||||
self._attr_native_value = self.entity_description.polling_value_fn(
|
||||
self._value
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tessie",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tessie", "tesla-fleet-api"],
|
||||
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.12"]
|
||||
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.13"]
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
|
||||
key="drive_state_shift_state",
|
||||
options=["p", "d", "r", "n"],
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
value_fn=lambda x: x.lower() if isinstance(x, str) else x,
|
||||
value_fn=lambda x: x.lower() if isinstance(x, str) else "p",
|
||||
),
|
||||
TessieSensorEntityDescription(
|
||||
key="vehicle_state_odometer",
|
||||
@@ -397,6 +397,7 @@ async def async_setup_entry(
|
||||
for energysite in entry.runtime_data.energysites
|
||||
for description in ENERGY_LIVE_DESCRIPTIONS
|
||||
if description.key in energysite.live_coordinator.data
|
||||
or description.key == "percentage_charged"
|
||||
),
|
||||
( # Add wall connectors
|
||||
TessieWallConnectorSensorEntity(energysite, din, description)
|
||||
@@ -449,7 +450,6 @@ class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity):
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update the attributes of the sensor."""
|
||||
self._attr_available = self._value is not None
|
||||
self._attr_native_value = self.entity_description.value_fn(self._value)
|
||||
|
||||
|
||||
|
||||
@@ -54,5 +54,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/thermobeacon",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["thermobeacon-ble==0.8.0"]
|
||||
"requirements": ["thermobeacon-ble==0.8.1"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/upb",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["upb_lib"],
|
||||
"requirements": ["upb-lib==0.6.0"]
|
||||
"requirements": ["upb-lib==0.6.1"]
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._device = "tls://"
|
||||
else:
|
||||
self._device = ""
|
||||
if user_input[CONF_PASSWORD] != "":
|
||||
if CONF_PASSWORD in user_input and user_input[CONF_PASSWORD] != "":
|
||||
self._device += f"{user_input[CONF_PASSWORD]}@"
|
||||
self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
|
||||
self._async_abort_entries_match({CONF_PORT: self._device})
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"velbus-protocol"
|
||||
],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["velbus-aio==2025.1.1"],
|
||||
"requirements": ["velbus-aio==2025.3.1"],
|
||||
"usb": [
|
||||
{
|
||||
"vid": "10CF",
|
||||
|
||||
@@ -71,9 +71,7 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = {
|
||||
CHARGER_MAX_ICP_CURRENT_KEY: WallboxNumberEntityDescription(
|
||||
key=CHARGER_MAX_ICP_CURRENT_KEY,
|
||||
translation_key="maximum_icp_current",
|
||||
max_value_fn=lambda coordinator: cast(
|
||||
float, coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY]
|
||||
),
|
||||
max_value_fn=lambda _: 255,
|
||||
min_value_fn=lambda _: 6,
|
||||
set_value_fn=lambda coordinator: coordinator.async_set_icp_current,
|
||||
native_step=1,
|
||||
|
||||
@@ -13,7 +13,11 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .helpers import async_create_client, async_ensure_path_exists
|
||||
from .helpers import (
|
||||
async_create_client,
|
||||
async_ensure_path_exists,
|
||||
async_migrate_wrong_folder_path,
|
||||
)
|
||||
|
||||
type WebDavConfigEntry = ConfigEntry[Client]
|
||||
|
||||
@@ -46,10 +50,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bo
|
||||
translation_key="cannot_connect",
|
||||
)
|
||||
|
||||
path = entry.data.get(CONF_BACKUP_PATH, "/")
|
||||
await async_migrate_wrong_folder_path(client, path)
|
||||
|
||||
# Ensure the backup directory exists
|
||||
if not await async_ensure_path_exists(
|
||||
client, entry.data.get(CONF_BACKUP_PATH, "/")
|
||||
):
|
||||
if not await async_ensure_path_exists(client, path):
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_access_or_create_backup_path",
|
||||
|
||||
@@ -171,6 +171,7 @@ class WebDavBackupAgent(BackupAgent):
|
||||
await open_stream(),
|
||||
f"{self._backup_path}/{filename_tar}",
|
||||
timeout=BACKUP_TIMEOUT,
|
||||
content_length=backup.size,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"""Helper functions for the WebDAV component."""
|
||||
|
||||
import logging
|
||||
|
||||
from aiowebdav2.client import Client, ClientOptions
|
||||
from aiowebdav2.exceptions import WebDavError
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_create_client(
|
||||
@@ -36,3 +44,25 @@ async def async_ensure_path_exists(client: Client, path: str) -> bool:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_wrong_folder_path(client: Client, path: str) -> None:
|
||||
"""Migrate the wrong encoded folder path to the correct one."""
|
||||
wrong_path = path.replace(" ", "%20")
|
||||
# migrate folder when the old folder exists
|
||||
if wrong_path != path and await client.check(wrong_path):
|
||||
try:
|
||||
await client.move(wrong_path, path)
|
||||
except WebDavError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_migrate_folder",
|
||||
translation_placeholders={
|
||||
"wrong_path": wrong_path,
|
||||
"correct_path": path,
|
||||
},
|
||||
) from err
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migrated wrong encoded folder path from %s to %s", wrong_path, path
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiowebdav2"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiowebdav2==0.3.1"]
|
||||
"requirements": ["aiowebdav2==0.4.2"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user