diff --git a/.github/assets/screenshot-integrations.png b/.github/assets/screenshot-integrations.png
index 8d71bf538d6..abbc0f76ff0 100644
Binary files a/.github/assets/screenshot-integrations.png and b/.github/assets/screenshot-integrations.png differ
diff --git a/Dockerfile b/Dockerfile
index 19b2c97b181..42a90107c4d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,7 +13,7 @@ ENV \
ARG QEMU_CPU
# Install uv
-RUN pip3 install uv==0.5.27
+RUN pip3 install uv==0.6.0
WORKDIR /usr/src
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index 58150ae7926..7c5cb7dce4c 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -134,14 +134,12 @@ DATA_REGISTRIES_LOADED: HassKey[None] = HassKey("bootstrap_registries_loaded")
LOG_SLOW_STARTUP_INTERVAL = 60
SLOW_STARTUP_CHECK_INTERVAL = 1
+STAGE_0_SUBSTAGE_TIMEOUT = 60
STAGE_1_TIMEOUT = 120
STAGE_2_TIMEOUT = 300
WRAP_UP_TIMEOUT = 300
COOLDOWN_TIME = 60
-
-DEBUGGER_INTEGRATIONS = {"debugpy"}
-
# Core integrations are unconditionally loaded
CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"}
@@ -152,6 +150,10 @@ LOGGING_AND_HTTP_DEPS_INTEGRATIONS = {
"isal",
# Set log levels
"logger",
+ # Ensure network config is available
+ # before hassio or any other integration is
+ # loaded that might create an aiohttp client session
+ "network",
# Error logging
"system_log",
"sentry",
@@ -172,12 +174,27 @@ FRONTEND_INTEGRATIONS = {
# add it here.
"backup",
}
-RECORDER_INTEGRATIONS = {
- # Setup after frontend
- # To record data
- "recorder",
-}
-DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb", "zeroconf")
+# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
+# The substage containing recorder should have no timeout, as it could cancel a database migration.
+# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
+# The substages preceding it should also have no timeout, until we ensure that the recorder
+# is not accidentally promoted as a dependency of any of the integrations in them.
+# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
+STAGE_0_INTEGRATIONS = (
+ # Load logging and http deps as soon as possible
+ ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
+ # Setup frontend
+ ("frontend", FRONTEND_INTEGRATIONS, None),
+ # Setup recorder
+ ("recorder", {"recorder"}, None),
+ # Start up debuggers. Start these first in case they want to wait.
+ ("debugger", {"debugpy"}, STAGE_0_SUBSTAGE_TIMEOUT),
+ # Zeroconf is used for mdns resolution in aiohttp client helper.
+ ("zeroconf", {"zeroconf"}, STAGE_0_SUBSTAGE_TIMEOUT),
+)
+
+DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb")
+# Stage 1 integrations are not to be preimported in bootstrap.
STAGE_1_INTEGRATIONS = {
# We need to make sure discovery integrations
# update their deps before stage 2 integrations
@@ -189,9 +206,8 @@ STAGE_1_INTEGRATIONS = {
"mqtt_eventstream",
# To provide account link implementations
"cloud",
- # Ensure supervisor is available
- "hassio",
}
+
DEFAULT_INTEGRATIONS = {
# These integrations are set up unless recovery mode is activated.
#
@@ -232,22 +248,12 @@ DEFAULT_INTEGRATIONS_SUPERVISOR = {
# These integrations are set up if using the Supervisor
"hassio",
}
+
CRITICAL_INTEGRATIONS = {
# Recovery mode is activated if these integrations fail to set up
"frontend",
}
-SETUP_ORDER = (
- # Load logging and http deps as soon as possible
- ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS),
- # Setup frontend
- ("frontend", FRONTEND_INTEGRATIONS),
- # Setup recorder
- ("recorder", RECORDER_INTEGRATIONS),
- # Start up debuggers. Start these first in case they want to wait.
- ("debugger", DEBUGGER_INTEGRATIONS),
-)
-
#
# Storage keys we are likely to load during startup
# in order of when we expect to load them.
@@ -694,7 +700,6 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
return deps_dir
-@core.callback
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
"""Get domains of components to set up."""
# Filter out the repeating and common config section [homeassistant]
@@ -890,69 +895,48 @@ async def _async_set_up_integrations(
domains_to_setup, integration_cache = await _async_resolve_domains_to_setup(
hass, config
)
+ stage_2_domains = domains_to_setup.copy()
# Initialize recorder
if "recorder" in domains_to_setup:
recorder.async_initialize_recorder(hass)
- pre_stage_domains = [
- (name, domains_to_setup & domain_group) for name, domain_group in SETUP_ORDER
+ stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [
+ *(
+ (name, domain_group & domains_to_setup, timeout)
+ for name, domain_group, timeout in STAGE_0_INTEGRATIONS
+ ),
+ ("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT),
]
- # calculate what components to setup in what stage
- stage_1_domains: set[str] = set()
+ _LOGGER.info("Setting up stage 0 and 1")
+ for name, domain_group, timeout in stage_0_and_1_domains:
+ if not domain_group:
+ continue
- # Find all dependencies of any dependency of any stage 1 integration that
- # we plan on loading and promote them to stage 1. This is done only to not
- # get misleading log messages
- deps_promotion: set[str] = STAGE_1_INTEGRATIONS
- while deps_promotion:
- old_deps_promotion = deps_promotion
- deps_promotion = set()
+ _LOGGER.info("Setting up %s: %s", name, domain_group)
+ to_be_loaded = domain_group.copy()
+ to_be_loaded.update(
+ dep
+ for domain in domain_group
+ if (integration := integration_cache.get(domain)) is not None
+ for dep in integration.all_dependencies
+ )
+ async_set_domains_to_be_loaded(hass, to_be_loaded)
+ stage_2_domains -= to_be_loaded
- for domain in old_deps_promotion:
- if domain not in domains_to_setup or domain in stage_1_domains:
- continue
-
- stage_1_domains.add(domain)
-
- if (dep_itg := integration_cache.get(domain)) is None:
- continue
-
- deps_promotion.update(dep_itg.all_dependencies)
-
- stage_2_domains = domains_to_setup - stage_1_domains
-
- for name, domain_group in pre_stage_domains:
- if domain_group:
- stage_2_domains -= domain_group
- _LOGGER.info("Setting up %s: %s", name, domain_group)
- to_be_loaded = domain_group.copy()
- to_be_loaded.update(
- dep
- for domain in domain_group
- if (integration := integration_cache.get(domain)) is not None
- for dep in integration.all_dependencies
- )
- async_set_domains_to_be_loaded(hass, to_be_loaded)
+ if timeout is None:
await _async_setup_multi_components(hass, domain_group, config)
-
- # Enables after dependencies when setting up stage 1 domains
- async_set_domains_to_be_loaded(hass, stage_1_domains)
-
- # Start setup
- if stage_1_domains:
- _LOGGER.info("Setting up stage 1: %s", stage_1_domains)
- try:
- async with hass.timeout.async_timeout(
- STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME
- ):
- await _async_setup_multi_components(hass, stage_1_domains, config)
- except TimeoutError:
- _LOGGER.warning(
- "Setup timed out for stage 1 waiting on %s - moving forward",
- hass._active_tasks, # noqa: SLF001
- )
+ else:
+ try:
+ async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
+ await _async_setup_multi_components(hass, domain_group, config)
+ except TimeoutError:
+ _LOGGER.warning(
+ "Setup timed out for %s waiting on %s - moving forward",
+ name,
+ hass._active_tasks, # noqa: SLF001
+ )
# Add after dependencies when setting up stage 2 domains
async_set_domains_to_be_loaded(hass, stage_2_domains)
diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py
index f8ddeba6767..bbc763d7ec3 100644
--- a/homeassistant/components/adguard/__init__.py
+++ b/homeassistant/components/adguard/__init__.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
@@ -123,12 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
"""Unload AdGuard Home config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# This is the last loaded instance of AdGuard, deregister any services
hass.services.async_remove(DOMAIN, SERVICE_ADD_URL)
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL)
diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json
index 13764142697..afaf2698ced 100644
--- a/homeassistant/components/airgradient/manifest.json
+++ b/homeassistant/components/airgradient/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["airgradient==0.9.1"],
+ "requirements": ["airgradient==0.9.2"],
"zeroconf": ["_airgradient._tcp.local."]
}
diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json
index 5f718280566..ed02b2d0ee8 100644
--- a/homeassistant/components/alarm_control_panel/strings.json
+++ b/homeassistant/components/alarm_control_panel/strings.json
@@ -90,7 +90,7 @@
},
"alarm_arm_home": {
"name": "Arm home",
- "description": "Sets the alarm to: _armed, but someone is home_.",
+ "description": "Arms the alarm in the home mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@@ -100,7 +100,7 @@
},
"alarm_arm_away": {
"name": "Arm away",
- "description": "Sets the alarm to: _armed, no one home_.",
+ "description": "Arms the alarm in the away mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@@ -110,7 +110,7 @@
},
"alarm_arm_night": {
"name": "Arm night",
- "description": "Sets the alarm to: _armed for the night_.",
+ "description": "Arms the alarm in the night mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@@ -120,7 +120,7 @@
},
"alarm_arm_vacation": {
"name": "Arm vacation",
- "description": "Sets the alarm to: _armed for vacation_.",
+ "description": "Arms the alarm in the vacation mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@@ -130,7 +130,7 @@
},
"alarm_trigger": {
"name": "Trigger",
- "description": "Trigger the alarm manually.",
+ "description": "Triggers the alarm manually.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py
index 9f513509ce7..5511119d377 100644
--- a/homeassistant/components/anthropic/conversation.py
+++ b/homeassistant/components/anthropic/conversation.py
@@ -1,16 +1,23 @@
"""Conversation support for Anthropic."""
-from collections.abc import Callable
+from collections.abc import AsyncGenerator, Callable
import json
-from typing import Any, Literal, cast
+from typing import Any, Literal
import anthropic
+from anthropic import AsyncStream
from anthropic._types import NOT_GIVEN
from anthropic.types import (
+ InputJSONDelta,
Message,
MessageParam,
+ MessageStreamEvent,
+ RawContentBlockDeltaEvent,
+ RawContentBlockStartEvent,
+ RawContentBlockStopEvent,
TextBlock,
TextBlockParam,
+ TextDelta,
ToolParam,
ToolResultBlockParam,
ToolUseBlock,
@@ -109,7 +116,7 @@ def _convert_content(chat_content: conversation.Content) -> MessageParam:
type="tool_use",
id=tool_call.id,
name=tool_call.tool_name,
- input=json.dumps(tool_call.tool_args),
+ input=tool_call.tool_args,
)
for tool_call in chat_content.tool_calls or ()
],
@@ -124,6 +131,66 @@ def _convert_content(chat_content: conversation.Content) -> MessageParam:
raise ValueError(f"Unexpected content type: {type(chat_content)}")
+async def _transform_stream(
+ result: AsyncStream[MessageStreamEvent],
+) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
+ """Transform the response stream into HA format.
+
+ A typical stream of responses might look something like the following:
+ - RawMessageStartEvent with no content
+ - RawContentBlockStartEvent with an empty TextBlock
+ - RawContentBlockDeltaEvent with a TextDelta
+ - RawContentBlockDeltaEvent with a TextDelta
+ - RawContentBlockDeltaEvent with a TextDelta
+ - ...
+ - RawContentBlockStopEvent
+ - RawContentBlockStartEvent with ToolUseBlock specifying the function name
+ - RawContentBlockDeltaEvent with a InputJSONDelta
+ - RawContentBlockDeltaEvent with a InputJSONDelta
+ - ...
+ - RawContentBlockStopEvent
+ - RawMessageDeltaEvent with a stop_reason='tool_use'
+ - RawMessageStopEvent(type='message_stop')
+ """
+ if result is None:
+ raise TypeError("Expected a stream of messages")
+
+ current_tool_call: dict | None = None
+
+ async for response in result:
+ LOGGER.debug("Received response: %s", response)
+
+ if isinstance(response, RawContentBlockStartEvent):
+ if isinstance(response.content_block, ToolUseBlock):
+ current_tool_call = {
+ "id": response.content_block.id,
+ "name": response.content_block.name,
+ "input": "",
+ }
+ elif isinstance(response.content_block, TextBlock):
+ yield {"role": "assistant"}
+ elif isinstance(response, RawContentBlockDeltaEvent):
+ if isinstance(response.delta, InputJSONDelta):
+ if current_tool_call is None:
+ raise ValueError("Unexpected delta without a tool call")
+ current_tool_call["input"] += response.delta.partial_json
+ elif isinstance(response.delta, TextDelta):
+ LOGGER.debug("yielding delta: %s", response.delta.text)
+ yield {"content": response.delta.text}
+ elif isinstance(response, RawContentBlockStopEvent):
+ if current_tool_call:
+ yield {
+ "tool_calls": [
+ llm.ToolInput(
+ id=current_tool_call["id"],
+ tool_name=current_tool_call["name"],
+ tool_args=json.loads(current_tool_call["input"]),
+ )
+ ]
+ }
+ current_tool_call = None
+
+
class AnthropicConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent
):
@@ -206,58 +273,30 @@ class AnthropicConversationEntity(
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
- response = await client.messages.create(
+ stream = await client.messages.create(
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
messages=messages,
tools=tools or NOT_GIVEN,
max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
system=system.content,
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
+ stream=True,
)
except anthropic.AnthropicError as err:
raise HomeAssistantError(
f"Sorry, I had a problem talking to Anthropic: {err}"
) from err
- LOGGER.debug("Response %s", response)
-
- messages.append(_message_convert(response))
-
- text = "".join(
+ messages.extend(
[
- content.text
- for content in response.content
- if isinstance(content, TextBlock)
+ _convert_content(content)
+ async for content in chat_log.async_add_delta_content_stream(
+ user_input.agent_id, _transform_stream(stream)
+ )
]
)
- tool_inputs = [
- llm.ToolInput(
- id=tool_call.id,
- tool_name=tool_call.name,
- tool_args=cast(dict[str, Any], tool_call.input),
- )
- for tool_call in response.content
- if isinstance(tool_call, ToolUseBlock)
- ]
- tool_results = [
- ToolResultBlockParam(
- type="tool_result",
- tool_use_id=tool_response.tool_call_id,
- content=json.dumps(tool_response.tool_result),
- )
- async for tool_response in chat_log.async_add_assistant_content(
- conversation.AssistantContent(
- agent_id=user_input.agent_id,
- content=text,
- tool_calls=tool_inputs or None,
- )
- )
- ]
- if tool_results:
- messages.append(MessageParam(role="user", content=tool_results))
-
- if not tool_inputs:
+ if not chat_log.unresponded_tool_results:
break
response_content = chat_log.content[-1]
diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py
index 9ba7d046b60..2ce8becbf80 100644
--- a/homeassistant/components/apsystems/entity.py
+++ b/homeassistant/components/apsystems/entity.py
@@ -19,10 +19,20 @@ class ApSystemsEntity(Entity):
data: ApSystemsData,
) -> None:
"""Initialize the APsystems entity."""
+
+ # Handle device version safely
+ sw_version = None
+ if data.coordinator.device_version:
+ version_parts = data.coordinator.device_version.split(" ")
+ if len(version_parts) > 1:
+ sw_version = version_parts[1]
+ else:
+ sw_version = version_parts[0]
+
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.device_id)},
manufacturer="APsystems",
model="EZ1-M",
serial_number=data.device_id,
- sw_version=data.coordinator.device_version.split(" ")[1],
+ sw_version=sw_version,
)
diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json
index 39d289f9cb1..944c70c1217 100644
--- a/homeassistant/components/arcam_fmj/manifest.json
+++ b/homeassistant/components/arcam_fmj/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
"iot_class": "local_polling",
"loggers": ["arcam"],
- "requirements": ["arcam-fmj==1.5.2"],
+ "requirements": ["arcam-fmj==1.8.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py
index 6cd7af2bbdb..4fc1708b866 100644
--- a/homeassistant/components/assist_satellite/websocket_api.py
+++ b/homeassistant/components/assist_satellite/websocket_api.py
@@ -19,6 +19,7 @@ from .const import (
DOMAIN,
AssistSatelliteEntityFeature,
)
+from .entity import AssistSatelliteConfiguration
CONNECTION_TEST_TIMEOUT = 30
@@ -91,7 +92,16 @@ def websocket_get_configuration(
)
return
- config_dict = asdict(satellite.async_get_configuration())
+ try:
+ config_dict = asdict(satellite.async_get_configuration())
+ except NotImplementedError:
+ # Stub configuration
+ config_dict = asdict(
+ AssistSatelliteConfiguration(
+ available_wake_words=[], active_wake_words=[], max_active_wake_words=1
+ )
+ )
+
config_dict["pipeline_entity_id"] = satellite.pipeline_entity_id
config_dict["vad_entity_id"] = satellite.vad_sensitivity_entity_id
diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py
index c982d59d513..207826d136e 100644
--- a/homeassistant/components/balboa/__init__.py
+++ b/homeassistant/components/balboa/__init__.py
@@ -24,6 +24,8 @@ PLATFORMS = [
Platform.FAN,
Platform.LIGHT,
Platform.SELECT,
+ Platform.SWITCH,
+ Platform.TIME,
]
diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json
index 61cb5bbbf69..38d32adc4af 100644
--- a/homeassistant/components/balboa/manifest.json
+++ b/homeassistant/components/balboa/manifest.json
@@ -14,5 +14,5 @@
"documentation": "https://www.home-assistant.io/integrations/balboa",
"iot_class": "local_push",
"loggers": ["pybalboa"],
- "requirements": ["pybalboa==1.1.2"]
+ "requirements": ["pybalboa==1.1.3"]
}
diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json
index c00567a6052..9779984b182 100644
--- a/homeassistant/components/balboa/strings.json
+++ b/homeassistant/components/balboa/strings.json
@@ -78,6 +78,19 @@
"high": "High"
}
}
+ },
+ "switch": {
+ "filter_cycle_2_enabled": {
+ "name": "Filter cycle 2 enabled"
+ }
+ },
+ "time": {
+ "filter_cycle_start": {
+ "name": "Filter cycle {index} start"
+ },
+ "filter_cycle_end": {
+ "name": "Filter cycle {index} end"
+ }
}
}
}
diff --git a/homeassistant/components/balboa/switch.py b/homeassistant/components/balboa/switch.py
new file mode 100644
index 00000000000..c8c947f499d
--- /dev/null
+++ b/homeassistant/components/balboa/switch.py
@@ -0,0 +1,48 @@
+"""Support for Balboa switches."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pybalboa import SpaClient
+
+from homeassistant.components.switch import SwitchEntity
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import BalboaConfigEntry
+from .entity import BalboaEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: BalboaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the spa's switches."""
+ spa = entry.runtime_data
+ async_add_entities([BalboaSwitchEntity(spa)])
+
+
+class BalboaSwitchEntity(BalboaEntity, SwitchEntity):
+ """Representation of a Balboa switch entity."""
+
+ def __init__(self, spa: SpaClient) -> None:
+ """Initialize a Balboa switch entity."""
+ super().__init__(spa, "filter_cycle_2_enabled")
+ self._attr_entity_category = EntityCategory.CONFIG
+ self._attr_translation_key = "filter_cycle_2_enabled"
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ return self._client.filter_cycle_2_enabled
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ await self._client.configure_filter_cycle(2, enabled=True)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ await self._client.configure_filter_cycle(2, enabled=False)
diff --git a/homeassistant/components/balboa/time.py b/homeassistant/components/balboa/time.py
new file mode 100644
index 00000000000..83467de8777
--- /dev/null
+++ b/homeassistant/components/balboa/time.py
@@ -0,0 +1,56 @@
+"""Support for Balboa times."""
+
+from __future__ import annotations
+
+from datetime import time
+import itertools
+from typing import Any
+
+from pybalboa import SpaClient
+
+from homeassistant.components.time import TimeEntity
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import BalboaConfigEntry
+from .entity import BalboaEntity
+
+FILTER_CYCLE = "filter_cycle_"
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: BalboaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the spa's times."""
+ spa = entry.runtime_data
+ async_add_entities(
+ BalboaTimeEntity(spa, index, period)
+ for index, period in itertools.product((1, 2), ("start", "end"))
+ )
+
+
+class BalboaTimeEntity(BalboaEntity, TimeEntity):
+ """Representation of a Balboa time entity."""
+
+ entity_category = EntityCategory.CONFIG
+
+ def __init__(self, spa: SpaClient, index: int, period: str) -> None:
+ """Initialize a Balboa time entity."""
+ super().__init__(spa, f"{FILTER_CYCLE}{index}_{period}")
+ self.index = index
+ self.period = period
+ self._attr_translation_key = f"{FILTER_CYCLE}{period}"
+ self._attr_translation_placeholders = {"index": str(index)}
+
+ @property
+ def native_value(self) -> time | None:
+ """Return the value reported by the time."""
+ return getattr(self._client, f"{FILTER_CYCLE}{self.index}_{self.period}")
+
+ async def async_set_value(self, value: time) -> None:
+ """Change the time."""
+ args: dict[str, Any] = {self.period: value}
+ await self._client.configure_filter_cycle(self.index, **args)
diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py
index bf7b06e694a..3835de7c551 100644
--- a/homeassistant/components/bang_olufsen/diagnostics.py
+++ b/homeassistant/components/bang_olufsen/diagnostics.py
@@ -4,12 +4,13 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
+from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import BangOlufsenConfigEntry
-from .const import DOMAIN
+from .const import DEVICE_BUTTONS, DOMAIN
async def async_get_config_entry_diagnostics(
@@ -25,8 +26,9 @@ async def async_get_config_entry_diagnostics(
if TYPE_CHECKING:
assert config_entry.unique_id
- # Add media_player entity's state
entity_registry = er.async_get(hass)
+
+ # Add media_player entity's state
if entity_id := entity_registry.async_get_entity_id(
MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id
):
@@ -37,4 +39,16 @@ async def async_get_config_entry_diagnostics(
state_dict.pop("context")
data["media_player"] = state_dict
+ # Add button Event entity states (if enabled)
+ for device_button in DEVICE_BUTTONS:
+ if entity_id := entity_registry.async_get_entity_id(
+ EVENT_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{device_button}"
+ ):
+ if state := hass.states.get(entity_id):
+ state_dict = dict(state.as_dict())
+
+ # Remove context as it is not relevant
+ state_dict.pop("context")
+ data[f"{device_button}_event"] = state_dict
+
return data
diff --git a/homeassistant/components/bayesian/strings.json b/homeassistant/components/bayesian/strings.json
index 9ebccedc88d..00de79a2229 100644
--- a/homeassistant/components/bayesian/strings.json
+++ b/homeassistant/components/bayesian/strings.json
@@ -5,14 +5,14 @@
"title": "Manual YAML fix required for Bayesian"
},
"no_prob_given_false": {
- "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yml` for `bayesian/{entity}`. These observations will be ignored until you do.",
+ "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yaml` for `bayesian/{entity}`. These observations will be ignored until you do.",
"title": "Manual YAML addition required for Bayesian"
}
},
"services": {
"reload": {
"name": "[%key:common::action::reload%]",
- "description": "Reloads bayesian sensors from the YAML-configuration."
+ "description": "Reloads Bayesian sensors from the YAML-configuration."
}
}
}
diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json
index b846cb1c5ca..f292b10f7dc 100644
--- a/homeassistant/components/bring/manifest.json
+++ b/homeassistant/components/bring/manifest.json
@@ -7,5 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
+ "quality_scale": "platinum",
"requirements": ["bring-api==1.0.2"]
}
diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml
index 58e67ab0e11..2d7d67be12e 100644
--- a/homeassistant/components/bring/quality_scale.yaml
+++ b/homeassistant/components/bring/quality_scale.yaml
@@ -10,9 +10,9 @@ rules:
config-flow: done
dependency-transparency: done
docs-actions: done
- docs-high-level-description: todo
- docs-installation-instructions: todo
- docs-removal-instructions: todo
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: The integration registers no events
@@ -26,8 +26,10 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
- docs-configuration-parameters: todo
- docs-installation-parameters: todo
+ docs-configuration-parameters:
+ status: exempt
+ comment: Integration has no configuration parameters
+ docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
@@ -46,13 +48,15 @@ rules:
discovery:
status: exempt
comment: Integration is a service and has no devices.
- docs-data-update: todo
- docs-examples: todo
- docs-known-limitations: todo
- docs-supported-devices: todo
- docs-supported-functions: todo
- docs-troubleshooting: todo
- docs-use-cases: todo
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: Integration is a service and has no devices.
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
diff --git a/homeassistant/components/broadlink/entity.py b/homeassistant/components/broadlink/entity.py
index 6c956d8c80a..a97374680f9 100644
--- a/homeassistant/components/broadlink/entity.py
+++ b/homeassistant/components/broadlink/entity.py
@@ -17,13 +17,13 @@ class BroadlinkEntity(Entity):
self._device = device
self._coordinator = device.update_manager.coordinator
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Call when the entity is added to hass."""
self.async_on_remove(self._coordinator.async_add_listener(self._recv_data))
if self._coordinator.data:
self._update_state(self._coordinator.data)
- async def async_update(self):
+ async def async_update(self) -> None:
"""Update the state of the entity."""
await self._coordinator.async_request_refresh()
@@ -49,7 +49,7 @@ class BroadlinkEntity(Entity):
"""
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if the entity is available."""
return self._device.available
diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py
index 55ffedd2781..97210b4197c 100644
--- a/homeassistant/components/cloud/__init__.py
+++ b/homeassistant/components/cloud/__init__.py
@@ -6,6 +6,7 @@ import asyncio
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from enum import Enum
+import logging
from typing import cast
from hass_nabucasa import Cloud
@@ -19,6 +20,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_REGION,
EVENT_HOMEASSISTANT_STOP,
+ FORMAT_DATETIME,
Platform,
)
from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
@@ -33,7 +35,7 @@ from homeassistant.helpers.dispatcher import (
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
-from homeassistant.loader import bind_hass
+from homeassistant.loader import async_get_integration, bind_hass
from homeassistant.util.signal_type import SignalType
# Pre-import backup to avoid it being imported
@@ -62,11 +64,13 @@ from .const import (
CONF_THINGTALK_SERVER,
CONF_USER_POOL_ID,
DATA_CLOUD,
+ DATA_CLOUD_LOG_HANDLER,
DATA_PLATFORMS_SETUP,
DOMAIN,
MODE_DEV,
MODE_PROD,
)
+from .helpers import FixedSizeQueueLogHandler
from .prefs import CloudPreferences
from .repairs import async_manage_legacy_subscription_issue
from .subscription import async_subscription_info
@@ -245,6 +249,8 @@ def async_remote_ui_url(hass: HomeAssistant) -> str:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the Home Assistant cloud."""
+ log_handler = hass.data[DATA_CLOUD_LOG_HANDLER] = await _setup_log_handler(hass)
+
# Process configs
if DOMAIN in config:
kwargs = dict(config[DOMAIN])
@@ -267,6 +273,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _shutdown(event: Event) -> None:
"""Shutdown event."""
await cloud.stop()
+ logging.root.removeHandler(log_handler)
+ del hass.data[DATA_CLOUD_LOG_HANDLER]
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
@@ -405,3 +413,19 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None:
async_register_admin_service(
hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler
)
+
+
+async def _setup_log_handler(hass: HomeAssistant) -> FixedSizeQueueLogHandler:
+ fmt = (
+ "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s"
+ )
+ handler = FixedSizeQueueLogHandler()
+ handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
+
+ integration = await async_get_integration(hass, DOMAIN)
+ loggers: set[str] = {integration.pkg_path, *(integration.loggers or [])}
+
+ for logger_name in loggers:
+ logging.getLogger(logger_name).addHandler(handler)
+
+ return handler
diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py
index 9531604ccc7..b31fe16fbe9 100644
--- a/homeassistant/components/cloud/backup.py
+++ b/homeassistant/components/cloud/backup.py
@@ -3,17 +3,20 @@
from __future__ import annotations
import asyncio
-import base64
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
-import hashlib
import logging
import random
-from typing import Any, Literal
+from typing import Any
from aiohttp import ClientError
from hass_nabucasa import Cloud, CloudError
from hass_nabucasa.api import CloudApiNonRetryableError
-from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list
+from hass_nabucasa.cloud_api import (
+ FilesHandlerListEntry,
+ async_files_delete_file,
+ async_files_list,
+)
+from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
from homeassistant.core import HomeAssistant, callback
@@ -24,20 +27,11 @@ from .client import CloudClient
from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
_LOGGER = logging.getLogger(__name__)
-_STORAGE_BACKUP: Literal["backup"] = "backup"
_RETRY_LIMIT = 5
_RETRY_SECONDS_MIN = 60
_RETRY_SECONDS_MAX = 600
-async def _b64md5(stream: AsyncIterator[bytes]) -> str:
- """Calculate the MD5 hash of a file."""
- file_hash = hashlib.md5()
- async for chunk in stream:
- file_hash.update(chunk)
- return base64.b64encode(file_hash.digest()).decode()
-
-
async def async_get_backup_agents(
hass: HomeAssistant,
**kwargs: Any,
@@ -86,11 +80,6 @@ class CloudBackupAgent(BackupAgent):
self._cloud = cloud
self._hass = hass
- @callback
- def _get_backup_filename(self) -> str:
- """Return the backup filename."""
- return f"{self._cloud.client.prefs.instance_id}.tar"
-
async def async_download_backup(
self,
backup_id: str,
@@ -101,13 +90,13 @@ class CloudBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: An async iterator that yields bytes.
"""
- if not await self.async_get_backup(backup_id):
+ if not (backup := await self._async_get_backup(backup_id)):
raise BackupAgentError("Backup not found")
try:
content = await self._cloud.files.download(
- storage_type=_STORAGE_BACKUP,
- filename=self._get_backup_filename(),
+ storage_type=StorageType.BACKUP,
+ filename=backup["Key"],
)
except CloudError as err:
raise BackupAgentError(f"Failed to download backup: {err}") from err
@@ -129,16 +118,19 @@ class CloudBackupAgent(BackupAgent):
if not backup.protected:
raise BackupAgentError("Cloud backups must be protected")
- base64md5hash = await _b64md5(await open_stream())
- filename = self._get_backup_filename()
- metadata = backup.as_dict()
size = backup.size
+ try:
+ base64md5hash = await calculate_b64md5(open_stream, size)
+ except FilesError as err:
+ raise BackupAgentError(err) from err
+ filename = f"{self._cloud.client.prefs.instance_id}.tar"
+ metadata = backup.as_dict()
tries = 1
while tries <= _RETRY_LIMIT:
try:
await self._cloud.files.upload(
- storage_type=_STORAGE_BACKUP,
+ storage_type=StorageType.BACKUP,
open_stream=open_stream,
filename=filename,
base64md5hash=base64md5hash,
@@ -179,27 +171,34 @@ class CloudBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups.
"""
- if not await self.async_get_backup(backup_id):
+ if not (backup := await self._async_get_backup(backup_id)):
return
try:
await async_files_delete_file(
self._cloud,
- storage_type=_STORAGE_BACKUP,
- filename=self._get_backup_filename(),
+ storage_type=StorageType.BACKUP,
+ filename=backup["Key"],
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to delete backup") from err
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups."""
+ backups = await self._async_list_backups()
+ return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
+
+ async def _async_list_backups(self) -> list[FilesHandlerListEntry]:
"""List backups."""
try:
- backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP)
- _LOGGER.debug("Cloud backups: %s", backups)
+ backups = await async_files_list(
+ self._cloud, storage_type=StorageType.BACKUP
+ )
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to list backups") from err
- return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
+ _LOGGER.debug("Cloud backups: %s", backups)
+ return backups
async def async_get_backup(
self,
@@ -207,10 +206,19 @@ class CloudBackupAgent(BackupAgent):
**kwargs: Any,
) -> AgentBackup | None:
"""Return a backup."""
- backups = await self.async_list_backups()
+ if not (backup := await self._async_get_backup(backup_id)):
+ return None
+ return AgentBackup.from_dict(backup["Metadata"])
+
+ async def _async_get_backup(
+ self,
+ backup_id: str,
+ ) -> FilesHandlerListEntry | None:
+ """Return a backup."""
+ backups = await self._async_list_backups()
for backup in backups:
- if backup.backup_id == backup_id:
+ if backup["Metadata"]["backup_id"] == backup_id:
return backup
return None
diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py
index 3883f19d1b7..e0c15c74cab 100644
--- a/homeassistant/components/cloud/const.py
+++ b/homeassistant/components/cloud/const.py
@@ -12,12 +12,14 @@ if TYPE_CHECKING:
from hass_nabucasa import Cloud
from .client import CloudClient
+ from .helpers import FixedSizeQueueLogHandler
DOMAIN = "cloud"
DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN)
DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey(
"cloud_platforms_setup"
)
+DATA_CLOUD_LOG_HANDLER: HassKey[FixedSizeQueueLogHandler] = HassKey("cloud_log_handler")
EVENT_CLOUD_EVENT = "cloud_event"
REQUEST_TIMEOUT = 10
diff --git a/homeassistant/components/cloud/helpers.py b/homeassistant/components/cloud/helpers.py
new file mode 100644
index 00000000000..7795a314fb7
--- /dev/null
+++ b/homeassistant/components/cloud/helpers.py
@@ -0,0 +1,31 @@
+"""Helpers for the cloud component."""
+
+from collections import deque
+import logging
+
+from homeassistant.core import HomeAssistant
+
+
+class FixedSizeQueueLogHandler(logging.Handler):
+ """Log handler to store messages, with auto rotation."""
+
+ MAX_RECORDS = 500
+
+ def __init__(self) -> None:
+ """Initialize a new LogHandler."""
+ super().__init__()
+ self._records: deque[logging.LogRecord] = deque(maxlen=self.MAX_RECORDS)
+
+ def emit(self, record: logging.LogRecord) -> None:
+ """Store log message."""
+ self._records.append(record)
+
+ async def get_logs(self, hass: HomeAssistant) -> list[str]:
+ """Get stored logs."""
+
+ def _get_logs() -> list[str]:
+ # copy the queue since it can mutate while iterating
+ records = self._records.copy()
+ return [self.format(record) for record in records]
+
+ return await hass.async_add_executor_job(_get_logs)
diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index b1a845ef8b0..af1c72f54f6 100644
--- a/homeassistant/components/cloud/http_api.py
+++ b/homeassistant/components/cloud/http_api.py
@@ -43,6 +43,7 @@ from .assist_pipeline import async_create_cloud_pipeline
from .client import CloudClient
from .const import (
DATA_CLOUD,
+ DATA_CLOUD_LOG_HANDLER,
EVENT_CLOUD_EVENT,
LOGIN_MFA_TIMEOUT,
PREF_ALEXA_REPORT_STATE,
@@ -397,8 +398,11 @@ class DownloadSupportPackageView(HomeAssistantView):
url = "/api/cloud/support_package"
name = "api:cloud:support_package"
- def _generate_markdown(
- self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]]
+ async def _generate_markdown(
+ self,
+ hass: HomeAssistant,
+ hass_info: dict[str, Any],
+ domains_info: dict[str, dict[str, str]],
) -> str:
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
if len(domain_info) == 0:
@@ -424,6 +428,17 @@ class DownloadSupportPackageView(HomeAssistantView):
"\n\n"
)
+ log_handler = hass.data[DATA_CLOUD_LOG_HANDLER]
+ logs = "\n".join(await log_handler.get_logs(hass))
+ markdown += (
+ "## Full logs\n\n"
+ "Logs
\n\n"
+ "```logs\n"
+ f"{logs}\n"
+ "```\n\n"
+ " \n"
+ )
+
return markdown
async def get(self, request: web.Request) -> web.Response:
@@ -433,7 +448,7 @@ class DownloadSupportPackageView(HomeAssistantView):
domain_health = await get_system_health_info(hass)
hass_info = domain_health.pop("homeassistant", {})
- markdown = self._generate_markdown(hass_info, domain_health)
+ markdown = await self._generate_markdown(hass, hass_info, domain_health)
return web.Response(
body=markdown,
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index 8e8ff4335db..4e99d08afb5 100644
--- a/homeassistant/components/cloud/manifest.json
+++ b/homeassistant/components/cloud/manifest.json
@@ -12,7 +12,7 @@
"documentation": "https://www.home-assistant.io/integrations/cloud",
"integration_type": "system",
"iot_class": "cloud_push",
- "loggers": ["hass_nabucasa"],
- "requirements": ["hass-nabucasa==0.89.0"],
+ "loggers": ["acme", "hass_nabucasa", "snitun"],
+ "requirements": ["hass-nabucasa==0.92.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py
index 23c201d7579..e8bd38f5adf 100644
--- a/homeassistant/components/conversation/default_agent.py
+++ b/homeassistant/components/conversation/default_agent.py
@@ -53,6 +53,7 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_added_domain
+from homeassistant.util import language as language_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .chat_log import AssistantContent, async_get_chat_log
@@ -914,26 +915,20 @@ class DefaultAgent(ConversationEntity):
def _load_intents(self, language: str) -> LanguageIntents | None:
"""Load all intents for language (run inside executor)."""
intents_dict: dict[str, Any] = {}
- language_variant: str | None = None
supported_langs = set(get_languages())
# Choose a language variant upfront and commit to it for custom
# sentences, etc.
- all_language_variants = {lang.lower(): lang for lang in supported_langs}
+ lang_matches = language_util.matches(language, supported_langs)
- # en-US, en_US, en, ...
- for maybe_variant in _get_language_variations(language):
- matching_variant = all_language_variants.get(maybe_variant.lower())
- if matching_variant:
- language_variant = matching_variant
- break
-
- if not language_variant:
+ if not lang_matches:
_LOGGER.warning(
"Unable to find supported language variant for %s", language
)
return None
+ language_variant = lang_matches[0]
+
# Load intents for this language variant
lang_variant_intents = get_intents(language_variant, json_load=json_load)
diff --git a/homeassistant/components/denonavr/strings.json b/homeassistant/components/denonavr/strings.json
index 6c055c5932a..192ab3bd71f 100644
--- a/homeassistant/components/denonavr/strings.json
+++ b/homeassistant/components/denonavr/strings.json
@@ -23,14 +23,14 @@
}
},
"error": {
- "discovery_error": "Failed to discover a Denon AVR Network Receiver"
+ "discovery_error": "Failed to discover a Denon AVR network receiver"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "cannot_connect": "Failed to connect, please try again, disconnecting mains power and ethernet cables and reconnecting them may help",
- "not_denonavr_manufacturer": "Not a Denon AVR Network Receiver, discovered manufacturer did not match",
- "not_denonavr_missing": "Not a Denon AVR Network Receiver, discovery information not complete"
+ "cannot_connect": "Failed to connect, please try again, disconnecting mains power and Ethernet cables and reconnecting them may help",
+ "not_denonavr_manufacturer": "Not a Denon AVR network receiver, discovered manufacturer did not match",
+ "not_denonavr_missing": "Not a Denon AVR network receiver, discovery information not complete"
}
},
"options": {
@@ -64,7 +64,7 @@
"fields": {
"dynamic_eq": {
"name": "Dynamic equalizer",
- "description": "True/false for enable/disable."
+ "description": "Whether DynamicEQ should be enabled or disabled."
}
}
},
diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json
index 96afffdf78f..502db7920a3 100644
--- a/homeassistant/components/easyenergy/strings.json
+++ b/homeassistant/components/easyenergy/strings.json
@@ -60,12 +60,12 @@
"description": "Requests gas prices from easyEnergy.",
"fields": {
"config_entry": {
- "name": "Config Entry",
+ "name": "Config entry",
"description": "The configuration entry to use for this action."
},
"incl_vat": {
- "name": "VAT Included",
- "description": "Include or exclude VAT in the prices, default is true."
+ "name": "VAT included",
+ "description": "Whether the prices should include VAT."
},
"start": {
"name": "Start",
diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py
index b9673869046..e7ccec33310 100644
--- a/homeassistant/components/econet/climate.py
+++ b/homeassistant/components/econet/climate.py
@@ -23,7 +23,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
+from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from . import EconetConfigEntry
from .const import DOMAIN
@@ -35,8 +35,13 @@ ECONET_STATE_TO_HA = {
ThermostatOperationMode.OFF: HVACMode.OFF,
ThermostatOperationMode.AUTO: HVACMode.HEAT_COOL,
ThermostatOperationMode.FAN_ONLY: HVACMode.FAN_ONLY,
+ ThermostatOperationMode.EMERGENCY_HEAT: HVACMode.HEAT,
+}
+HA_STATE_TO_ECONET = {
+ value: key
+ for key, value in ECONET_STATE_TO_HA.items()
+ if key != ThermostatOperationMode.EMERGENCY_HEAT
}
-HA_STATE_TO_ECONET = {value: key for key, value in ECONET_STATE_TO_HA.items()}
ECONET_FAN_STATE_TO_HA = {
ThermostatFanMode.AUTO: FAN_AUTO,
@@ -209,7 +214,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
- async_create_issue(
+ create_issue(
self.hass,
DOMAIN,
"migrate_aux_heat",
@@ -223,7 +228,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
def turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
- async_create_issue(
+ create_issue(
self.hass,
DOMAIN,
"migrate_aux_heat",
diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json
index 86e3b3527f0..bc7505740d7 100644
--- a/homeassistant/components/econet/manifest.json
+++ b/homeassistant/components/econet/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/econet",
"iot_class": "cloud_push",
"loggers": ["paho_mqtt", "pyeconet"],
- "requirements": ["pyeconet==0.1.27"]
+ "requirements": ["pyeconet==0.1.28"]
}
diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json
index 33a251c22dc..79e0c34e4b9 100644
--- a/homeassistant/components/ecovacs/manifest.json
+++ b/homeassistant/components/ecovacs/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
- "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"]
+ "requirements": ["py-sucks==0.9.10", "deebot-client==12.1.0"]
}
diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json
index 723bdef17f8..44c51c7ae43 100644
--- a/homeassistant/components/ecovacs/strings.json
+++ b/homeassistant/components/ecovacs/strings.json
@@ -250,7 +250,7 @@
"message": "Params are required for the command: {command}"
},
"vacuum_raw_get_positions_not_supported": {
- "message": "Getting the positions of the chargers and the device itself is not supported"
+ "message": "Retrieving the positions of the chargers and the device itself is not supported"
}
},
"selector": {
@@ -264,7 +264,7 @@
"services": {
"raw_get_positions": {
"name": "Get raw positions",
- "description": "Get the raw response for the positions of the chargers and the device itself."
+ "description": "Retrieves a raw response containing the positions of the chargers and the device itself."
}
}
}
diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py
index 4359a314494..6e96fb388ee 100644
--- a/homeassistant/components/eheimdigital/coordinator.py
+++ b/homeassistant/components/eheimdigital/coordinator.py
@@ -2,16 +2,18 @@
from __future__ import annotations
+import asyncio
from collections.abc import Callable
from aiohttp import ClientError
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.hub import EheimDigitalHub
-from eheimdigital.types import EheimDeviceType
+from eheimdigital.types import EheimDeviceType, EheimDigitalClientError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -43,12 +45,14 @@ class EheimDigitalUpdateCoordinator(
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
)
+ self.main_device_added_event = asyncio.Event()
self.hub = EheimDigitalHub(
host=self.config_entry.data[CONF_HOST],
session=async_get_clientsession(hass),
loop=hass.loop,
receive_callback=self._async_receive_callback,
device_found_callback=self._async_device_found,
+ main_device_added_event=self.main_device_added_event,
)
self.known_devices: set[str] = set()
self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set()
@@ -76,8 +80,17 @@ class EheimDigitalUpdateCoordinator(
self.async_set_updated_data(self.hub.devices)
async def _async_setup(self) -> None:
- await self.hub.connect()
- await self.hub.update()
+ try:
+ await self.hub.connect()
+ async with asyncio.timeout(2):
+ # This event gets triggered when the first message is received from
+ # the device, it contains the data necessary to create the main device.
+ # This removes the race condition where the main device is accessed
+ # before the response from the device is parsed.
+ await self.main_device_added_event.wait()
+ await self.hub.update()
+ except (TimeoutError, EheimDigitalClientError) as err:
+ raise ConfigEntryNotReady from err
async def _async_update_data(self) -> dict[str, EheimDigitalDevice]:
try:
diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py
index b8697552626..98e49cc8056 100644
--- a/homeassistant/components/elmax/config_flow.py
+++ b/homeassistant/components/elmax/config_flow.py
@@ -498,7 +498,11 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle device found via zeroconf."""
- host = discovery_info.host
+ host = (
+ f"[{discovery_info.ip_address}]"
+ if discovery_info.ip_address.version == 6
+ else str(discovery_info.ip_address)
+ )
https_port = (
int(discovery_info.port)
if discovery_info.port is not None
diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json
index da3912a9d25..384dd3556a9 100644
--- a/homeassistant/components/emulated_kasa/manifest.json
+++ b/homeassistant/components/emulated_kasa/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
- "requirements": ["sense-energy==0.13.4"]
+ "requirements": ["sense-energy==0.13.5"]
}
diff --git a/homeassistant/components/enocean/entity.py b/homeassistant/components/enocean/entity.py
index 5c12fc12a68..b2d73e65443 100644
--- a/homeassistant/components/enocean/entity.py
+++ b/homeassistant/components/enocean/entity.py
@@ -16,7 +16,7 @@ class EnOceanEntity(Entity):
"""Initialize the device."""
self.dev_id = dev_id
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self.async_on_remove(
async_dispatcher_connect(
diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json
index 0b1fd8b04b9..e51a7427504 100644
--- a/homeassistant/components/enphase_envoy/manifest.json
+++ b/homeassistant/components/enphase_envoy/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
- "requirements": ["pyenphase==1.23.1"],
+ "requirements": ["pyenphase==1.25.1"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py
index 546470a19d5..42b47e5d793 100644
--- a/homeassistant/components/enphase_envoy/select.py
+++ b/homeassistant/components/enphase_envoy/select.py
@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
-from .entity import EnvoyBaseEntity
+from .entity import EnvoyBaseEntity, exception_handler
PARALLEL_UPDATES = 1
@@ -192,6 +192,7 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity):
"""Return the state of the Enpower switch."""
return self.entity_description.value_fn(self.relay)
+ @exception_handler
async def async_select_option(self, option: str) -> None:
"""Update the relay."""
await self.entity_description.update_fn(self.envoy, self.relay, option)
@@ -243,6 +244,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity):
assert self.data.tariff.storage_settings is not None
return self.entity_description.value_fn(self.data.tariff.storage_settings)
+ @exception_handler
async def async_select_option(self, option: str) -> None:
"""Update the relay."""
await self.entity_description.update_fn(self.envoy, option)
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
index 185f9ea5cf0..8f9f06e6967 100644
--- a/homeassistant/components/esphome/manifest.json
+++ b/homeassistant/components/esphome/manifest.json
@@ -16,7 +16,7 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
"requirements": [
- "aioesphomeapi==29.0.0",
+ "aioesphomeapi==29.0.2",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.7.1"
],
diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py
index bf5385b6f2a..0f30a29cfba 100644
--- a/homeassistant/components/fireservicerota/__init__.py
+++ b/homeassistant/components/fireservicerota/__init__.py
@@ -4,50 +4,43 @@ from __future__ import annotations
from datetime import timedelta
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN
-from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator
+from .coordinator import (
+ FireServiceConfigEntry,
+ FireServiceRotaClient,
+ FireServiceUpdateCoordinator,
+)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: FireServiceConfigEntry) -> bool:
"""Set up FireServiceRota from a config entry."""
- hass.data.setdefault(DOMAIN, {})
-
client = FireServiceRotaClient(hass, entry)
await client.setup()
if client.token_refresh_failure:
return False
+ entry.async_on_unload(client.async_stop_listener)
coordinator = FireServiceUpdateCoordinator(hass, client, entry)
await coordinator.async_config_entry_first_refresh()
- hass.data[DOMAIN][entry.entry_id] = {
- DATA_CLIENT: client,
- DATA_COORDINATOR: coordinator,
- }
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: FireServiceConfigEntry
+) -> bool:
"""Unload FireServiceRota config entry."""
-
- await hass.async_add_executor_job(
- hass.data[DOMAIN][entry.entry_id][DATA_CLIENT].websocket.stop_listener
- )
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- del hass.data[DOMAIN][entry.entry_id]
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py
index b8a542cf37c..be7add191c0 100644
--- a/homeassistant/components/fireservicerota/binary_sensor.py
+++ b/homeassistant/components/fireservicerota/binary_sensor.py
@@ -10,24 +10,22 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN
-from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator
+from .coordinator import (
+ FireServiceConfigEntry,
+ FireServiceRotaClient,
+ FireServiceUpdateCoordinator,
+)
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FireServiceConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up FireServiceRota binary sensor based on a config entry."""
- client: FireServiceRotaClient = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][
- DATA_CLIENT
- ]
-
- coordinator: FireServiceUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][
- entry.entry_id
- ][DATA_COORDINATOR]
+ coordinator = entry.runtime_data
+ client = coordinator.client
async_add_entities([ResponseBinarySensor(coordinator, client, entry)])
diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py
index 14a8c40e469..6815bf39104 100644
--- a/homeassistant/components/fireservicerota/coordinator.py
+++ b/homeassistant/components/fireservicerota/coordinator.py
@@ -28,12 +28,19 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
+type FireServiceConfigEntry = ConfigEntry[FireServiceUpdateCoordinator]
+
class FireServiceUpdateCoordinator(DataUpdateCoordinator[dict | None]):
"""Data update coordinator for FireServiceRota."""
+ config_entry: FireServiceConfigEntry
+
def __init__(
- self, hass: HomeAssistant, client: FireServiceRotaClient, entry: ConfigEntry
+ self,
+ hass: HomeAssistant,
+ client: FireServiceRotaClient,
+ entry: FireServiceConfigEntry,
) -> None:
"""Initialize the FireServiceRota DataUpdateCoordinator."""
super().__init__(
@@ -213,3 +220,7 @@ class FireServiceRotaClient:
)
await self.update_call(self.fsr.set_incident_response, self.incident_id, value)
+
+ async def async_stop_listener(self) -> None:
+ """Stop listener."""
+ await self._hass.async_add_executor_job(self.websocket.stop_listener)
diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py
index 682c7bcc0fd..5ed65609dc8 100644
--- a/homeassistant/components/fireservicerota/sensor.py
+++ b/homeassistant/components/fireservicerota/sensor.py
@@ -4,27 +4,24 @@ import logging
from typing import Any
from homeassistant.components.sensor import SensorEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
-from .const import DATA_CLIENT, DOMAIN as FIRESERVICEROTA_DOMAIN
-from .coordinator import FireServiceRotaClient
+from .const import DOMAIN as FIRESERVICEROTA_DOMAIN
+from .coordinator import FireServiceConfigEntry, FireServiceRotaClient
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FireServiceConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up FireServiceRota sensor based on a config entry."""
- client = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_CLIENT]
-
- async_add_entities([IncidentsSensor(client)])
+ async_add_entities([IncidentsSensor(entry.runtime_data.client)])
# pylint: disable-next=hass-invalid-inheritance # needs fixing
diff --git a/homeassistant/components/fireservicerota/switch.py b/homeassistant/components/fireservicerota/switch.py
index 602a02a8e4a..d9fe382e4b1 100644
--- a/homeassistant/components/fireservicerota/switch.py
+++ b/homeassistant/components/fireservicerota/switch.py
@@ -9,21 +9,24 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN
-from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator
+from .const import DOMAIN as FIRESERVICEROTA_DOMAIN
+from .coordinator import (
+ FireServiceConfigEntry,
+ FireServiceRotaClient,
+ FireServiceUpdateCoordinator,
+)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FireServiceConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up FireServiceRota switch based on a config entry."""
- client = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_CLIENT]
-
- coordinator = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_COORDINATOR]
+ coordinator = entry.runtime_data
+ client = coordinator.client
async_add_entities([ResponseSwitch(coordinator, client, entry)])
diff --git a/homeassistant/components/flexit_bacnet/binary_sensor.py b/homeassistant/components/flexit_bacnet/binary_sensor.py
index faee803e915..50c49f45e3e 100644
--- a/homeassistant/components/flexit_bacnet/binary_sensor.py
+++ b/homeassistant/components/flexit_bacnet/binary_sensor.py
@@ -47,6 +47,10 @@ async def async_setup_entry(
)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
class FlexitBinarySensor(FlexitEntity, BinarySensorEntity):
"""Representation of a Flexit binary Sensor."""
diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py
index abfa59d0a6d..7dc855e3106 100644
--- a/homeassistant/components/flexit_bacnet/climate.py
+++ b/homeassistant/components/flexit_bacnet/climate.py
@@ -25,6 +25,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
+ DOMAIN,
MAX_TEMP,
MIN_TEMP,
PRESET_TO_VENTILATION_MODE_MAP,
@@ -43,6 +44,9 @@ async def async_setup_entry(
async_add_entities([FlexitClimateEntity(config_entry.runtime_data)])
+PARALLEL_UPDATES = 1
+
+
class FlexitClimateEntity(FlexitEntity, ClimateEntity):
"""Flexit air handling unit."""
@@ -130,7 +134,13 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
try:
await self.device.set_ventilation_mode(ventilation_mode)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_preset_mode",
+ translation_placeholders={
+ "preset": str(ventilation_mode),
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
@@ -150,6 +160,12 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
else:
await self.device.set_ventilation_mode(VENTILATION_MODE_HOME)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_hvac_mode",
+ translation_placeholders={
+ "mode": str(hvac_mode),
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
diff --git a/homeassistant/components/flexit_bacnet/coordinator.py b/homeassistant/components/flexit_bacnet/coordinator.py
index da9415f2b87..9148ec87883 100644
--- a/homeassistant/components/flexit_bacnet/coordinator.py
+++ b/homeassistant/components/flexit_bacnet/coordinator.py
@@ -49,7 +49,11 @@ class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]):
await self.device.update()
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
raise ConfigEntryNotReady(
- f"Timeout while connecting to {self.config_entry.data[CONF_IP_ADDRESS]}"
+ translation_domain=DOMAIN,
+ translation_key="not_ready",
+ translation_placeholders={
+ "ip": str(self.config_entry.data[CONF_IP_ADDRESS]),
+ },
) from exc
return self.device
diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json
index 6f6b094c950..5ef3f11a7b7 100644
--- a/homeassistant/components/flexit_bacnet/manifest.json
+++ b/homeassistant/components/flexit_bacnet/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/flexit_bacnet",
"integration_type": "device",
"iot_class": "local_polling",
+ "quality_scale": "bronze",
"requirements": ["flexit_bacnet==2.2.3"]
}
diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py
index dfcfc193692..b8c329bd1d4 100644
--- a/homeassistant/components/flexit_bacnet/number.py
+++ b/homeassistant/components/flexit_bacnet/number.py
@@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from .const import DOMAIN
from .coordinator import FlexitConfigEntry, FlexitCoordinator
from .entity import FlexitEntity
@@ -205,6 +206,9 @@ async def async_setup_entry(
)
+PARALLEL_UPDATES = 1
+
+
class FlexitNumber(FlexitEntity, NumberEntity):
"""Representation of a Flexit Number."""
@@ -246,6 +250,12 @@ class FlexitNumber(FlexitEntity, NumberEntity):
try:
await set_native_value_fn(int(value))
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_value_error",
+ translation_placeholders={
+ "value": str(value),
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml
new file mode 100644
index 00000000000..eb649656c9d
--- /dev/null
+++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml
@@ -0,0 +1,91 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ Integration does not define custom actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not use any actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities don't subscribe to events explicitly
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup:
+ status: done
+ comment: |
+ Done implicitly with `await coordinator.async_config_entry_first_refresh()`.
+ unique-config-entry: done
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ Integration does not use options flow.
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: done
+ comment: |
+ Done implicitly with coordinator.
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ Done implicitly with coordinator.
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ Integration doesn't require any form of authentication.
+ test-coverage: todo
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: todo
+ entity-disabled-by-default: todo
+ discovery: todo
+ stale-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+ diagnostics: todo
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+ discovery-update-info: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This is not applicable for this integration.
+ docs-use-cases: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-data-update: done
+ docs-known-limitations: todo
+ docs-troubleshooting: todo
+ docs-examples: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: done
diff --git a/homeassistant/components/flexit_bacnet/sensor.py b/homeassistant/components/flexit_bacnet/sensor.py
index 23d8f20da36..0506b13892b 100644
--- a/homeassistant/components/flexit_bacnet/sensor.py
+++ b/homeassistant/components/flexit_bacnet/sensor.py
@@ -161,6 +161,10 @@ async def async_setup_entry(
)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
class FlexitSensor(FlexitEntity, SensorEntity):
"""Representation of a Flexit (bacnet) Sensor."""
diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json
index f7c54c88050..e9acbd46a37 100644
--- a/homeassistant/components/flexit_bacnet/strings.json
+++ b/homeassistant/components/flexit_bacnet/strings.json
@@ -5,6 +5,10 @@
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]",
"device_id": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "ip_address": "The IP address of the Flexit Nordic device",
+ "device_id": "The device ID of the Flexit Nordic device"
}
}
},
@@ -115,5 +119,22 @@
"name": "Cooker hood mode"
}
}
+ },
+ "exceptions": {
+ "set_value_error": {
+ "message": "Failed setting the value {value}."
+ },
+ "switch_turn": {
+ "message": "Failed to turn the switch {state}."
+ },
+ "set_preset_mode": {
+ "message": "Failed to set preset mode {preset}."
+ },
+ "set_hvac_mode": {
+ "message": "Failed to set HVAC mode {mode}."
+ },
+ "not_ready": {
+ "message": "Timeout while connecting to {ip}."
+ }
}
}
diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py
index 283d0e1ec3b..bdeff006181 100644
--- a/homeassistant/components/flexit_bacnet/switch.py
+++ b/homeassistant/components/flexit_bacnet/switch.py
@@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from .const import DOMAIN
from .coordinator import FlexitConfigEntry, FlexitCoordinator
from .entity import FlexitEntity
@@ -68,6 +69,9 @@ async def async_setup_entry(
)
+PARALLEL_UPDATES = 1
+
+
class FlexitSwitch(FlexitEntity, SwitchEntity):
"""Representation of a Flexit Switch."""
@@ -94,19 +98,31 @@ class FlexitSwitch(FlexitEntity, SwitchEntity):
return self.entity_description.is_on_fn(self.coordinator.data)
async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn electric heater on."""
+ """Turn switch on."""
try:
await self.entity_description.turn_on_fn(self.coordinator.data)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="switch_turn",
+ translation_placeholders={
+ "state": "on",
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn electric heater off."""
+ """Turn switch off."""
try:
await self.entity_description.turn_off_fn(self.coordinator.data)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="switch_turn",
+ translation_placeholders={
+ "state": "off",
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py
index b0cf8d04313..072afbae4f2 100644
--- a/homeassistant/components/flo/entity.py
+++ b/homeassistant/components/flo/entity.py
@@ -45,10 +45,10 @@ class FloEntity(Entity):
"""Return True if device is available."""
return self._device.available
- async def async_update(self):
+ async def async_update(self) -> None:
"""Update Flo entity."""
await self._device.async_request_refresh()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
self.async_on_remove(self._device.async_add_listener(self.async_write_ha_state))
diff --git a/homeassistant/components/folder_watcher/strings.json b/homeassistant/components/folder_watcher/strings.json
index da1e3c1962a..5b1f72bf254 100644
--- a/homeassistant/components/folder_watcher/strings.json
+++ b/homeassistant/components/folder_watcher/strings.json
@@ -36,11 +36,11 @@
"issues": {
"import_failed_not_allowed_path": {
"title": "The Folder Watcher YAML configuration could not be imported",
- "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in config.yaml and restart Home Assistant to import it and fix this issue."
+ "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in configuration.yaml and restart Home Assistant to import it and fix this issue."
},
"setup_not_allowed_path": {
"title": "The Folder Watcher configuration for {path} could not start",
- "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in config.yaml and restart Home Assistant to fix this issue."
+ "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in configuration.yaml and restart Home Assistant to fix this issue."
}
},
"entity": {
diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py
index 7a03a9075ed..2db0a75c429 100644
--- a/homeassistant/components/forked_daapd/coordinator.py
+++ b/homeassistant/components/forked_daapd/coordinator.py
@@ -3,8 +3,13 @@
from __future__ import annotations
import asyncio
+from collections.abc import Sequence
import logging
+from typing import Any
+from pyforked_daapd import ForkedDaapdAPI
+
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -26,15 +31,15 @@ WEBSOCKET_RECONNECT_TIME = 30 # seconds
class ForkedDaapdUpdater:
"""Manage updates for the forked-daapd device."""
- def __init__(self, hass, api, entry_id):
+ def __init__(self, hass: HomeAssistant, api: ForkedDaapdAPI, entry_id: str) -> None:
"""Initialize."""
self.hass = hass
self._api = api
- self.websocket_handler = None
- self._all_output_ids = set()
+ self.websocket_handler: asyncio.Task[None] | None = None
+ self._all_output_ids: set[str] = set()
self._entry_id = entry_id
- async def async_init(self):
+ async def async_init(self) -> None:
"""Perform async portion of class initialization."""
if not (server_config := await self._api.get_request("config")):
raise PlatformNotReady
@@ -51,7 +56,7 @@ class ForkedDaapdUpdater:
else:
_LOGGER.error("Invalid websocket port")
- async def _disconnected_callback(self):
+ async def _disconnected_callback(self) -> None:
"""Send update signals when the websocket gets disconnected."""
async_dispatcher_send(
self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), False
@@ -60,9 +65,9 @@ class ForkedDaapdUpdater:
self.hass, SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), []
)
- async def _update(self, update_types):
+ async def _update(self, update_types_sequence: Sequence[str]) -> None:
"""Private update method."""
- update_types = set(update_types)
+ update_types = set(update_types_sequence)
update_events = {}
_LOGGER.debug("Updating %s", update_types)
if (
@@ -127,8 +132,8 @@ class ForkedDaapdUpdater:
self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), True
)
- def _add_zones(self, outputs):
- outputs_to_add = []
+ def _add_zones(self, outputs: list[dict[str, Any]]) -> None:
+ outputs_to_add: list[dict[str, Any]] = []
for output in outputs:
if output["id"] not in self._all_output_ids:
self._all_output_ids.add(output["id"])
diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py
index 6bc69a64eaa..8cbf33460aa 100644
--- a/homeassistant/components/forked_daapd/media_player.py
+++ b/homeassistant/components/forked_daapd/media_player.py
@@ -85,9 +85,9 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up forked-daapd from a config entry."""
- host = config_entry.data[CONF_HOST]
- port = config_entry.data[CONF_PORT]
- password = config_entry.data[CONF_PASSWORD]
+ host: str = config_entry.data[CONF_HOST]
+ port: int = config_entry.data[CONF_PORT]
+ password: str = config_entry.data[CONF_PASSWORD]
forked_daapd_api = ForkedDaapdAPI(
async_get_clientsession(hass), host, port, password
)
@@ -95,8 +95,6 @@ async def async_setup_entry(
clientsession=async_get_clientsession(hass),
api=forked_daapd_api,
ip_address=host,
- api_port=port,
- api_password=password,
config_entry=config_entry,
)
@@ -240,9 +238,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
_attr_should_poll = False
- def __init__(
- self, clientsession, api, ip_address, api_port, api_password, config_entry
- ):
+ def __init__(self, clientsession, api, ip_address, config_entry):
"""Initialize the ForkedDaapd Master Device."""
# Leave the api public so the browse media helpers can use it
self.api = api
@@ -269,7 +265,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
self._on_remove = None
self._available = False
self._clientsession = clientsession
- self._config_entry = config_entry
+ self._entry_id = config_entry.entry_id
self.update_options(config_entry.options)
self._paused_event = asyncio.Event()
self._pause_requested = False
@@ -282,42 +278,42 @@ class ForkedDaapdMaster(MediaPlayerEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_PLAYER.format(self._config_entry.entry_id),
+ SIGNAL_UPDATE_PLAYER.format(self._entry_id),
self._update_player,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_QUEUE.format(self._config_entry.entry_id),
+ SIGNAL_UPDATE_QUEUE.format(self._entry_id),
self._update_queue,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_OUTPUTS.format(self._config_entry.entry_id),
+ SIGNAL_UPDATE_OUTPUTS.format(self._entry_id),
self._update_outputs,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_MASTER.format(self._config_entry.entry_id),
+ SIGNAL_UPDATE_MASTER.format(self._entry_id),
self._update_callback,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_CONFIG_OPTIONS_UPDATE.format(self._config_entry.entry_id),
+ SIGNAL_CONFIG_OPTIONS_UPDATE.format(self._entry_id),
self.update_options,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_DATABASE.format(self._config_entry.entry_id),
+ SIGNAL_UPDATE_DATABASE.format(self._entry_id),
self._update_database,
)
)
@@ -411,9 +407,9 @@ class ForkedDaapdMaster(MediaPlayerEntity):
self._track_info = defaultdict(str)
@property
- def unique_id(self):
+ def unique_id(self) -> str:
"""Return unique ID."""
- return self._config_entry.entry_id
+ return self._entry_id
@property
def available(self) -> bool:
diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json
index 2784e541809..03351e3238f 100644
--- a/homeassistant/components/foscam/strings.json
+++ b/homeassistant/components/foscam/strings.json
@@ -35,7 +35,7 @@
"services": {
"ptz": {
"name": "PTZ",
- "description": "Pan/Tilt action for Foscam camera.",
+ "description": "Moves a Foscam camera to a specified direction.",
"fields": {
"movement": {
"name": "Movement",
@@ -49,7 +49,7 @@
},
"ptz_preset": {
"name": "PTZ preset",
- "description": "PTZ Preset action for Foscam camera.",
+ "description": "Moves a Foscam camera to a predefined position.",
"fields": {
"preset_name": {
"name": "Preset name",
diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py
index 38d76c92871..d60232ec8ad 100644
--- a/homeassistant/components/fritz/coordinator.py
+++ b/homeassistant/components/fritz/coordinator.py
@@ -196,6 +196,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self.hass = hass
self.host = host
self.mesh_role = MeshRoles.NONE
+ self.mesh_wifi_uplink = False
self.device_conn_type: str | None = None
self.device_is_router: bool = False
self.password = password
@@ -610,6 +611,12 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
ssid=interf.get("ssid", ""),
type=interf["type"],
)
+
+ if interf["type"].lower() == "wlan" and interf[
+ "name"
+ ].lower().startswith("uplink"):
+ self.mesh_wifi_uplink = True
+
if dr.format_mac(int_mac) == self.mac:
self.mesh_role = MeshRoles(node["mesh_role"])
diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py
index 1548f8fc755..8b4816f7451 100644
--- a/homeassistant/components/fritz/switch.py
+++ b/homeassistant/components/fritz/switch.py
@@ -207,8 +207,9 @@ async def async_all_entities_list(
local_ip: str,
) -> list[Entity]:
"""Get a list of all entities."""
-
if avm_wrapper.mesh_role == MeshRoles.SLAVE:
+ if not avm_wrapper.mesh_wifi_uplink:
+ return [*await _async_wifi_entities_list(avm_wrapper, device_friendly_name)]
return []
return [
@@ -565,6 +566,9 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
self._attributes = {}
self._attr_entity_category = EntityCategory.CONFIG
+ self._attr_entity_registry_enabled_default = (
+ avm_wrapper.mesh_role is not MeshRoles.SLAVE
+ )
self._network_num = network_num
switch_info = SwitchInfo(
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index 912ce508e00..c8506335e16 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20250210.0"]
+ "requirements": ["home-assistant-frontend==20250214.0"]
}
diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py
index d55fe6e3ee6..e38c17008a5 100644
--- a/homeassistant/components/geo_json_events/__init__.py
+++ b/homeassistant/components/geo_json_events/__init__.py
@@ -4,25 +4,27 @@ from __future__ import annotations
import logging
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from .const import DOMAIN, PLATFORMS
-from .manager import GeoJsonFeedEntityManager
+from .const import PLATFORMS
+from .manager import GeoJsonConfigEntry, GeoJsonFeedEntityManager
_LOGGER = logging.getLogger(__name__)
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: GeoJsonConfigEntry
+) -> bool:
"""Set up the GeoJSON events component as config entry."""
- feeds = hass.data.setdefault(DOMAIN, {})
# Create feed entity manager for all platforms.
manager = GeoJsonFeedEntityManager(hass, config_entry)
- feeds[config_entry.entry_id] = manager
_LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id)
await remove_orphaned_entities(hass, config_entry.entry_id)
+
+ config_entry.runtime_data = manager
+ config_entry.async_on_unload(manager.async_stop)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
await manager.async_init()
return True
@@ -46,10 +48,6 @@ async def remove_orphaned_entities(hass: HomeAssistant, entry_id: str) -> None:
entity_registry.async_remove(entry.entity_id)
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: GeoJsonConfigEntry) -> bool:
"""Unload the GeoJSON events config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- manager: GeoJsonFeedEntityManager = hass.data[DOMAIN].pop(entry.entry_id)
- await manager.async_stop()
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py
index dce4aac1630..a119571a0ca 100644
--- a/homeassistant/components/geo_json_events/geo_location.py
+++ b/homeassistant/components/geo_json_events/geo_location.py
@@ -9,31 +9,24 @@ from typing import Any
from aio_geojson_generic_client.feed_entry import GenericFeedEntry
from homeassistant.components.geo_location import GeolocationEvent
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import GeoJsonFeedEntityManager
-from .const import (
- ATTR_EXTERNAL_ID,
- DOMAIN,
- SIGNAL_DELETE_ENTITY,
- SIGNAL_UPDATE_ENTITY,
- SOURCE,
-)
+from .const import ATTR_EXTERNAL_ID, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY, SOURCE
+from .manager import GeoJsonConfigEntry, GeoJsonFeedEntityManager
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: GeoJsonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the GeoJSON Events platform."""
- manager: GeoJsonFeedEntityManager = hass.data[DOMAIN][entry.entry_id]
+ manager = entry.runtime_data
@callback
def async_add_geolocation(
diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py
index deff15436a6..223d3bf571f 100644
--- a/homeassistant/components/geo_json_events/manager.py
+++ b/homeassistant/components/geo_json_events/manager.py
@@ -25,6 +25,8 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
+type GeoJsonConfigEntry = ConfigEntry[GeoJsonFeedEntityManager]
+
class GeoJsonFeedEntityManager:
"""Feed Entity Manager for GeoJSON feeds."""
diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py
index 4ea496f2824..a08d7554516 100644
--- a/homeassistant/components/google_assistant_sdk/__init__.py
+++ b/homeassistant/components/google_assistant_sdk/__init__.py
@@ -10,7 +10,7 @@ from google.oauth2.credentials import Credentials
import voluptuous as vol
from homeassistant.components import conversation
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform
from homeassistant.core import (
HomeAssistant,
@@ -99,12 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
hass.data[DOMAIN].pop(entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
for service_name in hass.services.async_services_for_domain(DOMAIN):
hass.services.async_remove(DOMAIN, service_name)
diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py
index 7fae5f18da5..8ef978568dc 100644
--- a/homeassistant/components/google_mail/__init__.py
+++ b/homeassistant/components/google_mail/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
@@ -59,12 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -
async def async_unload_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool:
"""Unload a config entry."""
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
for service_name in hass.services.async_services_for_domain(DOMAIN):
hass.services.async_remove(DOMAIN, service_name)
diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py
index faf1ff1ee0b..afafce816a9 100644
--- a/homeassistant/components/google_sheets/__init__.py
+++ b/homeassistant/components/google_sheets/__init__.py
@@ -12,7 +12,7 @@ from gspread.exceptions import APIError
from gspread.utils import ValueInputOption
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import (
@@ -81,12 +81,7 @@ async def async_unload_entry(
hass: HomeAssistant, entry: GoogleSheetsConfigEntry
) -> bool:
"""Unload a config entry."""
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
for service_name in hass.services.async_services_for_domain(DOMAIN):
hass.services.async_remove(DOMAIN, service_name)
diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py
index c1cbb4c0e5a..075c388c4e4 100644
--- a/homeassistant/components/guardian/__init__.py
+++ b/homeassistant/components/guardian/__init__.py
@@ -11,7 +11,7 @@ from aioguardian import Client
from aioguardian.errors import GuardianError
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
CONF_DEVICE_ID,
@@ -247,12 +247,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# If this is the last loaded instance of Guardian, deregister any services
# defined during integration setup:
for service_name in SERVICES:
diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py
index f1ade2cac44..1669f124bc7 100644
--- a/homeassistant/components/habitica/image.py
+++ b/homeassistant/components/habitica/image.py
@@ -43,7 +43,7 @@ class HabiticaImage(HabiticaBase, ImageEntity):
translation_key=HabiticaImageEntity.AVATAR,
)
_attr_content_type = "image/png"
- _current_appearance: Avatar | None = None
+ _avatar: Avatar | None = None
_cache: bytes | None = None
def __init__(
@@ -55,13 +55,13 @@ class HabiticaImage(HabiticaBase, ImageEntity):
super().__init__(coordinator, self.entity_description)
ImageEntity.__init__(self, hass)
self._attr_image_last_updated = dt_util.utcnow()
+ self._avatar = extract_avatar(self.coordinator.data.user)
def _handle_coordinator_update(self) -> None:
"""Check if equipped gear and other things have changed since last avatar image generation."""
- new_appearance = extract_avatar(self.coordinator.data.user)
- if self._current_appearance != new_appearance:
- self._current_appearance = new_appearance
+ if self._avatar != self.coordinator.data.user:
+ self._avatar = extract_avatar(self.coordinator.data.user)
self._attr_image_last_updated = dt_util.utcnow()
self._cache = None
@@ -69,8 +69,6 @@ class HabiticaImage(HabiticaBase, ImageEntity):
async def async_image(self) -> bytes | None:
"""Return cached bytes, otherwise generate new avatar."""
- if not self._cache and self._current_appearance:
- self._cache = await self.coordinator.generate_avatar(
- self._current_appearance
- )
+ if not self._cache and self._avatar:
+ self._cache = await self.coordinator.generate_avatar(self._avatar)
return self._cache
diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json
index a58bd1296e0..48b6997239e 100644
--- a/homeassistant/components/habitica/manifest.json
+++ b/homeassistant/components/habitica/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
+ "quality_scale": "platinum",
"requirements": ["habiticalib==0.3.7"]
}
diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml
index 9eadba496f2..1752e67cf46 100644
--- a/homeassistant/components/habitica/quality_scale.yaml
+++ b/homeassistant/components/habitica/quality_scale.yaml
@@ -51,7 +51,7 @@ rules:
status: exempt
comment: No supportable devices.
docs-supported-functions: done
- docs-troubleshooting: todo
+ docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py
index 7bbd3765602..4df1a2fa0e1 100644
--- a/homeassistant/components/heos/__init__.py
+++ b/homeassistant/components/heos/__init__.py
@@ -69,3 +69,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_remove_config_entry_device(
+ hass: HomeAssistant, entry: HeosConfigEntry, device: dr.DeviceEntry
+) -> bool:
+ """Remove config entry from device if no longer present."""
+ return not any(
+ (domain, key)
+ for domain, key in device.identifiers
+ if domain == DOMAIN and int(key) in entry.runtime_data.heos.players
+ )
diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py
index 94aa4ad0ab5..0303d150794 100644
--- a/homeassistant/components/heos/coordinator.py
+++ b/homeassistant/components/heos/coordinator.py
@@ -16,6 +16,7 @@ from pyheos import (
HeosError,
HeosNowPlayingMedia,
HeosOptions,
+ HeosPlayer,
MediaItem,
MediaType,
PlayerUpdateResult,
@@ -58,6 +59,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
credentials=credentials,
)
)
+ self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = []
self._update_sources_pending: bool = False
self._source_list: list[str] = []
self._favorites: dict[int, MediaItem] = {}
@@ -124,6 +126,27 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
self.async_update_listeners()
return remove_listener
+ def async_add_platform_callback(
+ self, add_entities_callback: Callable[[Sequence[HeosPlayer]], None]
+ ) -> None:
+ """Add a callback to add entities for a platform."""
+ self._platform_callbacks.append(add_entities_callback)
+
+ def _async_handle_player_update_result(
+ self, update_result: PlayerUpdateResult
+ ) -> None:
+ """Handle a player update result."""
+ if update_result.added_player_ids and self._platform_callbacks:
+ new_players = [
+ self.heos.players[player_id]
+ for player_id in update_result.added_player_ids
+ ]
+ for add_entities_callback in self._platform_callbacks:
+ add_entities_callback(new_players)
+
+ if update_result.updated_player_ids:
+ self._async_update_player_ids(update_result.updated_player_ids)
+
async def _async_on_auth_failure(self) -> None:
"""Handle when the user credentials are no longer valid."""
assert self.config_entry is not None
@@ -147,8 +170,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
"""Handle a controller event, such as players or groups changed."""
if event == const.EVENT_PLAYERS_CHANGED:
assert data is not None
- if data.updated_player_ids:
- self._async_update_player_ids(data.updated_player_ids)
+ self._async_handle_player_update_result(data)
elif (
event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED)
and not self._update_sources_pending
@@ -242,9 +264,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
except HeosError as error:
_LOGGER.error("Unable to refresh players: %s", error)
return
- # After reconnecting, player_id may have changed
- if player_updates.updated_player_ids:
- self._async_update_player_ids(player_updates.updated_player_ids)
+ self._async_handle_player_update_result(player_updates)
@callback
def async_get_source_list(self) -> list[str]:
diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py
index 4dbaead67a7..b9aa05810e5 100644
--- a/homeassistant/components/heos/media_player.py
+++ b/homeassistant/components/heos/media_player.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from collections.abc import Awaitable, Callable, Coroutine
+from collections.abc import Awaitable, Callable, Coroutine, Sequence
from datetime import datetime
from functools import reduce, wraps
from operator import ior
@@ -93,11 +93,16 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add media players for a config entry."""
- devices = [
- HeosMediaPlayer(entry.runtime_data, player)
- for player in entry.runtime_data.heos.players.values()
- ]
- async_add_entities(devices)
+
+ def add_entities_callback(players: Sequence[HeosPlayer]) -> None:
+ """Add entities for each player."""
+ async_add_entities(
+ [HeosMediaPlayer(entry.runtime_data, player) for player in players]
+ )
+
+ coordinator = entry.runtime_data
+ coordinator.async_add_platform_callback(add_entities_callback)
+ add_entities_callback(list(coordinator.heos.players.values()))
type _FuncType[**_P] = Callable[_P, Awaitable[Any]]
diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml
index 67022ec492c..6ade4e6ffb9 100644
--- a/homeassistant/components/heos/quality_scale.yaml
+++ b/homeassistant/components/heos/quality_scale.yaml
@@ -49,7 +49,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
- dynamic-devices: todo
+ dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -57,8 +57,8 @@ rules:
exception-translations: done
icon-translations: done
reconfiguration-flow: done
- repair-issues: todo
- stale-devices: todo
+ repair-issues: done
+ stale-devices: done
# Platinum
async-dependency: done
inject-websession:
diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json
index f68478516ab..712ccf09cae 100644
--- a/homeassistant/components/hive/manifest.json
+++ b/homeassistant/components/hive/manifest.json
@@ -9,5 +9,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"],
- "requirements": ["pyhive-integration==1.0.1"]
+ "requirements": ["pyhive-integration==1.0.2"]
}
diff --git a/homeassistant/components/hlk_sw16/entity.py b/homeassistant/components/hlk_sw16/entity.py
index fdef5f6764b..91510760968 100644
--- a/homeassistant/components/hlk_sw16/entity.py
+++ b/homeassistant/components/hlk_sw16/entity.py
@@ -35,7 +35,7 @@ class SW16Entity(Entity):
self.async_write_ha_state()
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if entity is available."""
return bool(self._client.is_connected)
@@ -44,7 +44,7 @@ class SW16Entity(Entity):
"""Update availability state."""
self.async_write_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register update callback."""
self._client.register_status_callback(
self.handle_event_callback, self._device_port
diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py
index becc78cef90..a020b2370b9 100644
--- a/homeassistant/components/home_connect/__init__.py
+++ b/homeassistant/components/home_connect/__init__.py
@@ -2,11 +2,19 @@
from __future__ import annotations
+from collections.abc import Awaitable
import logging
from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient
-from aiohomeconnect.model import CommandKey, Option, OptionKey, ProgramKey, SettingKey
+from aiohomeconnect.model import (
+ ArrayOfOptions,
+ CommandKey,
+ Option,
+ OptionKey,
+ ProgramKey,
+ SettingKey,
+)
from aiohomeconnect.model.error import HomeConnectError
import voluptuous as vol
@@ -19,34 +27,74 @@ from homeassistant.helpers import (
device_registry as dr,
)
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
+from homeassistant.helpers.issue_registry import (
+ IssueSeverity,
+ async_create_issue,
+ async_delete_issue,
+)
from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth
from .const import (
+ AFFECTS_TO_ACTIVE_PROGRAM,
+ AFFECTS_TO_SELECTED_PROGRAM,
+ ATTR_AFFECTS_TO,
ATTR_KEY,
ATTR_PROGRAM,
ATTR_UNIT,
ATTR_VALUE,
DOMAIN,
OLD_NEW_UNIQUE_ID_SUFFIX_MAP,
+ PROGRAM_ENUM_OPTIONS,
SERVICE_OPTION_ACTIVE,
SERVICE_OPTION_SELECTED,
SERVICE_PAUSE_PROGRAM,
SERVICE_RESUME_PROGRAM,
SERVICE_SELECT_PROGRAM,
+ SERVICE_SET_PROGRAM_AND_OPTIONS,
SERVICE_SETTING,
SERVICE_START_PROGRAM,
SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
+ TRANSLATION_KEYS_PROGRAMS_MAP,
)
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
-from .utils import get_dict_from_home_connect_error
+from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+PROGRAM_OPTIONS = {
+ bsh_key_to_translation_key(key): (
+ key,
+ value,
+ )
+ for key, value in {
+ OptionKey.BSH_COMMON_DURATION: int,
+ OptionKey.BSH_COMMON_START_IN_RELATIVE: int,
+ OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int,
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int,
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool,
+ OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool,
+ OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool,
+ OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool,
+ OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool,
+ OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool,
+ OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool,
+ OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool,
+ OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool,
+ OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool,
+ OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int,
+ OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
+ OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
+ OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,
+ }.items()
+}
+
+
SERVICE_SETTING_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
@@ -58,6 +106,7 @@ SERVICE_SETTING_SCHEMA = vol.Schema(
}
)
+# DEPRECATED: Remove in 2025.9.0
SERVICE_OPTION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
@@ -70,6 +119,7 @@ SERVICE_OPTION_SCHEMA = vol.Schema(
}
)
+# DEPRECATED: Remove in 2025.9.0
SERVICE_PROGRAM_SCHEMA = vol.Any(
{
vol.Required(ATTR_DEVICE_ID): str,
@@ -93,6 +143,46 @@ SERVICE_PROGRAM_SCHEMA = vol.Any(
},
)
+
+def _require_program_or_at_least_one_option(data: dict) -> dict:
+ if ATTR_PROGRAM not in data and not any(
+ option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS)
+ ):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="required_program_or_one_option_at_least",
+ )
+ return data
+
+
+SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All(
+ vol.Schema(
+ {
+ vol.Required(ATTR_DEVICE_ID): str,
+ vol.Required(ATTR_AFFECTS_TO): vol.In(
+ [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM]
+ ),
+ vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()),
+ }
+ )
+ .extend(
+ {
+ vol.Optional(translation_key): vol.In(allowed_values.keys())
+ for translation_key, (
+ key,
+ allowed_values,
+ ) in PROGRAM_ENUM_OPTIONS.items()
+ }
+ )
+ .extend(
+ {
+ vol.Optional(translation_key): schema
+ for translation_key, (key, schema) in PROGRAM_OPTIONS.items()
+ }
+ ),
+ _require_program_or_at_least_one_option,
+)
+
SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
PLATFORMS = [
@@ -144,7 +234,7 @@ async def _get_client_and_ha_id(
return entry.runtime_data.client, ha_id
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
"""Set up Home Connect component."""
async def _async_service_program(call: ServiceCall, start: bool):
@@ -165,6 +255,57 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
else None
)
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_set_program_and_option_actions",
+ breaks_in_ha_version="2025.9.0",
+ is_fixable=True,
+ is_persistent=True,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_set_program_and_option_actions",
+ translation_placeholders={
+ "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS,
+ "remove_release": "2025.9.0",
+ "deprecated_action_yaml": "\n".join(
+ [
+ "```yaml",
+ f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}",
+ "data:",
+ f" {ATTR_DEVICE_ID}: DEVICE_ID",
+ f" {ATTR_PROGRAM}: {program}",
+ *([f" {ATTR_KEY}: {options[0].key}"] if options else []),
+ *([f" {ATTR_VALUE}: {options[0].value}"] if options else []),
+ *(
+ [f" {ATTR_UNIT}: {options[0].unit}"]
+ if options and options[0].unit
+ else []
+ ),
+ "```",
+ ]
+ ),
+ "new_action_yaml": "\n ".join(
+ [
+ "```yaml",
+ f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}",
+ "data:",
+ f" {ATTR_DEVICE_ID}: DEVICE_ID",
+ f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}",
+ f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}",
+ *(
+ [
+ f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}"
+ ]
+ if options
+ else []
+ ),
+ "```",
+ ]
+ ),
+ "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)",
+ },
+ )
+
try:
if start:
await client.start_program(ha_id, program_key=program, options=options)
@@ -189,6 +330,44 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
unit = call.data.get(ATTR_UNIT)
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_set_program_and_option_actions",
+ breaks_in_ha_version="2025.9.0",
+ is_fixable=True,
+ is_persistent=True,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_set_program_and_option_actions",
+ translation_placeholders={
+ "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS,
+ "remove_release": "2025.9.0",
+ "deprecated_action_yaml": "\n".join(
+ [
+ "```yaml",
+ f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}",
+ "data:",
+ f" {ATTR_DEVICE_ID}: DEVICE_ID",
+ f" {ATTR_KEY}: {option_key}",
+ f" {ATTR_VALUE}: {value}",
+ *([f" {ATTR_UNIT}: {unit}"] if unit else []),
+ "```",
+ ]
+ ),
+ "new_action_yaml": "\n ".join(
+ [
+ "```yaml",
+ f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}",
+ "data:",
+ f" {ATTR_DEVICE_ID}: DEVICE_ID",
+ f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}",
+ f" {bsh_key_to_translation_key(option_key)}: {value}",
+ "```",
+ ]
+ ),
+ "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)",
+ },
+ )
try:
if active:
await client.set_active_program_option(
@@ -272,6 +451,76 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Service for selecting a program."""
await _async_service_program(call, False)
+ async def async_service_set_program_and_options(call: ServiceCall):
+ """Service for setting a program and options."""
+ data = dict(call.data)
+ program = data.pop(ATTR_PROGRAM, None)
+ affects_to = data.pop(ATTR_AFFECTS_TO)
+ client, ha_id = await _get_client_and_ha_id(hass, data.pop(ATTR_DEVICE_ID))
+
+ options: list[Option] = []
+
+ for option, value in data.items():
+ if option in PROGRAM_ENUM_OPTIONS:
+ options.append(
+ Option(
+ PROGRAM_ENUM_OPTIONS[option][0],
+ PROGRAM_ENUM_OPTIONS[option][1][value],
+ )
+ )
+ elif option in PROGRAM_OPTIONS:
+ option_key = PROGRAM_OPTIONS[option][0]
+ options.append(Option(option_key, value))
+
+ method_call: Awaitable[Any]
+ exception_translation_key: str
+ if program:
+ program = (
+ program
+ if isinstance(program, ProgramKey)
+ else TRANSLATION_KEYS_PROGRAMS_MAP[program]
+ )
+
+ if affects_to == AFFECTS_TO_ACTIVE_PROGRAM:
+ method_call = client.start_program(
+ ha_id, program_key=program, options=options
+ )
+ exception_translation_key = "start_program"
+ elif affects_to == AFFECTS_TO_SELECTED_PROGRAM:
+ method_call = client.set_selected_program(
+ ha_id, program_key=program, options=options
+ )
+ exception_translation_key = "select_program"
+ else:
+ array_of_options = ArrayOfOptions(options)
+ if affects_to == AFFECTS_TO_ACTIVE_PROGRAM:
+ method_call = client.set_active_program_options(
+ ha_id, array_of_options=array_of_options
+ )
+ exception_translation_key = "set_options_active_program"
+ else:
+ # affects_to is AFFECTS_TO_SELECTED_PROGRAM
+ method_call = client.set_selected_program_options(
+ ha_id, array_of_options=array_of_options
+ )
+ exception_translation_key = "set_options_selected_program"
+
+ try:
+ await method_call
+ except HomeConnectError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=exception_translation_key,
+ translation_placeholders={
+ **get_dict_from_home_connect_error(err),
+ **(
+ {SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program}
+ if program
+ else {}
+ ),
+ },
+ ) from err
+
async def async_service_start_program(call: ServiceCall):
"""Service for starting a program."""
await _async_service_program(call, True)
@@ -315,6 +564,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_service_start_program,
schema=SERVICE_PROGRAM_SCHEMA,
)
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SET_PROGRAM_AND_OPTIONS,
+ async_service_set_program_and_options,
+ schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA,
+ )
return True
@@ -349,6 +604,7 @@ async def async_unload_entry(
hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> bool:
"""Unload a config entry."""
+ async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions")
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py
index 127aa1ffe92..3a22297ebee 100644
--- a/homeassistant/components/home_connect/const.py
+++ b/homeassistant/components/home_connect/const.py
@@ -1,6 +1,10 @@
"""Constants for the Home Connect integration."""
-from aiohomeconnect.model import EventKey, SettingKey, StatusKey
+from typing import cast
+
+from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey
+
+from .utils import bsh_key_to_translation_key
DOMAIN = "home_connect"
@@ -52,15 +56,18 @@ SERVICE_OPTION_SELECTED = "set_option_selected"
SERVICE_PAUSE_PROGRAM = "pause_program"
SERVICE_RESUME_PROGRAM = "resume_program"
SERVICE_SELECT_PROGRAM = "select_program"
+SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options"
SERVICE_SETTING = "change_setting"
SERVICE_START_PROGRAM = "start_program"
-
+ATTR_AFFECTS_TO = "affects_to"
ATTR_KEY = "key"
ATTR_PROGRAM = "program"
ATTR_UNIT = "unit"
ATTR_VALUE = "value"
+AFFECTS_TO_ACTIVE_PROGRAM = "active_program"
+AFFECTS_TO_SELECTED_PROGRAM = "selected_program"
SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity"
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name"
@@ -70,6 +77,269 @@ SVE_TRANSLATION_PLACEHOLDER_KEY = "key"
SVE_TRANSLATION_PLACEHOLDER_VALUE = "value"
+TRANSLATION_KEYS_PROGRAMS_MAP = {
+ bsh_key_to_translation_key(program.value): cast(ProgramKey, program)
+ for program in ProgramKey
+ if program != ProgramKey.UNKNOWN
+}
+
+PROGRAMS_TRANSLATION_KEYS_MAP = {
+ value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
+}
+
+REFERENCE_MAP_ID_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap",
+ "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map1",
+ "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map2",
+ "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map3",
+ )
+}
+
+CLEANING_MODE_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Silent",
+ "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Standard",
+ "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Power",
+ )
+}
+
+BEAN_AMOUNT_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryMild",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Mild",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.MildPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.NormalPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Strong",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.StrongPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrong",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrongPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.ExtraStrong",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShot",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlusPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShot",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.CoffeeGround",
+ )
+}
+
+COFFEE_TEMPERATURE_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.88C",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.90C",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.92C",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.94C",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.95C",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.96C",
+ )
+}
+
+BEAN_CONTAINER_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Right",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Left",
+ )
+}
+
+FLOW_RATE_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Normal",
+ "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Intense",
+ "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.IntensePlus",
+ )
+}
+
+COFFEE_MILK_RATIO_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.10Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.20Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.25Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.30Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.40Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.55Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.60Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.65Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.67Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.70Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.75Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.80Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.85Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.90Percent",
+ )
+}
+
+HOT_WATER_TEMPERATURE_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.WhiteTea",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.GreenTea",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.BlackTea",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.50C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.55C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.60C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.65C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.70C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.75C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.80C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.85C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.90C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.95C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.97C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.122F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.131F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.140F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.149F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.158F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.167F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.176F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.185F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.194F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.203F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.Max",
+ )
+}
+
+DRYING_TARGET_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "LaundryCare.Dryer.EnumType.DryingTarget.IronDry",
+ "LaundryCare.Dryer.EnumType.DryingTarget.GentleDry",
+ "LaundryCare.Dryer.EnumType.DryingTarget.CupboardDry",
+ "LaundryCare.Dryer.EnumType.DryingTarget.CupboardDryPlus",
+ "LaundryCare.Dryer.EnumType.DryingTarget.ExtraDry",
+ )
+}
+
+VENTING_LEVEL_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "Cooking.Hood.EnumType.Stage.FanOff",
+ "Cooking.Hood.EnumType.Stage.FanStage01",
+ "Cooking.Hood.EnumType.Stage.FanStage02",
+ "Cooking.Hood.EnumType.Stage.FanStage03",
+ "Cooking.Hood.EnumType.Stage.FanStage04",
+ "Cooking.Hood.EnumType.Stage.FanStage05",
+ )
+}
+
+INTENSIVE_LEVEL_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff",
+ "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage1",
+ "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage2",
+ )
+}
+
+WARMING_LEVEL_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "Cooking.Oven.EnumType.WarmingLevel.Low",
+ "Cooking.Oven.EnumType.WarmingLevel.Medium",
+ "Cooking.Oven.EnumType.WarmingLevel.High",
+ )
+}
+
+TEMPERATURE_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "LaundryCare.Washer.EnumType.Temperature.Cold",
+ "LaundryCare.Washer.EnumType.Temperature.GC20",
+ "LaundryCare.Washer.EnumType.Temperature.GC30",
+ "LaundryCare.Washer.EnumType.Temperature.GC40",
+ "LaundryCare.Washer.EnumType.Temperature.GC50",
+ "LaundryCare.Washer.EnumType.Temperature.GC60",
+ "LaundryCare.Washer.EnumType.Temperature.GC70",
+ "LaundryCare.Washer.EnumType.Temperature.GC80",
+ "LaundryCare.Washer.EnumType.Temperature.GC90",
+ "LaundryCare.Washer.EnumType.Temperature.UlCold",
+ "LaundryCare.Washer.EnumType.Temperature.UlWarm",
+ "LaundryCare.Washer.EnumType.Temperature.UlHot",
+ "LaundryCare.Washer.EnumType.Temperature.UlExtraHot",
+ )
+}
+
+SPIN_SPEED_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "LaundryCare.Washer.EnumType.SpinSpeed.Off",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM400",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM600",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM800",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM1200",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM1400",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM1600",
+ "LaundryCare.Washer.EnumType.SpinSpeed.UlOff",
+ "LaundryCare.Washer.EnumType.SpinSpeed.UlLow",
+ "LaundryCare.Washer.EnumType.SpinSpeed.UlMedium",
+ "LaundryCare.Washer.EnumType.SpinSpeed.UlHigh",
+ )
+}
+
+VARIO_PERFECT_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "LaundryCare.Common.EnumType.VarioPerfect.Off",
+ "LaundryCare.Common.EnumType.VarioPerfect.EcoPerfect",
+ "LaundryCare.Common.EnumType.VarioPerfect.SpeedPerfect",
+ )
+}
+
+
+PROGRAM_ENUM_OPTIONS = {
+ bsh_key_to_translation_key(option_key): (
+ option_key,
+ options,
+ )
+ for option_key, options in (
+ (
+ OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
+ REFERENCE_MAP_ID_OPTIONS,
+ ),
+ (
+ OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,
+ CLEANING_MODE_OPTIONS,
+ ),
+ (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, BEAN_AMOUNT_OPTIONS),
+ (
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE,
+ COFFEE_TEMPERATURE_OPTIONS,
+ ),
+ (
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION,
+ BEAN_CONTAINER_OPTIONS,
+ ),
+ (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, FLOW_RATE_OPTIONS),
+ (
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO,
+ COFFEE_MILK_RATIO_OPTIONS,
+ ),
+ (
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE,
+ HOT_WATER_TEMPERATURE_OPTIONS,
+ ),
+ (OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, DRYING_TARGET_OPTIONS),
+ (OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, VENTING_LEVEL_OPTIONS),
+ (OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, INTENSIVE_LEVEL_OPTIONS),
+ (OptionKey.COOKING_OVEN_WARMING_LEVEL, WARMING_LEVEL_OPTIONS),
+ (OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, TEMPERATURE_OPTIONS),
+ (OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, SPIN_SPEED_OPTIONS),
+ (OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, VARIO_PERFECT_OPTIONS),
+ )
+}
+
+
OLD_NEW_UNIQUE_ID_SUFFIX_MAP = {
"ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK,
"Operation State": StatusKey.BSH_COMMON_OPERATION_STATE,
diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json
index 166b2fe2c34..6b604fc004e 100644
--- a/homeassistant/components/home_connect/icons.json
+++ b/homeassistant/components/home_connect/icons.json
@@ -18,6 +18,9 @@
"set_option_selected": {
"service": "mdi:gesture-tap"
},
+ "set_program_and_options": {
+ "service": "mdi:form-select"
+ },
"change_setting": {
"service": "mdi:cog"
}
diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json
index 94085af2fc3..06325afaed8 100644
--- a/homeassistant/components/home_connect/manifest.json
+++ b/homeassistant/components/home_connect/manifest.json
@@ -3,7 +3,7 @@
"name": "Home Connect",
"codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"],
"config_flow": true,
- "dependencies": ["application_credentials"],
+ "dependencies": ["application_credentials", "repairs"],
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py
index 165842abf1c..bc281e3d928 100644
--- a/homeassistant/components/home_connect/select.py
+++ b/homeassistant/components/home_connect/select.py
@@ -15,24 +15,20 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
-from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
+from .const import (
+ APPLIANCES_WITH_PROGRAMS,
+ DOMAIN,
+ PROGRAMS_TRANSLATION_KEYS_MAP,
+ SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
+ TRANSLATION_KEYS_PROGRAMS_MAP,
+)
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity
-from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
-
-TRANSLATION_KEYS_PROGRAMS_MAP = {
- bsh_key_to_translation_key(program.value): cast(ProgramKey, program)
- for program in ProgramKey
- if program != ProgramKey.UNKNOWN
-}
-
-PROGRAMS_TRANSLATION_KEYS_MAP = {
- value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
-}
+from .utils import get_dict_from_home_connect_error
@dataclass(frozen=True, kw_only=True)
diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml
index 0738b58595a..91b0089d653 100644
--- a/homeassistant/components/home_connect/services.yaml
+++ b/homeassistant/components/home_connect/services.yaml
@@ -46,6 +46,558 @@ select_program:
example: "seconds"
selector:
text:
+set_program_and_options:
+ fields:
+ device_id:
+ required: true
+ selector:
+ device:
+ integration: home_connect
+ affects_to:
+ example: active_program
+ required: true
+ selector:
+ select:
+ translation_key: affects_to
+ options:
+ - active_program
+ - selected_program
+ program:
+ example: dishcare_dishwasher_program_auto2
+ required: true
+ selector:
+ select:
+ mode: dropdown
+ custom_value: false
+ translation_key: programs
+ options:
+ - consumer_products_cleaning_robot_program_cleaning_clean_all
+ - consumer_products_cleaning_robot_program_cleaning_clean_map
+ - consumer_products_cleaning_robot_program_basic_go_home
+ - consumer_products_coffee_maker_program_beverage_ristretto
+ - consumer_products_coffee_maker_program_beverage_espresso
+ - consumer_products_coffee_maker_program_beverage_espresso_doppio
+ - consumer_products_coffee_maker_program_beverage_coffee
+ - consumer_products_coffee_maker_program_beverage_x_l_coffee
+ - consumer_products_coffee_maker_program_beverage_caffe_grande
+ - consumer_products_coffee_maker_program_beverage_espresso_macchiato
+ - consumer_products_coffee_maker_program_beverage_cappuccino
+ - consumer_products_coffee_maker_program_beverage_latte_macchiato
+ - consumer_products_coffee_maker_program_beverage_caffe_latte
+ - consumer_products_coffee_maker_program_beverage_milk_froth
+ - consumer_products_coffee_maker_program_beverage_warm_milk
+ - consumer_products_coffee_maker_program_coffee_world_kleiner_brauner
+ - consumer_products_coffee_maker_program_coffee_world_grosser_brauner
+ - consumer_products_coffee_maker_program_coffee_world_verlaengerter
+ - consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun
+ - consumer_products_coffee_maker_program_coffee_world_wiener_melange
+ - consumer_products_coffee_maker_program_coffee_world_flat_white
+ - consumer_products_coffee_maker_program_coffee_world_cortado
+ - consumer_products_coffee_maker_program_coffee_world_cafe_cortado
+ - consumer_products_coffee_maker_program_coffee_world_cafe_con_leche
+ - consumer_products_coffee_maker_program_coffee_world_cafe_au_lait
+ - consumer_products_coffee_maker_program_coffee_world_doppio
+ - consumer_products_coffee_maker_program_coffee_world_kaapi
+ - consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd
+ - consumer_products_coffee_maker_program_coffee_world_galao
+ - consumer_products_coffee_maker_program_coffee_world_garoto
+ - consumer_products_coffee_maker_program_coffee_world_americano
+ - consumer_products_coffee_maker_program_coffee_world_red_eye
+ - consumer_products_coffee_maker_program_coffee_world_black_eye
+ - consumer_products_coffee_maker_program_coffee_world_dead_eye
+ - consumer_products_coffee_maker_program_beverage_hot_water
+ - dishcare_dishwasher_program_pre_rinse
+ - dishcare_dishwasher_program_auto_1
+ - dishcare_dishwasher_program_auto_2
+ - dishcare_dishwasher_program_auto_3
+ - dishcare_dishwasher_program_eco_50
+ - dishcare_dishwasher_program_quick_45
+ - dishcare_dishwasher_program_intensiv_70
+ - dishcare_dishwasher_program_normal_65
+ - dishcare_dishwasher_program_glas_40
+ - dishcare_dishwasher_program_glass_care
+ - dishcare_dishwasher_program_night_wash
+ - dishcare_dishwasher_program_quick_65
+ - dishcare_dishwasher_program_normal_45
+ - dishcare_dishwasher_program_intensiv_45
+ - dishcare_dishwasher_program_auto_half_load
+ - dishcare_dishwasher_program_intensiv_power
+ - dishcare_dishwasher_program_magic_daily
+ - dishcare_dishwasher_program_super_60
+ - dishcare_dishwasher_program_kurz_60
+ - dishcare_dishwasher_program_express_sparkle_65
+ - dishcare_dishwasher_program_machine_care
+ - dishcare_dishwasher_program_steam_fresh
+ - dishcare_dishwasher_program_maximum_cleaning
+ - dishcare_dishwasher_program_mixed_load
+ - laundry_care_dryer_program_cotton
+ - laundry_care_dryer_program_synthetic
+ - laundry_care_dryer_program_mix
+ - laundry_care_dryer_program_blankets
+ - laundry_care_dryer_program_business_shirts
+ - laundry_care_dryer_program_down_feathers
+ - laundry_care_dryer_program_hygiene
+ - laundry_care_dryer_program_jeans
+ - laundry_care_dryer_program_outdoor
+ - laundry_care_dryer_program_synthetic_refresh
+ - laundry_care_dryer_program_towels
+ - laundry_care_dryer_program_delicates
+ - laundry_care_dryer_program_super_40
+ - laundry_care_dryer_program_shirts_15
+ - laundry_care_dryer_program_pillow
+ - laundry_care_dryer_program_anti_shrink
+ - laundry_care_dryer_program_my_time_my_drying_time
+ - laundry_care_dryer_program_time_cold
+ - laundry_care_dryer_program_time_warm
+ - laundry_care_dryer_program_in_basket
+ - laundry_care_dryer_program_time_cold_fix_time_cold_20
+ - laundry_care_dryer_program_time_cold_fix_time_cold_30
+ - laundry_care_dryer_program_time_cold_fix_time_cold_60
+ - laundry_care_dryer_program_time_warm_fix_time_warm_30
+ - laundry_care_dryer_program_time_warm_fix_time_warm_40
+ - laundry_care_dryer_program_time_warm_fix_time_warm_60
+ - laundry_care_dryer_program_dessous
+ - cooking_common_program_hood_automatic
+ - cooking_common_program_hood_venting
+ - cooking_common_program_hood_delayed_shut_off
+ - cooking_oven_program_heating_mode_pre_heating
+ - cooking_oven_program_heating_mode_hot_air
+ - cooking_oven_program_heating_mode_hot_air_eco
+ - cooking_oven_program_heating_mode_hot_air_grilling
+ - cooking_oven_program_heating_mode_top_bottom_heating
+ - cooking_oven_program_heating_mode_top_bottom_heating_eco
+ - cooking_oven_program_heating_mode_bottom_heating
+ - cooking_oven_program_heating_mode_pizza_setting
+ - cooking_oven_program_heating_mode_slow_cook
+ - cooking_oven_program_heating_mode_intensive_heat
+ - cooking_oven_program_heating_mode_keep_warm
+ - cooking_oven_program_heating_mode_preheat_ovenware
+ - cooking_oven_program_heating_mode_frozen_heatup_special
+ - cooking_oven_program_heating_mode_desiccation
+ - cooking_oven_program_heating_mode_defrost
+ - cooking_oven_program_heating_mode_proof
+ - cooking_oven_program_heating_mode_hot_air_30_steam
+ - cooking_oven_program_heating_mode_hot_air_60_steam
+ - cooking_oven_program_heating_mode_hot_air_80_steam
+ - cooking_oven_program_heating_mode_hot_air_100_steam
+ - cooking_oven_program_heating_mode_sabbath_programme
+ - cooking_oven_program_microwave_90_watt
+ - cooking_oven_program_microwave_180_watt
+ - cooking_oven_program_microwave_360_watt
+ - cooking_oven_program_microwave_600_watt
+ - cooking_oven_program_microwave_900_watt
+ - cooking_oven_program_microwave_1000_watt
+ - cooking_oven_program_microwave_max
+ - cooking_oven_program_heating_mode_warming_drawer
+ - laundry_care_washer_program_cotton
+ - laundry_care_washer_program_cotton_cotton_eco
+ - laundry_care_washer_program_cotton_eco_4060
+ - laundry_care_washer_program_cotton_colour
+ - laundry_care_washer_program_easy_care
+ - laundry_care_washer_program_mix
+ - laundry_care_washer_program_mix_night_wash
+ - laundry_care_washer_program_delicates_silk
+ - laundry_care_washer_program_wool
+ - laundry_care_washer_program_sensitive
+ - laundry_care_washer_program_auto_30
+ - laundry_care_washer_program_auto_40
+ - laundry_care_washer_program_auto_60
+ - laundry_care_washer_program_chiffon
+ - laundry_care_washer_program_curtains
+ - laundry_care_washer_program_dark_wash
+ - laundry_care_washer_program_dessous
+ - laundry_care_washer_program_monsoon
+ - laundry_care_washer_program_outdoor
+ - laundry_care_washer_program_plush_toy
+ - laundry_care_washer_program_shirts_blouses
+ - laundry_care_washer_program_sport_fitness
+ - laundry_care_washer_program_towels
+ - laundry_care_washer_program_water_proof
+ - laundry_care_washer_program_power_speed_59
+ - laundry_care_washer_program_super_153045_super_15
+ - laundry_care_washer_program_super_153045_super_1530
+ - laundry_care_washer_program_down_duvet_duvet
+ - laundry_care_washer_program_rinse_rinse_spin_drain
+ - laundry_care_washer_program_drum_clean
+ - laundry_care_washer_dryer_program_cotton
+ - laundry_care_washer_dryer_program_cotton_eco_4060
+ - laundry_care_washer_dryer_program_mix
+ - laundry_care_washer_dryer_program_easy_care
+ - laundry_care_washer_dryer_program_wash_and_dry_60
+ - laundry_care_washer_dryer_program_wash_and_dry_90
+ cleaning_robot_options:
+ collapsed: true
+ fields:
+ consumer_products_cleaning_robot_option_reference_map_id:
+ example: consumer_products_cleaning_robot_enum_type_available_maps_map1
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: available_maps
+ options:
+ - consumer_products_cleaning_robot_enum_type_available_maps_temp_map
+ - consumer_products_cleaning_robot_enum_type_available_maps_map1
+ - consumer_products_cleaning_robot_enum_type_available_maps_map2
+ - consumer_products_cleaning_robot_enum_type_available_maps_map3
+ consumer_products_cleaning_robot_option_cleaning_mode:
+ example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: cleaning_mode
+ options:
+ - consumer_products_cleaning_robot_enum_type_cleaning_modes_silent
+ - consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
+ - consumer_products_cleaning_robot_enum_type_cleaning_modes_power
+ coffee_maker_options:
+ collapsed: true
+ fields:
+ consumer_products_coffee_maker_option_bean_amount:
+ example: consumer_products_coffee_maker_enum_type_bean_amount_normal
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: bean_amount
+ options:
+ - consumer_products_coffee_maker_enum_type_bean_amount_very_mild
+ - consumer_products_coffee_maker_enum_type_bean_amount_mild
+ - consumer_products_coffee_maker_enum_type_bean_amount_mild_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_normal
+ - consumer_products_coffee_maker_enum_type_bean_amount_normal_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_strong
+ - consumer_products_coffee_maker_enum_type_bean_amount_strong_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_very_strong
+ - consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_extra_strong
+ - consumer_products_coffee_maker_enum_type_bean_amount_double_shot
+ - consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_triple_shot
+ - consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground
+ consumer_products_coffee_maker_option_fill_quantity:
+ example: 60
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ mode: box
+ unit_of_measurement: ml
+ consumer_products_coffee_maker_option_coffee_temperature:
+ example: consumer_products_coffee_maker_enum_type_coffee_temperature_88_c
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: coffee_temperature
+ options:
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_88_c
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_90_c
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_92_c
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_94_c
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_95_c
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_96_c
+ consumer_products_coffee_maker_option_bean_container:
+ example: consumer_products_coffee_maker_enum_type_bean_container_selection_right
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: bean_container
+ options:
+ - consumer_products_coffee_maker_enum_type_bean_container_selection_right
+ - consumer_products_coffee_maker_enum_type_bean_container_selection_left
+ consumer_products_coffee_maker_option_flow_rate:
+ example: consumer_products_coffee_maker_enum_type_flow_rate_normal
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: flow_rate
+ options:
+ - consumer_products_coffee_maker_enum_type_flow_rate_normal
+ - consumer_products_coffee_maker_enum_type_flow_rate_intense
+ - consumer_products_coffee_maker_enum_type_flow_rate_intense_plus
+ consumer_products_coffee_maker_option_multiple_beverages:
+ example: false
+ required: false
+ selector:
+ boolean:
+ consumer_products_coffee_maker_option_coffee_milk_ratio:
+ example: consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: coffee_milk_ratio
+ options:
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent
+ consumer_products_coffee_maker_option_hot_water_temperature:
+ example: consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: hot_water_temperature
+ options:
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_max
+ dish_washer_options:
+ collapsed: true
+ fields:
+ b_s_h_common_option_start_in_relative:
+ example: 3600
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ mode: box
+ unit_of_measurement: s
+ dishcare_dishwasher_option_intensiv_zone:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_brilliance_dry:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_vario_speed_plus:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_silence_on_demand:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_half_load:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_extra_dry:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_hygiene_plus:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_eco_dry:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_zeolite_dry:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dryer_options:
+ collapsed: true
+ fields:
+ laundry_care_dryer_option_drying_target:
+ example: laundry_care_dryer_enum_type_drying_target_iron_dry
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: drying_target
+ options:
+ - laundry_care_dryer_enum_type_drying_target_iron_dry
+ - laundry_care_dryer_enum_type_drying_target_gentle_dry
+ - laundry_care_dryer_enum_type_drying_target_cupboard_dry
+ - laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus
+ - laundry_care_dryer_enum_type_drying_target_extra_dry
+ hood_options:
+ collapsed: true
+ fields:
+ cooking_hood_option_venting_level:
+ example: cooking_hood_enum_type_stage_fan_stage01
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ 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_option_intensive_level:
+ example: cooking_hood_enum_type_intensive_stage_intensive_stage1
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: intensive_level
+ options:
+ - cooking_hood_enum_type_intensive_stage_intensive_stage_off
+ - cooking_hood_enum_type_intensive_stage_intensive_stage1
+ - cooking_hood_enum_type_intensive_stage_intensive_stage2
+ oven_options:
+ collapsed: true
+ fields:
+ cooking_oven_option_setpoint_temperature:
+ example: 180
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ mode: box
+ unit_of_measurement: °C/°F
+ b_s_h_common_option_duration:
+ example: 900
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ mode: box
+ unit_of_measurement: s
+ cooking_oven_option_fast_pre_heat:
+ example: false
+ required: false
+ selector:
+ boolean:
+ warming_drawer_options:
+ collapsed: true
+ fields:
+ cooking_oven_option_warming_level:
+ example: cooking_oven_enum_type_warming_level_medium
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: warming_level
+ options:
+ - cooking_oven_enum_type_warming_level_low
+ - cooking_oven_enum_type_warming_level_medium
+ - cooking_oven_enum_type_warming_level_high
+ washer_options:
+ collapsed: true
+ fields:
+ laundry_care_washer_option_temperature:
+ example: laundry_care_washer_enum_type_temperature_g_c40
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ 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_ul_cold
+ - laundry_care_washer_enum_type_temperature_ul_warm
+ - laundry_care_washer_enum_type_temperature_ul_hot
+ - laundry_care_washer_enum_type_temperature_ul_extra_hot
+ laundry_care_washer_option_spin_speed:
+ example: laundry_care_washer_enum_type_spin_speed_r_p_m800
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ 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_ul_off
+ - laundry_care_washer_enum_type_spin_speed_ul_low
+ - laundry_care_washer_enum_type_spin_speed_ul_medium
+ - laundry_care_washer_enum_type_spin_speed_ul_high
+ b_s_h_common_option_finish_in_relative:
+ example: 3600
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ mode: box
+ unit_of_measurement: s
+ laundry_care_washer_option_i_dos1_active:
+ example: false
+ required: false
+ selector:
+ boolean:
+ laundry_care_washer_option_i_dos2_active:
+ example: false
+ required: false
+ selector:
+ boolean:
+ laundry_care_washer_option_vario_perfect:
+ example: laundry_care_common_enum_type_vario_perfect_eco_perfect
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: vario_perfect
+ options:
+ - laundry_care_common_enum_type_vario_perfect_off
+ - laundry_care_common_enum_type_vario_perfect_eco_perfect
+ - laundry_care_common_enum_type_vario_perfect_speed_perfect
pause_program:
fields:
device_id:
diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json
index d07cfcdf854..3ffd84e61b2 100644
--- a/homeassistant/components/home_connect/strings.json
+++ b/homeassistant/components/home_connect/strings.json
@@ -95,6 +95,9 @@
},
"fetch_api_error": {
"message": "Error obtaining data from the API: {error}"
+ },
+ "required_program_or_one_option_at_least": {
+ "message": "A program or at least one of the possible options for a program should be specified"
}
},
"issues": {
@@ -104,7 +107,363 @@
},
"deprecated_program_switch": {
"title": "Deprecated program switch detected in some automations or scripts",
- "description": "Program switch are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use active program select entity to run the program without any additional option and get the current running program on the above automations or scripts to fix this issue."
+ "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue."
+ },
+ "deprecated_set_program_and_option_actions": {
+ "title": "The executed action is deprecated",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::home_connect::issues::deprecated_set_program_and_option_actions::title%]",
+ "description": "`start_program`, `select_program`, `set_option_active`, and `set_option_selected` actions are deprecated and will be removed in the {remove_release} release, please use the `{new_action_key}` action instead. For the executed action:\n{deprecated_action_yaml}\nyou can do the following transformation using the recognized options:\n {new_action_yaml}\nIf the option is not in the recognized options, please submit an issue or a pull request requesting the addition of the option at {repo_link}."
+ }
+ }
+ }
+ }
+ },
+ "selector": {
+ "affects_to": {
+ "options": {
+ "active_program": "Active program",
+ "selected_program": "Selected program"
+ }
+ },
+ "programs": {
+ "options": {
+ "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all",
+ "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map",
+ "consumer_products_cleaning_robot_program_basic_go_home": "Go home",
+ "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto",
+ "consumer_products_coffee_maker_program_beverage_espresso": "Espresso",
+ "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio",
+ "consumer_products_coffee_maker_program_beverage_coffee": "Coffee",
+ "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee",
+ "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande",
+ "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato",
+ "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino",
+ "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato",
+ "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte",
+ "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth",
+ "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk",
+ "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner",
+ "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun",
+ "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange",
+ "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white",
+ "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait",
+ "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio",
+ "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi",
+ "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd",
+ "consumer_products_coffee_maker_program_coffee_world_galao": "Galao",
+ "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto",
+ "consumer_products_coffee_maker_program_coffee_world_americano": "Americano",
+ "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye",
+ "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye",
+ "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye",
+ "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water",
+ "dishcare_dishwasher_program_pre_rinse": "Pre_rinse",
+ "dishcare_dishwasher_program_auto_1": "Auto 1",
+ "dishcare_dishwasher_program_auto_2": "Auto 2",
+ "dishcare_dishwasher_program_auto_3": "Auto 3",
+ "dishcare_dishwasher_program_eco_50": "Eco 50ºC",
+ "dishcare_dishwasher_program_quick_45": "Quick 45ºC",
+ "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC",
+ "dishcare_dishwasher_program_normal_65": "Normal 65ºC",
+ "dishcare_dishwasher_program_glas_40": "Glass 40ºC",
+ "dishcare_dishwasher_program_glass_care": "Glass care",
+ "dishcare_dishwasher_program_night_wash": "Night wash",
+ "dishcare_dishwasher_program_quick_65": "Quick 65ºC",
+ "dishcare_dishwasher_program_normal_45": "Normal 45ºC",
+ "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC",
+ "dishcare_dishwasher_program_auto_half_load": "Auto half load",
+ "dishcare_dishwasher_program_intensiv_power": "Intensive power",
+ "dishcare_dishwasher_program_magic_daily": "Magic daily",
+ "dishcare_dishwasher_program_super_60": "Super 60ºC",
+ "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC",
+ "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC",
+ "dishcare_dishwasher_program_machine_care": "Machine care",
+ "dishcare_dishwasher_program_steam_fresh": "Steam fresh",
+ "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning",
+ "dishcare_dishwasher_program_mixed_load": "Mixed load",
+ "laundry_care_dryer_program_cotton": "Cotton",
+ "laundry_care_dryer_program_synthetic": "Synthetic",
+ "laundry_care_dryer_program_mix": "Mix",
+ "laundry_care_dryer_program_blankets": "Blankets",
+ "laundry_care_dryer_program_business_shirts": "Business shirts",
+ "laundry_care_dryer_program_down_feathers": "Down feathers",
+ "laundry_care_dryer_program_hygiene": "Hygiene",
+ "laundry_care_dryer_program_jeans": "Jeans",
+ "laundry_care_dryer_program_outdoor": "Outdoor",
+ "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh",
+ "laundry_care_dryer_program_towels": "Towels",
+ "laundry_care_dryer_program_delicates": "Delicates",
+ "laundry_care_dryer_program_super_40": "Super 40ºC",
+ "laundry_care_dryer_program_shirts_15": "Shirts 15ºC",
+ "laundry_care_dryer_program_pillow": "Pillow",
+ "laundry_care_dryer_program_anti_shrink": "Anti shrink",
+ "laundry_care_dryer_program_my_time_my_drying_time": "My drying time",
+ "laundry_care_dryer_program_time_cold": "Cold (variable time)",
+ "laundry_care_dryer_program_time_warm": "Warm (variable time)",
+ "laundry_care_dryer_program_in_basket": "In basket",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)",
+ "laundry_care_dryer_program_dessous": "Dessous",
+ "cooking_common_program_hood_automatic": "Automatic",
+ "cooking_common_program_hood_venting": "Venting",
+ "cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
+ "cooking_oven_program_heating_mode_pre_heating": "Pre-heating",
+ "cooking_oven_program_heating_mode_hot_air": "Hot air",
+ "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco",
+ "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling",
+ "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating",
+ "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco",
+ "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
+ "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting",
+ "cooking_oven_program_heating_mode_slow_cook": "Slow cook",
+ "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
+ "cooking_oven_program_heating_mode_keep_warm": "Keep warm",
+ "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware",
+ "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products",
+ "cooking_oven_program_heating_mode_desiccation": "Desiccation",
+ "cooking_oven_program_heating_mode_defrost": "Defrost",
+ "cooking_oven_program_heating_mode_proof": "Proof",
+ "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH",
+ "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH",
+ "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH",
+ "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
+ "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme",
+ "cooking_oven_program_microwave_90_watt": "90 Watt",
+ "cooking_oven_program_microwave_180_watt": "180 Watt",
+ "cooking_oven_program_microwave_360_watt": "360 Watt",
+ "cooking_oven_program_microwave_600_watt": "600 Watt",
+ "cooking_oven_program_microwave_900_watt": "900 Watt",
+ "cooking_oven_program_microwave_1000_watt": "1000 Watt",
+ "cooking_oven_program_microwave_max": "Max",
+ "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer",
+ "laundry_care_washer_program_cotton": "Cotton",
+ "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco",
+ "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC",
+ "laundry_care_washer_program_cotton_colour": "Cotton color",
+ "laundry_care_washer_program_easy_care": "Easy care",
+ "laundry_care_washer_program_mix": "Mix",
+ "laundry_care_washer_program_mix_night_wash": "Mix night wash",
+ "laundry_care_washer_program_delicates_silk": "Delicates silk",
+ "laundry_care_washer_program_wool": "Wool",
+ "laundry_care_washer_program_sensitive": "Sensitive",
+ "laundry_care_washer_program_auto_30": "Auto 30ºC",
+ "laundry_care_washer_program_auto_40": "Auto 40ºC",
+ "laundry_care_washer_program_auto_60": "Auto 60ºC",
+ "laundry_care_washer_program_chiffon": "Chiffon",
+ "laundry_care_washer_program_curtains": "Curtains",
+ "laundry_care_washer_program_dark_wash": "Dark wash",
+ "laundry_care_washer_program_dessous": "Dessous",
+ "laundry_care_washer_program_monsoon": "Monsoon",
+ "laundry_care_washer_program_outdoor": "Outdoor",
+ "laundry_care_washer_program_plush_toy": "Plush toy",
+ "laundry_care_washer_program_shirts_blouses": "Shirts blouses",
+ "laundry_care_washer_program_sport_fitness": "Sport fitness",
+ "laundry_care_washer_program_towels": "Towels",
+ "laundry_care_washer_program_water_proof": "Water proof",
+ "laundry_care_washer_program_power_speed_59": "Power speed <59 min",
+ "laundry_care_washer_program_super_153045_super_15": "Super 15 min",
+ "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",
+ "laundry_care_washer_program_down_duvet_duvet": "Down duvet",
+ "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain",
+ "laundry_care_washer_program_drum_clean": "Drum clean",
+ "laundry_care_washer_dryer_program_cotton": "Cotton",
+ "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60ºC",
+ "laundry_care_washer_dryer_program_mix": "Mix",
+ "laundry_care_washer_dryer_program_easy_care": "Easy care",
+ "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)",
+ "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)"
+ }
+ },
+ "available_maps": {
+ "options": {
+ "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3"
+ }
+ },
+ "cleaning_mode": {
+ "options": {
+ "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "Silent",
+ "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "Standard",
+ "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power"
+ }
+ },
+ "bean_amount": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "Very mild",
+ "consumer_products_coffee_maker_enum_type_bean_amount_mild": "Mild",
+ "consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "Mild +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_normal": "Normal",
+ "consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "Normal +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_strong": "Strong",
+ "consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "Strong +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "Very strong",
+ "consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "Very strong +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "Extra strong",
+ "consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "Double shot",
+ "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "Double shot +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "Double shot ++",
+ "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "Triple shot",
+ "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "Triple shot +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "Coffee ground"
+ }
+ },
+ "coffee_temperature": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "88ºC",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "90ºC",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "92ºC",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "94ºC",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "95ºC",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "96ºC"
+ }
+ },
+ "bean_container": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "Right",
+ "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "Left"
+ }
+ },
+ "flow_rate": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_flow_rate_normal": "Normal",
+ "consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense",
+ "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense plus"
+ }
+ },
+ "coffee_milk_ratio": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "10%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "20%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "25%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "30%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "40%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "50%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "55%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "60%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "65%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "67%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "70%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "75%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "80%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "85%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "90%"
+ }
+ },
+ "hot_water_temperature": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "White tea",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "Green tea",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "Black tea",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "50ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "55ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "60ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "65ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "70ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "75ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "80ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "85ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "90ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "95ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "97ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "122ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "131ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "140ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "149ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "158ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "167ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "176ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "185ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "194ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "203ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "Max"
+ }
+ },
+ "drying_target": {
+ "options": {
+ "laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry",
+ "laundry_care_dryer_enum_type_drying_target_gentle_dry": "Gentle dry",
+ "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "Cupboard dry",
+ "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry plus",
+ "laundry_care_dryer_enum_type_drying_target_extra_dry": "Extra dry"
+ }
+ },
+ "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"
+ }
+ },
+ "intensive_level": {
+ "options": {
+ "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off",
+ "cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1",
+ "cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2"
+ }
+ },
+ "warming_level": {
+ "options": {
+ "cooking_oven_enum_type_warming_level_low": "Low",
+ "cooking_oven_enum_type_warming_level_medium": "Medium",
+ "cooking_oven_enum_type_warming_level_high": "High"
+ }
+ },
+ "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_ul_cold": "Cold",
+ "laundry_care_washer_enum_type_temperature_ul_warm": "Warm",
+ "laundry_care_washer_enum_type_temperature_ul_hot": "Hot",
+ "laundry_care_washer_enum_type_temperature_ul_extra_hot": "Extra hot"
+ }
+ },
+ "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_ul_off": "Off",
+ "laundry_care_washer_enum_type_spin_speed_ul_low": "Low",
+ "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium",
+ "laundry_care_washer_enum_type_spin_speed_ul_high": "High"
+ }
+ },
+ "vario_perfect": {
+ "options": {
+ "laundry_care_common_enum_type_vario_perfect_off": "Off",
+ "laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect",
+ "laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect"
+ }
}
},
"services": {
@@ -113,8 +472,8 @@
"description": "Selects a program and starts it.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "ID of the device."
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
},
"program": { "name": "Program", "description": "Program to select." },
"key": { "name": "Option key", "description": "Key of the option." },
@@ -130,8 +489,8 @@
"description": "Selects a program without starting it.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
},
"program": {
"name": "[%key:component::home_connect::services::start_program::fields::program::name%]",
@@ -151,13 +510,197 @@
}
}
},
+ "set_program_and_options": {
+ "name": "Set program and options",
+ "description": "Starts or selects a program with options or sets the options for the active or the selected program.",
+ "fields": {
+ "device_id": {
+ "name": "Device ID",
+ "description": "ID of the device."
+ },
+ "affects_to": {
+ "name": "Affects to",
+ "description": "Selects if the program affected by the action should be the active or the selected program."
+ },
+ "program": {
+ "name": "Program",
+ "description": "Program to select"
+ },
+ "consumer_products_cleaning_robot_option_reference_map_id": {
+ "name": "Reference map ID",
+ "description": "Defines which reference map is to be used."
+ },
+ "consumer_products_cleaning_robot_option_cleaning_mode": {
+ "name": "Cleaning mode",
+ "description": "Defines the favoured cleaning mode."
+ },
+ "consumer_products_coffee_maker_option_bean_amount": {
+ "name": "Bean amount",
+ "description": "Describes the amount of coffee beans used in a coffee machine program."
+ },
+ "consumer_products_coffee_maker_option_fill_quantity": {
+ "name": "Fill quantity",
+ "description": "Describes the amount of water (in ml) used in a coffee machine program."
+ },
+ "consumer_products_coffee_maker_option_coffee_temperature": {
+ "name": "Coffee Temperature",
+ "description": "Describes the coffee temperature used in a coffee machine program."
+ },
+ "consumer_products_coffee_maker_option_bean_container": {
+ "name": "Bean container",
+ "description": "Defines the preferred bean container."
+ },
+ "consumer_products_coffee_maker_option_flow_rate": {
+ "name": "Flow rate",
+ "description": "Defines the water-coffee contact time. The duration extends to coffee intensity."
+ },
+ "consumer_products_coffee_maker_option_multiple_beverages": {
+ "name": "Multiple beverages",
+ "description": "Defines if double dispensing is enabled."
+ },
+ "consumer_products_coffee_maker_option_coffee_milk_ratio": {
+ "name": "Coffee milk ratio",
+ "description": "Defines the amount of milk."
+ },
+ "consumer_products_coffee_maker_option_hot_water_temperature": {
+ "name": "Hot water temperature",
+ "description": "Defines the temperature suitable for the type of tea."
+ },
+ "b_s_h_common_option_start_in_relative": {
+ "name": "Start in relative",
+ "description": "Defines in how many time the program should start."
+ },
+ "dishcare_dishwasher_option_intensiv_zone": {
+ "name": "Intensive zone",
+ "description": "Defines if the cleaning is done with higher spray pressure on the lower basket for very dirty pots and pans."
+ },
+ "dishcare_dishwasher_option_brilliance_dry": {
+ "name": "Brilliance dry",
+ "description": "Defines if the program sequence is optimized with a special drying cycle to ensure more shine on glasses and plastic items."
+ },
+ "dishcare_dishwasher_option_vario_speed_plus": {
+ "name": "Vario speed plus",
+ "description": "Defines if the program run time is reduced by up to 66% with the usual optimum cleaning and drying."
+ },
+ "dishcare_dishwasher_option_silence_on_demand": {
+ "name": "Silence on demand",
+ "description": "Defines if the extra silent mode is activated for a selected period of time."
+ },
+ "dishcare_dishwasher_option_half_load": {
+ "name": "Half load",
+ "description": "Defines if economical cleaning is enabled for smaller loads. This reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets."
+ },
+ "dishcare_dishwasher_option_extra_dry": {
+ "name": "Extra dry",
+ "description": "Defines if improved drying for glasses and plasticware is enabled."
+ },
+ "dishcare_dishwasher_option_hygiene_plus": {
+ "name": "Hygiene plus",
+ "description": "Defines if the cleaning is done with increased temperature. This ensures maximum hygienic cleanliness for regular use."
+ },
+ "dishcare_dishwasher_option_eco_dry": {
+ "name": "Eco dry",
+ "description": "Defines if the door is opened automatically for extra energy efficient and effective drying."
+ },
+ "dishcare_dishwasher_option_zeolite_dry": {
+ "name": "Zeolite dry",
+ "description": "Defines if the program sequence is optimized with special drying cycle ensures improved drying for glasses, plates and plasticware."
+ },
+ "laundry_care_dryer_option_drying_target": {
+ "name": "Drying target",
+ "description": "Describes the drying target for a dryer program."
+ },
+ "cooking_hood_option_venting_level": {
+ "name": "Venting level",
+ "description": "Defines the required fan setting."
+ },
+ "cooking_hood_option_intensive_level": {
+ "name": "Intensive level",
+ "description": "Defines the intensive setting."
+ },
+ "cooking_oven_option_setpoint_temperature": {
+ "name": "Setpoint temperature",
+ "description": "Defines the target cavity temperature, which will be hold by the oven."
+ },
+ "b_s_h_common_option_duration": {
+ "name": "Duration",
+ "description": "Defines the run-time of the program. Afterwards, the appliance is stopped."
+ },
+ "cooking_oven_option_fast_pre_heat": {
+ "name": "Fast pre-heat",
+ "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal to or higher than 100 °C or 212 °F. Otherwise, the fast pre-heat option is not activated."
+ },
+ "cooking_oven_option_warming_level": {
+ "name": "Warming level",
+ "description": "Defines the level of the warming drawer."
+ },
+ "laundry_care_washer_option_temperature": {
+ "name": "Temperature",
+ "description": "Defines the temperature of the washing program."
+ },
+ "laundry_care_washer_option_spin_speed": {
+ "name": "Spin speed",
+ "description": "Defines the spin speed of a washer program."
+ },
+ "b_s_h_common_option_finish_in_relative": {
+ "name": "Finish in relative",
+ "description": "Defines when the program should end in seconds."
+ },
+ "laundry_care_washer_option_i_dos1_active": {
+ "name": "i-Dos 1 Active",
+ "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)"
+ },
+ "laundry_care_washer_option_i_dos2_active": {
+ "name": "i-Dos 2 Active",
+ "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)"
+ },
+ "laundry_care_washer_option_vario_perfect": {
+ "name": "Vario perfect",
+ "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect)."
+ }
+ },
+ "sections": {
+ "cleaning_robot_options": {
+ "name": "Cleaning robot options",
+ "description": "Options for cleaning robots."
+ },
+ "coffee_maker_options": {
+ "name": "Coffee maker options",
+ "description": "Options for coffee makers."
+ },
+ "dish_washer_options": {
+ "name": "Dishwasher options",
+ "description": "Options for dishwashers."
+ },
+ "dryer_options": {
+ "name": "Dryer options",
+ "description": "Options for dryers (and washer dryers)."
+ },
+ "hood_options": {
+ "name": "Hood options",
+ "description": "Options for hoods."
+ },
+ "oven_options": {
+ "name": "Oven options",
+ "description": "Options for ovens."
+ },
+ "warming_drawer_options": {
+ "name": "Warming drawer options",
+ "description": "Options for warming drawers."
+ },
+ "washer_options": {
+ "name": "Washer options",
+ "description": "Options for washers (and washer dryers)."
+ }
+ }
+ },
"pause_program": {
"name": "Pause program",
"description": "Pauses the current running program.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
}
}
},
@@ -166,8 +709,8 @@
"description": "Resumes a paused program.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
}
}
},
@@ -176,8 +719,8 @@
"description": "Sets an option for the active program.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
},
"key": {
"name": "Key",
@@ -191,18 +734,18 @@
},
"set_option_selected": {
"name": "Set selected program option",
- "description": "Sets an option for the selected program.",
+ "description": "Sets options for the selected program.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
},
"key": {
- "name": "Key",
+ "name": "[%key:component::home_connect::services::start_program::fields::key::name%]",
"description": "[%key:component::home_connect::services::start_program::fields::key::description%]"
},
"value": {
- "name": "Value",
+ "name": "[%key:component::home_connect::services::start_program::fields::value::name%]",
"description": "[%key:component::home_connect::services::start_program::fields::value::description%]"
}
}
@@ -212,8 +755,8 @@
"description": "Changes a setting.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
},
"key": { "name": "Key", "description": "Key of the setting." },
"value": { "name": "Value", "description": "Value of the setting." }
@@ -307,319 +850,319 @@
"selected_program": {
"name": "Selected program",
"state": {
- "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all",
- "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map",
- "consumer_products_cleaning_robot_program_basic_go_home": "Go home",
- "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto",
- "consumer_products_coffee_maker_program_beverage_espresso": "Espresso",
- "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio",
- "consumer_products_coffee_maker_program_beverage_coffee": "Coffee",
- "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee",
- "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande",
- "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato",
- "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino",
- "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato",
- "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte",
- "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth",
- "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk",
- "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner",
- "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner",
- "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter",
- "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun",
- "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange",
- "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white",
- "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado",
- "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado",
- "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche",
- "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait",
- "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio",
- "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi",
- "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd",
- "consumer_products_coffee_maker_program_coffee_world_galao": "Galao",
- "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto",
- "consumer_products_coffee_maker_program_coffee_world_americano": "Americano",
- "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye",
- "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye",
- "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye",
- "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water",
- "dishcare_dishwasher_program_pre_rinse": "Pre_rinse",
- "dishcare_dishwasher_program_auto_1": "Auto 1",
- "dishcare_dishwasher_program_auto_2": "Auto 2",
- "dishcare_dishwasher_program_auto_3": "Auto 3",
- "dishcare_dishwasher_program_eco_50": "Eco 50ºC",
- "dishcare_dishwasher_program_quick_45": "Quick 45ºC",
- "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC",
- "dishcare_dishwasher_program_normal_65": "Normal 65ºC",
- "dishcare_dishwasher_program_glas_40": "Glass 40ºC",
- "dishcare_dishwasher_program_glass_care": "Glass care",
- "dishcare_dishwasher_program_night_wash": "Night wash",
- "dishcare_dishwasher_program_quick_65": "Quick 65ºC",
- "dishcare_dishwasher_program_normal_45": "Normal 45ºC",
- "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC",
- "dishcare_dishwasher_program_auto_half_load": "Auto half load",
- "dishcare_dishwasher_program_intensiv_power": "Intensive power",
- "dishcare_dishwasher_program_magic_daily": "Magic daily",
- "dishcare_dishwasher_program_super_60": "Super 60ºC",
- "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC",
- "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC",
- "dishcare_dishwasher_program_machine_care": "Machine care",
- "dishcare_dishwasher_program_steam_fresh": "Steam fresh",
- "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning",
- "dishcare_dishwasher_program_mixed_load": "Mixed load",
- "laundry_care_dryer_program_cotton": "Cotton",
- "laundry_care_dryer_program_synthetic": "Synthetic",
- "laundry_care_dryer_program_mix": "Mix",
- "laundry_care_dryer_program_blankets": "Blankets",
- "laundry_care_dryer_program_business_shirts": "Business shirts",
- "laundry_care_dryer_program_down_feathers": "Down feathers",
- "laundry_care_dryer_program_hygiene": "Hygiene",
- "laundry_care_dryer_program_jeans": "Jeans",
- "laundry_care_dryer_program_outdoor": "Outdoor",
- "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh",
- "laundry_care_dryer_program_towels": "Towels",
- "laundry_care_dryer_program_delicates": "Delicates",
- "laundry_care_dryer_program_super_40": "Super 40ºC",
- "laundry_care_dryer_program_shirts_15": "Shirts 15ºC",
- "laundry_care_dryer_program_pillow": "Pillow",
- "laundry_care_dryer_program_anti_shrink": "Anti shrink",
- "laundry_care_dryer_program_my_time_my_drying_time": "My drying time",
- "laundry_care_dryer_program_time_cold": "Cold (variable time)",
- "laundry_care_dryer_program_time_warm": "Warm (variable time)",
- "laundry_care_dryer_program_in_basket": "In basket",
- "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)",
- "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)",
- "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)",
- "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)",
- "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)",
- "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)",
- "laundry_care_dryer_program_dessous": "Dessous",
- "cooking_common_program_hood_automatic": "Automatic",
- "cooking_common_program_hood_venting": "Venting",
- "cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
- "cooking_oven_program_heating_mode_pre_heating": "Pre-heating",
- "cooking_oven_program_heating_mode_hot_air": "Hot air",
- "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco",
- "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling",
- "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating",
- "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco",
- "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
- "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting",
- "cooking_oven_program_heating_mode_slow_cook": "Slow cook",
- "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
- "cooking_oven_program_heating_mode_keep_warm": "Keep warm",
- "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware",
- "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products",
- "cooking_oven_program_heating_mode_desiccation": "Desiccation",
- "cooking_oven_program_heating_mode_defrost": "Defrost",
- "cooking_oven_program_heating_mode_proof": "Proof",
- "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH",
- "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH",
- "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH",
- "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
- "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme",
- "cooking_oven_program_microwave_90_watt": "90 Watt",
- "cooking_oven_program_microwave_180_watt": "180 Watt",
- "cooking_oven_program_microwave_360_watt": "360 Watt",
- "cooking_oven_program_microwave_600_watt": "600 Watt",
- "cooking_oven_program_microwave_900_watt": "900 Watt",
- "cooking_oven_program_microwave_1000_watt": "1000 Watt",
- "cooking_oven_program_microwave_max": "Max",
- "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer",
- "laundry_care_washer_program_cotton": "Cotton",
- "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco",
- "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC",
- "laundry_care_washer_program_cotton_colour": "Cotton color",
- "laundry_care_washer_program_easy_care": "Easy care",
- "laundry_care_washer_program_mix": "Mix",
- "laundry_care_washer_program_mix_night_wash": "Mix night wash",
- "laundry_care_washer_program_delicates_silk": "Delicates silk",
- "laundry_care_washer_program_wool": "Wool",
- "laundry_care_washer_program_sensitive": "Sensitive",
- "laundry_care_washer_program_auto_30": "Auto 30ºC",
- "laundry_care_washer_program_auto_40": "Auto 40ºC",
- "laundry_care_washer_program_auto_60": "Auto 60ºC",
- "laundry_care_washer_program_chiffon": "Chiffon",
- "laundry_care_washer_program_curtains": "Curtains",
- "laundry_care_washer_program_dark_wash": "Dark wash",
- "laundry_care_washer_program_dessous": "Dessous",
- "laundry_care_washer_program_monsoon": "Monsoon",
- "laundry_care_washer_program_outdoor": "Outdoor",
- "laundry_care_washer_program_plush_toy": "Plush toy",
- "laundry_care_washer_program_shirts_blouses": "Shirts blouses",
- "laundry_care_washer_program_sport_fitness": "Sport fitness",
- "laundry_care_washer_program_towels": "Towels",
- "laundry_care_washer_program_water_proof": "Water proof",
- "laundry_care_washer_program_power_speed_59": "Power speed <60 min",
- "laundry_care_washer_program_super_153045_super_15": "Super 15 min",
- "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",
- "laundry_care_washer_program_down_duvet_duvet": "Down duvet",
- "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain",
- "laundry_care_washer_program_drum_clean": "Drum clean",
- "laundry_care_washer_dryer_program_cotton": "Cotton",
- "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60 ºC",
- "laundry_care_washer_dryer_program_mix": "Mix",
- "laundry_care_washer_dryer_program_easy_care": "Easy care",
- "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)",
- "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)"
+ "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_all%]",
+ "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_map%]",
+ "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_basic_go_home%]",
+ "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_ristretto%]",
+ "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_doppio%]",
+ "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_x_l_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_grande%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_cappuccino%]",
+ "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_latte_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_latte%]",
+ "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_milk_froth%]",
+ "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_warm_milk%]",
+ "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]",
+ "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]",
+ "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_flat_white%]",
+ "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]",
+ "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_doppio%]",
+ "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kaapi%]",
+ "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]",
+ "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_galao%]",
+ "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_garoto%]",
+ "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_americano%]",
+ "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_red_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_black_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_dead_eye%]",
+ "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_hot_water%]",
+ "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_pre_rinse%]",
+ "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]",
+ "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]",
+ "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]",
+ "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_eco_50%]",
+ "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_45%]",
+ "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]",
+ "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_65%]",
+ "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glas_40%]",
+ "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glass_care%]",
+ "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_night_wash%]",
+ "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]",
+ "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_45%]",
+ "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]",
+ "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_half_load%]",
+ "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]",
+ "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_magic_daily%]",
+ "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]",
+ "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]",
+ "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_express_sparkle_65%]",
+ "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]",
+ "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]",
+ "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_maximum_cleaning%]",
+ "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_mixed_load%]",
+ "laundry_care_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_cotton%]",
+ "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic%]",
+ "laundry_care_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_mix%]",
+ "laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]",
+ "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]",
+ "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_down_feathers%]",
+ "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_hygiene%]",
+ "laundry_care_dryer_program_jeans": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_jeans%]",
+ "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_outdoor%]",
+ "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic_refresh%]",
+ "laundry_care_dryer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_towels%]",
+ "laundry_care_dryer_program_delicates": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_delicates%]",
+ "laundry_care_dryer_program_super_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_super_40%]",
+ "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_shirts_15%]",
+ "laundry_care_dryer_program_pillow": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_pillow%]",
+ "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]",
+ "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_my_time_my_drying_time%]",
+ "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold%]",
+ "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm%]",
+ "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_in_basket%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_20%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_30%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_60%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_30%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_40%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_60%]",
+ "laundry_care_dryer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_dessous%]",
+ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
+ "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
+ "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
+ "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pre_heating%]",
+ "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
+ "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
+ "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating_eco%]",
+ "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
+ "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pizza_setting%]",
+ "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_slow_cook%]",
+ "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
+ "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
+ "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_preheat_ovenware%]",
+ "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
+ "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
+ "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
+ "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_proof%]",
+ "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
+ "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_sabbath_programme%]",
+ "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
+ "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]",
+ "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]",
+ "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]",
+ "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
+ "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]",
+ "cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]",
+ "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_warming_drawer%]",
+ "laundry_care_washer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton%]",
+ "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_cotton_eco%]",
+ "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_eco_4060%]",
+ "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_colour%]",
+ "laundry_care_washer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_easy_care%]",
+ "laundry_care_washer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix%]",
+ "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix_night_wash%]",
+ "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_delicates_silk%]",
+ "laundry_care_washer_program_wool": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_wool%]",
+ "laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
+ "laundry_care_washer_program_auto_30": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_30%]",
+ "laundry_care_washer_program_auto_40": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_40%]",
+ "laundry_care_washer_program_auto_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_60%]",
+ "laundry_care_washer_program_chiffon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_chiffon%]",
+ "laundry_care_washer_program_curtains": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_curtains%]",
+ "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dark_wash%]",
+ "laundry_care_washer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dessous%]",
+ "laundry_care_washer_program_monsoon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_monsoon%]",
+ "laundry_care_washer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_outdoor%]",
+ "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_plush_toy%]",
+ "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
+ "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
+ "laundry_care_washer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_towels%]",
+ "laundry_care_washer_program_water_proof": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_water_proof%]",
+ "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_power_speed_59%]",
+ "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
+ "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
+ "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_down_duvet_duvet%]",
+ "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
+ "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_drum_clean%]",
+ "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton%]",
+ "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton_eco_4060%]",
+ "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_mix%]",
+ "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_easy_care%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]"
}
},
"active_program": {
"name": "Active program",
"state": {
- "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_all%]",
- "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_map%]",
- "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_basic_go_home%]",
- "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_ristretto%]",
- "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso%]",
- "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_doppio%]",
- "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_coffee%]",
- "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_x_l_coffee%]",
- "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_grande%]",
- "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]",
- "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_cappuccino%]",
- "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_latte_macchiato%]",
- "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_latte%]",
- "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_milk_froth%]",
- "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_warm_milk%]",
- "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]",
- "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]",
- "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]",
- "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]",
- "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]",
- "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_flat_white%]",
- "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cortado%]",
- "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]",
- "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]",
- "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]",
- "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_doppio%]",
- "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kaapi%]",
- "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]",
- "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_galao%]",
- "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_garoto%]",
- "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_americano%]",
- "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_red_eye%]",
- "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_black_eye%]",
- "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_dead_eye%]",
- "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_hot_water%]",
- "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_pre_rinse%]",
- "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_1%]",
- "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_2%]",
- "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_3%]",
- "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_eco_50%]",
- "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_45%]",
- "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_70%]",
- "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_65%]",
- "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glas_40%]",
- "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glass_care%]",
- "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_night_wash%]",
- "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_65%]",
- "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_45%]",
- "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_45%]",
- "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_half_load%]",
- "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_power%]",
- "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_magic_daily%]",
- "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_super_60%]",
- "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_kurz_60%]",
- "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_express_sparkle_65%]",
- "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_machine_care%]",
- "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_steam_fresh%]",
- "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_maximum_cleaning%]",
- "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_mixed_load%]",
- "laundry_care_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_cotton%]",
- "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic%]",
- "laundry_care_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_mix%]",
- "laundry_care_dryer_program_blankets": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_blankets%]",
- "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_business_shirts%]",
- "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_down_feathers%]",
- "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_hygiene%]",
- "laundry_care_dryer_program_jeans": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_jeans%]",
- "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_outdoor%]",
- "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic_refresh%]",
- "laundry_care_dryer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_towels%]",
- "laundry_care_dryer_program_delicates": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_delicates%]",
- "laundry_care_dryer_program_super_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_super_40%]",
- "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_shirts_15%]",
- "laundry_care_dryer_program_pillow": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_pillow%]",
- "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_anti_shrink%]",
- "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_my_time_my_drying_time%]",
- "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold%]",
- "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm%]",
- "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_in_basket%]",
- "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_20%]",
- "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_30%]",
- "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_60%]",
- "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_30%]",
- "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_40%]",
- "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_60%]",
- "laundry_care_dryer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_dessous%]",
- "cooking_common_program_hood_automatic": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_automatic%]",
- "cooking_common_program_hood_venting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_venting%]",
- "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_delayed_shut_off%]",
- "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pre_heating%]",
- "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air%]",
- "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_eco%]",
- "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_grilling%]",
- "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating%]",
- "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating_eco%]",
- "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_bottom_heating%]",
- "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pizza_setting%]",
- "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_slow_cook%]",
- "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_intensive_heat%]",
- "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_keep_warm%]",
- "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_preheat_ovenware%]",
- "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_frozen_heatup_special%]",
- "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_desiccation%]",
- "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_defrost%]",
- "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_proof%]",
- "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_30_steam%]",
- "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_60_steam%]",
- "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_80_steam%]",
- "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_100_steam%]",
- "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_sabbath_programme%]",
- "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_90_watt%]",
- "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_180_watt%]",
- "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_360_watt%]",
- "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_600_watt%]",
- "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_900_watt%]",
- "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_1000_watt%]",
- "cooking_oven_program_microwave_max": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_max%]",
- "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_warming_drawer%]",
- "laundry_care_washer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton%]",
- "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_cotton_eco%]",
- "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_eco_4060%]",
- "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_colour%]",
- "laundry_care_washer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_easy_care%]",
- "laundry_care_washer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix%]",
- "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix_night_wash%]",
- "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_delicates_silk%]",
- "laundry_care_washer_program_wool": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_wool%]",
- "laundry_care_washer_program_sensitive": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sensitive%]",
- "laundry_care_washer_program_auto_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_30%]",
- "laundry_care_washer_program_auto_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_40%]",
- "laundry_care_washer_program_auto_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_60%]",
- "laundry_care_washer_program_chiffon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_chiffon%]",
- "laundry_care_washer_program_curtains": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_curtains%]",
- "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dark_wash%]",
- "laundry_care_washer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dessous%]",
- "laundry_care_washer_program_monsoon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_monsoon%]",
- "laundry_care_washer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_outdoor%]",
- "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_plush_toy%]",
- "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_shirts_blouses%]",
- "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sport_fitness%]",
- "laundry_care_washer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_towels%]",
- "laundry_care_washer_program_water_proof": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_water_proof%]",
- "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_power_speed_59%]",
- "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_15%]",
- "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_1530%]",
- "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_down_duvet_duvet%]",
- "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_rinse_rinse_spin_drain%]",
- "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_drum_clean%]",
- "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton%]",
- "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton_eco_4060%]",
- "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_mix%]",
- "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_easy_care%]",
- "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_60%]",
- "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_90%]"
+ "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_all%]",
+ "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_map%]",
+ "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_basic_go_home%]",
+ "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_ristretto%]",
+ "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_doppio%]",
+ "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_x_l_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_grande%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_cappuccino%]",
+ "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_latte_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_latte%]",
+ "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_milk_froth%]",
+ "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_warm_milk%]",
+ "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]",
+ "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]",
+ "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_flat_white%]",
+ "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]",
+ "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_doppio%]",
+ "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kaapi%]",
+ "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]",
+ "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_galao%]",
+ "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_garoto%]",
+ "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_americano%]",
+ "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_red_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_black_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_dead_eye%]",
+ "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_hot_water%]",
+ "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_pre_rinse%]",
+ "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]",
+ "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]",
+ "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]",
+ "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_eco_50%]",
+ "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_45%]",
+ "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]",
+ "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_65%]",
+ "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glas_40%]",
+ "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glass_care%]",
+ "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_night_wash%]",
+ "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]",
+ "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_45%]",
+ "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]",
+ "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_half_load%]",
+ "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]",
+ "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_magic_daily%]",
+ "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]",
+ "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]",
+ "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_express_sparkle_65%]",
+ "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]",
+ "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]",
+ "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_maximum_cleaning%]",
+ "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_mixed_load%]",
+ "laundry_care_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_cotton%]",
+ "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic%]",
+ "laundry_care_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_mix%]",
+ "laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]",
+ "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]",
+ "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_down_feathers%]",
+ "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_hygiene%]",
+ "laundry_care_dryer_program_jeans": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_jeans%]",
+ "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_outdoor%]",
+ "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic_refresh%]",
+ "laundry_care_dryer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_towels%]",
+ "laundry_care_dryer_program_delicates": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_delicates%]",
+ "laundry_care_dryer_program_super_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_super_40%]",
+ "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_shirts_15%]",
+ "laundry_care_dryer_program_pillow": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_pillow%]",
+ "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]",
+ "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_my_time_my_drying_time%]",
+ "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold%]",
+ "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm%]",
+ "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_in_basket%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_20%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_30%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_60%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_30%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_40%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_60%]",
+ "laundry_care_dryer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_dessous%]",
+ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
+ "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
+ "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
+ "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pre_heating%]",
+ "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
+ "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
+ "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating_eco%]",
+ "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
+ "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pizza_setting%]",
+ "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_slow_cook%]",
+ "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
+ "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
+ "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_preheat_ovenware%]",
+ "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
+ "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
+ "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
+ "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_proof%]",
+ "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
+ "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_sabbath_programme%]",
+ "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
+ "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]",
+ "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]",
+ "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]",
+ "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
+ "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]",
+ "cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]",
+ "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_warming_drawer%]",
+ "laundry_care_washer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton%]",
+ "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_cotton_eco%]",
+ "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_eco_4060%]",
+ "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_colour%]",
+ "laundry_care_washer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_easy_care%]",
+ "laundry_care_washer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix%]",
+ "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix_night_wash%]",
+ "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_delicates_silk%]",
+ "laundry_care_washer_program_wool": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_wool%]",
+ "laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
+ "laundry_care_washer_program_auto_30": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_30%]",
+ "laundry_care_washer_program_auto_40": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_40%]",
+ "laundry_care_washer_program_auto_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_60%]",
+ "laundry_care_washer_program_chiffon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_chiffon%]",
+ "laundry_care_washer_program_curtains": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_curtains%]",
+ "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dark_wash%]",
+ "laundry_care_washer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dessous%]",
+ "laundry_care_washer_program_monsoon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_monsoon%]",
+ "laundry_care_washer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_outdoor%]",
+ "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_plush_toy%]",
+ "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
+ "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
+ "laundry_care_washer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_towels%]",
+ "laundry_care_washer_program_water_proof": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_water_proof%]",
+ "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_power_speed_59%]",
+ "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
+ "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
+ "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_down_duvet_duvet%]",
+ "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
+ "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_drum_clean%]",
+ "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton%]",
+ "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton_eco_4060%]",
+ "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_mix%]",
+ "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_easy_care%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]"
}
}
},
diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py
index 53cbcbae5d4..bd1ff642d10 100644
--- a/homeassistant/components/homeassistant_hardware/util.py
+++ b/homeassistant/components/homeassistant_hardware/util.py
@@ -12,7 +12,7 @@ import logging
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.flasher import Flasher
-from homeassistant.components.hassio import AddonError, AddonState
+from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.hassio import is_hassio
@@ -143,6 +143,31 @@ class FirmwareInfo:
return all(states)
+async def get_otbr_addon_firmware_info(
+ hass: HomeAssistant, otbr_addon_manager: AddonManager
+) -> FirmwareInfo | None:
+ """Get firmware info from the OTBR add-on."""
+ try:
+ otbr_addon_info = await otbr_addon_manager.async_get_addon_info()
+ except AddonError:
+ return None
+
+ if otbr_addon_info.state == AddonState.NOT_INSTALLED:
+ return None
+
+ if (otbr_path := otbr_addon_info.options.get("device")) is None:
+ return None
+
+ # Only create a new entry if there are no existing OTBR ones
+ return FirmwareInfo(
+ device=otbr_path,
+ firmware_type=ApplicationType.SPINEL,
+ firmware_version=None,
+ source="otbr",
+ owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)],
+ )
+
+
async def guess_hardware_owners(
hass: HomeAssistant, device_path: str
) -> list[FirmwareInfo]:
@@ -155,28 +180,19 @@ async def guess_hardware_owners(
# It may be possible for the OTBR addon to be present without the integration
if is_hassio(hass):
otbr_addon_manager = get_otbr_addon_manager(hass)
+ otbr_addon_fw_info = await get_otbr_addon_firmware_info(
+ hass, otbr_addon_manager
+ )
+ otbr_path = (
+ otbr_addon_fw_info.device if otbr_addon_fw_info is not None else None
+ )
- try:
- otbr_addon_info = await otbr_addon_manager.async_get_addon_info()
- except AddonError:
- pass
- else:
- if otbr_addon_info.state != AddonState.NOT_INSTALLED:
- otbr_path = otbr_addon_info.options.get("device")
-
- # Only create a new entry if there are no existing OTBR ones
- if otbr_path is not None and not any(
- info.source == "otbr" for info in device_guesses[otbr_path]
- ):
- device_guesses[otbr_path].append(
- FirmwareInfo(
- device=otbr_path,
- firmware_type=ApplicationType.SPINEL,
- firmware_version=None,
- source="otbr",
- owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)],
- )
- )
+ # Only create a new entry if there are no existing OTBR ones
+ if otbr_path is not None and not any(
+ info.source == "otbr" for info in device_guesses[otbr_path]
+ ):
+ assert otbr_addon_fw_info is not None
+ device_guesses[otbr_path].append(otbr_addon_fw_info)
if is_hassio(hass):
multipan_addon_manager = await get_multiprotocol_addon_manager(hass)
diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py
index 5a5b2a3b8c8..44e95e98f38 100644
--- a/homeassistant/components/homematic/entity.py
+++ b/homeassistant/components/homematic/entity.py
@@ -62,7 +62,7 @@ class HMDevice(Entity):
if self._state:
self._state = self._state.upper()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Load data init callbacks."""
self._subscribe_homematic_events()
@@ -77,7 +77,7 @@ class HMDevice(Entity):
return self._name
@property
- def available(self):
+ def available(self) -> bool:
"""Return true if device is available."""
return self._available
diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py
index 6bcc51f939e..68dc54aef0e 100644
--- a/homeassistant/components/homewizard/config_flow.py
+++ b/homeassistant/components/homewizard/config_flow.py
@@ -23,8 +23,10 @@ import voluptuous as vol
from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
+from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import instance_id
from homeassistant.helpers.selector import TextSelector
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -88,7 +90,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
# Tell device we want a token, user must now press the button within 30 seconds
# The first attempt will always fail, but this opens the window to press the button
- token = await async_request_token(self.ip_address)
+ token = await async_request_token(self.hass, self.ip_address)
errors: dict[str, str] | None = None
if token is None:
@@ -250,7 +252,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] | None = None
- token = await async_request_token(self.ip_address)
+ token = await async_request_token(self.hass, self.ip_address)
if user_input is not None:
if token is None:
@@ -353,7 +355,7 @@ async def async_try_connect(ip_address: str, token: str | None = None) -> Device
await energy_api.close()
-async def async_request_token(ip_address: str) -> str | None:
+async def async_request_token(hass: HomeAssistant, ip_address: str) -> str | None:
"""Try to request a token from the device.
This method is used to request a token from the device,
@@ -362,8 +364,12 @@ async def async_request_token(ip_address: str) -> str | None:
api = HomeWizardEnergyV2(ip_address)
+ # Get a part of the unique id to make the token unique
+ # This is to prevent token conflicts when multiple HA instances are used
+ uuid = await instance_id.async_get(hass)
+
try:
- return await api.get_token("home-assistant")
+ return await api.get_token(f"home-assistant#{uuid[:6]}")
except DisabledError:
return None
finally:
diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py
index 4c9a03b493f..60790202032 100644
--- a/homeassistant/components/homewizard/repairs.py
+++ b/homeassistant/components/homewizard/repairs.py
@@ -47,7 +47,7 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
# Tell device we want a token, user must now press the button within 30 seconds
# The first attempt will always fail, but this opens the window to press the button
- token = await async_request_token(ip_address)
+ token = await async_request_token(self.hass, ip_address)
errors: dict[str, str] | None = None
if token is None:
diff --git a/homeassistant/components/ihc/entity.py b/homeassistant/components/ihc/entity.py
index f90b2ee943c..8847ffc9f49 100644
--- a/homeassistant/components/ihc/entity.py
+++ b/homeassistant/components/ihc/entity.py
@@ -54,7 +54,7 @@ class IHCEntity(Entity):
self.ihc_note = ""
self.ihc_position = ""
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Add callback for IHC changes."""
_LOGGER.debug("Adding IHC entity notify event: %s", self.ihc_id)
self.ihc_controller.add_notify_event(self.ihc_id, self.on_ihc_change, True)
diff --git a/homeassistant/components/insteon/entity.py b/homeassistant/components/insteon/entity.py
index 79e5c18a934..b7886723fdf 100644
--- a/homeassistant/components/insteon/entity.py
+++ b/homeassistant/components/insteon/entity.py
@@ -109,7 +109,7 @@ class InsteonEntity(Entity):
)
self.async_write_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register INSTEON update events."""
_LOGGER.debug(
"Tracking updates for device %s group %d name %s",
@@ -137,7 +137,7 @@ class InsteonEntity(Entity):
)
)
- async def async_will_remove_from_hass(self):
+ async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe to INSTEON update events."""
_LOGGER.debug(
"Remove tracking updates for device %s group %d name %s",
diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py
index 893b33644fe..1da727fdee8 100644
--- a/homeassistant/components/isy994/entity.py
+++ b/homeassistant/components/isy994/entity.py
@@ -106,7 +106,7 @@ class ISYNodeEntity(ISYEntity):
return getattr(self._node, TAG_ENABLED, True)
@property
- def extra_state_attributes(self) -> dict:
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Get the state attributes for the device.
The 'aux_properties' in the pyisy Node class are combined with the
@@ -189,7 +189,7 @@ class ISYProgramEntity(ISYEntity):
self._actions = actions
@property
- def extra_state_attributes(self) -> dict:
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Get the state attributes for the device."""
attr = {}
if self._actions:
diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json
index 86a1f14ff91..8872226daba 100644
--- a/homeassistant/components/isy994/strings.json
+++ b/homeassistant/components/isy994/strings.json
@@ -58,7 +58,7 @@
"services": {
"send_raw_node_command": {
"name": "Send raw node command",
- "description": "[%key:component::isy994::options::step::init::description%]",
+ "description": "Sends a “raw” (e.g., DON, DOF) ISY REST device command to a node using its Home Assistant entity ID. This is useful for devices that aren’t fully supported in Home Assistant yet, such as controls for many NodeServer nodes.",
"fields": {
"command": {
"name": "Command",
diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py
index 3d741e8f1a8..16d7e8b2bb8 100644
--- a/homeassistant/components/lacrosse_view/coordinator.py
+++ b/homeassistant/components/lacrosse_view/coordinator.py
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import SCAN_INTERVAL
+from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
@@ -75,16 +75,28 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
try:
# Fetch last hour of data
for sensor in self.devices:
- sensor.data = (
- await self.api.get_sensor_status(
- sensor=sensor,
- tz=self.hass.config.time_zone,
+ data = await self.api.get_sensor_status(
+ sensor=sensor,
+ tz=self.hass.config.time_zone,
+ )
+ _LOGGER.debug("Got data: %s", data)
+
+ if data_error := data.get("error"):
+ if data_error == "no_readings":
+ sensor.data = None
+ _LOGGER.debug("No readings for %s", sensor.name)
+ continue
+ _LOGGER.debug("Error: %s", data_error)
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="update_error"
)
- )["data"]["current"]
- _LOGGER.debug("Got data: %s", sensor.data)
+
+ sensor.data = data["data"]["current"]
except HTTPError as error:
- raise UpdateFailed from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="update_error"
+ ) from error
# Verify that we have permission to read the sensors
for sensor in self.devices:
diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py
index df66b7ba96a..ea5a82a3df8 100644
--- a/homeassistant/components/lacrosse_view/sensor.py
+++ b/homeassistant/components/lacrosse_view/sensor.py
@@ -64,6 +64,7 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_value,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ suggested_display_precision=2,
),
"Humidity": LaCrosseSensorEntityDescription(
key="Humidity",
@@ -71,6 +72,7 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_value,
native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=2,
),
"HeatIndex": LaCrosseSensorEntityDescription(
key="HeatIndex",
@@ -79,6 +81,7 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_value,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ suggested_display_precision=2,
),
"WindSpeed": LaCrosseSensorEntityDescription(
key="WindSpeed",
@@ -86,6 +89,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
+ suggested_display_precision=2,
),
"Rain": LaCrosseSensorEntityDescription(
key="Rain",
@@ -93,12 +97,14 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
+ suggested_display_precision=2,
),
"WindHeading": LaCrosseSensorEntityDescription(
key="WindHeading",
translation_key="wind_heading",
value_fn=get_value,
native_unit_of_measurement=DEGREE,
+ suggested_display_precision=2,
),
"WetDry": LaCrosseSensorEntityDescription(
key="WetDry",
@@ -117,6 +123,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
native_unit_of_measurement=UnitOfPressure.HPA,
+ suggested_display_precision=2,
),
"FeelsLike": LaCrosseSensorEntityDescription(
key="FeelsLike",
@@ -125,6 +132,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ suggested_display_precision=2,
),
"WindChill": LaCrosseSensorEntityDescription(
key="WindChill",
@@ -133,6 +141,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ suggested_display_precision=2,
),
}
# map of API returned unit of measurement strings to their corresponding unit of measurement
diff --git a/homeassistant/components/lacrosse_view/strings.json b/homeassistant/components/lacrosse_view/strings.json
index 8dc27ba259e..c5d9a11e49a 100644
--- a/homeassistant/components/lacrosse_view/strings.json
+++ b/homeassistant/components/lacrosse_view/strings.json
@@ -42,5 +42,10 @@
"name": "Wind chill"
}
}
+ },
+ "exceptions": {
+ "update_error": {
+ "message": "Error updating data"
+ }
}
}
diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py
index bc84c22d4a2..50c73f949a3 100644
--- a/homeassistant/components/letpot/__init__.py
+++ b/homeassistant/components/letpot/__init__.py
@@ -22,7 +22,12 @@ from .const import (
)
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
-PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME]
+PLATFORMS: list[Platform] = [
+ Platform.BINARY_SENSOR,
+ Platform.SENSOR,
+ Platform.SWITCH,
+ Platform.TIME,
+]
async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool:
diff --git a/homeassistant/components/letpot/binary_sensor.py b/homeassistant/components/letpot/binary_sensor.py
new file mode 100644
index 00000000000..bfc7a5ab4a7
--- /dev/null
+++ b/homeassistant/components/letpot/binary_sensor.py
@@ -0,0 +1,122 @@
+"""Support for LetPot binary sensor entities."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from letpot.models import DeviceFeature, LetPotDeviceStatus
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
+from .entity import LetPotEntity, LetPotEntityDescription
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class LetPotBinarySensorEntityDescription(
+ LetPotEntityDescription, BinarySensorEntityDescription
+):
+ """Describes a LetPot binary sensor entity."""
+
+ is_on_fn: Callable[[LetPotDeviceStatus], bool]
+
+
+BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = (
+ LetPotBinarySensorEntityDescription(
+ key="low_nutrients",
+ translation_key="low_nutrients",
+ is_on_fn=lambda status: bool(status.errors.low_nutrients),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ supported_fn=(
+ lambda coordinator: coordinator.data.errors.low_nutrients is not None
+ ),
+ ),
+ LetPotBinarySensorEntityDescription(
+ key="low_water",
+ translation_key="low_water",
+ is_on_fn=lambda status: bool(status.errors.low_water),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ supported_fn=lambda coordinator: coordinator.data.errors.low_water is not None,
+ ),
+ LetPotBinarySensorEntityDescription(
+ key="pump",
+ translation_key="pump",
+ is_on_fn=lambda status: status.pump_status == 1,
+ device_class=BinarySensorDeviceClass.RUNNING,
+ supported_fn=(
+ lambda coordinator: DeviceFeature.PUMP_STATUS
+ in coordinator.device_client.device_features
+ ),
+ ),
+ LetPotBinarySensorEntityDescription(
+ key="pump_error",
+ translation_key="pump_error",
+ is_on_fn=lambda status: bool(status.errors.pump_malfunction),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ supported_fn=(
+ lambda coordinator: coordinator.data.errors.pump_malfunction is not None
+ ),
+ ),
+ LetPotBinarySensorEntityDescription(
+ key="refill_error",
+ translation_key="refill_error",
+ is_on_fn=lambda status: bool(status.errors.refill_error),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ supported_fn=(
+ lambda coordinator: coordinator.data.errors.refill_error is not None
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: LetPotConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up LetPot binary sensor entities based on a config entry and device status/features."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ LetPotBinarySensorEntity(coordinator, description)
+ for description in BINARY_SENSORS
+ for coordinator in coordinators
+ if description.supported_fn(coordinator)
+ )
+
+
+class LetPotBinarySensorEntity(LetPotEntity, BinarySensorEntity):
+ """Defines a LetPot binary sensor entity."""
+
+ entity_description: LetPotBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: LetPotDeviceCoordinator,
+ description: LetPotBinarySensorEntityDescription,
+ ) -> None:
+ """Initialize LetPot binary sensor entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
+
+ @property
+ def is_on(self) -> bool:
+ """Return if the binary sensor is on."""
+ return self.entity_description.is_on_fn(self.coordinator.data)
diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py
index b4d505f4092..5e2c46fee84 100644
--- a/homeassistant/components/letpot/entity.py
+++ b/homeassistant/components/letpot/entity.py
@@ -1,18 +1,27 @@
"""Base class for LetPot entities."""
from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
from typing import Any, Concatenate
from letpot.exceptions import LetPotConnectionException, LetPotException
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LetPotDeviceCoordinator
+@dataclass(frozen=True, kw_only=True)
+class LetPotEntityDescription(EntityDescription):
+ """Description for all LetPot entities."""
+
+ supported_fn: Callable[[LetPotDeviceCoordinator], bool] = lambda _: True
+
+
class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]):
"""Defines a base LetPot entity."""
diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json
index 2a2b727adcd..43541b57150 100644
--- a/homeassistant/components/letpot/icons.json
+++ b/homeassistant/components/letpot/icons.json
@@ -1,5 +1,30 @@
{
"entity": {
+ "binary_sensor": {
+ "low_nutrients": {
+ "default": "mdi:beaker-alert",
+ "state": {
+ "off": "mdi:beaker"
+ }
+ },
+ "low_water": {
+ "default": "mdi:water-percent-alert",
+ "state": {
+ "off": "mdi:water-percent"
+ }
+ },
+ "pump": {
+ "default": "mdi:pump",
+ "state": {
+ "off": "mdi:pump-off"
+ }
+ }
+ },
+ "sensor": {
+ "water_level": {
+ "default": "mdi:water-percent"
+ }
+ },
"switch": {
"alarm_sound": {
"default": "mdi:bell-ring",
diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml
index 0eda413a461..9804a5ec3a4 100644
--- a/homeassistant/components/letpot/quality_scale.yaml
+++ b/homeassistant/components/letpot/quality_scale.yaml
@@ -44,7 +44,7 @@ rules:
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
- test-coverage: todo
+ test-coverage: done
# Gold
devices: done
@@ -59,9 +59,9 @@ rules:
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
- entity-category: todo
- entity-device-class: todo
- entity-disabled-by-default: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
diff --git a/homeassistant/components/letpot/sensor.py b/homeassistant/components/letpot/sensor.py
new file mode 100644
index 00000000000..b0b113eb063
--- /dev/null
+++ b/homeassistant/components/letpot/sensor.py
@@ -0,0 +1,110 @@
+"""Support for LetPot sensor entities."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from letpot.models import DeviceFeature, LetPotDeviceStatus, TemperatureUnit
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import PERCENTAGE, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
+from .entity import LetPotEntity, LetPotEntityDescription
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+LETPOT_TEMPERATURE_UNIT_HA_UNIT = {
+ TemperatureUnit.CELSIUS: UnitOfTemperature.CELSIUS,
+ TemperatureUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
+}
+
+
+@dataclass(frozen=True, kw_only=True)
+class LetPotSensorEntityDescription(LetPotEntityDescription, SensorEntityDescription):
+ """Describes a LetPot sensor entity."""
+
+ native_unit_of_measurement_fn: Callable[[LetPotDeviceStatus], str | None]
+ value_fn: Callable[[LetPotDeviceStatus], StateType]
+
+
+SENSORS: tuple[LetPotSensorEntityDescription, ...] = (
+ LetPotSensorEntityDescription(
+ key="temperature",
+ value_fn=lambda status: status.temperature_value,
+ native_unit_of_measurement_fn=(
+ lambda status: LETPOT_TEMPERATURE_UNIT_HA_UNIT[
+ status.temperature_unit or TemperatureUnit.CELSIUS
+ ]
+ ),
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ supported_fn=(
+ lambda coordinator: DeviceFeature.TEMPERATURE
+ in coordinator.device_client.device_features
+ ),
+ ),
+ LetPotSensorEntityDescription(
+ key="water_level",
+ translation_key="water_level",
+ value_fn=lambda status: status.water_level,
+ native_unit_of_measurement_fn=lambda _: PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ supported_fn=(
+ lambda coordinator: DeviceFeature.WATER_LEVEL
+ in coordinator.device_client.device_features
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: LetPotConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up LetPot sensor entities based on a device features."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ LetPotSensorEntity(coordinator, description)
+ for description in SENSORS
+ for coordinator in coordinators
+ if description.supported_fn(coordinator)
+ )
+
+
+class LetPotSensorEntity(LetPotEntity, SensorEntity):
+ """Defines a LetPot sensor entity."""
+
+ entity_description: LetPotSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: LetPotDeviceCoordinator,
+ description: LetPotSensorEntityDescription,
+ ) -> None:
+ """Initialize LetPot sensor entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
+
+ @property
+ def native_unit_of_measurement(self) -> str | None:
+ """Return the native unit of measurement."""
+ return self.entity_description.native_unit_of_measurement_fn(
+ self.coordinator.data
+ )
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json
index 12913085644..cdc5a36a15f 100644
--- a/homeassistant/components/letpot/strings.json
+++ b/homeassistant/components/letpot/strings.json
@@ -32,6 +32,28 @@
}
},
"entity": {
+ "binary_sensor": {
+ "low_nutrients": {
+ "name": "Low nutrients"
+ },
+ "low_water": {
+ "name": "Low water"
+ },
+ "pump": {
+ "name": "Pump"
+ },
+ "pump_error": {
+ "name": "Pump error"
+ },
+ "refill_error": {
+ "name": "Refill error"
+ }
+ },
+ "sensor": {
+ "water_level": {
+ "name": "Water level"
+ }
+ },
"switch": {
"alarm_sound": {
"name": "Alarm sound"
diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py
index 41150d1b1e9..0b00318c53b 100644
--- a/homeassistant/components/letpot/switch.py
+++ b/homeassistant/components/letpot/switch.py
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
-from .entity import LetPotEntity, exception_handler
+from .entity import LetPotEntity, LetPotEntityDescription, exception_handler
# Each change pushes a 'full' device status with the change. The library will cache
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
@@ -21,14 +21,33 @@ PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
-class LetPotSwitchEntityDescription(SwitchEntityDescription):
+class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescription):
"""Describes a LetPot switch entity."""
value_fn: Callable[[LetPotDeviceStatus], bool | None]
set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]]
-BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
+SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
+ LetPotSwitchEntityDescription(
+ key="alarm_sound",
+ translation_key="alarm_sound",
+ value_fn=lambda status: status.system_sound,
+ set_value_fn=lambda device_client, value: device_client.set_sound(value),
+ entity_category=EntityCategory.CONFIG,
+ supported_fn=lambda coordinator: coordinator.data.system_sound is not None,
+ ),
+ LetPotSwitchEntityDescription(
+ key="auto_mode",
+ translation_key="auto_mode",
+ value_fn=lambda status: status.water_mode == 1,
+ set_value_fn=lambda device_client, value: device_client.set_water_mode(value),
+ entity_category=EntityCategory.CONFIG,
+ supported_fn=(
+ lambda coordinator: DeviceFeature.PUMP_AUTO
+ in coordinator.device_client.device_features
+ ),
+ ),
LetPotSwitchEntityDescription(
key="power",
translation_key="power",
@@ -44,20 +63,6 @@ BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
),
)
-ALARM_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription(
- key="alarm_sound",
- translation_key="alarm_sound",
- value_fn=lambda status: status.system_sound,
- set_value_fn=lambda device_client, value: device_client.set_sound(value),
- entity_category=EntityCategory.CONFIG,
-)
-AUTO_MODE_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription(
- key="auto_mode",
- translation_key="auto_mode",
- value_fn=lambda status: status.water_mode == 1,
- set_value_fn=lambda device_client, value: device_client.set_water_mode(value),
- entity_category=EntityCategory.CONFIG,
-)
async def async_setup_entry(
@@ -69,19 +74,10 @@ async def async_setup_entry(
coordinators = entry.runtime_data
entities: list[SwitchEntity] = [
LetPotSwitchEntity(coordinator, description)
- for description in BASE_SWITCHES
+ for description in SWITCHES
for coordinator in coordinators
+ if description.supported_fn(coordinator)
]
- entities.extend(
- LetPotSwitchEntity(coordinator, ALARM_SWITCH)
- for coordinator in coordinators
- if coordinator.data.system_sound is not None
- )
- entities.extend(
- LetPotSwitchEntity(coordinator, AUTO_MODE_SWITCH)
- for coordinator in coordinators
- if DeviceFeature.PUMP_AUTO in coordinator.device_client.device_features
- )
async_add_entities(entities)
diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json
index 42ae5746f24..db33106da79 100644
--- a/homeassistant/components/lg_thinq/icons.json
+++ b/homeassistant/components/lg_thinq/icons.json
@@ -407,6 +407,12 @@
},
"power_level_for_location": {
"default": "mdi:radiator"
+ },
+ "cycle_count": {
+ "default": "mdi:counter"
+ },
+ "cycle_count_for_location": {
+ "default": "mdi:counter"
}
}
}
diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py
index bb190cccde9..95198d931a1 100644
--- a/homeassistant/components/lg_thinq/sensor.py
+++ b/homeassistant/components/lg_thinq/sensor.py
@@ -248,6 +248,24 @@ TEMPERATURE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT,
translation_key=ThinQProperty.CURRENT_TEMPERATURE,
),
+ ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE: SensorEntityDescription(
+ key=ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key=ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE,
+ ),
+ ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE: SensorEntityDescription(
+ key=ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key=ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE,
+ ),
+ ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE: SensorEntityDescription(
+ key=ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key=ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE,
+ ),
}
WATER_FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
ThinQProperty.USED_TIME: SensorEntityDescription(
@@ -341,6 +359,10 @@ TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
}
WASHER_SENSORS: tuple[SensorEntityDescription, ...] = (
+ SensorEntityDescription(
+ key=ThinQProperty.CYCLE_COUNT,
+ translation_key=ThinQProperty.CYCLE_COUNT,
+ ),
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
TIMER_SENSOR_DESC[TimerProperty.TOTAL],
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM],
@@ -470,6 +492,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
),
DeviceType.STYLER: WASHER_SENSORS,
+ DeviceType.SYSTEM_BOILER: (
+ TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE],
+ TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE],
+ TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE],
+ ),
DeviceType.WASHCOMBO_MAIN: WASHER_SENSORS,
DeviceType.WASHCOMBO_MINI: WASHER_SENSORS,
DeviceType.WASHER: WASHER_SENSORS,
diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json
index dee2d21e05a..359ac40e1f1 100644
--- a/homeassistant/components/lg_thinq/strings.json
+++ b/homeassistant/components/lg_thinq/strings.json
@@ -305,6 +305,15 @@
"current_temperature": {
"name": "Current temperature"
},
+ "room_air_current_temperature": {
+ "name": "Indoor temperature"
+ },
+ "room_in_water_current_temperature": {
+ "name": "Inlet temperature"
+ },
+ "room_out_water_current_temperature": {
+ "name": "Outlet temperature"
+ },
"temperature": {
"name": "Temperature"
},
@@ -848,6 +857,12 @@
},
"power_level_for_location": {
"name": "{location} power level"
+ },
+ "cycle_count": {
+ "name": "Cycles"
+ },
+ "cycle_count_for_location": {
+ "name": "{location} cycles"
}
},
"select": {
diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py
index 2fbabc12747..247282309e4 100644
--- a/homeassistant/components/lookin/__init__.py
+++ b/homeassistant/components/lookin/__init__.py
@@ -19,7 +19,7 @@ from aiolookin import (
)
from aiolookin.models import UDPCommandType, UDPEvent
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -192,12 +192,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER]
await manager.async_stop()
return unload_ok
diff --git a/homeassistant/components/lupusec/entity.py b/homeassistant/components/lupusec/entity.py
index dc0dac89dc8..8cfb559b84f 100644
--- a/homeassistant/components/lupusec/entity.py
+++ b/homeassistant/components/lupusec/entity.py
@@ -18,7 +18,7 @@ class LupusecDevice(Entity):
self._device = device
self._attr_unique_id = device.device_id
- def update(self):
+ def update(self) -> None:
"""Update automation state."""
self._device.refresh()
diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py
index f954be74f1d..5ab211ed87b 100644
--- a/homeassistant/components/lutron_caseta/entity.py
+++ b/homeassistant/components/lutron_caseta/entity.py
@@ -63,7 +63,7 @@ class LutronCasetaEntity(Entity):
info[ATTR_SUGGESTED_AREA] = area
self._attr_device_info = info
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state)
diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py
index e109b0418c9..a30b01694fa 100644
--- a/homeassistant/components/media_player/__init__.py
+++ b/homeassistant/components/media_player/__init__.py
@@ -52,7 +52,7 @@ from homeassistant.const import ( # noqa: F401
STATE_PLAYING,
STATE_STANDBY,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.deprecation import (
@@ -124,6 +124,7 @@ from .const import ( # noqa: F401
CONTENT_AUTH_EXPIRY_TIME,
DOMAIN,
REPEAT_MODES,
+ SERVICE_BROWSE_MEDIA,
SERVICE_CLEAR_PLAYLIST,
SERVICE_JOIN,
SERVICE_PLAY_MEDIA,
@@ -201,6 +202,12 @@ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = {
vol.Optional(ATTR_MEDIA_EXTRA, default={}): dict,
}
+MEDIA_PLAYER_BROWSE_MEDIA_SCHEMA = {
+ vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string,
+ vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string,
+}
+
+
ATTR_TO_PROPERTY = [
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
@@ -431,6 +438,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_play_media",
[MediaPlayerEntityFeature.PLAY_MEDIA],
)
+ component.async_register_entity_service(
+ SERVICE_BROWSE_MEDIA,
+ {
+ vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string,
+ vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string,
+ },
+ "async_browse_media",
+ supports_response=SupportsResponse.ONLY,
+ )
component.async_register_entity_service(
SERVICE_SHUFFLE_SET,
{vol.Required(ATTR_MEDIA_SHUFFLE): cv.boolean},
diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py
index ca2f3307846..387fdb05401 100644
--- a/homeassistant/components/media_player/const.py
+++ b/homeassistant/components/media_player/const.py
@@ -173,6 +173,7 @@ _DEPRECATED_MEDIA_TYPE_VIDEO = DeprecatedConstantEnum(MediaType.VIDEO, "2025.10"
SERVICE_CLEAR_PLAYLIST = "clear_playlist"
SERVICE_JOIN = "join"
SERVICE_PLAY_MEDIA = "play_media"
+SERVICE_BROWSE_MEDIA = "browse_media"
SERVICE_SELECT_SOUND_MODE = "select_sound_mode"
SERVICE_SELECT_SOURCE = "select_source"
SERVICE_UNJOIN = "unjoin"
diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json
index c11211c38ec..5008ea62d2e 100644
--- a/homeassistant/components/media_player/icons.json
+++ b/homeassistant/components/media_player/icons.json
@@ -32,6 +32,9 @@
}
},
"services": {
+ "browse_media": {
+ "service": "mdi:folder-search"
+ },
"clear_playlist": {
"service": "mdi:playlist-remove"
},
diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml
index 7338747b545..6b13a6b9c09 100644
--- a/homeassistant/components/media_player/services.yaml
+++ b/homeassistant/components/media_player/services.yaml
@@ -165,6 +165,22 @@ play_media:
selector:
boolean:
+browse_media:
+ target:
+ entity:
+ domain: media_player
+ fields:
+ media_content_type:
+ required: false
+ example: "music"
+ selector:
+ text:
+ media_content_id:
+ required: false
+ example: "A:ALBUMARTIST/Beatles"
+ selector:
+ text:
+
select_source:
target:
entity:
diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json
index be06ae22cdc..2127716cd66 100644
--- a/homeassistant/components/media_player/strings.json
+++ b/homeassistant/components/media_player/strings.json
@@ -260,6 +260,20 @@
}
}
},
+ "browse_media": {
+ "name": "Browse media",
+ "description": "Browses the available media.",
+ "fields": {
+ "media_content_id": {
+ "name": "Content ID",
+ "description": "The ID of the content to browse. Integration dependent."
+ },
+ "media_content_type": {
+ "name": "Content type",
+ "description": "The type of the content to browse, such as image, music, tv show, video, episode, channel, or playlist."
+ }
+ }
+ },
"select_source": {
"name": "Select source",
"description": "Sends the media player the command to change input source.",
diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py
index fa1664353e1..df06ffb75fc 100644
--- a/homeassistant/components/motion_blinds/__init__.py
+++ b/homeassistant/components/motion_blinds/__init__.py
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
from motionblinds import AsyncMotionMulticast
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -124,12 +124,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST])
hass.data[DOMAIN].pop(config_entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# No motion gateways left, stop Motion multicast
unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP)
unsub_stop()
diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py
index c9d76ebb8d5..4bb880311f9 100644
--- a/homeassistant/components/motionmount/binary_sensor.py
+++ b/homeassistant/components/motionmount/binary_sensor.py
@@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -31,6 +32,8 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.MOVING
_attr_translation_key = "motionmount_is_moving"
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_entity_registry_enabled_default = False
def __init__(
self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry
diff --git a/homeassistant/components/motionmount/icons.json b/homeassistant/components/motionmount/icons.json
new file mode 100644
index 00000000000..8d6d867f4d0
--- /dev/null
+++ b/homeassistant/components/motionmount/icons.json
@@ -0,0 +1,12 @@
+{
+ "entity": {
+ "sensor": {
+ "motionmount_error_status": {
+ "default": "mdi:alert-circle-outline",
+ "state": {
+ "none": "mdi:check-circle-outline"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml
index f8fee8739e9..8b210931eaf 100644
--- a/homeassistant/components/motionmount/quality_scale.yaml
+++ b/homeassistant/components/motionmount/quality_scale.yaml
@@ -56,12 +56,12 @@ rules:
dynamic-devices:
status: exempt
comment: Single device per config entry
- entity-category: todo
- entity-device-class: todo
- entity-disabled-by-default: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
entity-translations: done
exception-translations: done
- icon-translations: todo
+ icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py
index 4950e5d6662..28fe921d9ac 100644
--- a/homeassistant/components/motionmount/sensor.py
+++ b/homeassistant/components/motionmount/sensor.py
@@ -6,6 +6,7 @@ import motionmount
from motionmount import MotionMountSystemError
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -47,6 +48,8 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity):
"internal",
]
_attr_translation_key = "motionmount_error_status"
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_entity_registry_enabled_default = False
def __init__(
self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry
diff --git a/homeassistant/components/mqtt/async_client.py b/homeassistant/components/mqtt/async_client.py
index 5f90136df44..0467eb3a289 100644
--- a/homeassistant/components/mqtt/async_client.py
+++ b/homeassistant/components/mqtt/async_client.py
@@ -6,7 +6,14 @@ from functools import lru_cache
from types import TracebackType
from typing import Self
-from paho.mqtt.client import Client as MQTTClient
+from paho.mqtt.client import (
+ CallbackOnConnect_v2,
+ CallbackOnDisconnect_v2,
+ CallbackOnPublish_v2,
+ CallbackOnSubscribe_v2,
+ CallbackOnUnsubscribe_v2,
+ Client as MQTTClient,
+)
_MQTT_LOCK_COUNT = 7
@@ -44,6 +51,12 @@ class AsyncMQTTClient(MQTTClient):
that is not needed since we are running in an async event loop.
"""
+ on_connect: CallbackOnConnect_v2
+ on_disconnect: CallbackOnDisconnect_v2
+ on_publish: CallbackOnPublish_v2
+ on_subscribe: CallbackOnSubscribe_v2
+ on_unsubscribe: CallbackOnUnsubscribe_v2
+
def setup(self) -> None:
"""Set up the client.
diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py
index 3aca566dbfc..af62851e15b 100644
--- a/homeassistant/components/mqtt/client.py
+++ b/homeassistant/components/mqtt/client.py
@@ -311,8 +311,8 @@ class MqttClientSetup:
client_id = None
transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
self._client = AsyncMQTTClient(
- mqtt.CallbackAPIVersion.VERSION1,
- client_id,
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
+ client_id=client_id,
protocol=proto,
transport=transport, # type: ignore[arg-type]
reconnect_on_failure=False,
@@ -476,9 +476,9 @@ class MQTT:
mqttc.on_connect = self._async_mqtt_on_connect
mqttc.on_disconnect = self._async_mqtt_on_disconnect
mqttc.on_message = self._async_mqtt_on_message
- mqttc.on_publish = self._async_mqtt_on_callback
- mqttc.on_subscribe = self._async_mqtt_on_callback
- mqttc.on_unsubscribe = self._async_mqtt_on_callback
+ mqttc.on_publish = self._async_mqtt_on_publish
+ mqttc.on_subscribe = self._async_mqtt_on_subscribe_unsubscribe
+ mqttc.on_unsubscribe = self._async_mqtt_on_subscribe_unsubscribe
# suppress exceptions at callback
mqttc.suppress_exceptions = True
@@ -498,7 +498,7 @@ class MQTT:
def _async_reader_callback(self, client: mqtt.Client) -> None:
"""Handle reading data from the socket."""
if (status := client.loop_read(MAX_PACKETS_TO_READ)) != 0:
- self._async_on_disconnect(status)
+ self._async_handle_callback_exception(status)
@callback
def _async_start_misc_periodic(self) -> None:
@@ -593,7 +593,7 @@ class MQTT:
def _async_writer_callback(self, client: mqtt.Client) -> None:
"""Handle writing data to the socket."""
if (status := client.loop_write()) != 0:
- self._async_on_disconnect(status)
+ self._async_handle_callback_exception(status)
def _on_socket_register_write(
self, client: mqtt.Client, userdata: Any, sock: SocketType
@@ -983,9 +983,9 @@ class MQTT:
self,
_mqttc: mqtt.Client,
_userdata: None,
- _flags: dict[str, int],
- result_code: int,
- properties: mqtt.Properties | None = None,
+ _connect_flags: mqtt.ConnectFlags,
+ reason_code: mqtt.ReasonCode,
+ _properties: mqtt.Properties | None = None,
) -> None:
"""On connect callback.
@@ -993,19 +993,20 @@ class MQTT:
message.
"""
# pylint: disable-next=import-outside-toplevel
- import paho.mqtt.client as mqtt
- if result_code != mqtt.CONNACK_ACCEPTED:
- if result_code in (
- mqtt.CONNACK_REFUSED_BAD_USERNAME_PASSWORD,
- mqtt.CONNACK_REFUSED_NOT_AUTHORIZED,
- ):
+ if reason_code.is_failure:
+ # 24: Continue authentication
+ # 25: Re-authenticate
+ # 134: Bad user name or password
+ # 135: Not authorized
+ # 140: Bad authentication method
+ if reason_code.value in (24, 25, 134, 135, 140):
self._should_reconnect = False
self.hass.async_create_task(self.async_disconnect())
self.config_entry.async_start_reauth(self.hass)
_LOGGER.error(
"Unable to connect to the MQTT broker: %s",
- mqtt.connack_string(result_code),
+ reason_code.getName(), # type: ignore[no-untyped-call]
)
self._async_connection_result(False)
return
@@ -1016,7 +1017,7 @@ class MQTT:
"Connected to MQTT server %s:%s (%s)",
self.conf[CONF_BROKER],
self.conf.get(CONF_PORT, DEFAULT_PORT),
- result_code,
+ reason_code,
)
birth: dict[str, Any]
@@ -1153,18 +1154,32 @@ class MQTT:
self._mqtt_data.state_write_requests.process_write_state_requests(msg)
@callback
- def _async_mqtt_on_callback(
+ def _async_mqtt_on_publish(
self,
_mqttc: mqtt.Client,
_userdata: None,
mid: int,
- _granted_qos_reason: tuple[int, ...] | mqtt.ReasonCodes | None = None,
- _properties_reason: mqtt.ReasonCodes | None = None,
+ _reason_code: mqtt.ReasonCode,
+ _properties: mqtt.Properties | None,
) -> None:
+ """Publish callback."""
+ self._async_mqtt_on_callback(mid)
+
+ @callback
+ def _async_mqtt_on_subscribe_unsubscribe(
+ self,
+ _mqttc: mqtt.Client,
+ _userdata: None,
+ mid: int,
+ _reason_code: list[mqtt.ReasonCode],
+ _properties: mqtt.Properties | None,
+ ) -> None:
+ """Subscribe / Unsubscribe callback."""
+ self._async_mqtt_on_callback(mid)
+
+ @callback
+ def _async_mqtt_on_callback(self, mid: int) -> None:
"""Publish / Subscribe / Unsubscribe callback."""
- # The callback signature for on_unsubscribe is different from on_subscribe
- # see https://github.com/eclipse/paho.mqtt.python/issues/687
- # properties and reason codes are not used in Home Assistant
future = self._async_get_mid_future(mid)
if future.done() and (future.cancelled() or future.exception()):
# Timed out or cancelled
@@ -1180,19 +1195,28 @@ class MQTT:
self._pending_operations[mid] = future
return future
+ @callback
+ def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None:
+ """Handle a callback exception."""
+ # 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
+
+ _LOGGER.warning(
+ "Error returned from MQTT server: %s",
+ mqtt.error_string(status),
+ )
+
@callback
def _async_mqtt_on_disconnect(
self,
_mqttc: mqtt.Client,
_userdata: None,
- result_code: int,
+ _disconnect_flags: mqtt.DisconnectFlags,
+ reason_code: mqtt.ReasonCode,
properties: mqtt.Properties | None = None,
) -> None:
"""Disconnected callback."""
- self._async_on_disconnect(result_code)
-
- @callback
- def _async_on_disconnect(self, result_code: int) -> None:
if not self.connected:
# This function is re-entrant and may be called multiple times
# when there is a broken pipe error.
@@ -1203,11 +1227,11 @@ class MQTT:
self.connected = False
async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, False)
_LOGGER.log(
- logging.INFO if result_code == 0 else logging.DEBUG,
+ logging.INFO if reason_code == 0 else logging.DEBUG,
"Disconnected from MQTT server %s:%s (%s)",
self.conf[CONF_BROKER],
self.conf.get(CONF_PORT, DEFAULT_PORT),
- result_code,
+ reason_code,
)
@callback
diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py
index a9d417fc783..22568b0f2b8 100644
--- a/homeassistant/components/mqtt/config_flow.py
+++ b/homeassistant/components/mqtt/config_flow.py
@@ -1023,14 +1023,14 @@ def try_connection(
result: queue.Queue[bool] = queue.Queue(maxsize=1)
def on_connect(
- client_: mqtt.Client,
- userdata: None,
- flags: dict[str, Any],
- result_code: int,
- properties: mqtt.Properties | None = None,
+ _mqttc: mqtt.Client,
+ _userdata: None,
+ _connect_flags: mqtt.ConnectFlags,
+ reason_code: mqtt.ReasonCode,
+ _properties: mqtt.Properties | None = None,
) -> None:
"""Handle connection result."""
- result.put(result_code == mqtt.CONNACK_ACCEPTED)
+ result.put(not reason_code.is_failure)
client.on_connect = on_connect
diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py
index 67c14bbf544..af85f1fc5ae 100644
--- a/homeassistant/components/nest/__init__.py
+++ b/homeassistant/components/nest/__init__.py
@@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable
from http import HTTPStatus
import logging
-from aiohttp import web
+from aiohttp import ClientError, ClientResponseError, web
from google_nest_sdm.camera_traits import CameraClipPreviewTrait
from google_nest_sdm.device import Device
from google_nest_sdm.event import EventMessage
@@ -201,11 +201,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool
auth = await api.new_auth(hass, entry)
try:
await auth.async_get_access_token()
- except AuthException as err:
- raise ConfigEntryAuthFailed(f"Authentication error: {err!s}") from err
- except ConfigurationException as err:
- _LOGGER.error("Configuration error: %s", err)
- return False
+ except ClientResponseError as err:
+ if 400 <= err.status < 500:
+ raise ConfigEntryAuthFailed from err
+ raise ConfigEntryNotReady from err
+ except ClientError as err:
+ raise ConfigEntryNotReady from err
subscriber = await api.new_subscriber(hass, entry, auth)
if not subscriber:
diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py
index f7a683326d3..c8ecd8e7e1d 100644
--- a/homeassistant/components/netgear/const.py
+++ b/homeassistant/components/netgear/const.py
@@ -62,6 +62,7 @@ MODELS_V2 = [
"RBR",
"RBS",
"RBW",
+ "RS",
"LBK",
"LBR",
"CBK",
diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py
index a756d85c866..47a39a39be0 100644
--- a/homeassistant/components/netgear_lte/__init__.py
+++ b/homeassistant/components/netgear_lte/__init__.py
@@ -6,7 +6,6 @@ from aiohttp.cookiejar import CookieJar
import eternalegypt
from eternalegypt.eternalegypt import SMS
-from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -117,12 +116,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -
async def async_unload_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
hass.data.pop(DOMAIN, None)
for service_name in hass.services.async_services()[DOMAIN]:
hass.services.async_remove(DOMAIN, service_name)
diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py
index 10046f75127..200cce86997 100644
--- a/homeassistant/components/network/__init__.py
+++ b/homeassistant/components/network/__init__.py
@@ -20,7 +20,7 @@ from .const import (
PUBLIC_TARGET_IP,
)
from .models import Adapter
-from .network import Network, async_get_network
+from .network import Network, async_get_loaded_network, async_get_network
_LOGGER = logging.getLogger(__name__)
@@ -34,6 +34,12 @@ async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]:
return network.adapters
+@callback
+def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]:
+ """Get the network adapter configuration."""
+ return async_get_loaded_network(hass).adapters
+
+
@bind_hass
async def async_get_source_ip(
hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED
@@ -74,7 +80,14 @@ async def async_get_enabled_source_ips(
hass: HomeAssistant,
) -> list[IPv4Address | IPv6Address]:
"""Build the list of enabled source ips."""
- adapters = await async_get_adapters(hass)
+ return async_get_enabled_source_ips_from_adapters(await async_get_adapters(hass))
+
+
+@callback
+def async_get_enabled_source_ips_from_adapters(
+ adapters: list[Adapter],
+) -> list[IPv4Address | IPv6Address]:
+ """Build the list of enabled source ips."""
sources: list[IPv4Address | IPv6Address] = []
for adapter in adapters:
if not adapter["enabled"]:
@@ -151,5 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_websocket_commands,
)
+ await async_get_network(hass)
+
async_register_websocket_commands(hass)
return True
diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py
index 120ae9dfd7c..d8c8858be72 100644
--- a/homeassistant/components/network/const.py
+++ b/homeassistant/components/network/const.py
@@ -12,8 +12,6 @@ DOMAIN: Final = "network"
STORAGE_KEY: Final = "core.network"
STORAGE_VERSION: Final = 1
-DATA_NETWORK: Final = "network"
-
ATTR_ADAPTERS: Final = "adapters"
ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters"
DEFAULT_CONFIGURED_ADAPTERS: list[str] = []
diff --git a/homeassistant/components/network/network.py b/homeassistant/components/network/network.py
index 4158307bb1a..db25bedcaea 100644
--- a/homeassistant/components/network/network.py
+++ b/homeassistant/components/network/network.py
@@ -9,11 +9,12 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.async_ import create_eager_task
+from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_CONFIGURED_ADAPTERS,
- DATA_NETWORK,
DEFAULT_CONFIGURED_ADAPTERS,
+ DOMAIN,
STORAGE_KEY,
STORAGE_VERSION,
)
@@ -22,8 +23,16 @@ from .util import async_load_adapters, enable_adapters, enable_auto_detected_ada
_LOGGER = logging.getLogger(__name__)
+DATA_NETWORK: HassKey[Network] = HassKey(DOMAIN)
-@singleton(DATA_NETWORK)
+
+@callback
+def async_get_loaded_network(hass: HomeAssistant) -> Network:
+ """Get network singleton."""
+ return hass.data[DATA_NETWORK]
+
+
+@singleton(DOMAIN)
async def async_get_network(hass: HomeAssistant) -> Network:
"""Get network singleton."""
network = Network(hass)
diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py
index e486263fbe5..ffdd9dbd518 100644
--- a/homeassistant/components/nice_go/coordinator.py
+++ b/homeassistant/components/nice_go/coordinator.py
@@ -153,7 +153,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
)
try:
if datetime.now().timestamp() >= expiry_time:
- await self._update_refresh_token()
+ await self.update_refresh_token()
else:
await self.api.authenticate_refresh(
self.refresh_token, async_get_clientsession(self.hass)
@@ -178,7 +178,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
else:
self.async_set_updated_data(devices)
- async def _update_refresh_token(self) -> None:
+ async def update_refresh_token(self) -> None:
"""Update the refresh token with Nice G.O. API."""
_LOGGER.debug("Updating the refresh token with Nice G.O. API")
try:
diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py
index 03124971410..b9b39711a01 100644
--- a/homeassistant/components/nice_go/cover.py
+++ b/homeassistant/components/nice_go/cover.py
@@ -2,21 +2,17 @@
from typing import Any
-from aiohttp import ClientError
-from nice_go import ApiError
-
from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
from .coordinator import NiceGOConfigEntry
from .entity import NiceGOEntity
+from .util import retry
DEVICE_CLASSES = {
"WallStation": CoverDeviceClass.GARAGE,
@@ -71,30 +67,18 @@ class NiceGOCoverEntity(NiceGOEntity, CoverEntity):
"""Return if cover is closing."""
return self.data.barrier_status == "closing"
+ @retry("close_cover_error")
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the garage door."""
if self.is_closed:
return
- try:
- await self.coordinator.api.close_barrier(self._device_id)
- except (ApiError, ClientError) as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="close_cover_error",
- translation_placeholders={"exception": str(err)},
- ) from err
+ await self.coordinator.api.close_barrier(self._device_id)
+ @retry("open_cover_error")
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the garage door."""
if self.is_opened:
return
- try:
- await self.coordinator.api.open_barrier(self._device_id)
- except (ApiError, ClientError) as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="open_cover_error",
- translation_placeholders={"exception": str(err)},
- ) from err
+ await self.coordinator.api.open_barrier(self._device_id)
diff --git a/homeassistant/components/nice_go/light.py b/homeassistant/components/nice_go/light.py
index 5b06c02f5db..bf283ed6eff 100644
--- a/homeassistant/components/nice_go/light.py
+++ b/homeassistant/components/nice_go/light.py
@@ -3,23 +3,19 @@
import logging
from typing import TYPE_CHECKING, Any
-from aiohttp import ClientError
-from nice_go import ApiError
-
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
- DOMAIN,
KNOWN_UNSUPPORTED_DEVICE_TYPES,
SUPPORTED_DEVICE_TYPES,
UNSUPPORTED_DEVICE_WARNING,
)
from .coordinator import NiceGOConfigEntry
from .entity import NiceGOEntity
+from .util import retry
_LOGGER = logging.getLogger(__name__)
@@ -63,26 +59,14 @@ class NiceGOLightEntity(NiceGOEntity, LightEntity):
assert self.data.light_status is not None
return self.data.light_status
+ @retry("light_on_error")
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
- try:
- await self.coordinator.api.light_on(self._device_id)
- except (ApiError, ClientError) as error:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="light_on_error",
- translation_placeholders={"exception": str(error)},
- ) from error
+ await self.coordinator.api.light_on(self._device_id)
+ @retry("light_off_error")
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
- try:
- await self.coordinator.api.light_off(self._device_id)
- except (ApiError, ClientError) as error:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="light_off_error",
- translation_placeholders={"exception": str(error)},
- ) from error
+ await self.coordinator.api.light_off(self._device_id)
diff --git a/homeassistant/components/nice_go/switch.py b/homeassistant/components/nice_go/switch.py
index e81ea489d2f..f043a23eab5 100644
--- a/homeassistant/components/nice_go/switch.py
+++ b/homeassistant/components/nice_go/switch.py
@@ -5,23 +5,19 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
-from aiohttp import ClientError
-from nice_go import ApiError
-
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
- DOMAIN,
KNOWN_UNSUPPORTED_DEVICE_TYPES,
SUPPORTED_DEVICE_TYPES,
UNSUPPORTED_DEVICE_WARNING,
)
from .coordinator import NiceGOConfigEntry
from .entity import NiceGOEntity
+from .util import retry
_LOGGER = logging.getLogger(__name__)
@@ -65,26 +61,14 @@ class NiceGOSwitchEntity(NiceGOEntity, SwitchEntity):
assert self.data.vacation_mode is not None
return self.data.vacation_mode
+ @retry("switch_on_error")
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
- try:
- await self.coordinator.api.vacation_mode_on(self.data.id)
- except (ApiError, ClientError) as error:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="switch_on_error",
- translation_placeholders={"exception": str(error)},
- ) from error
+ await self.coordinator.api.vacation_mode_on(self.data.id)
+ @retry("switch_off_error")
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
- try:
- await self.coordinator.api.vacation_mode_off(self.data.id)
- except (ApiError, ClientError) as error:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="switch_off_error",
- translation_placeholders={"exception": str(error)},
- ) from error
+ await self.coordinator.api.vacation_mode_off(self.data.id)
diff --git a/homeassistant/components/nice_go/util.py b/homeassistant/components/nice_go/util.py
new file mode 100644
index 00000000000..02dee6b0ac1
--- /dev/null
+++ b/homeassistant/components/nice_go/util.py
@@ -0,0 +1,66 @@
+"""Utilities for Nice G.O."""
+
+from collections.abc import Callable, Coroutine
+from functools import wraps
+from typing import Any, Protocol, runtime_checkable
+
+from aiohttp import ClientError
+from nice_go import ApiError, AuthFailedError
+
+from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
+from homeassistant.helpers.update_coordinator import UpdateFailed
+
+from .const import DOMAIN
+
+
+@runtime_checkable
+class _ArgsProtocol(Protocol):
+ coordinator: Any
+ hass: Any
+
+
+def retry[_R, **P](
+ translation_key: str,
+) -> Callable[
+ [Callable[P, Coroutine[Any, Any, _R]]], Callable[P, Coroutine[Any, Any, _R]]
+]:
+ """Retry decorator to handle API errors."""
+
+ def decorator(
+ func: Callable[P, Coroutine[Any, Any, _R]],
+ ) -> Callable[P, Coroutine[Any, Any, _R]]:
+ @wraps(func)
+ async def wrapper(*args: P.args, **kwargs: P.kwargs):
+ instance = args[0]
+ if not isinstance(instance, _ArgsProtocol):
+ raise TypeError("First argument must have correct attributes")
+ try:
+ return await func(*args, **kwargs)
+ except (ApiError, ClientError) as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=translation_key,
+ translation_placeholders={"exception": str(err)},
+ ) from err
+ except AuthFailedError:
+ # Try refreshing token and retry
+ try:
+ await instance.coordinator.update_refresh_token()
+ return await func(*args, **kwargs)
+ except (ApiError, ClientError, UpdateFailed) as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=translation_key,
+ translation_placeholders={"exception": str(err)},
+ ) from err
+ except (AuthFailedError, ConfigEntryAuthFailed) as err:
+ instance.coordinator.config_entry.async_start_reauth(instance.hass)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=translation_key,
+ translation_placeholders={"exception": str(err)},
+ ) from err
+
+ return wrapper
+
+ return decorator
diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json
index e832bfc248a..b33af360448 100644
--- a/homeassistant/components/notify/strings.json
+++ b/homeassistant/components/notify/strings.json
@@ -24,7 +24,7 @@
},
"data": {
"name": "Data",
- "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation."
+ "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation."
}
}
},
@@ -56,7 +56,7 @@
},
"data": {
"name": "Data",
- "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation.."
+ "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation."
}
}
}
diff --git a/homeassistant/components/nws/strings.json b/homeassistant/components/nws/strings.json
index c9ee8349631..72b6a2c86b6 100644
--- a/homeassistant/components/nws/strings.json
+++ b/homeassistant/components/nws/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, an API Key can be anything. It is recommended to use a valid email address.",
+ "description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, the API key can be anything. It is recommended to use a valid email address.",
"title": "Connect to the National Weather Service",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
@@ -30,12 +30,12 @@
},
"services": {
"get_forecasts_extra": {
- "name": "Get extra forecasts data.",
- "description": "Get extra data for weather forecasts.",
+ "name": "Get extra forecasts data",
+ "description": "Retrieves extra data for weather forecasts.",
"fields": {
"type": {
"name": "Forecast type",
- "description": "Forecast type: hourly or twice_daily."
+ "description": "The scope of the weather forecast."
}
}
}
diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json
index 7a27156b2fe..ade48b4f80f 100644
--- a/homeassistant/components/ohme/icons.json
+++ b/homeassistant/components/ohme/icons.json
@@ -6,6 +6,9 @@
}
},
"number": {
+ "preconditioning_duration": {
+ "default": "mdi:fan-clock"
+ },
"target_percentage": {
"default": "mdi:battery-heart"
}
diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json
index 100967f819f..c1ca2bac62f 100644
--- a/homeassistant/components/ohme/manifest.json
+++ b/homeassistant/components/ohme/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "silver",
- "requirements": ["ohme==1.2.9"]
+ "requirements": ["ohme==1.3.0"]
}
diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py
index 8c5be2b48be..0c71bab009f 100644
--- a/homeassistant/components/ohme/number.py
+++ b/homeassistant/components/ohme/number.py
@@ -6,7 +6,7 @@ from dataclasses import dataclass
from ohme import ApiException, OhmeApiClient
from homeassistant.components.number import NumberEntity, NumberEntityDescription
-from homeassistant.const import PERCENTAGE
+from homeassistant.const import PERCENTAGE, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -37,6 +37,18 @@ NUMBER_DESCRIPTION = [
native_step=1,
native_unit_of_measurement=PERCENTAGE,
),
+ OhmeNumberDescription(
+ key="preconditioning_duration",
+ translation_key="preconditioning_duration",
+ value_fn=lambda client: client.preconditioning,
+ set_fn=lambda client, value: client.async_set_target(
+ pre_condition_length=value
+ ),
+ native_min_value=0,
+ native_max_value=60,
+ native_step=5,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ ),
]
diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json
index eb5bbffda52..46ccfca71fd 100644
--- a/homeassistant/components/ohme/strings.json
+++ b/homeassistant/components/ohme/strings.json
@@ -51,6 +51,9 @@
}
},
"number": {
+ "preconditioning_duration": {
+ "name": "Preconditioning duration"
+ },
"target_percentage": {
"name": "Target percentage"
}
@@ -73,7 +76,8 @@
"plugged_in": "Plugged in",
"charging": "Charging",
"paused": "[%key:common::state::paused%]",
- "pending_approval": "Pending approval"
+ "pending_approval": "Pending approval",
+ "finished": "Finished charging"
}
},
"ct_current": {
diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py
index cb0dc4fdfa7..ea955987d80 100644
--- a/homeassistant/components/onboarding/views.py
+++ b/homeassistant/components/onboarding/views.py
@@ -33,7 +33,6 @@ from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.helpers.translation import async_get_translations
from homeassistant.setup import async_setup_component
-from homeassistant.util.async_ import create_eager_task
if TYPE_CHECKING:
from . import OnboardingData, OnboardingStorage, OnboardingStoreData
@@ -235,22 +234,21 @@ class CoreConfigOnboardingView(_BaseOnboardingView):
):
onboard_integrations.append("rpi_power")
- coros: list[Coroutine[Any, Any, Any]] = [
- hass.config_entries.flow.async_init(
- domain, context={"source": "onboarding"}
+ for domain in onboard_integrations:
+ # Create tasks so onboarding isn't affected
+ # by errors in these integrations.
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ domain, context={"source": "onboarding"}
+ ),
+ f"onboarding_setup_{domain}",
)
- for domain in onboard_integrations
- ]
if "analytics" not in hass.config.components:
# If by some chance that analytics has not finished
# setting up, wait for it here so its ready for the
# next step.
- coros.append(async_setup_component(hass, "analytics", {}))
-
- # Set up integrations after onboarding and ensure
- # analytics is ready for the next step.
- await asyncio.gather(*(create_eager_task(coro) for coro in coros))
+ await async_setup_component(hass, "analytics", {})
return self.json({})
diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py
index 8355cddb0b5..c82757dca31 100644
--- a/homeassistant/components/onedrive/__init__.py
+++ b/homeassistant/components/onedrive/__init__.py
@@ -2,8 +2,6 @@
from __future__ import annotations
-from collections.abc import Awaitable, Callable
-from dataclasses import dataclass
from html import unescape
from json import dumps, loads
import logging
@@ -17,8 +15,7 @@ from onedrive_personal_sdk.exceptions import (
)
from onedrive_personal_sdk.models.items import ItemUpdate
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_ACCESS_TOKEN
+from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -29,18 +26,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+from .coordinator import (
+ OneDriveConfigEntry,
+ OneDriveRuntimeData,
+ OneDriveUpdateCoordinator,
+)
+PLATFORMS = [Platform.SENSOR]
-@dataclass
-class OneDriveRuntimeData:
- """Runtime data for the OneDrive integration."""
-
- client: OneDriveClient
- token_function: Callable[[], Awaitable[str]]
- backup_folder_id: str
-
-
-type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData]
_LOGGER = logging.getLogger(__name__)
@@ -85,10 +78,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
translation_placeholders={"folder": backup_folder_name},
) from err
+ coordinator = OneDriveUpdateCoordinator(hass, entry, client)
+ await coordinator.async_config_entry_first_refresh()
+
entry.runtime_data = OneDriveRuntimeData(
client=client,
token_function=get_access_token,
backup_folder_id=backup_folder.id,
+ coordinator=coordinator,
)
try:
@@ -100,6 +97,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
) from err
_async_notify_backup_listeners_soon(hass)
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -107,7 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
"""Unload a OneDrive config entry."""
_async_notify_backup_listeners_soon(hass)
- return True
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py
index 343c332f384..674708b0cb3 100644
--- a/homeassistant/components/onedrive/backup.py
+++ b/homeassistant/components/onedrive/backup.py
@@ -25,13 +25,14 @@ from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupAgentError,
+ BackupNotFound,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from . import OneDriveConfigEntry
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+from .coordinator import OneDriveConfigEntry
_LOGGER = logging.getLogger(__name__)
UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB
@@ -137,7 +138,7 @@ class OneDriveBackupAgent(BackupAgent):
"""Download a backup file."""
backups = await self._list_cached_backups()
if backup_id not in backups:
- raise BackupAgentError("Backup not found")
+ raise BackupNotFound("Backup not found")
stream = await self._client.download_drive_item(
backups[backup_id].backup_file_id, timeout=TIMEOUT
diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py
new file mode 100644
index 00000000000..7b2dbaab87a
--- /dev/null
+++ b/homeassistant/components/onedrive/coordinator.py
@@ -0,0 +1,95 @@
+"""Coordinator for OneDrive."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+
+from onedrive_personal_sdk import OneDriveClient
+from onedrive_personal_sdk.const import DriveState
+from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException
+from onedrive_personal_sdk.models.items import Drive
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import issue_registry as ir
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class OneDriveRuntimeData:
+ """Runtime data for the OneDrive integration."""
+
+ client: OneDriveClient
+ token_function: Callable[[], Awaitable[str]]
+ backup_folder_id: str
+ coordinator: OneDriveUpdateCoordinator
+
+
+type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData]
+
+
+class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]):
+ """Class to handle fetching data from the Graph API centrally."""
+
+ config_entry: OneDriveConfigEntry
+
+ def __init__(
+ self, hass: HomeAssistant, entry: OneDriveConfigEntry, client: OneDriveClient
+ ) -> None:
+ """Initialize coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+ self._client = client
+
+ async def _async_update_data(self) -> Drive:
+ """Fetch data from API endpoint."""
+
+ try:
+ drive = await self._client.get_drive()
+ except AuthenticationError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN, translation_key="authentication_failed"
+ ) from err
+ except OneDriveException as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="update_failed"
+ ) from err
+
+ # create an issue if the drive is almost full
+ if drive.quota and (state := drive.quota.state) in (
+ DriveState.CRITICAL,
+ DriveState.EXCEEDED,
+ ):
+ key = "drive_full" if state is DriveState.EXCEEDED else "drive_almost_full"
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ key,
+ is_fixable=False,
+ severity=(
+ ir.IssueSeverity.ERROR
+ if state is DriveState.EXCEEDED
+ else ir.IssueSeverity.WARNING
+ ),
+ translation_key=key,
+ translation_placeholders={
+ "total": str(drive.quota.total),
+ "used": str(drive.quota.used),
+ },
+ )
+ return drive
diff --git a/homeassistant/components/onedrive/icons.json b/homeassistant/components/onedrive/icons.json
new file mode 100644
index 00000000000..b693f69934e
--- /dev/null
+++ b/homeassistant/components/onedrive/icons.json
@@ -0,0 +1,24 @@
+{
+ "entity": {
+ "sensor": {
+ "total_size": {
+ "default": "mdi:database"
+ },
+ "used_size": {
+ "default": "mdi:database"
+ },
+ "remaining_size": {
+ "default": "mdi:database"
+ },
+ "drive_state": {
+ "default": "mdi:harddisk",
+ "state": {
+ "normal": "mdi:harddisk",
+ "nearing": "mdi:alert-circle-outline",
+ "critical": "mdi:alert",
+ "exceeded": "mdi:alert-octagon"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml
index f0d58d89c9a..84b980c5e01 100644
--- a/homeassistant/components/onedrive/quality_scale.yaml
+++ b/homeassistant/components/onedrive/quality_scale.yaml
@@ -3,10 +3,7 @@ rules:
action-setup:
status: exempt
comment: Integration does not register custom actions.
- appropriate-polling:
- status: exempt
- comment: |
- This integration does not poll.
+ appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
@@ -23,14 +20,8 @@ rules:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
- entity-unique-id:
- status: exempt
- comment: |
- This integration does not have entities.
- has-entity-name:
- status: exempt
- comment: |
- This integration does not have entities.
+ entity-unique-id: done
+ has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
@@ -44,27 +35,15 @@ rules:
comment: |
No Options flow.
docs-installation-parameters: done
- entity-unavailable:
- status: exempt
- comment: |
- This integration does not have entities.
+ entity-unavailable: done
integration-owner: done
- log-when-unavailable:
- status: exempt
- comment: |
- This integration does not have entities.
- parallel-updates:
- status: exempt
- comment: |
- This integration does not have platforms.
+ log-when-unavailable: done
+ parallel-updates: done
reauthentication-flow: done
- test-coverage: todo
+ test-coverage: done
# Gold
- devices:
- status: exempt
- comment: |
- This integration connects to a single service.
+ devices: done
diagnostics:
status: exempt
comment: |
@@ -77,53 +56,26 @@ rules:
status: exempt
comment: |
This integration is a cloud service and does not support discovery.
- docs-data-update:
- status: exempt
- comment: |
- This integration does not poll or push.
- docs-examples:
- status: exempt
- comment: |
- This integration only serves backup.
+ docs-data-update: done
+ docs-examples: done
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: |
This integration is a cloud service.
- docs-supported-functions:
- status: exempt
- comment: |
- This integration does not have entities.
- docs-troubleshooting:
- status: exempt
- comment: |
- No issues known to troubleshoot.
+ docs-supported-functions: done
+ docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
This integration connects to a single service.
- entity-category:
- status: exempt
- comment: |
- This integration does not have entities.
- entity-device-class:
- status: exempt
- comment: |
- This integration does not have entities.
- entity-disabled-by-default:
- status: exempt
- comment: |
- This integration does not have entities.
- entity-translations:
- status: exempt
- comment: |
- This integration does not have entities.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
exception-translations: done
- icon-translations:
- status: exempt
- comment: |
- This integration does not have entities.
+ icon-translations: done
reconfiguration-flow:
status: exempt
comment: |
diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py
new file mode 100644
index 00000000000..0ca2b166e3f
--- /dev/null
+++ b/homeassistant/components/onedrive/sensor.py
@@ -0,0 +1,122 @@
+"""Sensors for OneDrive."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from onedrive_personal_sdk.const import DriveState
+from onedrive_personal_sdk.models.items import DriveQuota
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
+from homeassistant.const import EntityCategory, UnitOfInformation
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import OneDriveConfigEntry, OneDriveUpdateCoordinator
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(kw_only=True, frozen=True)
+class OneDriveSensorEntityDescription(SensorEntityDescription):
+ """Describes OneDrive sensor entity."""
+
+ value_fn: Callable[[DriveQuota], StateType]
+
+
+DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = (
+ OneDriveSensorEntityDescription(
+ key="total_size",
+ value_fn=lambda quota: quota.total,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+ suggested_display_precision=0,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ OneDriveSensorEntityDescription(
+ key="used_size",
+ value_fn=lambda quota: quota.used,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ OneDriveSensorEntityDescription(
+ key="remaining_size",
+ value_fn=lambda quota: quota.remaining,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ OneDriveSensorEntityDescription(
+ key="drive_state",
+ value_fn=lambda quota: quota.state.value,
+ options=[state.value for state in DriveState],
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: OneDriveConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up OneDrive sensors based on a config entry."""
+ coordinator = entry.runtime_data.coordinator
+ async_add_entities(
+ OneDriveDriveStateSensor(coordinator, description)
+ for description in DRIVE_STATE_ENTITIES
+ )
+
+
+class OneDriveDriveStateSensor(
+ CoordinatorEntity[OneDriveUpdateCoordinator], SensorEntity
+):
+ """Define a OneDrive sensor."""
+
+ entity_description: OneDriveSensorEntityDescription
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: OneDriveUpdateCoordinator,
+ description: OneDriveSensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_translation_key = description.key
+ self._attr_unique_id = f"{coordinator.data.id}_{description.key}"
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ name=coordinator.data.name,
+ identifiers={(DOMAIN, coordinator.data.id)},
+ manufacturer="Microsoft",
+ model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}",
+ configuration_url=f"https://onedrive.live.com/?id=root&cid={coordinator.data.id}",
+ )
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ assert self.coordinator.data.quota
+ return self.entity_description.value_fn(self.coordinator.data.quota)
+
+ @property
+ def available(self) -> bool:
+ """Availability of the sensor."""
+ return super().available and self.coordinator.data.quota is not None
diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json
index ebc46d3eb12..20d139a4bc0 100644
--- a/homeassistant/components/onedrive/strings.json
+++ b/homeassistant/components/onedrive/strings.json
@@ -29,6 +29,16 @@
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
+ "issues": {
+ "drive_full": {
+ "title": "OneDrive data cap exceeded",
+ "description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB."
+ },
+ "drive_almost_full": {
+ "title": "OneDrive near data cap",
+ "description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB."
+ }
+ },
"exceptions": {
"authentication_failed": {
"message": "Authentication failed"
@@ -38,6 +48,31 @@
},
"failed_to_migrate_files": {
"message": "Failed to migrate metadata to separate files"
+ },
+ "update_failed": {
+ "message": "Failed to update drive state"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "total_size": {
+ "name": "Total available storage"
+ },
+ "used_size": {
+ "name": "Used storage"
+ },
+ "remaining_size": {
+ "name": "Remaining storage"
+ },
+ "drive_state": {
+ "name": "Drive state",
+ "state": {
+ "normal": "Normal",
+ "nearing": "Nearing limit",
+ "critical": "Critical",
+ "exceeded": "Exceeded"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/onvif/entity.py b/homeassistant/components/onvif/entity.py
index c9900106256..783df743e86 100644
--- a/homeassistant/components/onvif/entity.py
+++ b/homeassistant/components/onvif/entity.py
@@ -17,7 +17,7 @@ class ONVIFBaseEntity(Entity):
self.device: ONVIFDevice = device
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if device is available."""
return self.device.available
diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json
index 405af126c03..b49dea4a267 100644
--- a/homeassistant/components/opentherm_gw/strings.json
+++ b/homeassistant/components/opentherm_gw/strings.json
@@ -385,7 +385,7 @@
},
"set_central_heating_ovrd": {
"name": "Set central heating override",
- "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the set_control_setpoint action with temperature value 0. You will only need this if you are writing your own software thermostat.",
+ "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a 'Set control set point' action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the 'Set control set point' action with temperature value 0. You will only need this if you are writing your own software thermostat.",
"fields": {
"gateway_id": {
"name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]",
@@ -393,7 +393,7 @@
},
"ch_override": {
"name": "Central heating override",
- "description": "The desired boolean value for the central heating override."
+ "description": "Whether to enable or disable the override."
}
}
},
diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json
index d168cba5752..2da4511c0aa 100644
--- a/homeassistant/components/opower/manifest.json
+++ b/homeassistant/components/opower/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/opower",
"iot_class": "cloud_polling",
"loggers": ["opower"],
- "requirements": ["opower==0.8.9"]
+ "requirements": ["opower==0.9.0"]
}
diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py
index 4b95be1d40d..0756f32ab18 100644
--- a/homeassistant/components/otbr/__init__.py
+++ b/homeassistant/components/otbr/__init__.py
@@ -7,16 +7,20 @@ import logging
import aiohttp
import python_otbr_api
+from homeassistant.components.homeassistant_hardware.helpers import (
+ async_notify_firmware_info,
+ async_register_firmware_info_provider,
+)
from homeassistant.components.thread import async_add_dataset
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
-from . import websocket_api
+from . import homeassistant_hardware, websocket_api
from .const import DOMAIN
+from .types import OTBRConfigEntry
from .util import (
GetBorderAgentIdNotSupported,
OTBRData,
@@ -28,12 +32,13 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
-type OTBRConfigEntry = ConfigEntry[OTBRData]
-
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Open Thread Border Router component."""
websocket_api.async_setup(hass)
+
+ async_register_firmware_info_provider(hass, DOMAIN, homeassistant_hardware)
+
return True
@@ -77,6 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
entry.runtime_data = otbrdata
+ if fw_info := await homeassistant_hardware.async_get_firmware_info(hass, entry):
+ await async_notify_firmware_info(hass, DOMAIN, fw_info)
+
return True
diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py
index aff79ca4651..514f6c7617c 100644
--- a/homeassistant/components/otbr/config_flow.py
+++ b/homeassistant/components/otbr/config_flow.py
@@ -16,7 +16,12 @@ import yarl
from homeassistant.components.hassio import AddonError, AddonManager
from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
from homeassistant.components.thread import async_get_preferred_dataset
-from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ SOURCE_HASSIO,
+ ConfigEntryState,
+ ConfigFlow,
+ ConfigFlowResult,
+)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -201,12 +206,23 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN):
# we have to assume it's the first version
# This check can be removed in HA Core 2025.9
unique_id = discovery_info.uuid
+
+ if unique_id != discovery_info.uuid:
+ continue
+
if (
- unique_id != discovery_info.uuid
- or current_url.host != config["host"]
+ current_url.host != config["host"]
or current_url.port == config["port"]
):
+ # Reload the entry since OTBR has restarted
+ if current_entry.state == ConfigEntryState.LOADED:
+ assert current_entry.unique_id is not None
+ await self.hass.config_entries.async_reload(
+ current_entry.entry_id
+ )
+
continue
+
# Update URL with the new port
self.hass.config_entries.async_update_entry(
current_entry,
diff --git a/homeassistant/components/otbr/homeassistant_hardware.py b/homeassistant/components/otbr/homeassistant_hardware.py
new file mode 100644
index 00000000000..94193be1359
--- /dev/null
+++ b/homeassistant/components/otbr/homeassistant_hardware.py
@@ -0,0 +1,76 @@
+"""Home Assistant Hardware firmware utilities."""
+
+from __future__ import annotations
+
+import logging
+
+from yarl import URL
+
+from homeassistant.components.hassio import AddonManager
+from homeassistant.components.homeassistant_hardware.util import (
+ ApplicationType,
+ FirmwareInfo,
+ OwningAddon,
+ OwningIntegration,
+ get_otbr_addon_firmware_info,
+)
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.hassio import is_hassio
+
+from .const import DOMAIN
+from .types import OTBRConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_firmware_info(
+ hass: HomeAssistant, config_entry: OTBRConfigEntry
+) -> FirmwareInfo | None:
+ """Return firmware information for the OpenThread Border Router."""
+ owners: list[OwningIntegration | OwningAddon] = [
+ OwningIntegration(config_entry_id=config_entry.entry_id)
+ ]
+
+ device = None
+
+ if is_hassio(hass) and (host := URL(config_entry.data["url"]).host) is not None:
+ otbr_addon_manager = AddonManager(
+ hass=hass,
+ logger=_LOGGER,
+ addon_name="OpenThread Border Router",
+ addon_slug=host.replace("-", "_"),
+ )
+
+ if (
+ addon_fw_info := await get_otbr_addon_firmware_info(
+ hass, otbr_addon_manager
+ )
+ ) is not None:
+ device = addon_fw_info.device
+ owners.extend(addon_fw_info.owners)
+
+ firmware_version = None
+
+ if config_entry.state in (
+ # This function is called during OTBR config entry setup so we need to account
+ # for both config entry states
+ ConfigEntryState.LOADED,
+ ConfigEntryState.SETUP_IN_PROGRESS,
+ ):
+ try:
+ firmware_version = await config_entry.runtime_data.get_coprocessor_version()
+ except HomeAssistantError:
+ firmware_version = None
+
+ if device is None:
+ return None
+
+ return FirmwareInfo(
+ device=device,
+ firmware_type=ApplicationType.SPINEL,
+ firmware_version=firmware_version,
+ source=DOMAIN,
+ owners=owners,
+ )
diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json
index e1afa5b8909..3a9661c454d 100644
--- a/homeassistant/components/otbr/strings.json
+++ b/homeassistant/components/otbr/strings.json
@@ -5,7 +5,7 @@
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
- "description": "Provide URL for the Open Thread Border Router's REST API"
+ "description": "Provide URL for the OpenThread Border Router's REST API"
}
},
"error": {
@@ -20,8 +20,8 @@
},
"issues": {
"get_get_border_agent_id_unsupported": {
- "title": "The OTBR does not support border agent ID",
- "description": "Your OTBR does not support border agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nTo update the OTBR, update the Open Thread Border Router or Silicon Labs Multiprotocol add-on if you use the OTBR from the add-on, otherwise update your self managed OTBR."
+ "title": "The OTBR does not support Border Agent ID",
+ "description": "Your OTBR does not support Border Agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nIf you are using an OTBR integrated in Home Assistant, update either the OpenThread Border Router add-on or the Silicon Labs Multiprotocol add-on. Otherwise update your self-managed OTBR."
},
"insecure_thread_network": {
"title": "Insecure Thread network settings detected",
diff --git a/homeassistant/components/otbr/types.py b/homeassistant/components/otbr/types.py
new file mode 100644
index 00000000000..eff6aa980d6
--- /dev/null
+++ b/homeassistant/components/otbr/types.py
@@ -0,0 +1,7 @@
+"""The Open Thread Border Router integration types."""
+
+from homeassistant.config_entries import ConfigEntry
+
+from .util import OTBRData
+
+type OTBRConfigEntry = ConfigEntry[OTBRData]
diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py
index 351e23c7736..30e456e11a8 100644
--- a/homeassistant/components/otbr/util.py
+++ b/homeassistant/components/otbr/util.py
@@ -163,6 +163,11 @@ class OTBRData:
"""Get extended address (EUI-64)."""
return await self.api.get_extended_address()
+ @_handle_otbr_error
+ async def get_coprocessor_version(self) -> str:
+ """Get coprocessor firmware version."""
+ return await self.api.get_coprocessor_version()
+
async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None:
"""Return the allowed channel, or None if there's no restriction."""
diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py
index fbb924d7f8f..fbfa5cfb5e1 100644
--- a/homeassistant/components/pilight/entity.py
+++ b/homeassistant/components/pilight/entity.py
@@ -86,7 +86,7 @@ class PilightBaseDevice(RestoreEntity):
self._brightness = 255
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to hass."""
await super().async_added_to_hass()
if state := await self.async_get_last_state():
@@ -99,7 +99,7 @@ class PilightBaseDevice(RestoreEntity):
return self._name
@property
- def assumed_state(self):
+ def assumed_state(self) -> bool:
"""Return True if unable to access real state of the entity."""
return True
diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py
index 7ab8367bd1d..9cc63a38a64 100644
--- a/homeassistant/components/plaato/entity.py
+++ b/homeassistant/components/plaato/entity.py
@@ -73,13 +73,13 @@ class PlaatoEntity(entity.Entity):
return None
@property
- def available(self):
+ def available(self) -> bool:
"""Return if sensor is available."""
if self._coordinator is not None:
return self._coordinator.last_update_success
return True
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
if self._coordinator is not None:
self.async_on_remove(
diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json
index 983ff10b0a6..87878980f2d 100644
--- a/homeassistant/components/plugwise/manifest.json
+++ b/homeassistant/components/plugwise/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
- "requirements": ["plugwise==1.7.1"],
+ "requirements": ["plugwise==1.7.2"],
"zeroconf": ["_plugwise._tcp.local."]
}
diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py
index 4784dd43180..5c52e81e6f7 100644
--- a/homeassistant/components/point/entity.py
+++ b/homeassistant/components/point/entity.py
@@ -52,7 +52,7 @@ class MinutPointEntity(Entity):
)
await self._update_callback()
- async def async_will_remove_from_hass(self):
+ async def async_will_remove_from_hass(self) -> None:
"""Disconnect dispatcher listener when removed."""
if self._async_unsub_dispatcher_connect:
self._async_unsub_dispatcher_connect()
@@ -61,7 +61,7 @@ class MinutPointEntity(Entity):
"""Update the value of the sensor."""
@property
- def available(self):
+ def available(self) -> bool:
"""Return true if device is not offline."""
return self._client.is_available(self.device_id)
diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py
index 9f4610cff64..23ec485fcd4 100644
--- a/homeassistant/components/qbittorrent/sensor.py
+++ b/homeassistant/components/qbittorrent/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import STATE_IDLE, UnitOfDataRate
+from homeassistant.const import STATE_IDLE, UnitOfDataRate, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,8 +27,14 @@ from .coordinator import QBittorrentDataCoordinator
_LOGGER = logging.getLogger(__name__)
SENSOR_TYPE_CURRENT_STATUS = "current_status"
+SENSOR_TYPE_CONNECTION_STATUS = "connection_status"
SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed"
SENSOR_TYPE_UPLOAD_SPEED = "upload_speed"
+SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT = "download_speed_limit"
+SENSOR_TYPE_UPLOAD_SPEED_LIMIT = "upload_speed_limit"
+SENSOR_TYPE_ALLTIME_DOWNLOAD = "alltime_download"
+SENSOR_TYPE_ALLTIME_UPLOAD = "alltime_upload"
+SENSOR_TYPE_GLOBAL_RATIO = "global_ratio"
SENSOR_TYPE_ALL_TORRENTS = "all_torrents"
SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents"
SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents"
@@ -50,18 +56,54 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str:
return STATE_IDLE
-def get_dl(coordinator: QBittorrentDataCoordinator) -> int:
+def get_connection_status(coordinator: QBittorrentDataCoordinator) -> str:
+ """Get current download/upload state."""
+ server_state = cast(Mapping, coordinator.data.get("server_state"))
+ return cast(str, server_state.get("connection_status"))
+
+
+def get_download_speed(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current download speed."""
server_state = cast(Mapping, coordinator.data.get("server_state"))
return cast(int, server_state.get("dl_info_speed"))
-def get_up(coordinator: QBittorrentDataCoordinator) -> int:
+def get_upload_speed(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current upload speed."""
server_state = cast(Mapping[str, Any], coordinator.data.get("server_state"))
return cast(int, server_state.get("up_info_speed"))
+def get_download_speed_limit(coordinator: QBittorrentDataCoordinator) -> int:
+ """Get current download speed."""
+ server_state = cast(Mapping, coordinator.data.get("server_state"))
+ return cast(int, server_state.get("dl_rate_limit"))
+
+
+def get_upload_speed_limit(coordinator: QBittorrentDataCoordinator) -> int:
+ """Get current upload speed."""
+ server_state = cast(Mapping[str, Any], coordinator.data.get("server_state"))
+ return cast(int, server_state.get("up_rate_limit"))
+
+
+def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int:
+ """Get current download speed."""
+ server_state = cast(Mapping, coordinator.data.get("server_state"))
+ return cast(int, server_state.get("alltime_dl"))
+
+
+def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int:
+ """Get current download speed."""
+ server_state = cast(Mapping, coordinator.data.get("server_state"))
+ return cast(int, server_state.get("alltime_ul"))
+
+
+def get_global_ratio(coordinator: QBittorrentDataCoordinator) -> float:
+ """Get current download speed."""
+ server_state = cast(Mapping, coordinator.data.get("server_state"))
+ return cast(float, server_state.get("global_ratio"))
+
+
@dataclass(frozen=True, kw_only=True)
class QBittorrentSensorEntityDescription(SensorEntityDescription):
"""Entity description class for qBittorent sensors."""
@@ -77,6 +119,13 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING],
value_fn=get_state,
),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_CONNECTION_STATUS,
+ translation_key="connection_status",
+ device_class=SensorDeviceClass.ENUM,
+ options=["connected", "firewalled", "disconnected"],
+ value_fn=get_connection_status,
+ ),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_DOWNLOAD_SPEED,
translation_key="download_speed",
@@ -85,7 +134,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
- value_fn=get_dl,
+ value_fn=get_download_speed,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_UPLOAD_SPEED,
@@ -95,7 +144,56 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
- value_fn=get_up,
+ value_fn=get_upload_speed,
+ ),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT,
+ translation_key="download_speed_limit",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.DATA_RATE,
+ native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
+ suggested_display_precision=2,
+ suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
+ value_fn=get_download_speed_limit,
+ entity_registry_enabled_default=False,
+ ),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_UPLOAD_SPEED_LIMIT,
+ translation_key="upload_speed_limit",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.DATA_RATE,
+ native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
+ suggested_display_precision=2,
+ suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
+ value_fn=get_upload_speed_limit,
+ entity_registry_enabled_default=False,
+ ),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_ALLTIME_DOWNLOAD,
+ translation_key="alltime_download",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_display_precision=2,
+ suggested_unit_of_measurement=UnitOfInformation.TEBIBYTES,
+ value_fn=get_alltime_download,
+ ),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_ALLTIME_UPLOAD,
+ translation_key="alltime_upload",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ native_unit_of_measurement="B",
+ suggested_display_precision=2,
+ suggested_unit_of_measurement="TiB",
+ value_fn=get_alltime_upload,
+ ),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_GLOBAL_RATIO,
+ translation_key="global_ratio",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=get_global_ratio,
+ entity_registry_enabled_default=False,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ALL_TORRENTS,
diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json
index 9c9ee371737..0dcb9298f1f 100644
--- a/homeassistant/components/qbittorrent/strings.json
+++ b/homeassistant/components/qbittorrent/strings.json
@@ -26,6 +26,21 @@
"upload_speed": {
"name": "Upload speed"
},
+ "download_speed_limit": {
+ "name": "Download speed limit"
+ },
+ "upload_speed_limit": {
+ "name": "Upload speed limit"
+ },
+ "alltime_download": {
+ "name": "Alltime download"
+ },
+ "alltime_upload": {
+ "name": "Alltime upload"
+ },
+ "global_ratio": {
+ "name": "Global ratio"
+ },
"current_status": {
"name": "Status",
"state": {
@@ -35,6 +50,14 @@
"downloading": "Downloading"
}
},
+ "connection_status": {
+ "name": "Connection status",
+ "state": {
+ "connected": "Connected",
+ "firewalled": "Firewalled",
+ "disconnected": "Disconnected"
+ }
+ },
"active_torrents": {
"name": "Active torrents",
"unit_of_measurement": "torrents"
@@ -86,16 +109,16 @@
},
"exceptions": {
"invalid_device": {
- "message": "No device with id {device_id} was found"
+ "message": "No device with ID {device_id} was found"
},
"invalid_entry_id": {
- "message": "No entry with id {device_id} was found"
+ "message": "No entry with ID {device_id} was found"
},
"login_error": {
- "message": "A login error occured. Please check you username and password."
+ "message": "A login error occured. Please check your username and password."
},
"cannot_connect": {
- "message": "Can't connect to QBittorrent, please check your configuration."
+ "message": "Can't connect to qBittorrent, please check your configuration."
}
}
}
diff --git a/homeassistant/components/qwikswitch/entity.py b/homeassistant/components/qwikswitch/entity.py
index 3a2ec5a9206..ff7a1d2e98a 100644
--- a/homeassistant/components/qwikswitch/entity.py
+++ b/homeassistant/components/qwikswitch/entity.py
@@ -35,7 +35,7 @@ class QSEntity(Entity):
"""Receive update packet from QSUSB. Match dispather_send signature."""
self.async_write_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Listen for updates from QSUSb via dispatcher."""
self.async_on_remove(
async_dispatcher_connect(self.hass, self.qsid, self.update_packet)
diff --git a/homeassistant/components/raincloud/entity.py b/homeassistant/components/raincloud/entity.py
index 337324d96eb..b45684ac72b 100644
--- a/homeassistant/components/raincloud/entity.py
+++ b/homeassistant/components/raincloud/entity.py
@@ -45,7 +45,7 @@ class RainCloudEntity(Entity):
"""Return the name of the sensor."""
return self._name
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self.async_on_remove(
async_dispatcher_connect(
diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py
index 4d486c9c6aa..65648b8d44f 100644
--- a/homeassistant/components/rainmachine/__init__.py
+++ b/homeassistant/components/rainmachine/__init__.py
@@ -13,7 +13,7 @@ from regenmaschine.controller import Controller
from regenmaschine.errors import RainMachineError, UnknownAPICallError
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_IP_ADDRESS,
@@ -465,12 +465,7 @@ async def async_unload_entry(
) -> bool:
"""Unload an RainMachine config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state is ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# If this is the last loaded instance of RainMachine, deregister any services
# defined during integration setup:
for service_name in (
diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py
index 82637aae538..92090a192e8 100644
--- a/homeassistant/components/refoss/sensor.py
+++ b/homeassistant/components/refoss/sensor.py
@@ -94,7 +94,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = {
key="energy",
translation_key="this_month_energy",
device_class=SensorDeviceClass.ENERGY,
- state_class=SensorStateClass.TOTAL,
+ state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_display_precision=2,
subkey="mConsume",
@@ -104,7 +104,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = {
key="energy_returned",
translation_key="this_month_energy_returned",
device_class=SensorDeviceClass.ENERGY,
- state_class=SensorStateClass.TOTAL,
+ state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_display_precision=2,
subkey="mConsume",
diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py
index 0d1c54efb56..2a95ed46b20 100644
--- a/homeassistant/components/remember_the_milk/__init__.py
+++ b/homeassistant/components/remember_the_milk/__init__.py
@@ -2,7 +2,7 @@
import json
import logging
-import os
+from pathlib import Path
from rtmapi import Rtm
import voluptuous as vol
@@ -160,56 +160,64 @@ class RememberTheMilkConfiguration:
This class stores the authentication token it get from the backend.
"""
- def __init__(self, hass):
+ def __init__(self, hass: HomeAssistant) -> None:
"""Create new instance of configuration."""
self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
- if not os.path.isfile(self._config_file_path):
- self._config = {}
- return
+ self._config = {}
+ _LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
try:
- _LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
- with open(self._config_file_path, encoding="utf8") as config_file:
- self._config = json.load(config_file)
- except ValueError:
- _LOGGER.error(
- "Failed to load configuration file, creating a new one: %s",
+ self._config = json.loads(
+ Path(self._config_file_path).read_text(encoding="utf8")
+ )
+ except FileNotFoundError:
+ _LOGGER.debug("Missing configuration file: %s", self._config_file_path)
+ except OSError:
+ _LOGGER.debug(
+ "Failed to read from configuration file, %s, using empty configuration",
+ self._config_file_path,
+ )
+ except ValueError:
+ _LOGGER.error(
+ "Failed to parse configuration file, %s, using empty configuration",
self._config_file_path,
)
- self._config = {}
- def save_config(self):
+ def _save_config(self) -> None:
"""Write the configuration to a file."""
- with open(self._config_file_path, "w", encoding="utf8") as config_file:
- json.dump(self._config, config_file)
+ Path(self._config_file_path).write_text(
+ json.dumps(self._config), encoding="utf8"
+ )
- def get_token(self, profile_name):
+ def get_token(self, profile_name: str) -> str | None:
"""Get the server token for a profile."""
if profile_name in self._config:
return self._config[profile_name][CONF_TOKEN]
return None
- def set_token(self, profile_name, token):
+ def set_token(self, profile_name: str, token: str) -> None:
"""Store a new server token for a profile."""
self._initialize_profile(profile_name)
self._config[profile_name][CONF_TOKEN] = token
- self.save_config()
+ self._save_config()
- def delete_token(self, profile_name):
+ def delete_token(self, profile_name: str) -> None:
"""Delete a token for a profile.
Usually called when the token has expired.
"""
self._config.pop(profile_name, None)
- self.save_config()
+ self._save_config()
- def _initialize_profile(self, profile_name):
+ def _initialize_profile(self, profile_name: str) -> None:
"""Initialize the data structures for a profile."""
if profile_name not in self._config:
self._config[profile_name] = {}
if CONF_ID_MAP not in self._config[profile_name]:
self._config[profile_name][CONF_ID_MAP] = {}
- def get_rtm_id(self, profile_name, hass_id):
+ def get_rtm_id(
+ self, profile_name: str, hass_id: str
+ ) -> tuple[str, str, str] | None:
"""Get the RTM ids for a Home Assistant task ID.
The id of a RTM tasks consists of the tuple:
@@ -221,7 +229,14 @@ class RememberTheMilkConfiguration:
return None
return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
- def set_rtm_id(self, profile_name, hass_id, list_id, time_series_id, rtm_task_id):
+ def set_rtm_id(
+ self,
+ profile_name: str,
+ hass_id: str,
+ list_id: str,
+ time_series_id: str,
+ rtm_task_id: str,
+ ) -> None:
"""Add/Update the RTM task ID for a Home Assistant task IS."""
self._initialize_profile(profile_name)
id_tuple = {
@@ -230,11 +245,11 @@ class RememberTheMilkConfiguration:
CONF_TASK_ID: rtm_task_id,
}
self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple
- self.save_config()
+ self._save_config()
- def delete_rtm_id(self, profile_name, hass_id):
+ def delete_rtm_id(self, profile_name: str, hass_id: str) -> None:
"""Delete a key mapping."""
self._initialize_profile(profile_name)
if hass_id in self._config[profile_name][CONF_ID_MAP]:
del self._config[profile_name][CONF_ID_MAP][hass_id]
- self.save_config()
+ self._save_config()
diff --git a/homeassistant/components/rflink/entity.py b/homeassistant/components/rflink/entity.py
index 26153acf7ba..0caec4ea2c3 100644
--- a/homeassistant/components/rflink/entity.py
+++ b/homeassistant/components/rflink/entity.py
@@ -105,12 +105,12 @@ class RflinkDevice(Entity):
return self._state
@property
- def assumed_state(self):
+ def assumed_state(self) -> bool:
"""Assume device state until first device event sets state."""
return self._state is None
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if entity is available."""
return self._available
@@ -120,7 +120,7 @@ class RflinkDevice(Entity):
self._available = availability
self.async_write_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register update callback."""
await super().async_added_to_hass()
# Remove temporary bogus entity_id if added
@@ -300,7 +300,7 @@ class RflinkCommand(RflinkDevice):
class SwitchableRflinkDevice(RflinkCommand, RestoreEntity):
"""Rflink entity which can switch on/off (eg: light, switch)."""
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Restore RFLink device state (ON/OFF)."""
await super().async_added_to_hass()
if (old_state := await self.async_get_last_state()) is not None:
diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py
index ae5577da4e4..14c7ac3af3e 100644
--- a/homeassistant/components/roomba/entity.py
+++ b/homeassistant/components/roomba/entity.py
@@ -80,7 +80,7 @@ class IRobotEntity(Entity):
return None
return dt_util.utc_from_timestamp(ts)
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callback function."""
self.vacuum.register_on_message_callback(self.on_message)
diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json
index 966488b6a48..a7cee28f9c9 100644
--- a/homeassistant/components/sense/manifest.json
+++ b/homeassistant/components/sense/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/sense",
"iot_class": "cloud_polling",
"loggers": ["sense_energy"],
- "requirements": ["sense-energy==0.13.4"]
+ "requirements": ["sense-energy==0.13.5"]
}
diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json
index 6c5210d12bf..6aba2be52fc 100644
--- a/homeassistant/components/sensibo/strings.json
+++ b/homeassistant/components/sensibo/strings.json
@@ -429,16 +429,16 @@
}
},
"enable_pure_boost": {
- "name": "Enable pure boost",
+ "name": "Enable Pure Boost",
"description": "Enables and configures Pure Boost settings.",
"fields": {
"ac_integration": {
"name": "AC integration",
- "description": "Integrate with Air Conditioner."
+ "description": "Integrate with air conditioner."
},
"geo_integration": {
"name": "Geo integration",
- "description": "Integrate with Presence."
+ "description": "Integrate with presence."
},
"indoor_integration": {
"name": "Indoor air quality",
@@ -468,7 +468,7 @@
},
"fan_mode": {
"name": "Fan mode",
- "description": "set fan mode."
+ "description": "Set fan mode."
},
"swing_mode": {
"name": "Swing mode",
diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json
index 425225e07ef..4c3a7518085 100644
--- a/homeassistant/components/sentry/manifest.json
+++ b/homeassistant/components/sentry/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sentry",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["sentry-sdk==1.40.3"]
+ "requirements": ["sentry-sdk==1.45.1"]
}
diff --git a/homeassistant/components/seventeentrack/icons.json b/homeassistant/components/seventeentrack/icons.json
index a5cac0a9f84..c48e147e973 100644
--- a/homeassistant/components/seventeentrack/icons.json
+++ b/homeassistant/components/seventeentrack/icons.json
@@ -19,7 +19,7 @@
"delivered": {
"default": "mdi:package"
},
- "returned": {
+ "alert": {
"default": "mdi:package"
},
"package": {
diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json
index a130fbe9aee..34019208a14 100644
--- a/homeassistant/components/seventeentrack/manifest.json
+++ b/homeassistant/components/seventeentrack/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyseventeentrack"],
- "requirements": ["pyseventeentrack==1.0.1"]
+ "requirements": ["pyseventeentrack==1.0.2"]
}
diff --git a/homeassistant/components/seventeentrack/services.yaml b/homeassistant/components/seventeentrack/services.yaml
index d4592dc8aab..45d7c0a530a 100644
--- a/homeassistant/components/seventeentrack/services.yaml
+++ b/homeassistant/components/seventeentrack/services.yaml
@@ -11,7 +11,7 @@ get_packages:
- "ready_to_be_picked_up"
- "undelivered"
- "delivered"
- - "returned"
+ - "alert"
translation_key: package_state
config_entry_id:
required: true
diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json
index 982b15ab629..c95a553ae7b 100644
--- a/homeassistant/components/seventeentrack/strings.json
+++ b/homeassistant/components/seventeentrack/strings.json
@@ -57,8 +57,8 @@
"delivered": {
"name": "Delivered"
},
- "returned": {
- "name": "Returned"
+ "alert": {
+ "name": "Alert"
},
"package": {
"name": "Package {name}"
@@ -68,7 +68,7 @@
"services": {
"get_packages": {
"name": "Get packages",
- "description": "Get packages from 17Track",
+ "description": "Queries the 17track API for the latest package data.",
"fields": {
"package_state": {
"name": "Package states",
@@ -82,7 +82,7 @@
},
"archive_package": {
"name": "Archive package",
- "description": "Archive a package",
+ "description": "Archives a package using the 17track API.",
"fields": {
"package_tracking_number": {
"name": "Package tracking number",
@@ -104,7 +104,7 @@
"ready_to_be_picked_up": "[%key:component::seventeentrack::entity::sensor::ready_to_be_picked_up::name%]",
"undelivered": "[%key:component::seventeentrack::entity::sensor::undelivered::name%]",
"delivered": "[%key:component::seventeentrack::entity::sensor::delivered::name%]",
- "returned": "[%key:component::seventeentrack::entity::sensor::returned::name%]"
+ "alert": "[%key:component::seventeentrack::entity::sensor::alert::name%]"
}
}
}
diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py
index 2f19c5117a4..8a75baa69c6 100644
--- a/homeassistant/components/simplisafe/__init__.py
+++ b/homeassistant/components/simplisafe/__init__.py
@@ -39,7 +39,7 @@ from simplipy.websocket import (
)
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_CODE,
ATTR_DEVICE_ID,
@@ -402,12 +402,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# If this is the last loaded instance of SimpliSafe, deregister any services
# defined during integration setup:
for service_name in SERVICES:
diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json
index ca3133d8add..c295647b8e5 100644
--- a/homeassistant/components/smarty/manifest.json
+++ b/homeassistant/components/smarty/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pymodbus", "pysmarty2"],
- "requirements": ["pysmarty2==0.10.1"]
+ "requirements": ["pysmarty2==0.10.2"]
}
diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py
index 667e6e2884b..fcfc364d983 100644
--- a/homeassistant/components/smlight/config_flow.py
+++ b/homeassistant/components/smlight/config_flow.py
@@ -77,12 +77,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
- info = await self.client.get_info()
-
- if info.model not in Devices:
- return self.async_abort(reason="unsupported_device")
-
if not await self._async_check_auth_required(user_input):
+ info = await self.client.get_info()
+ self._host = str(info.device_ip)
+ self._device_name = str(info.hostname)
+
+ if info.model not in Devices:
+ return self.async_abort(reason="unsupported_device")
+
return await self._async_complete_entry(user_input)
except SmlightConnectionError:
return self.async_abort(reason="cannot_connect")
diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json
index 21ff5098d27..ca52f6fea38 100644
--- a/homeassistant/components/smlight/strings.json
+++ b/homeassistant/components/smlight/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "Set up SMLIGHT Zigbee Integration",
+ "description": "Set up SMLIGHT Zigbee integration",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
@@ -111,7 +111,7 @@
"name": "Zigbee flash mode"
},
"reconnect_zigbee_router": {
- "name": "Reconnect zigbee router"
+ "name": "Reconnect Zigbee router"
}
},
"switch": {
diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json
index 94ca434e589..ca252b2117c 100644
--- a/homeassistant/components/snooz/strings.json
+++ b/homeassistant/components/snooz/strings.json
@@ -27,25 +27,25 @@
"services": {
"transition_on": {
"name": "Transition on",
- "description": "Transitions to a target volume level over time.",
+ "description": "Transitions the volume level over a specified duration. If the device is powered off, the transition will start at the lowest volume level.",
"fields": {
"duration": {
"name": "Transition duration",
- "description": "Time it takes to reach the target volume level."
+ "description": "Time to transition to the target volume."
},
"volume": {
"name": "Target volume",
- "description": "If not specified, the volume level is read from the device."
+ "description": "Relative volume level. If not specified, the setting on the device is used."
}
}
},
"transition_off": {
"name": "Transition off",
- "description": "Transitions volume off over time.",
+ "description": "Transitions the volume level to the lowest setting over a specified duration, then powers off the device.",
"fields": {
"duration": {
"name": "[%key:component::snooz::services::transition_on::fields::duration::name%]",
- "description": "Time it takes to turn off."
+ "description": "Time to complete the transition."
}
}
}
diff --git a/homeassistant/components/soma/entity.py b/homeassistant/components/soma/entity.py
index f9824d107b1..4b2fcee5405 100644
--- a/homeassistant/components/soma/entity.py
+++ b/homeassistant/components/soma/entity.py
@@ -71,7 +71,7 @@ class SomaEntity(Entity):
self.api_is_available = True
@property
- def available(self):
+ def available(self) -> bool:
"""Return true if the last API commands returned successfully."""
return self.is_available
diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json
index bfdf0da9dbb..bb3d99c4c93 100644
--- a/homeassistant/components/sonos/manifest.json
+++ b/homeassistant/components/sonos/manifest.json
@@ -7,7 +7,7 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/sonos",
"iot_class": "local_push",
- "loggers": ["soco"],
+ "loggers": ["soco", "sonos_websocket"],
"requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"],
"ssdp": [
{
diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py
index 789f6ddb3a8..fd641d3389d 100644
--- a/homeassistant/components/squeezebox/__init__.py
+++ b/homeassistant/components/squeezebox/__init__.py
@@ -129,10 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -
server_coordinator = LMSStatusDataUpdateCoordinator(hass, entry, lms)
- entry.runtime_data = SqueezeboxData(
- coordinator=server_coordinator,
- server=lms,
- )
+ entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms)
# set up player discovery
known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {})
diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py
index 331bf383c70..c0458067a23 100644
--- a/homeassistant/components/squeezebox/browse_media.py
+++ b/homeassistant/components/squeezebox/browse_media.py
@@ -81,11 +81,12 @@ CONTENT_TYPE_TO_CHILD_TYPE = {
"New Music": MediaType.ALBUM,
}
-BROWSE_LIMIT = 1000
-
async def build_item_response(
- entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None]
+ entity: MediaPlayerEntity,
+ player: Player,
+ payload: dict[str, str | None],
+ browse_limit: int,
) -> BrowseMedia:
"""Create response payload for search described by payload."""
@@ -107,7 +108,7 @@ async def build_item_response(
result = await player.async_browse(
MEDIA_TYPE_TO_SQUEEZEBOX[search_type],
- limit=BROWSE_LIMIT,
+ limit=browse_limit,
browse_id=browse_id,
)
@@ -237,7 +238,11 @@ def media_source_content_filter(item: BrowseMedia) -> bool:
return item.media_content_type.startswith("audio/")
-async def generate_playlist(player: Player, payload: dict[str, str]) -> list | None:
+async def generate_playlist(
+ player: Player,
+ payload: dict[str, str],
+ browse_limit: int,
+) -> list | None:
"""Generate playlist from browsing payload."""
media_type = payload["search_type"]
media_id = payload["search_id"]
@@ -247,7 +252,7 @@ async def generate_playlist(player: Player, payload: dict[str, str]) -> list | N
browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id)
result = await player.async_browse(
- "titles", limit=BROWSE_LIMIT, browse_id=browse_id
+ "titles", limit=browse_limit, browse_id=browse_id
)
if result and "items" in result:
items: list = result["items"]
diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py
index 97eb848c21c..2853ad14217 100644
--- a/homeassistant/components/squeezebox/config_flow.py
+++ b/homeassistant/components/squeezebox/config_flow.py
@@ -11,15 +11,34 @@ from pysqueezebox import Server, async_discover
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
+from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.selector import (
+ NumberSelector,
+ NumberSelectorConfig,
+ NumberSelectorMode,
+)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
-from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN
+from .const import (
+ CONF_BROWSE_LIMIT,
+ CONF_HTTPS,
+ CONF_VOLUME_STEP,
+ DEFAULT_BROWSE_LIMIT,
+ DEFAULT_PORT,
+ DEFAULT_VOLUME_STEP,
+ DOMAIN,
+)
_LOGGER = logging.getLogger(__name__)
@@ -77,6 +96,12 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
self.data_schema = _base_schema()
self.discovery_info: dict[str, Any] | None = None
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler:
+ """Get the options flow for this handler."""
+ return OptionsFlowHandler()
+
async def _discover(self, uuid: str | None = None) -> None:
"""Discover an unconfigured LMS server."""
self.discovery_info = None
@@ -222,3 +247,48 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
# if the player is unknown, then we likely need to configure its server
return await self.async_step_user()
+
+
+OPTIONS_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_BROWSE_LIMIT): vol.All(
+ NumberSelector(
+ NumberSelectorConfig(min=1, max=65534, mode=NumberSelectorMode.BOX)
+ ),
+ vol.Coerce(int),
+ ),
+ vol.Required(CONF_VOLUME_STEP): vol.All(
+ NumberSelector(
+ NumberSelectorConfig(min=1, max=20, mode=NumberSelectorMode.SLIDER)
+ ),
+ vol.Coerce(int),
+ ),
+ }
+)
+
+
+class OptionsFlowHandler(OptionsFlow):
+ """Options Flow Handler."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Options Flow Steps."""
+
+ if user_input is not None:
+ return self.async_create_entry(title="", data=user_input)
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ OPTIONS_SCHEMA,
+ {
+ CONF_BROWSE_LIMIT: self.config_entry.options.get(
+ CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT
+ ),
+ CONF_VOLUME_STEP: self.config_entry.options.get(
+ CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
+ ),
+ },
+ ),
+ )
diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py
index 8bc33214170..f24c452282f 100644
--- a/homeassistant/components/squeezebox/const.py
+++ b/homeassistant/components/squeezebox/const.py
@@ -32,3 +32,7 @@ SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered"
SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered"
DISCOVERY_INTERVAL = 60
PLAYER_UPDATE_INTERVAL = 5
+CONF_BROWSE_LIMIT = "browse_limit"
+CONF_VOLUME_STEP = "volume_step"
+DEFAULT_BROWSE_LIMIT = 1000
+DEFAULT_VOLUME_STEP = 5
diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json
index 09eaa4026f4..e9b89291749 100644
--- a/homeassistant/components/squeezebox/manifest.json
+++ b/homeassistant/components/squeezebox/manifest.json
@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/squeezebox",
"iot_class": "local_polling",
"loggers": ["pysqueezebox"],
- "requirements": ["pysqueezebox==0.11.1"]
+ "requirements": ["pysqueezebox==0.12.0"]
}
diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py
index 1b810019373..a98ee13275c 100644
--- a/homeassistant/components/squeezebox/media_player.py
+++ b/homeassistant/components/squeezebox/media_player.py
@@ -52,6 +52,10 @@ from .browse_media import (
media_source_content_filter,
)
from .const import (
+ CONF_BROWSE_LIMIT,
+ CONF_VOLUME_STEP,
+ DEFAULT_BROWSE_LIMIT,
+ DEFAULT_VOLUME_STEP,
DISCOVERY_TASK,
DOMAIN,
KNOWN_PLAYERS,
@@ -166,6 +170,7 @@ class SqueezeBoxMediaPlayerEntity(
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
+ | MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK
@@ -184,10 +189,7 @@ class SqueezeBoxMediaPlayerEntity(
_attr_name = None
_last_update: datetime | None = None
- def __init__(
- self,
- coordinator: SqueezeBoxPlayerUpdateCoordinator,
- ) -> None:
+ def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None:
"""Initialize the SqueezeBox device."""
super().__init__(coordinator)
player = coordinator.player
@@ -223,6 +225,23 @@ class SqueezeBoxMediaPlayerEntity(
self._last_update = utcnow()
self.async_write_ha_state()
+ @property
+ def volume_step(self) -> float:
+ """Return the step to be used for volume up down."""
+ return float(
+ self.coordinator.config_entry.options.get(
+ CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
+ )
+ / 100
+ )
+
+ @property
+ def browse_limit(self) -> int:
+ """Return the step to be used for volume up down."""
+ return self.coordinator.config_entry.options.get(
+ CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT
+ )
+
@property
def available(self) -> bool:
"""Return True if entity is available."""
@@ -366,16 +385,6 @@ class SqueezeBoxMediaPlayerEntity(
await self._player.async_set_power(False)
await self.coordinator.async_refresh()
- async def async_volume_up(self) -> None:
- """Volume up media player."""
- await self._player.async_set_volume("+5")
- await self.coordinator.async_refresh()
-
- async def async_volume_down(self) -> None:
- """Volume down media player."""
- await self._player.async_set_volume("-5")
- await self.coordinator.async_refresh()
-
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
volume_percent = str(int(volume * 100))
@@ -466,7 +475,11 @@ class SqueezeBoxMediaPlayerEntity(
"search_id": media_id,
"search_type": MediaType.PLAYLIST,
}
- playlist = await generate_playlist(self._player, payload)
+ playlist = await generate_playlist(
+ self._player,
+ payload,
+ self.browse_limit,
+ )
except BrowseError:
# a list of urls
content = json.loads(media_id)
@@ -477,7 +490,11 @@ class SqueezeBoxMediaPlayerEntity(
"search_id": media_id,
"search_type": media_type,
}
- playlist = await generate_playlist(self._player, payload)
+ playlist = await generate_playlist(
+ self._player,
+ payload,
+ self.browse_limit,
+ )
_LOGGER.debug("Generated playlist: %s", playlist)
@@ -587,7 +604,12 @@ class SqueezeBoxMediaPlayerEntity(
"search_id": media_content_id,
}
- return await build_item_response(self, self._player, payload)
+ return await build_item_response(
+ self,
+ self._player,
+ payload,
+ self.browse_limit,
+ )
async def async_get_browse_image(
self,
diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json
index bce71ddb5f2..ed569989b56 100644
--- a/homeassistant/components/squeezebox/strings.json
+++ b/homeassistant/components/squeezebox/strings.json
@@ -103,5 +103,20 @@
"unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "LMS Configuration",
+ "data": {
+ "browse_limit": "Browse limit",
+ "volume_step": "Volume step"
+ },
+ "data_description": {
+ "browse_limit": "Maximum number of items when browsing or in a playlist.",
+ "volume_step": "Amount to adjust the volume when turning volume up or down."
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py
index 74807996dfb..f8846c2a97f 100644
--- a/homeassistant/components/starline/entity.py
+++ b/homeassistant/components/starline/entity.py
@@ -27,20 +27,20 @@ class StarlineEntity(Entity):
self._unsubscribe_api: Callable | None = None
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if entity is available."""
return self._account.api.available
- def update(self):
+ def update(self) -> None:
"""Read new state data."""
self.schedule_update_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to Home Assistant."""
await super().async_added_to_hass()
self._unsubscribe_api = self._account.api.add_update_listener(self.update)
- async def async_will_remove_from_hass(self):
+ async def async_will_remove_from_hass(self) -> None:
"""Call when entity is being removed from Home Assistant."""
await super().async_will_remove_from_hass()
if self._unsubscribe_api is not None:
diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py
index 4528a35858c..0c512bb21c5 100644
--- a/homeassistant/components/starlink/__init__.py
+++ b/homeassistant/components/starlink/__init__.py
@@ -2,12 +2,10 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import StarlinkUpdateCoordinator
+from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -19,21 +17,19 @@ PLATFORMS = [
]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: StarlinkConfigEntry
+) -> bool:
"""Set up Starlink from a config entry."""
- coordinator = StarlinkUpdateCoordinator(hass, entry)
+ config_entry.runtime_data = StarlinkUpdateCoordinator(hass, config_entry)
+ await config_entry.runtime_data.async_config_entry_first_refresh()
- await coordinator.async_config_entry_first_refresh()
-
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
-
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: StarlinkConfigEntry
+) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py
index f5eaf2baba0..e06e79009c3 100644
--- a/homeassistant/components/starlink/binary_sensor.py
+++ b/homeassistant/components/starlink/binary_sensor.py
@@ -10,26 +10,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import StarlinkData
+from .coordinator import StarlinkConfigEntry, StarlinkData
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkBinarySensorEntity(coordinator, description)
+ StarlinkBinarySensorEntity(config_entry.runtime_data, description)
for description in BINARY_SENSORS
)
diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py
index dc23e31d8d2..15f35659b49 100644
--- a/homeassistant/components/starlink/button.py
+++ b/homeassistant/components/starlink/button.py
@@ -10,26 +10,23 @@ from homeassistant.components.button import (
ButtonEntity,
ButtonEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import StarlinkUpdateCoordinator
+from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkButtonEntity(coordinator, description) for description in BUTTONS
+ StarlinkButtonEntity(config_entry.runtime_data, description)
+ for description in BUTTONS
)
diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py
index 4ae771c9582..02d51cd805e 100644
--- a/homeassistant/components/starlink/coordinator.py
+++ b/homeassistant/components/starlink/coordinator.py
@@ -34,6 +34,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
_LOGGER = logging.getLogger(__name__)
+type StarlinkConfigEntry = ConfigEntry[StarlinkUpdateCoordinator]
+
@dataclass
class StarlinkData:
@@ -51,9 +53,9 @@ class StarlinkData:
class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
"""Coordinates updates between all Starlink sensors defined in this file."""
- config_entry: ConfigEntry
+ config_entry: StarlinkConfigEntry
- def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: StarlinkConfigEntry) -> None:
"""Initialize an UpdateCoordinator for a group of sensors."""
self.channel_context = ChannelContext(target=config_entry.data[CONF_IP_ADDRESS])
self.history_stats_start = None
diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py
index 53e7ab1cee0..dbe31947b55 100644
--- a/homeassistant/components/starlink/device_tracker.py
+++ b/homeassistant/components/starlink/device_tracker.py
@@ -8,25 +8,22 @@ from homeassistant.components.device_tracker import (
TrackerEntity,
TrackerEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import ATTR_ALTITUDE, DOMAIN
-from .coordinator import StarlinkData
+from .const import ATTR_ALTITUDE
+from .coordinator import StarlinkConfigEntry, StarlinkData
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkDeviceTrackerEntity(coordinator, description)
+ StarlinkDeviceTrackerEntity(config_entry.runtime_data, description)
for description in DEVICE_TRACKERS
)
diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py
index c619458b1dd..543fe9d8dde 100644
--- a/homeassistant/components/starlink/diagnostics.py
+++ b/homeassistant/components/starlink/diagnostics.py
@@ -4,18 +4,15 @@ from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import StarlinkUpdateCoordinator
+from .coordinator import StarlinkConfigEntry
TO_REDACT = {"id", "latitude", "longitude", "altitude"}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, config_entry: StarlinkConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for Starlink config entries."""
- coordinator: StarlinkUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
- return async_redact_data(asdict(coordinator.data), TO_REDACT)
+ return async_redact_data(asdict(config_entry.runtime_data.data), TO_REDACT)
diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py
index dadbf8a061a..d07e8174b27 100644
--- a/homeassistant/components/starlink/sensor.py
+++ b/homeassistant/components/starlink/sensor.py
@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
DEGREE,
PERCENTAGE,
@@ -28,21 +27,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import now
-from .const import DOMAIN
-from .coordinator import StarlinkData
+from .coordinator import StarlinkConfigEntry, StarlinkData
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all sensors for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkSensorEntity(coordinator, description) for description in SENSORS
+ StarlinkSensorEntity(config_entry.runtime_data, description)
+ for description in SENSORS
)
diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py
index 51603850690..c6dc237643e 100644
--- a/homeassistant/components/starlink/switch.py
+++ b/homeassistant/components/starlink/switch.py
@@ -11,25 +11,22 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import StarlinkData, StarlinkUpdateCoordinator
+from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkSwitchEntity(coordinator, description) for description in SWITCHES
+ StarlinkSwitchEntity(config_entry.runtime_data, description)
+ for description in SWITCHES
)
diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py
index 3540123e1eb..9f564333218 100644
--- a/homeassistant/components/starlink/time.py
+++ b/homeassistant/components/starlink/time.py
@@ -8,26 +8,23 @@ from datetime import UTC, datetime, time, tzinfo
import math
from homeassistant.components.time import TimeEntity, TimeEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import StarlinkData, StarlinkUpdateCoordinator
+from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator
from .entity import StarlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
+ config_entry: StarlinkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all time entities for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkTimeEntity(coordinator, description) for description in TIMES
+ StarlinkTimeEntity(config_entry.runtime_data, description)
+ for description in TIMES
)
diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json
index 3fe16fb3d33..8243b903e8d 100644
--- a/homeassistant/components/stookwijzer/manifest.json
+++ b/homeassistant/components/stookwijzer/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/stookwijzer",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["stookwijzer==1.5.1"]
+ "requirements": ["stookwijzer==1.5.4"]
}
diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py
index bde69429bc3..282d23bfd1a 100644
--- a/homeassistant/components/switchbot/entity.py
+++ b/homeassistant/components/switchbot/entity.py
@@ -61,7 +61,7 @@ class SwitchbotEntity(
return self.coordinator.device.parsed_data
@property
- def extra_state_attributes(self) -> Mapping[Any, Any]:
+ def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the state attributes."""
return {"last_run_success": self._last_run_success}
diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py
index 0b8b8731f8f..97095f5d299 100644
--- a/homeassistant/components/synology_dsm/__init__.py
+++ b/homeassistant/components/synology_dsm/__init__.py
@@ -10,7 +10,7 @@ from synology_dsm.api.surveillance_station.camera import SynoCamera
from synology_dsm.exceptions import SynologyDSMNotLoggedInException
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL
+from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
@@ -68,6 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None},
)
+ if CONF_SCAN_INTERVAL in entry.options:
+ current_options = {**entry.options}
+ current_options.pop(CONF_SCAN_INTERVAL)
+ hass.config_entries.async_update_entry(entry, options=current_options)
# Continue setup
api = SynoApi(hass, entry)
diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py
index 83c3455bdf1..670c4c9bef0 100644
--- a/homeassistant/components/synology_dsm/backup.py
+++ b/homeassistant/components/synology_dsm/backup.py
@@ -14,6 +14,7 @@ from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupAgentError,
+ BackupNotFound,
suggested_filename,
)
from homeassistant.config_entries import ConfigEntry
@@ -101,6 +102,7 @@ class SynologyDSMBackupAgent(BackupAgent):
)
syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
self.api = syno_data.api
+ self.backup_base_names: dict[str, str] = {}
@property
def _file_station(self) -> SynoFileStation:
@@ -109,18 +111,19 @@ class SynologyDSMBackupAgent(BackupAgent):
assert self.api.file_station
return self.api.file_station
- async def _async_suggested_filenames(
+ async def _async_backup_filenames(
self,
backup_id: str,
) -> tuple[str, str]:
- """Suggest filenames for the backup.
+ """Return the actual backup filenames.
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: A tuple of tar_filename and meta_filename
"""
- if (backup := await self.async_get_backup(backup_id)) is None:
- raise BackupAgentError("Backup not found")
- return suggested_filenames(backup)
+ if await self.async_get_backup(backup_id) is None:
+ raise BackupNotFound
+ base_name = self.backup_base_names[backup_id]
+ return (f"{base_name}.tar", f"{base_name}_meta.json")
async def async_download_backup(
self,
@@ -132,7 +135,7 @@ class SynologyDSMBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: An async iterator that yields bytes.
"""
- (filename_tar, _) = await self._async_suggested_filenames(backup_id)
+ (filename_tar, _) = await self._async_backup_filenames(backup_id)
try:
resp = await self._file_station.download_file(
@@ -193,7 +196,7 @@ class SynologyDSMBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups.
"""
try:
- (filename_tar, filename_meta) = await self._async_suggested_filenames(
+ (filename_tar, filename_meta) = await self._async_backup_filenames(
backup_id
)
except BackupAgentError:
@@ -247,6 +250,7 @@ class SynologyDSMBackupAgent(BackupAgent):
assert files
backups: dict[str, AgentBackup] = {}
+ backup_base_names: dict[str, str] = {}
for file in files:
if file.name.endswith("_meta.json"):
try:
@@ -255,7 +259,10 @@ class SynologyDSMBackupAgent(BackupAgent):
LOGGER.error("Failed to download meta data: %s", err)
continue
agent_backup = AgentBackup.from_dict(meta_data)
- backups[agent_backup.backup_id] = agent_backup
+ backup_id = agent_backup.backup_id
+ backups[backup_id] = agent_backup
+ backup_base_names[backup_id] = file.name.replace("_meta.json", "")
+ self.backup_base_names = backup_base_names
return backups
async def async_get_backup(
diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py
index dfc372e6bde..d61944c146d 100644
--- a/homeassistant/components/synology_dsm/common.py
+++ b/homeassistant/components/synology_dsm/common.py
@@ -35,13 +35,17 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
+ CONF_BACKUP_PATH,
CONF_DEVICE_TOKEN,
DEFAULT_TIMEOUT,
+ DOMAIN,
EXCEPTION_DETAILS,
EXCEPTION_UNKNOWN,
+ ISSUE_MISSING_BACKUP_SETUP,
SYNOLOGY_CONNECTION_EXCEPTIONS,
)
@@ -174,6 +178,19 @@ class SynoApi:
" permissions or no writable shared folders available"
)
+ if shares and not self._entry.options.get(CONF_BACKUP_PATH):
+ ir.async_create_issue(
+ self._hass,
+ DOMAIN,
+ f"{ISSUE_MISSING_BACKUP_SETUP}_{self._entry.unique_id}",
+ data={"entry_id": self._entry.entry_id},
+ is_fixable=True,
+ is_persistent=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key=ISSUE_MISSING_BACKUP_SETUP,
+ translation_placeholders={"title": self._entry.title},
+ )
+
LOGGER.debug(
"State of File Station during setup of '%s': %s",
self._entry.unique_id,
diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py
index b4453366718..58784862305 100644
--- a/homeassistant/components/synology_dsm/config_flow.py
+++ b/homeassistant/components/synology_dsm/config_flow.py
@@ -33,14 +33,12 @@ from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
- CONF_SCAN_INTERVAL,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
@@ -67,7 +65,6 @@ from .const import (
DEFAULT_BACKUP_PATH,
DEFAULT_PORT,
DEFAULT_PORT_SSL,
- DEFAULT_SCAN_INTERVAL,
DEFAULT_SNAPSHOT_QUALITY,
DEFAULT_TIMEOUT,
DEFAULT_USE_SSL,
@@ -458,12 +455,6 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow):
data_schema = vol.Schema(
{
- vol.Required(
- CONF_SCAN_INTERVAL,
- default=self.config_entry.options.get(
- CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
- ),
- ): cv.positive_int,
vol.Required(
CONF_SNAPSHOT_QUALITY,
default=self.config_entry.options.get(
diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py
index dbee85b99d6..758fad53970 100644
--- a/homeassistant/components/synology_dsm/const.py
+++ b/homeassistant/components/synology_dsm/const.py
@@ -35,6 +35,8 @@ PLATFORMS = [
EXCEPTION_DETAILS = "details"
EXCEPTION_UNKNOWN = "unknown"
+ISSUE_MISSING_BACKUP_SETUP = "missing_backup_setup"
+
# Configuration
CONF_SERIAL = "serial"
CONF_VOLUMES = "volumes"
@@ -48,7 +50,6 @@ DEFAULT_VERIFY_SSL = False
DEFAULT_PORT = 5000
DEFAULT_PORT_SSL = 5001
# Options
-DEFAULT_SCAN_INTERVAL = 15 # min
DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15)
DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED
DEFAULT_BACKUP_PATH = "ha_backup"
diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py
index 30d1260ef32..1b3e21090b8 100644
--- a/homeassistant/components/synology_dsm/coordinator.py
+++ b/homeassistant/components/synology_dsm/coordinator.py
@@ -14,14 +14,12 @@ from synology_dsm.exceptions import (
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .common import SynoApi, raise_config_entry_auth_error
from .const import (
- DEFAULT_SCAN_INTERVAL,
SIGNAL_CAMERA_SOURCE_CHANGED,
SYNOLOGY_AUTH_FAILED_EXCEPTIONS,
SYNOLOGY_CONNECTION_EXCEPTIONS,
@@ -122,14 +120,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]):
api: SynoApi,
) -> None:
"""Initialize DataUpdateCoordinator for central device."""
- super().__init__(
- hass,
- entry,
- api,
- timedelta(
- minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
- ),
- )
+ super().__init__(hass, entry, api, timedelta(minutes=15))
@async_re_login_on_expired
async def _async_update_data(self) -> None:
diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json
index a083fa5a15f..d076d843c36 100644
--- a/homeassistant/components/synology_dsm/manifest.json
+++ b/homeassistant/components/synology_dsm/manifest.json
@@ -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.6.2"],
+ "requirements": ["py-synologydsm-api==2.6.3"],
"ssdp": [
{
"manufacturer": "Synology",
diff --git a/homeassistant/components/synology_dsm/repairs.py b/homeassistant/components/synology_dsm/repairs.py
new file mode 100644
index 00000000000..725e77a2593
--- /dev/null
+++ b/homeassistant/components/synology_dsm/repairs.py
@@ -0,0 +1,125 @@
+"""Repair flows for the Synology DSM integration."""
+
+from __future__ import annotations
+
+from contextlib import suppress
+import logging
+from typing import cast
+
+from synology_dsm.api.file_station.models import SynoFileSharedFolder
+import voluptuous as vol
+
+from homeassistant import data_entry_flow
+from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import issue_registry as ir
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
+
+from .const import (
+ CONF_BACKUP_PATH,
+ CONF_BACKUP_SHARE,
+ DOMAIN,
+ ISSUE_MISSING_BACKUP_SETUP,
+ SYNOLOGY_CONNECTION_EXCEPTIONS,
+)
+from .models import SynologyDSMData
+
+LOGGER = logging.getLogger(__name__)
+
+
+class MissingBackupSetupRepairFlow(RepairsFlow):
+ """Handler for an issue fixing flow."""
+
+ def __init__(self, entry: ConfigEntry, issue_id: str) -> None:
+ """Create flow."""
+ self.entry = entry
+ self.issue_id = issue_id
+ super().__init__()
+
+ async def async_step_init(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the first step of a fix flow."""
+
+ return self.async_show_menu(
+ menu_options=["confirm", "ignore"],
+ description_placeholders={
+ "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location"
+ },
+ )
+
+ async def async_step_confirm(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the confirm step of a fix flow."""
+
+ syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.entry.unique_id]
+
+ if user_input is not None:
+ self.hass.config_entries.async_update_entry(
+ self.entry, options={**dict(self.entry.options), **user_input}
+ )
+ return self.async_create_entry(data={})
+
+ shares: list[SynoFileSharedFolder] | None = None
+ if syno_data.api.file_station:
+ with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS):
+ shares = await syno_data.api.file_station.get_shared_folders(
+ only_writable=True
+ )
+
+ if not shares:
+ return self.async_abort(reason="no_shares")
+
+ return self.async_show_form(
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_BACKUP_SHARE,
+ default=self.entry.options[CONF_BACKUP_SHARE],
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(value=s.path, label=s.name)
+ for s in shares
+ ],
+ mode=SelectSelectorMode.DROPDOWN,
+ ),
+ ),
+ vol.Required(
+ CONF_BACKUP_PATH,
+ default=self.entry.options[CONF_BACKUP_PATH],
+ ): str,
+ }
+ ),
+ )
+
+ async def async_step_ignore(
+ self, _: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the confirm step of a fix flow."""
+ ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True)
+ return self.async_abort(reason="ignored")
+
+
+async def async_create_fix_flow(
+ hass: HomeAssistant,
+ issue_id: str,
+ data: dict[str, str | int | float | None] | None,
+) -> RepairsFlow:
+ """Create flow."""
+ entry = None
+ if data and (entry_id := data.get("entry_id")):
+ entry_id = cast(str, entry_id)
+ entry = hass.config_entries.async_get_entry(entry_id)
+
+ if entry and issue_id.startswith(ISSUE_MISSING_BACKUP_SETUP):
+ return MissingBackupSetupRepairFlow(entry, issue_id)
+
+ return ConfirmRepairFlow()
diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json
index d6d40be3fea..f51184ef1cb 100644
--- a/homeassistant/components/synology_dsm/strings.json
+++ b/homeassistant/components/synology_dsm/strings.json
@@ -68,8 +68,6 @@
"step": {
"init": {
"data": {
- "scan_interval": "Minutes between scans",
- "timeout": "Timeout (seconds)",
"snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)",
"backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]",
"backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]"
@@ -187,6 +185,37 @@
}
}
},
+ "issues": {
+ "missing_backup_setup": {
+ "title": "Backup location not configured for {title}",
+ "fix_flow": {
+ "step": {
+ "init": {
+ "description": "The backup location for {title} is not configured. Do you want to set it up now? Details can be found in the integration documentation under [Backup Location]({docs_url})",
+ "menu_options": {
+ "confirm": "Set up the backup location now",
+ "ignore": "Don't set it up now"
+ }
+ },
+ "confirm": {
+ "title": "[%key:component::synology_dsm::config::step::backup_share::title%]",
+ "data": {
+ "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]",
+ "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]"
+ },
+ "data_description": {
+ "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]",
+ "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]"
+ }
+ }
+ },
+ "abort": {
+ "no_shares": "There are no shared folders available for the user.\nPlease check the documentation.",
+ "ignored": "The backup location has not been configured.\nYou can still set it up later via the integration options."
+ }
+ }
+ }
+ },
"services": {
"reboot": {
"name": "Reboot",
diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json
index 714e7b74db0..8f4894f42a7 100644
--- a/homeassistant/components/telegram_bot/strings.json
+++ b/homeassistant/components/telegram_bot/strings.json
@@ -96,7 +96,7 @@
},
"verify_ssl": {
"name": "Verify SSL",
- "description": "Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server."
+ "description": "Enable or disable SSL certificate verification. Disable if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server."
},
"timeout": {
"name": "Read timeout",
@@ -530,11 +530,11 @@
},
"is_anonymous": {
"name": "Is anonymous",
- "description": "If the poll needs to be anonymous, defaults to True."
+ "description": "If the poll needs to be anonymous. This is the default."
},
"allows_multiple_answers": {
"name": "Allow multiple answers",
- "description": "If the poll allows multiple answers, defaults to False."
+ "description": "If the poll allows multiple answers."
},
"open_period": {
"name": "Open period",
diff --git a/homeassistant/components/tellduslive/entity.py b/homeassistant/components/tellduslive/entity.py
index a71fcb685c0..5366e4c27df 100644
--- a/homeassistant/components/tellduslive/entity.py
+++ b/homeassistant/components/tellduslive/entity.py
@@ -33,7 +33,7 @@ class TelldusLiveEntity(Entity):
self._id = device_id
self._client = client
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
_LOGGER.debug("Created device %s", self)
self.async_on_remove(
@@ -58,12 +58,12 @@ class TelldusLiveEntity(Entity):
return self.device.state
@property
- def assumed_state(self):
+ def assumed_state(self) -> bool:
"""Return true if unable to access real state of entity."""
return True
@property
- def available(self):
+ def available(self) -> bool:
"""Return true if device is not offline."""
return self._client.is_available(self.device_id)
diff --git a/homeassistant/components/tellstick/entity.py b/homeassistant/components/tellstick/entity.py
index 746c7f4dd4d..5be3d1f48f4 100644
--- a/homeassistant/components/tellstick/entity.py
+++ b/homeassistant/components/tellstick/entity.py
@@ -40,7 +40,7 @@ class TellstickDevice(Entity):
self._attr_name = tellcore_device.name
self._attr_unique_id = tellcore_device.id
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self.async_on_remove(
async_dispatcher_connect(
@@ -146,6 +146,6 @@ class TellstickDevice(Entity):
except TelldusError as err:
_LOGGER.error(err)
- def update(self):
+ def update(self) -> None:
"""Poll the current state of the device."""
self._update_from_tellcore()
diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py
index 129f460ff90..128c15068f6 100644
--- a/homeassistant/components/tesla_fleet/coordinator.py
+++ b/homeassistant/components/tesla_fleet/coordinator.py
@@ -17,7 +17,6 @@ from tesla_fleet_api.exceptions import (
TeslaFleetError,
VehicleOffline,
)
-from tesla_fleet_api.ratecalculator import RateCalculator
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -66,7 +65,6 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
updated_once: bool
pre2021: bool
last_active: datetime
- rate: RateCalculator
def __init__(
self,
@@ -87,44 +85,36 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.data = flatten(product)
self.updated_once = False
self.last_active = datetime.now()
- self.rate = RateCalculator(100, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5)
async def _async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using TeslaFleet API."""
try:
- # Check if the vehicle is awake using a non-rate limited API call
- if self.data["state"] != TeslaFleetState.ONLINE:
- response = await self.api.vehicle()
- self.data["state"] = response["response"]["state"]
+ # Check if the vehicle is awake using a free API call
+ response = await self.api.vehicle()
+ self.data["state"] = response["response"]["state"]
if self.data["state"] != TeslaFleetState.ONLINE:
return self.data
- # This is a rated limited API call
- self.rate.consume()
response = await self.api.vehicle_data(endpoints=ENDPOINTS)
data = response["response"]
except VehicleOffline:
self.data["state"] = TeslaFleetState.ASLEEP
return self.data
- except RateLimited as e:
+ except RateLimited:
LOGGER.warning(
- "%s rate limited, will retry in %s seconds",
+ "%s rate limited, will skip refresh",
self.name,
- e.data.get("after"),
)
- if "after" in e.data:
- self.update_interval = timedelta(seconds=int(e.data["after"]))
return self.data
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
- # Calculate ideal refresh interval
- self.update_interval = timedelta(seconds=self.rate.calculate())
+ self.update_interval = VEHICLE_INTERVAL
self.updated_once = True
diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json
index 330745316d7..bb8f6041771 100644
--- a/homeassistant/components/tesla_fleet/manifest.json
+++ b/homeassistant/components/tesla_fleet/manifest.json
@@ -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.8"]
+ "requirements": ["tesla-fleet-api==0.9.10"]
}
diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json
index bfa0d831a16..dfe6d7cb3f9 100644
--- a/homeassistant/components/teslemetry/manifest.json
+++ b/homeassistant/components/teslemetry/manifest.json
@@ -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.8", "teslemetry-stream==0.6.10"]
+ "requirements": ["tesla-fleet-api==0.9.10", "teslemetry-stream==0.6.10"]
}
diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json
index 68ad12a46b6..b6b3d17e37c 100644
--- a/homeassistant/components/teslemetry/strings.json
+++ b/homeassistant/components/teslemetry/strings.json
@@ -712,7 +712,7 @@
"name": "Navigate to coordinates"
},
"set_scheduled_charging": {
- "description": "Sets a time at which charging should be completed.",
+ "description": "Sets a time at which charging should be started.",
"fields": {
"device_id": {
"description": "Vehicle to schedule.",
diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json
index ef4d366c779..d777cf5051e 100644
--- a/homeassistant/components/tessie/manifest.json
+++ b/homeassistant/components/tessie/manifest.json
@@ -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.8"]
+ "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.10"]
}
diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py
index b0ade17b9c9..3cf8307e9b3 100644
--- a/homeassistant/components/timer/__init__.py
+++ b/homeassistant/components/timer/__init__.py
@@ -374,6 +374,9 @@ class Timer(collection.CollectionEntity, RestoreEntity):
@callback
def async_cancel(self) -> None:
"""Cancel a timer."""
+ if self._state == STATUS_IDLE:
+ return
+
if self._listener:
self._listener()
self._listener = None
@@ -389,13 +392,15 @@ class Timer(collection.CollectionEntity, RestoreEntity):
@callback
def async_finish(self) -> None:
"""Reset and updates the states, fire finished event."""
- if self._state != STATUS_ACTIVE or self._end is None:
+ if self._state == STATUS_IDLE:
return
if self._listener:
self._listener()
self._listener = None
end = self._end
+ if end is None:
+ end = dt_util.utcnow().replace(microsecond=0)
self._state = STATUS_IDLE
self._end = None
self._remaining = None
diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py
index fcd1335a77a..1a7b40457f0 100644
--- a/homeassistant/components/tplink/coordinator.py
+++ b/homeassistant/components/tplink/coordinator.py
@@ -9,6 +9,7 @@ import logging
from kasa import AuthenticationError, Credentials, Device, KasaException
from kasa.iot import IotStrip
+from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -46,11 +47,9 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
device: Device,
update_interval: timedelta,
config_entry: TPLinkConfigEntry,
- parent_coordinator: TPLinkDataUpdateCoordinator | None = None,
) -> None:
"""Initialize DataUpdateCoordinator to gather data for specific SmartPlug."""
self.device = device
- self.parent_coordinator = parent_coordinator
# The iot HS300 allows a limited number of concurrent requests and
# fetching the emeter information requires separate ones, so child
@@ -97,12 +96,6 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
) from ex
await self._process_child_devices()
- if not self._update_children:
- # If the children are not being updated, it means this is an
- # IotStrip, and we need to tell the children to write state
- # since the power state is provided by the parent.
- for child_coordinator in self._child_coordinators.values():
- child_coordinator.async_set_updated_data(None)
async def _process_child_devices(self) -> None:
"""Process child devices and remove stale devices."""
@@ -131,20 +124,19 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
def get_child_coordinator(
self,
child: Device,
+ platform_domain: str,
) -> TPLinkDataUpdateCoordinator:
"""Get separate child coordinator for a device or self if not needed."""
# The iot HS300 allows a limited number of concurrent requests and fetching the
# emeter information requires separate ones so create child coordinators here.
- if isinstance(self.device, IotStrip):
+ # This does not happen for switches as the state is available on the
+ # parent device info.
+ if isinstance(self.device, IotStrip) and platform_domain != SWITCH_DOMAIN:
if not (child_coordinator := self._child_coordinators.get(child.device_id)):
# The child coordinators only update energy data so we can
# set a longer update interval to avoid flooding the device
child_coordinator = TPLinkDataUpdateCoordinator(
- self.hass,
- child,
- timedelta(seconds=60),
- self.config_entry,
- parent_coordinator=self,
+ self.hass, child, timedelta(seconds=60), self.config_entry
)
self._child_coordinators[child.device_id] = child_coordinator
return child_coordinator
diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py
index 7a0d811b30d..7c1e9e72b85 100644
--- a/homeassistant/components/tplink/entity.py
+++ b/homeassistant/components/tplink/entity.py
@@ -151,13 +151,7 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P](
"exc": str(ex),
},
) from ex
- coordinator = self.coordinator
- if coordinator.parent_coordinator:
- # If there is a parent coordinator we need to refresh
- # the parent as its what provides the power state data
- # for the child entities.
- coordinator = coordinator.parent_coordinator
- await coordinator.async_request_refresh()
+ await self.coordinator.async_request_refresh()
return _async_wrap
@@ -514,7 +508,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
)
for child in children:
- child_coordinator = coordinator.get_child_coordinator(child)
+ child_coordinator = coordinator.get_child_coordinator(
+ child, platform_domain
+ )
child_entities = cls._entities_for_device(
hass,
@@ -657,7 +653,9 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC):
device.host,
)
for child in children:
- child_coordinator = coordinator.get_child_coordinator(child)
+ child_coordinator = coordinator.get_child_coordinator(
+ child, platform_domain
+ )
child_entities: list[_E] = cls._entities_for_device(
hass,
diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json
index ff65211c9b3..cdd6ab57c6a 100644
--- a/homeassistant/components/tplink/manifest.json
+++ b/homeassistant/components/tplink/manifest.json
@@ -301,5 +301,5 @@
"iot_class": "local_polling",
"loggers": ["kasa"],
"quality_scale": "platinum",
- "requirements": ["python-kasa[speedups]==0.10.1"]
+ "requirements": ["python-kasa[speedups]==0.10.2"]
}
diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py
index 06df118463b..7ea7fd95fef 100644
--- a/homeassistant/components/tplink_omada/__init__.py
+++ b/homeassistant/components/tplink_omada/__init__.py
@@ -11,7 +11,7 @@ from tplink_omada_client.exceptions import (
UnsupportedControllerVersion,
)
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -80,12 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo
async def async_unload_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# This is the last loaded instance of Omada, deregister any services
hass.services.async_remove(DOMAIN, "reconnect_client")
diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py
index b07b9e9959e..c04a8a043dc 100644
--- a/homeassistant/components/tuya/camera.py
+++ b/homeassistant/components/tuya/camera.py
@@ -20,6 +20,9 @@ CAMERAS: tuple[str, ...] = (
# Smart Camera (including doorbells)
# https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
"sp",
+ # Smart Camera - Low power consumption camera
+ # Undocumented, see https://github.com/home-assistant/core/issues/132844
+ "dghsxj",
)
diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py
index 7f4a964f47e..40d0fd73f0e 100644
--- a/homeassistant/components/tuya/light.py
+++ b/homeassistant/components/tuya/light.py
@@ -261,6 +261,20 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
+ # Smart Camera - Low power consumption camera
+ # Undocumented, see https://github.com/home-assistant/core/issues/132844
+ "dghsxj": (
+ TuyaLightEntityDescription(
+ key=DPCode.FLOODLIGHT_SWITCH,
+ brightness=DPCode.FLOODLIGHT_LIGHTNESS,
+ name="Floodlight",
+ ),
+ TuyaLightEntityDescription(
+ key=DPCode.BASIC_INDICATOR,
+ name="Indicator light",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
# Smart Gardening system
# https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0
"sz": (
diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py
index 4e98cf34d4d..ce1f434bcdd 100644
--- a/homeassistant/components/tuya/number.py
+++ b/homeassistant/components/tuya/number.py
@@ -174,6 +174,15 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
+ # Smart Camera - Low power consumption camera
+ # Undocumented, see https://github.com/home-assistant/core/issues/132844
+ "dghsxj": (
+ NumberEntityDescription(
+ key=DPCode.BASIC_DEVICE_VOLUME,
+ translation_key="volume",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
# Dimmer Switch
# https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o
"tgkg": (
diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py
index 766cdd295f1..0ae49cd127e 100644
--- a/homeassistant/components/tuya/select.py
+++ b/homeassistant/components/tuya/select.py
@@ -128,6 +128,40 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = {
translation_key="motion_sensitivity",
),
),
+ # Smart Camera - Low power consumption camera
+ # Undocumented, see https://github.com/home-assistant/core/issues/132844
+ "dghsxj": (
+ SelectEntityDescription(
+ key=DPCode.IPC_WORK_MODE,
+ entity_category=EntityCategory.CONFIG,
+ translation_key="ipc_work_mode",
+ ),
+ SelectEntityDescription(
+ key=DPCode.DECIBEL_SENSITIVITY,
+ entity_category=EntityCategory.CONFIG,
+ translation_key="decibel_sensitivity",
+ ),
+ SelectEntityDescription(
+ key=DPCode.RECORD_MODE,
+ entity_category=EntityCategory.CONFIG,
+ translation_key="record_mode",
+ ),
+ SelectEntityDescription(
+ key=DPCode.BASIC_NIGHTVISION,
+ entity_category=EntityCategory.CONFIG,
+ translation_key="basic_nightvision",
+ ),
+ SelectEntityDescription(
+ key=DPCode.BASIC_ANTI_FLICKER,
+ entity_category=EntityCategory.CONFIG,
+ translation_key="basic_anti_flicker",
+ ),
+ SelectEntityDescription(
+ key=DPCode.MOTION_SENSITIVITY,
+ entity_category=EntityCategory.CONFIG,
+ translation_key="motion_sensitivity",
+ ),
+ ),
# IoT Switch?
# Note: Undocumented
"tdq": (
diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py
index cb7602e24fe..76825e9c814 100644
--- a/homeassistant/components/tuya/sensor.py
+++ b/homeassistant/components/tuya/sensor.py
@@ -632,6 +632,29 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
),
),
+ # Smart Camera - Low power consumption camera
+ # Undocumented, see https://github.com/home-assistant/core/issues/132844
+ "dghsxj": (
+ TuyaSensorEntityDescription(
+ key=DPCode.SENSOR_TEMPERATURE,
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.SENSOR_HUMIDITY,
+ translation_key="humidity",
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.WIRELESS_ELECTRICITY,
+ translation_key="battery",
+ device_class=SensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ ),
# Fingerbot
"szjqr": BATTERY_SENSORS,
# Solar Light
diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py
index 310385df93d..9c60f7bcaac 100644
--- a/homeassistant/components/tuya/siren.py
+++ b/homeassistant/components/tuya/siren.py
@@ -44,6 +44,13 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = {
key=DPCode.SIREN_SWITCH,
),
),
+ # Smart Camera - Low power consumption camera
+ # Undocumented, see https://github.com/home-assistant/core/issues/132844
+ "dghsxj": (
+ SirenEntityDescription(
+ key=DPCode.SIREN_SWITCH,
+ ),
+ ),
# CO2 Detector
# https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
"co2bj": (
diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py
index d0192b41ee6..519a9e83606 100644
--- a/homeassistant/components/tuya/switch.py
+++ b/homeassistant/components/tuya/switch.py
@@ -509,6 +509,65 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
+ # Smart Camera - Low power consumption camera
+ # Undocumented, see https://github.com/home-assistant/core/issues/132844
+ "dghsxj": (
+ SwitchEntityDescription(
+ key=DPCode.WIRELESS_BATTERYLOCK,
+ translation_key="battery_lock",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.CRY_DETECTION_SWITCH,
+ translation_key="cry_detection",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.DECIBEL_SWITCH,
+ translation_key="sound_detection",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.RECORD_SWITCH,
+ translation_key="video_recording",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.MOTION_RECORD,
+ translation_key="motion_recording",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.BASIC_PRIVATE,
+ translation_key="privacy_mode",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.BASIC_FLIP,
+ translation_key="flip",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.BASIC_OSD,
+ translation_key="time_watermark",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.BASIC_WDR,
+ translation_key="wide_dynamic_range",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.MOTION_TRACKING,
+ translation_key="motion_tracking",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ SwitchEntityDescription(
+ key=DPCode.MOTION_SWITCH,
+ translation_key="motion_alarm",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ ),
# Smart Gardening system
# https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0
"sz": (
diff --git a/homeassistant/components/upb/entity.py b/homeassistant/components/upb/entity.py
index 13037adf680..8a9afa453b1 100644
--- a/homeassistant/components/upb/entity.py
+++ b/homeassistant/components/upb/entity.py
@@ -30,7 +30,7 @@ class UpbEntity(Entity):
return self._element.as_dict()
@property
- def available(self):
+ def available(self) -> bool:
"""Is the entity available to be updated."""
return self._upb.is_connected()
@@ -43,7 +43,7 @@ class UpbEntity(Entity):
self._element_changed(element, changeset)
self.async_write_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callback for UPB changes and update entity state."""
self._element.add_callback(self._element_callback)
self._element_callback(self._element, {})
diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py
index 674ba5dde45..1231a98e0a8 100644
--- a/homeassistant/components/velux/entity.py
+++ b/homeassistant/components/velux/entity.py
@@ -31,6 +31,6 @@ class VeluxEntity(Entity):
self.node.register_device_updated_cb(after_update_callback)
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Store register state change callback."""
self.async_register_callbacks()
diff --git a/homeassistant/components/vera/entity.py b/homeassistant/components/vera/entity.py
index 84e21e54983..b3013c288c1 100644
--- a/homeassistant/components/vera/entity.py
+++ b/homeassistant/components/vera/entity.py
@@ -52,7 +52,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity):
"""Update the state."""
self.schedule_update_ha_state(True)
- def update(self):
+ def update(self) -> None:
"""Force a refresh from the device if the device is unavailable."""
refresh_needed = self.vera_device.should_poll or not self.available
_LOGGER.debug("%s: update called (refresh=%s)", self._name, refresh_needed)
@@ -90,7 +90,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity):
return attr
@property
- def available(self):
+ def available(self) -> bool:
"""If device communications have failed return false."""
return not self.vera_device.comm_failure
diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py
index 4951bdb2dc1..f9371d44507 100644
--- a/homeassistant/components/vesync/__init__.py
+++ b/homeassistant/components/vesync/__init__.py
@@ -27,6 +27,7 @@ PLATFORMS = [
Platform.HUMIDIFIER,
Platform.LIGHT,
Platform.NUMBER,
+ Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py
index 34454081567..2e51b96451c 100644
--- a/homeassistant/components/vesync/const.py
+++ b/homeassistant/components/vesync/const.py
@@ -29,6 +29,10 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity"
VS_HUMIDIFIER_MODE_MANUAL = "manual"
VS_HUMIDIFIER_MODE_SLEEP = "sleep"
+NIGHT_LIGHT_LEVEL_BRIGHT = "bright"
+NIGHT_LIGHT_LEVEL_DIM = "dim"
+NIGHT_LIGHT_LEVEL_OFF = "off"
+
VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S
"""Humidifier device types"""
@@ -59,6 +63,7 @@ SKU_TO_BASE_DEVICE = {
# Air Purifiers
"LV-PUR131S": "LV-PUR131S",
"LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S
+ "LV-RH131S-WM": "LV-PUR131S", # Alt ID Model LV-PUR131S
"Core200S": "Core200S",
"LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S
"LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S
diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json
index b3697844f19..9e2fbcc1782 100644
--- a/homeassistant/components/vesync/manifest.json
+++ b/homeassistant/components/vesync/manifest.json
@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/vesync",
"iot_class": "cloud_polling",
"loggers": ["pyvesync"],
- "requirements": ["pyvesync==2.1.17"]
+ "requirements": ["pyvesync==2.1.18"]
}
diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py
new file mode 100644
index 00000000000..c266985fc2b
--- /dev/null
+++ b/homeassistant/components/vesync/select.py
@@ -0,0 +1,133 @@
+"""Support for VeSync numeric entities."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import logging
+
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .common import rgetattr
+from .const import (
+ DOMAIN,
+ NIGHT_LIGHT_LEVEL_BRIGHT,
+ NIGHT_LIGHT_LEVEL_DIM,
+ NIGHT_LIGHT_LEVEL_OFF,
+ VS_COORDINATOR,
+ VS_DEVICES,
+ VS_DISCOVERY,
+)
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP = {
+ 100: NIGHT_LIGHT_LEVEL_BRIGHT,
+ 50: NIGHT_LIGHT_LEVEL_DIM,
+ 0: NIGHT_LIGHT_LEVEL_OFF,
+}
+
+HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP = {
+ v: k for k, v in VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.items()
+}
+
+
+@dataclass(frozen=True, kw_only=True)
+class VeSyncSelectEntityDescription(SelectEntityDescription):
+ """Class to describe a Vesync select entity."""
+
+ exists_fn: Callable[[VeSyncBaseDevice], bool]
+ current_option_fn: Callable[[VeSyncBaseDevice], str]
+ select_option_fn: Callable[[VeSyncBaseDevice, str], bool]
+
+
+SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [
+ VeSyncSelectEntityDescription(
+ key="night_light_level",
+ translation_key="night_light_level",
+ options=list(VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.values()),
+ icon="mdi:brightness-6",
+ exists_fn=lambda device: rgetattr(device, "night_light"),
+ # The select_option service framework ensures that only options specified are
+ # accepted. ServiceValidationError gets raised for invalid value.
+ select_option_fn=lambda device, value: device.set_night_light_brightness(
+ HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP.get(value, 0)
+ ),
+ # Reporting "off" as the choice for unhandled level.
+ current_option_fn=lambda device: VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.get(
+ device.details.get("night_light_brightness"), NIGHT_LIGHT_LEVEL_OFF
+ ),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up select entities."""
+
+ coordinator = hass.data[DOMAIN][VS_COORDINATOR]
+
+ @callback
+ def discover(devices):
+ """Add new devices to platform."""
+ _setup_entities(devices, async_add_entities, coordinator)
+
+ config_entry.async_on_unload(
+ async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
+ )
+
+ _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+
+
+@callback
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ coordinator: VeSyncDataCoordinator,
+):
+ """Add select entities."""
+
+ async_add_entities(
+ VeSyncSelectEntity(dev, description, coordinator)
+ for dev in devices
+ for description in SELECT_DESCRIPTIONS
+ if description.exists_fn(dev)
+ )
+
+
+class VeSyncSelectEntity(VeSyncBaseEntity, SelectEntity):
+ """A class to set numeric options on Vesync device."""
+
+ entity_description: VeSyncSelectEntityDescription
+
+ def __init__(
+ self,
+ device: VeSyncBaseDevice,
+ description: VeSyncSelectEntityDescription,
+ coordinator: VeSyncDataCoordinator,
+ ) -> None:
+ """Initialize the VeSync select device."""
+ super().__init__(device, coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{super().unique_id}-{description.key}"
+
+ @property
+ def current_option(self) -> str | None:
+ """Return an option."""
+ return self.entity_description.current_option_fn(self.device)
+
+ async def async_select_option(self, option: str) -> None:
+ """Set an option."""
+ if await self.hass.async_add_executor_job(
+ self.entity_description.select_option_fn, self.device, option
+ ):
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json
index 3eb2a0c3fd5..2232b16329b 100644
--- a/homeassistant/components/vesync/strings.json
+++ b/homeassistant/components/vesync/strings.json
@@ -56,6 +56,16 @@
"name": "Mist level"
}
},
+ "select": {
+ "night_light_level": {
+ "name": "Night light level",
+ "state": {
+ "bright": "Bright",
+ "dim": "Dim",
+ "off": "[%key:common::state::off%]"
+ }
+ }
+ },
"fan": {
"vesync": {
"state_attributes": {
diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json
index 489d4accb8a..a5718962f55 100644
--- a/homeassistant/components/vicare/manifest.json
+++ b/homeassistant/components/vicare/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/vicare",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
- "requirements": ["PyViCare==2.42.0"]
+ "requirements": ["PyViCare==2.43.0"]
}
diff --git a/homeassistant/components/volvooncall/entity.py b/homeassistant/components/volvooncall/entity.py
index 6ebc4bdc754..5a1194e8b1a 100644
--- a/homeassistant/components/volvooncall/entity.py
+++ b/homeassistant/components/volvooncall/entity.py
@@ -57,7 +57,7 @@ class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]):
return f"{self._vehicle_name} {self._entity_name}"
@property
- def assumed_state(self):
+ def assumed_state(self) -> bool:
"""Return true if unable to access real state of entity."""
return True
diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json
index 85d331f5bd0..31e644b32e3 100644
--- a/homeassistant/components/weather/strings.json
+++ b/homeassistant/components/weather/strings.json
@@ -90,17 +90,17 @@
"services": {
"get_forecasts": {
"name": "Get forecasts",
- "description": "Get weather forecasts.",
+ "description": "Retrieves the forecast from selected weather services.",
"fields": {
"type": {
"name": "Forecast type",
- "description": "Forecast type: daily, hourly or twice daily."
+ "description": "The scope of the weather forecast."
}
}
},
"get_forecast": {
"name": "Get forecast",
- "description": "Get weather forecast.",
+ "description": "Retrieves the forecast from a selected weather service.",
"fields": {
"type": {
"name": "[%key:component::weather::services::get_forecasts::fields::type::name%]",
@@ -111,12 +111,12 @@
},
"issues": {
"deprecated_service_weather_get_forecast": {
- "title": "Detected use of deprecated service weather.get_forecast",
+ "title": "Detected use of deprecated action weather.get_forecast",
"fix_flow": {
"step": {
"confirm": {
"title": "[%key:component::weather::issues::deprecated_service_weather_get_forecast::title%]",
- "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **Submit** to close this issue."
+ "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this action and adjust your automations and scripts and select **Submit** to close this issue."
}
}
}
diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json
index 174e8025dd0..5fbcf759ee3 100644
--- a/homeassistant/components/webostv/manifest.json
+++ b/homeassistant/components/webostv/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/webostv",
"iot_class": "local_push",
"loggers": ["aiowebostv"],
- "requirements": ["aiowebostv==0.6.1"],
+ "requirements": ["aiowebostv==0.6.2"],
"ssdp": [
{
"st": "urn:lge-com:service:webos-second-screen:1"
diff --git a/homeassistant/components/wiffi/entity.py b/homeassistant/components/wiffi/entity.py
index fd774c930c8..84bbc9b3df1 100644
--- a/homeassistant/components/wiffi/entity.py
+++ b/homeassistant/components/wiffi/entity.py
@@ -41,7 +41,7 @@ class WiffiEntity(Entity):
self._value = None
self._timeout = options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Entity has been added to hass."""
self.async_on_remove(
async_dispatcher_connect(
diff --git a/homeassistant/components/wirelesstag/entity.py b/homeassistant/components/wirelesstag/entity.py
index 31f8ee99d0d..73b13cdc397 100644
--- a/homeassistant/components/wirelesstag/entity.py
+++ b/homeassistant/components/wirelesstag/entity.py
@@ -60,11 +60,11 @@ class WirelessTagBaseSensor(Entity):
return f"{value:.1f}"
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if entity is available."""
return self._tag.is_alive
- def update(self):
+ def update(self) -> None:
"""Update state."""
if not self.should_poll:
return
diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py
index 96cb433deba..28a0fbd1492 100644
--- a/homeassistant/components/withings/sensor.py
+++ b/homeassistant/components/withings/sensor.py
@@ -425,6 +425,9 @@ SLEEP_SENSORS = [
key="sleep_snoring",
value_fn=lambda sleep_summary: sleep_summary.snoring,
translation_key="snoring",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.MINUTES,
+ device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py
index 579994aaf6b..6e4d143d84e 100644
--- a/homeassistant/components/xiaomi_aqara/__init__.py
+++ b/homeassistant/components/xiaomi_aqara/__init__.py
@@ -7,7 +7,7 @@ import voluptuous as vol
from xiaomi_gateway import AsyncXiaomiGatewayMulticast, XiaomiGateway
from homeassistant.components import persistent_notification
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
CONF_HOST,
@@ -216,12 +216,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
if unload_ok:
hass.data[DOMAIN][GATEWAYS_KEY].pop(config_entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# No gateways left, stop Xiaomi socket
unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP)
unsub_stop()
diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py
index db47015c0cf..59107984ddf 100644
--- a/homeassistant/components/xiaomi_aqara/entity.py
+++ b/homeassistant/components/xiaomi_aqara/entity.py
@@ -57,7 +57,7 @@ class XiaomiDevice(Entity):
self._is_gateway = False
self._device_id = self._sid
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Start unavailability tracking."""
self._xiaomi_hub.callbacks[self._sid].append(self.push_data)
self._async_track_unavailable()
@@ -100,7 +100,7 @@ class XiaomiDevice(Entity):
return device_info
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if entity is available."""
return self._is_available
diff --git a/homeassistant/components/xiaomi_miio/entity.py b/homeassistant/components/xiaomi_miio/entity.py
index 0343a7526d7..ba1148985ba 100644
--- a/homeassistant/components/xiaomi_miio/entity.py
+++ b/homeassistant/components/xiaomi_miio/entity.py
@@ -185,7 +185,7 @@ class XiaomiGatewayDevice(CoordinatorEntity, Entity):
)
@property
- def available(self):
+ def available(self) -> bool:
"""Return if entity is available."""
if self.coordinator.data is None:
return False
diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json
index 75563b07559..bd3b3499689 100644
--- a/homeassistant/components/xiaomi_miio/strings.json
+++ b/homeassistant/components/xiaomi_miio/strings.json
@@ -331,7 +331,7 @@
"fields": {
"entity_id": {
"name": "Entity ID",
- "description": "Name of the xiaomi miio entity."
+ "description": "Name of the Xiaomi Miio entity."
}
}
},
@@ -365,7 +365,7 @@
},
"light_set_delayed_turn_off": {
"name": "Light set delayed turn off",
- "description": "Delayed turn off.",
+ "description": "Sets the delayed turning off of a light.",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -373,7 +373,7 @@
},
"time_period": {
"name": "Time period",
- "description": "Time period for the delayed turn off."
+ "description": "Time period for the delayed turning off."
}
}
},
@@ -398,8 +398,8 @@
}
},
"light_night_light_mode_on": {
- "name": "Night light mode on",
- "description": "Turns the eyecare mode on (EYECARE SMART LAMP 2 ONLY).",
+ "name": "Light night light mode on",
+ "description": "Turns on the night light mode of a light (EYECARE SMART LAMP 2 ONLY).",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -408,8 +408,8 @@
}
},
"light_night_light_mode_off": {
- "name": "Night light mode off",
- "description": "Turns the eyecare mode fan_set_dry_off (EYECARE SMART LAMP 2 ONLY).",
+ "name": "Light night light mode off",
+ "description": "Turns off the night light mode of a light (EYECARE SMART LAMP 2 ONLY).",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -419,7 +419,7 @@
},
"light_eyecare_mode_on": {
"name": "Light eyecare mode on",
- "description": "[%key:component::xiaomi_miio::services::light_reminder_on::description%]",
+ "description": "Turns on the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -429,7 +429,7 @@
},
"light_eyecare_mode_off": {
"name": "Light eyecare mode off",
- "description": "[%key:component::xiaomi_miio::services::light_reminder_off::description%]",
+ "description": "Turns off the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -439,7 +439,7 @@
},
"remote_learn_command": {
"name": "Remote learn command",
- "description": "Learns an IR command, select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.",
+ "description": "Learns an IR command. Select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.",
"fields": {
"slot": {
"name": "Slot",
@@ -447,21 +447,21 @@
},
"timeout": {
"name": "Timeout",
- "description": "Define the timeout, before which the command must be learned."
+ "description": "Define the timeout before which the command must be learned."
}
}
},
"remote_set_led_on": {
"name": "Remote set LED on",
- "description": "Turns on blue LED."
+ "description": "Turns on the remote’s blue LED."
},
"remote_set_led_off": {
"name": "Remote set LED off",
- "description": "Turns off blue LED."
+ "description": "Turns off the remote’s blue LED."
},
"switch_set_wifi_led_on": {
"name": "Switch set Wi-Fi LED on",
- "description": "Turns the Wi-Fi LED on.",
+ "description": "Turns on the Wi-Fi LED of a switch.",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -471,7 +471,7 @@
},
"switch_set_wifi_led_off": {
"name": "Switch set Wi-Fi LED off",
- "description": "Turns the Wi-Fi LED off.",
+ "description": "Turns off the Wi-Fi LED of a switch.",
"fields": {
"entity_id": {
"name": "Entity ID",
diff --git a/homeassistant/components/xs1/entity.py b/homeassistant/components/xs1/entity.py
index 7239a6fd446..c1ec43ec33c 100644
--- a/homeassistant/components/xs1/entity.py
+++ b/homeassistant/components/xs1/entity.py
@@ -17,7 +17,7 @@ class XS1DeviceEntity(Entity):
"""Initialize the XS1 device."""
self.device = device
- async def async_update(self):
+ async def async_update(self) -> None:
"""Retrieve latest device state."""
async with UPDATE_LOCK:
await self.hass.async_add_executor_job(self.device.update)
diff --git a/homeassistant/components/yamaha_musiccast/entity.py b/homeassistant/components/yamaha_musiccast/entity.py
index 4f1add825e4..8023b13c10a 100644
--- a/homeassistant/components/yamaha_musiccast/entity.py
+++ b/homeassistant/components/yamaha_musiccast/entity.py
@@ -78,13 +78,13 @@ class MusicCastDeviceEntity(MusicCastEntity):
return device_info
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
await super().async_added_to_hass()
# All entities should register callbacks to update HA when their state changes
self.coordinator.musiccast.register_callback(self.async_write_ha_state)
- async def async_will_remove_from_hass(self):
+ async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
await super().async_will_remove_from_hass()
self.coordinator.musiccast.remove_callback(self.async_write_ha_state)
diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json
index 72e400b7cf3..d53c28cb64a 100644
--- a/homeassistant/components/yeelight/strings.json
+++ b/homeassistant/components/yeelight/strings.json
@@ -161,11 +161,11 @@
},
"set_music_mode": {
"name": "Set music mode",
- "description": "Enables or disables music_mode.",
+ "description": "Enables or disables music mode.",
"fields": {
"music_mode": {
"name": "Music mode",
- "description": "Use true or false to enable / disable music_mode."
+ "description": "Whether to enable or disable music mode."
}
}
}
diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json
index 78b553d7978..52ae8281f59 100644
--- a/homeassistant/components/yolink/manifest.json
+++ b/homeassistant/components/yolink/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["auth", "application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/yolink",
"iot_class": "cloud_push",
- "requirements": ["yolink-api==0.4.7"]
+ "requirements": ["yolink-api==0.4.8"]
}
diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py
index b748006336c..e80b6b8cfdb 100644
--- a/homeassistant/components/zeroconf/__init__.py
+++ b/homeassistant/components/zeroconf/__init__.py
@@ -141,13 +141,13 @@ def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf:
return _async_get_instance(hass)
-def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf:
+def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf:
if DOMAIN in hass.data:
return cast(HaAsyncZeroconf, hass.data[DOMAIN])
logging.getLogger("zeroconf").setLevel(logging.NOTSET)
- zeroconf = HaZeroconf(**zcargs)
+ zeroconf = HaZeroconf(**_async_get_zc_args(hass))
aio_zc = HaAsyncZeroconf(zc=zeroconf)
install_multiple_zeroconf_catcher(zeroconf)
@@ -175,12 +175,10 @@ def _async_zc_has_functional_dual_stack() -> bool:
)
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up Zeroconf and make Home Assistant discoverable."""
- zc_args: dict = {"ip_version": IPVersion.V4Only}
-
- adapters = await network.async_get_adapters(hass)
-
+def _async_get_zc_args(hass: HomeAssistant) -> dict[str, Any]:
+ """Get zeroconf arguments from config."""
+ zc_args: dict[str, Any] = {"ip_version": IPVersion.V4Only}
+ adapters = network.async_get_loaded_adapters(hass)
ipv6 = False
if _async_zc_has_functional_dual_stack():
if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters):
@@ -195,7 +193,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
else:
zc_args["interfaces"] = [
str(source_ip)
- for source_ip in await network.async_get_enabled_source_ips(hass)
+ for source_ip in network.async_get_enabled_source_ips_from_adapters(
+ adapters
+ )
if not source_ip.is_loopback
and not (isinstance(source_ip, IPv6Address) and source_ip.is_global)
and not (
@@ -207,8 +207,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
and zc_args["ip_version"] == IPVersion.V6Only
)
]
+ return zc_args
- aio_zc = _async_get_instance(hass, **zc_args)
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Zeroconf and make Home Assistant discoverable."""
+ aio_zc = _async_get_instance(hass)
zeroconf = cast(HaZeroconf, aio_zc.zeroconf)
zeroconf_types = await async_get_zeroconf(hass)
homekit_models = await async_get_homekit(hass)
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index f4a78cd99e9..7a17c0dc5c3 100644
--- a/homeassistant/components/zeroconf/manifest.json
+++ b/homeassistant/components/zeroconf/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["zeroconf"],
"quality_scale": "internal",
- "requirements": ["zeroconf==0.143.0"]
+ "requirements": ["zeroconf==0.144.3"]
}
diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py
index c31627d3dc3..700e2833705 100644
--- a/homeassistant/components/zha/helpers.py
+++ b/homeassistant/components/zha/helpers.py
@@ -11,6 +11,7 @@ import enum
import functools
import itertools
import logging
+import queue
import re
import time
from types import MappingProxyType
@@ -111,9 +112,10 @@ from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
)
-from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util.logging import HomeAssistantQueueHandler
from .const import (
ATTR_ACTIVE_COORDINATOR,
@@ -505,7 +507,14 @@ class ZHAGatewayProxy(EventBase):
DEBUG_LEVEL_CURRENT: async_capture_log_levels(),
}
self.debug_enabled: bool = False
- self._log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self)
+
+ log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self)
+ log_simple_queue: queue.SimpleQueue[logging.Handler] = queue.SimpleQueue()
+ self._log_queue_handler = HomeAssistantQueueHandler(log_simple_queue)
+ self._log_queue_handler.listener = logging.handlers.QueueListener(
+ log_simple_queue, log_relay_handler
+ )
+
self._unsubs: list[Callable[[], None]] = []
self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol))
self._reload_task: asyncio.Task | None = None
@@ -736,10 +745,13 @@ class ZHAGatewayProxy(EventBase):
self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels()
if filterer:
- self._log_relay_handler.addFilter(filterer)
+ self._log_queue_handler.addFilter(filterer)
+
+ if self._log_queue_handler.listener:
+ self._log_queue_handler.listener.start()
for logger_name in DEBUG_RELAY_LOGGERS:
- logging.getLogger(logger_name).addHandler(self._log_relay_handler)
+ logging.getLogger(logger_name).addHandler(self._log_queue_handler)
self.debug_enabled = True
@@ -749,9 +761,14 @@ class ZHAGatewayProxy(EventBase):
async_set_logger_levels(self._log_levels[DEBUG_LEVEL_ORIGINAL])
self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels()
for logger_name in DEBUG_RELAY_LOGGERS:
- logging.getLogger(logger_name).removeHandler(self._log_relay_handler)
+ logging.getLogger(logger_name).removeHandler(self._log_queue_handler)
+
+ if self._log_queue_handler.listener:
+ self._log_queue_handler.listener.stop()
+
if filterer:
- self._log_relay_handler.removeFilter(filterer)
+ self._log_queue_handler.removeFilter(filterer)
+
self.debug_enabled = False
async def shutdown(self) -> None:
@@ -978,7 +995,7 @@ class LogRelayHandler(logging.Handler):
entry = LogEntry(
record, self.paths_re, figure_out_source=record.levelno >= logging.WARNING
)
- async_dispatcher_send(
+ dispatcher_send(
self.hass,
ZHA_GW_MSG,
{ATTR_TYPE: ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_LOG_ENTRY: entry.to_dict()},
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index 821159afb22..54de60b8669 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
- "requirements": ["zha==0.0.48"],
+ "requirements": ["zha==0.0.49"],
"usb": [
{
"vid": "10C4",
diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json
index c73a0989faa..2007adca0da 100644
--- a/homeassistant/components/zha/strings.json
+++ b/homeassistant/components/zha/strings.json
@@ -3,11 +3,11 @@
"flow_title": "{name}",
"step": {
"choose_serial_port": {
- "title": "Select a Serial Port",
+ "title": "Select a serial port",
+ "description": "Select the serial port for your Zigbee radio",
"data": {
- "path": "Serial Device Path"
- },
- "description": "Select the serial port for your Zigbee radio"
+ "path": "Serial device path"
+ }
},
"confirm": {
"description": "Do you want to set up {name}?"
@@ -16,14 +16,14 @@
"description": "Do you want to set up {name}?"
},
"manual_pick_radio_type": {
+ "title": "Select a radio type",
+ "description": "Pick your Zigbee radio type",
"data": {
- "radio_type": "Radio Type"
- },
- "title": "[%key:component::zha::config::step::manual_pick_radio_type::data::radio_type%]",
- "description": "Pick your Zigbee radio type"
+ "radio_type": "Radio type"
+ }
},
"manual_port_config": {
- "title": "Serial Port Settings",
+ "title": "Serial port settings",
"description": "Enter the serial port settings",
"data": {
"path": "Serial device path",
@@ -36,7 +36,7 @@
"description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})."
},
"choose_formation_strategy": {
- "title": "Network Formation",
+ "title": "Network formation",
"description": "Choose the network settings for your radio.",
"menu_options": {
"form_new_network": "Erase network settings and create a new network",
@@ -47,21 +47,21 @@
}
},
"choose_automatic_backup": {
- "title": "Restore Automatic Backup",
+ "title": "Restore automatic backup",
"description": "Restore your network settings from an automatic backup",
"data": {
"choose_automatic_backup": "Choose an automatic backup"
}
},
"upload_manual_backup": {
- "title": "Upload a Manual Backup",
+ "title": "Upload a manual backup",
"description": "Restore your network settings from an uploaded backup JSON file. You can download one from a different ZHA installation from **Network Settings**, or use a Zigbee2MQTT `coordinator_backup.json` file.",
"data": {
"uploaded_backup_file": "Upload a file"
}
},
"maybe_confirm_ezsp_restore": {
- "title": "Overwrite Radio IEEE Address",
+ "title": "Overwrite radio IEEE address",
"description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.",
"data": {
"overwrite_coordinator_ieee": "Permanently replace the radio IEEE address"
@@ -74,10 +74,10 @@
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
- "not_zha_device": "This device is not a zha device",
- "usb_probe_failed": "Failed to probe the usb device",
+ "not_zha_device": "This device is not a ZHA device",
+ "usb_probe_failed": "Failed to probe the USB device",
"wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.",
- "invalid_zeroconf_data": "The coordinator has invalid zeroconf service info and cannot be identified by ZHA"
+ "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA"
}
},
"options": {
@@ -307,7 +307,7 @@
}
},
"set_zigbee_cluster_attribute": {
- "name": "Set zigbee cluster attribute",
+ "name": "Set Zigbee cluster attribute",
"description": "Sets an attribute value for the specified cluster on the specified entity.",
"fields": {
"ieee": {
@@ -323,7 +323,7 @@
"description": "ZCL cluster to retrieve attributes for."
},
"cluster_type": {
- "name": "Cluster Type",
+ "name": "Cluster type",
"description": "Type of the cluster."
},
"attribute": {
@@ -341,7 +341,7 @@
}
},
"issue_zigbee_cluster_command": {
- "name": "Issue zigbee cluster command",
+ "name": "Issue Zigbee cluster command",
"description": "Issues a command on the specified cluster on the specified entity.",
"fields": {
"ieee": {
@@ -383,8 +383,8 @@
}
},
"issue_zigbee_group_command": {
- "name": "Issue zigbee group command",
- "description": "Issue command on the specified cluster on the specified group.",
+ "name": "Issue Zigbee group command",
+ "description": "Issues a command on the specified cluster on the specified group.",
"fields": {
"group": {
"name": "Group",
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index b4de9749250..871b476227c 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -155,6 +155,8 @@ class ConfigEntryState(Enum):
"""An error occurred when trying to unload the entry"""
SETUP_IN_PROGRESS = "setup_in_progress", False
"""The config entry is setting up."""
+ UNLOAD_IN_PROGRESS = "unload_in_progress", False
+ """The config entry is being unloaded."""
_recoverable: bool
@@ -955,18 +957,25 @@ class ConfigEntry[_DataT = Any]:
)
return False
+ if domain_is_integration:
+ self._async_set_state(hass, ConfigEntryState.UNLOAD_IN_PROGRESS, None)
try:
result = await component.async_unload_entry(hass, self)
assert isinstance(result, bool)
- # Only adjust state if we unloaded the component
- if domain_is_integration and result:
- await self._async_process_on_unload(hass)
- if hasattr(self, "runtime_data"):
- object.__delattr__(self, "runtime_data")
+ # Only do side effects if we unloaded the integration
+ if domain_is_integration:
+ if result:
+ await self._async_process_on_unload(hass)
+ if hasattr(self, "runtime_data"):
+ object.__delattr__(self, "runtime_data")
- self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None)
+ self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None)
+ else:
+ self._async_set_state(
+ hass, ConfigEntryState.FAILED_UNLOAD, "Unload failed"
+ )
except Exception as exc:
_LOGGER.exception(
@@ -1952,7 +1961,7 @@ class ConfigEntries:
Raises UnknownEntry if entry is not found.
"""
if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ raise UnknownEntry(entry_id)
return entry
@callback
@@ -2052,9 +2061,9 @@ class ConfigEntries:
else:
unload_success = await self.async_unload(entry_id, _lock=False)
+ del self._entries[entry.entry_id]
await entry.async_remove(self.hass)
- del self._entries[entry.entry_id]
self.async_update_issues()
self._async_schedule_save()
@@ -3423,7 +3432,7 @@ class ConfigSubentryFlow(
if data_updates is not UNDEFINED:
if data is not UNDEFINED:
raise ValueError("Cannot set both data and data_updates")
- data = entry.data | data_updates
+ data = subentry.data | data_updates
self.hass.config_entries.async_update_subentry(
entry=entry,
subentry=subentry,
@@ -3462,7 +3471,7 @@ class ConfigSubentryFlow(
)
subentry_id = self._reconfigure_subentry_id
if subentry_id not in entry.subentries:
- raise UnknownEntry
+ raise UnknownSubEntry(subentry_id)
return entry.subentries[subentry_id]
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 4f52f49ce09..2b9e5c307a6 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -4,7 +4,7 @@ aiodhcpwatcher==1.1.0
aiodiscover==2.6.0
aiodns==3.2.0
aiohasupervisor==0.3.0
-aiohttp-asyncmdnsresolver==0.1.0
+aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.2.2
aiohttp==3.11.12
aiohttp_cors==0.7.0
@@ -28,16 +28,16 @@ cached-ipaddress==0.8.0
certifi>=2021.5.30
ciso8601==2.3.2
cronsim==2.6
-cryptography==44.0.0
+cryptography==44.0.1
dbus-fast==2.33.0
fnv-hash-fast==1.2.2
go2rtc-client==0.1.2
ha-ffmpeg==3.2.2
habluetooth==3.21.1
-hass-nabucasa==0.89.0
+hass-nabucasa==0.92.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
-home-assistant-frontend==20250210.0
+home-assistant-frontend==20250214.0
home-assistant-intents==2025.2.5
httpx==0.28.1
ifaddr==0.2.0
@@ -67,13 +67,13 @@ standard-telnetlib==3.13.0
typing-extensions>=4.12.2,<5.0
ulid-transform==1.2.0
urllib3>=1.26.5,<2
-uv==0.5.27
+uv==0.6.0
voluptuous-openapi==0.0.6
voluptuous-serialize==2.6.0
voluptuous==0.15.2
webrtc-models==0.3.0
yarl==1.18.3
-zeroconf==0.143.0
+zeroconf==0.144.3
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238
diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py
index e2b6de6e6a3..a4590207294 100644
--- a/pylint/plugins/hass_enforce_type_hints.py
+++ b/pylint/plugins/hass_enforce_type_hints.py
@@ -1410,6 +1410,16 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
],
),
],
+ "entity": [
+ ClassTypeHintMatch(
+ base_class="Entity",
+ matches=_ENTITY_MATCH,
+ ),
+ ClassTypeHintMatch(
+ base_class="RestoreEntity",
+ matches=_RESTORE_ENTITY_MATCH,
+ ),
+ ],
"fan": [
ClassTypeHintMatch(
base_class="Entity",
diff --git a/pyproject.toml b/pyproject.toml
index 3936fdb3a1e..44fef7dea9a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,7 +31,7 @@ dependencies = [
"aiohttp==3.11.12",
"aiohttp_cors==0.7.0",
"aiohttp-fast-zlib==0.2.2",
- "aiohttp-asyncmdnsresolver==0.1.0",
+ "aiohttp-asyncmdnsresolver==0.1.1",
"aiozoneinfo==0.2.3",
"astral==2.2",
"async-interrupt==1.2.1",
@@ -46,7 +46,7 @@ dependencies = [
"fnv-hash-fast==1.2.2",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
- "hass-nabucasa==0.89.0",
+ "hass-nabucasa==0.92.0",
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.28.1",
@@ -56,7 +56,7 @@ dependencies = [
"lru-dict==1.3.0",
"PyJWT==2.10.1",
# PyJWT has loose dependency. We want the latest one.
- "cryptography==44.0.0",
+ "cryptography==44.0.1",
"Pillow==11.1.0",
"propcache==0.2.1",
"pyOpenSSL==25.0.0",
@@ -76,13 +76,13 @@ dependencies = [
# Temporary setting an upper bound, to prevent compat issues with urllib3>=2
# https://github.com/home-assistant/core/issues/97248
"urllib3>=1.26.5,<2",
- "uv==0.5.27",
+ "uv==0.6.0",
"voluptuous==0.15.2",
"voluptuous-serialize==2.6.0",
"voluptuous-openapi==0.0.6",
"yarl==1.18.3",
"webrtc-models==0.3.0",
- "zeroconf==0.143.0"
+ "zeroconf==0.144.3"
]
[project.urls]
diff --git a/requirements.txt b/requirements.txt
index f0ff3b8054a..c06beefab37 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,7 +8,7 @@ aiohasupervisor==0.3.0
aiohttp==3.11.12
aiohttp_cors==0.7.0
aiohttp-fast-zlib==0.2.2
-aiohttp-asyncmdnsresolver==0.1.0
+aiohttp-asyncmdnsresolver==0.1.1
aiozoneinfo==0.2.3
astral==2.2
async-interrupt==1.2.1
@@ -21,14 +21,14 @@ certifi>=2021.5.30
ciso8601==2.3.2
cronsim==2.6
fnv-hash-fast==1.2.2
-hass-nabucasa==0.89.0
+hass-nabucasa==0.92.0
httpx==0.28.1
home-assistant-bluetooth==1.13.1
ifaddr==0.2.0
Jinja2==3.1.5
lru-dict==1.3.0
PyJWT==2.10.1
-cryptography==44.0.0
+cryptography==44.0.1
Pillow==11.1.0
propcache==0.2.1
pyOpenSSL==25.0.0
@@ -45,10 +45,10 @@ standard-telnetlib==3.13.0
typing-extensions>=4.12.2,<5.0
ulid-transform==1.2.0
urllib3>=1.26.5,<2
-uv==0.5.27
+uv==0.6.0
voluptuous==0.15.2
voluptuous-serialize==2.6.0
voluptuous-openapi==0.0.6
yarl==1.18.3
webrtc-models==0.3.0
-zeroconf==0.143.0
+zeroconf==0.144.3
diff --git a/requirements_all.txt b/requirements_all.txt
index f0e427714ae..109daf0c4c0 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -100,7 +100,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.7.5
# homeassistant.components.vicare
-PyViCare==2.42.0
+PyViCare==2.43.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==29.0.0
+aioesphomeapi==29.0.2
# homeassistant.components.flo
aioflo==2021.11.0
@@ -425,7 +425,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webostv
-aiowebostv==0.6.1
+aiowebostv==0.6.2
# homeassistant.components.withings
aiowithings==3.1.5
@@ -434,7 +434,7 @@ aiowithings==3.1.5
aioymaps==1.2.5
# homeassistant.components.airgradient
-airgradient==0.9.1
+airgradient==0.9.2
# homeassistant.components.airly
airly==1.1.0
@@ -500,7 +500,7 @@ aqualogic==2.6
aranet4==2.5.1
# homeassistant.components.arcam_fmj
-arcam-fmj==1.5.2
+arcam-fmj==1.8.0
# homeassistant.components.arris_tg2492lg
arris-tg2492lg==2.2.0
@@ -753,7 +753,7 @@ debugpy==1.8.11
# decora==0.6
# homeassistant.components.ecovacs
-deebot-client==12.0.0
+deebot-client==12.1.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -1109,7 +1109,7 @@ habiticalib==0.3.7
habluetooth==3.21.1
# homeassistant.components.cloud
-hass-nabucasa==0.89.0
+hass-nabucasa==0.92.0
# homeassistant.components.splunk
hass-splunk==0.1.1
@@ -1149,7 +1149,7 @@ hole==0.8.0
holidays==0.66
# homeassistant.components.frontend
-home-assistant-frontend==20250210.0
+home-assistant-frontend==20250214.0
# homeassistant.components.conversation
home-assistant-intents==2025.2.5
@@ -1550,7 +1550,7 @@ odp-amsterdam==6.0.2
oemthermostat==1.1.1
# homeassistant.components.ohme
-ohme==1.2.9
+ohme==1.3.0
# homeassistant.components.ollama
ollama==0.4.7
@@ -1598,7 +1598,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
-opower==0.8.9
+opower==0.9.0
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1666,7 +1666,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
-plugwise==1.7.1
+plugwise==1.7.2
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1752,7 +1752,7 @@ py-schluter==0.1.7
py-sucks==0.9.10
# homeassistant.components.synology_dsm
-py-synologydsm-api==2.6.2
+py-synologydsm-api==2.6.3
# homeassistant.components.atome
pyAtome==0.1.1
@@ -1828,7 +1828,7 @@ pyatv==0.16.0
pyaussiebb==0.1.5
# homeassistant.components.balboa
-pybalboa==1.1.2
+pybalboa==1.1.3
# homeassistant.components.bbox
pybbox==0.0.5-alpha
@@ -1915,7 +1915,7 @@ pyebox==1.1.4
pyecoforest==0.4.0
# homeassistant.components.econet
-pyeconet==0.1.27
+pyeconet==0.1.28
# homeassistant.components.ista_ecotrend
pyecotrend-ista==3.3.1
@@ -1936,7 +1936,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
-pyenphase==1.23.1
+pyenphase==1.25.1
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -1996,7 +1996,7 @@ pyhaversion==22.8.0
pyheos==1.0.2
# homeassistant.components.hive
-pyhive-integration==1.0.1
+pyhive-integration==1.0.2
# homeassistant.components.homematic
pyhomematic==0.1.77
@@ -2289,7 +2289,7 @@ pyserial==3.5
pysesame2==1.0.1
# homeassistant.components.seventeentrack
-pyseventeentrack==1.0.1
+pyseventeentrack==1.0.2
# homeassistant.components.sia
pysiaalarm==3.1.1
@@ -2313,7 +2313,7 @@ pysmartapp==0.3.5
pysmartthings==0.7.8
# homeassistant.components.smarty
-pysmarty2==0.10.1
+pysmarty2==0.10.2
# homeassistant.components.smhi
pysmhi==1.0.0
@@ -2340,7 +2340,7 @@ pyspcwebgw==0.7.0
pyspeex-noise==1.0.2
# homeassistant.components.squeezebox
-pysqueezebox==0.11.1
+pysqueezebox==0.12.0
# homeassistant.components.stiebel_eltron
pystiebeleltron==0.0.1.dev2
@@ -2418,7 +2418,7 @@ python-join-api==0.0.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.10.1
+python-kasa[speedups]==0.10.2
# homeassistant.components.linkplay
python-linkplay==0.1.3
@@ -2525,7 +2525,7 @@ pyvera==0.3.15
pyversasense==0.0.6
# homeassistant.components.vesync
-pyvesync==2.1.17
+pyvesync==2.1.18
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2694,7 +2694,7 @@ sendgrid==6.8.2
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
-sense-energy==0.13.4
+sense-energy==0.13.5
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@@ -2709,7 +2709,7 @@ sensorpush-ble==1.7.1
sensoterra==2.0.1
# homeassistant.components.sentry
-sentry-sdk==1.40.3
+sentry-sdk==1.45.1
# homeassistant.components.sfr_box
sfrbox-api==0.0.11
@@ -2802,7 +2802,7 @@ statsd==3.2.1
steamodd==4.21
# homeassistant.components.stookwijzer
-stookwijzer==1.5.1
+stookwijzer==1.5.4
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -2866,7 +2866,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==0.9.8
+tesla-fleet-api==0.9.10
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -3116,7 +3116,7 @@ yeelight==0.7.16
yeelightsunflower==0.0.10
# homeassistant.components.yolink
-yolink-api==0.4.7
+yolink-api==0.4.8
# homeassistant.components.youless
youless-api==2.2.0
@@ -3137,13 +3137,13 @@ zamg==0.3.6
zengge==0.2
# homeassistant.components.zeroconf
-zeroconf==0.143.0
+zeroconf==0.144.3
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.48
+zha==0.0.49
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
diff --git a/requirements_test.txt b/requirements_test.txt
index 2731114043b..0a7a3bb18e5 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -12,7 +12,7 @@ coverage==7.6.10
freezegun==1.5.1
license-expression==30.4.1
mock-open==1.4.0
-mypy-dev==1.16.0a2
+mypy-dev==1.16.0a3
pre-commit==4.0.0
pydantic==2.10.6
pylint==3.3.4
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index c0bfebe5673..0ced3ce92ab 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -94,7 +94,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.7.5
# homeassistant.components.vicare
-PyViCare==2.42.0
+PyViCare==2.43.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==29.0.0
+aioesphomeapi==29.0.2
# homeassistant.components.flo
aioflo==2021.11.0
@@ -404,7 +404,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webostv
-aiowebostv==0.6.1
+aiowebostv==0.6.2
# homeassistant.components.withings
aiowithings==3.1.5
@@ -413,7 +413,7 @@ aiowithings==3.1.5
aioymaps==1.2.5
# homeassistant.components.airgradient
-airgradient==0.9.1
+airgradient==0.9.2
# homeassistant.components.airly
airly==1.1.0
@@ -467,7 +467,7 @@ apsystems-ez1==2.4.0
aranet4==2.5.1
# homeassistant.components.arcam_fmj
-arcam-fmj==1.5.2
+arcam-fmj==1.8.0
# homeassistant.components.dlna_dmr
# homeassistant.components.dlna_dms
@@ -640,7 +640,7 @@ dbus-fast==2.33.0
debugpy==1.8.11
# homeassistant.components.ecovacs
-deebot-client==12.0.0
+deebot-client==12.1.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -947,7 +947,7 @@ habiticalib==0.3.7
habluetooth==3.21.1
# homeassistant.components.cloud
-hass-nabucasa==0.89.0
+hass-nabucasa==0.92.0
# homeassistant.components.conversation
hassil==2.2.3
@@ -975,7 +975,7 @@ hole==0.8.0
holidays==0.66
# homeassistant.components.frontend
-home-assistant-frontend==20250210.0
+home-assistant-frontend==20250214.0
# homeassistant.components.conversation
home-assistant-intents==2025.2.5
@@ -1295,7 +1295,7 @@ objgraph==3.5.0
odp-amsterdam==6.0.2
# homeassistant.components.ohme
-ohme==1.2.9
+ohme==1.3.0
# homeassistant.components.ollama
ollama==0.4.7
@@ -1331,7 +1331,7 @@ openhomedevice==2.2.0
openwebifpy==4.3.1
# homeassistant.components.opower
-opower==0.8.9
+opower==0.9.0
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1376,7 +1376,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
-plugwise==1.7.1
+plugwise==1.7.2
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1447,7 +1447,7 @@ py-nightscout==1.2.2
py-sucks==0.9.10
# homeassistant.components.synology_dsm
-py-synologydsm-api==2.6.2
+py-synologydsm-api==2.6.3
# homeassistant.components.hdmi_cec
pyCEC==0.5.2
@@ -1505,7 +1505,7 @@ pyatv==0.16.0
pyaussiebb==0.1.5
# homeassistant.components.balboa
-pybalboa==1.1.2
+pybalboa==1.1.3
# homeassistant.components.blackbird
pyblackbird==0.6
@@ -1559,7 +1559,7 @@ pydroid-ipcam==2.0.0
pyecoforest==0.4.0
# homeassistant.components.econet
-pyeconet==0.1.27
+pyeconet==0.1.28
# homeassistant.components.ista_ecotrend
pyecotrend-ista==3.3.1
@@ -1577,7 +1577,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
-pyenphase==1.23.1
+pyenphase==1.25.1
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1622,7 +1622,7 @@ pyhaversion==22.8.0
pyheos==1.0.2
# homeassistant.components.hive
-pyhive-integration==1.0.1
+pyhive-integration==1.0.2
# homeassistant.components.homematic
pyhomematic==0.1.77
@@ -1861,7 +1861,7 @@ pysensibo==1.1.0
pyserial==3.5
# homeassistant.components.seventeentrack
-pyseventeentrack==1.0.1
+pyseventeentrack==1.0.2
# homeassistant.components.sia
pysiaalarm==3.1.1
@@ -1882,7 +1882,7 @@ pysmartapp==0.3.5
pysmartthings==0.7.8
# homeassistant.components.smarty
-pysmarty2==0.10.1
+pysmarty2==0.10.2
# homeassistant.components.smhi
pysmhi==1.0.0
@@ -1909,7 +1909,7 @@ pyspcwebgw==0.7.0
pyspeex-noise==1.0.2
# homeassistant.components.squeezebox
-pysqueezebox==0.11.1
+pysqueezebox==0.12.0
# homeassistant.components.suez_water
pysuezV2==2.0.3
@@ -1954,7 +1954,7 @@ python-izone==1.2.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.10.1
+python-kasa[speedups]==0.10.2
# homeassistant.components.linkplay
python-linkplay==0.1.3
@@ -2040,7 +2040,7 @@ pyuptimerobot==22.2.0
pyvera==0.3.15
# homeassistant.components.vesync
-pyvesync==2.1.17
+pyvesync==2.1.18
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2167,7 +2167,7 @@ securetar==2025.1.4
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
-sense-energy==0.13.4
+sense-energy==0.13.5
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@@ -2182,7 +2182,7 @@ sensorpush-ble==1.7.1
sensoterra==2.0.1
# homeassistant.components.sentry
-sentry-sdk==1.40.3
+sentry-sdk==1.45.1
# homeassistant.components.sfr_box
sfrbox-api==0.0.11
@@ -2257,7 +2257,7 @@ statsd==3.2.1
steamodd==4.21
# homeassistant.components.stookwijzer
-stookwijzer==1.5.1
+stookwijzer==1.5.4
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -2300,7 +2300,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==0.9.8
+tesla-fleet-api==0.9.10
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -2505,7 +2505,7 @@ yalexs==8.10.0
yeelight==0.7.16
# homeassistant.components.yolink
-yolink-api==0.4.7
+yolink-api==0.4.8
# homeassistant.components.youless
youless-api==2.2.0
@@ -2520,13 +2520,13 @@ yt-dlp[default]==2025.01.26
zamg==0.3.6
# homeassistant.components.zeroconf
-zeroconf==0.143.0
+zeroconf==0.144.3
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.48
+zha==0.0.49
# homeassistant.components.zwave_js
zwave-js-server-python==0.60.0
diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile
index 5598c839257..9d652ec1641 100644
--- a/script/hassfest/docker/Dockerfile
+++ b/script/hassfest/docker/Dockerfile
@@ -14,7 +14,7 @@ WORKDIR "/github/workspace"
COPY . /usr/src/homeassistant
# Uv is only needed during build
-RUN --mount=from=ghcr.io/astral-sh/uv:0.5.27,source=/uv,target=/bin/uv \
+RUN --mount=from=ghcr.io/astral-sh/uv:0.6.0,source=/uv,target=/bin/uv \
# Uv creates a lock file in /tmp
--mount=type=tmpfs,target=/tmp \
# Required for PyTurboJPEG
diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py
index e5eee2f4157..bd8a5a9f318 100644
--- a/script/hassfest/quality_scale.py
+++ b/script/hassfest/quality_scale.py
@@ -391,7 +391,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"fjaraskupan",
"fleetgo",
"flexit",
- "flexit_bacnet",
"flic",
"flick_electric",
"flipr",
@@ -1286,7 +1285,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"brottsplatskartan",
"browser",
"brunt",
- "bring",
"bryant_evolution",
"bsblan",
"bt_home_hub_5",
@@ -1456,7 +1454,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"fjaraskupan",
"fleetgo",
"flexit",
- "flexit_bacnet",
"flic",
"flick_electric",
"flipr",
@@ -1536,7 +1533,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"gstreamer",
"gtfs",
"guardian",
- "habitica",
"harman_kardon_avr",
"harmony",
"hassio",
diff --git a/tests/common.py b/tests/common.py
index 65e84bc6f00..4d767f0611c 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -410,6 +410,25 @@ def async_mock_intent(hass: HomeAssistant, intent_typ: str) -> list[intent.Inten
return intents
+class MockMqttReasonCode:
+ """Class to fake a MQTT ReasonCode."""
+
+ value: int
+ is_failure: bool
+
+ def __init__(
+ self, value: int = 0, is_failure: bool = False, name: str = "Success"
+ ) -> None:
+ """Initialize the mock reason code."""
+ self.value = value
+ self.is_failure = is_failure
+ self._name = name
+
+ def getName(self) -> str:
+ """Return the name of the reason code."""
+ return self._name
+
+
@callback
def async_fire_mqtt_message(
hass: HomeAssistant,
diff --git a/tests/components/airgradient/snapshots/test_diagnostics.ambr b/tests/components/airgradient/snapshots/test_diagnostics.ambr
index a96dfb95382..624a6f76f8d 100644
--- a/tests/components/airgradient/snapshots/test_diagnostics.ambr
+++ b/tests/components/airgradient/snapshots/test_diagnostics.ambr
@@ -25,13 +25,13 @@
'nitrogen_index': 1,
'pm003_count': 270,
'pm01': 22,
- 'pm02': 34,
+ 'pm02': 34.0,
'pm10': 41,
'raw_ambient_temperature': 27.96,
- 'raw_nitrogen': 16931,
+ 'raw_nitrogen': 16931.0,
'raw_pm02': 34,
'raw_relative_humidity': 48.0,
- 'raw_total_volatile_organic_component': 31792,
+ 'raw_total_volatile_organic_component': 31792.0,
'rco2': 778,
'relative_humidity': 47.0,
'serial_number': '84fce612f5b8',
diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr
index 38a6774b6db..374d9a60e4e 100644
--- a/tests/components/airgradient/snapshots/test_sensor.ambr
+++ b/tests/components/airgradient/snapshots/test_sensor.ambr
@@ -724,7 +724,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '34',
+ 'state': '34.0',
})
# ---
# name: test_all_entities[indoor][sensor.airgradient_raw_nox-entry]
@@ -775,7 +775,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '16931',
+ 'state': '16931.0',
})
# ---
# name: test_all_entities[indoor][sensor.airgradient_raw_pm2_5-entry]
@@ -878,7 +878,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '31792',
+ 'state': '31792.0',
})
# ---
# name: test_all_entities[indoor][sensor.airgradient_signal_strength-entry]
@@ -1280,7 +1280,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '16359',
+ 'state': '16359.0',
})
# ---
# name: test_all_entities[outdoor][sensor.airgradient_raw_voc-entry]
@@ -1331,7 +1331,7 @@
'last_changed': ,
'last_reported': ,
'last_updated': ,
- 'state': '30802',
+ 'state': '30802.0',
})
# ---
# name: test_all_entities[outdoor][sensor.airgradient_signal_strength-entry]
diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py
index 2f1de3a2db9..bda9ca32b34 100644
--- a/tests/components/anthropic/test_conversation.py
+++ b/tests/components/anthropic/test_conversation.py
@@ -1,9 +1,24 @@
"""Tests for the Anthropic integration."""
+from collections.abc import AsyncGenerator
+from typing import Any
from unittest.mock import AsyncMock, Mock, patch
from anthropic import RateLimitError
-from anthropic.types import Message, TextBlock, ToolUseBlock, Usage
+from anthropic.types import (
+ InputJSONDelta,
+ Message,
+ RawContentBlockDeltaEvent,
+ RawContentBlockStartEvent,
+ RawContentBlockStopEvent,
+ RawMessageStartEvent,
+ RawMessageStopEvent,
+ RawMessageStreamEvent,
+ TextBlock,
+ TextDelta,
+ ToolUseBlock,
+ Usage,
+)
from freezegun import freeze_time
from httpx import URL, Request, Response
from syrupy.assertion import SnapshotAssertion
@@ -20,6 +35,81 @@ from homeassistant.util import ulid as ulid_util
from tests.common import MockConfigEntry
+async def stream_generator(
+ responses: list[RawMessageStreamEvent],
+) -> AsyncGenerator[RawMessageStreamEvent]:
+ """Generate a response from the assistant."""
+ for msg in responses:
+ yield msg
+
+
+def create_messages(
+ content_blocks: list[RawMessageStreamEvent],
+) -> list[RawMessageStreamEvent]:
+ """Create a stream of messages with the specified content blocks."""
+ return [
+ RawMessageStartEvent(
+ message=Message(
+ type="message",
+ id="msg_1234567890ABCDEFGHIJKLMN",
+ content=[],
+ role="assistant",
+ model="claude-3-5-sonnet-20240620",
+ usage=Usage(input_tokens=0, output_tokens=0),
+ ),
+ type="message_start",
+ ),
+ *content_blocks,
+ RawMessageStopEvent(type="message_stop"),
+ ]
+
+
+def create_content_block(
+ index: int, text_parts: list[str]
+) -> list[RawMessageStreamEvent]:
+ """Create a text content block with the specified deltas."""
+ return [
+ RawContentBlockStartEvent(
+ type="content_block_start",
+ content_block=TextBlock(text="", type="text"),
+ index=index,
+ ),
+ *[
+ RawContentBlockDeltaEvent(
+ delta=TextDelta(text=text_part, type="text_delta"),
+ index=index,
+ type="content_block_delta",
+ )
+ for text_part in text_parts
+ ],
+ RawContentBlockStopEvent(index=index, type="content_block_stop"),
+ ]
+
+
+def create_tool_use_block(
+ index: int, tool_id: str, tool_name: str, json_parts: list[str]
+) -> list[RawMessageStreamEvent]:
+ """Create a tool use content block with the specified deltas."""
+ return [
+ RawContentBlockStartEvent(
+ type="content_block_start",
+ content_block=ToolUseBlock(
+ id=tool_id, name=tool_name, input={}, type="tool_use"
+ ),
+ index=index,
+ ),
+ *[
+ RawContentBlockDeltaEvent(
+ delta=InputJSONDelta(partial_json=json_part, type="input_json_delta"),
+ index=index,
+ type="content_block_delta",
+ )
+ for json_part in json_parts
+ ],
+ RawContentBlockStopEvent(index=index, type="content_block_stop"),
+ ]
+
+
async def test_entity(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -120,6 +210,13 @@ async def test_template_variables(
) as mock_create,
patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user),
):
+ mock_create.return_value = stream_generator(
+ create_messages(
+ create_content_block(
+ 0, ["Okay, let", " me take care of that for you", "."]
+ )
+ )
+ )
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await conversation.async_converse(
@@ -129,6 +226,10 @@ async def test_template_variables(
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE, (
result
)
+ assert (
+ result.response.speech["plain"]["speech"]
+ == "Okay, let me take care of that for you."
+ )
assert "The user name is Test User." in mock_create.mock_calls[1][2]["system"]
assert "The user id is 12345." in mock_create.mock_calls[1][2]["system"]
@@ -168,39 +269,26 @@ async def test_function_call(
for message in messages:
for content in message["content"]:
if not isinstance(content, str) and content["type"] == "tool_use":
- return Message(
- type="message",
- id="msg_1234567890ABCDEFGHIJKLMN",
- content=[
- TextBlock(
- type="text",
- text="I have successfully called the function",
- )
- ],
- model="claude-3-5-sonnet-20240620",
- role="assistant",
- stop_reason="end_turn",
- stop_sequence=None,
- usage=Usage(input_tokens=8, output_tokens=12),
+ return stream_generator(
+ create_messages(
+ create_content_block(
+ 0, ["I have ", "successfully called ", "the function"]
+ ),
+ )
)
- return Message(
- type="message",
- id="msg_1234567890ABCDEFGHIJKLMN",
- content=[
- TextBlock(type="text", text="Certainly, calling it now!"),
- ToolUseBlock(
- type="tool_use",
- id="toolu_0123456789AbCdEfGhIjKlM",
- name="test_tool",
- input={"param1": "test_value"},
- ),
- ],
- model="claude-3-5-sonnet-20240620",
- role="assistant",
- stop_reason="tool_use",
- stop_sequence=None,
- usage=Usage(input_tokens=8, output_tokens=12),
+ return stream_generator(
+ create_messages(
+ [
+ *create_content_block(0, ["Certainly, calling it now!"]),
+ *create_tool_use_block(
+ 1,
+ "toolu_0123456789AbCdEfGhIjKlM",
+ "test_tool",
+ ['{"para', 'm1": "test_valu', 'e"}'],
+ ),
+ ]
+ )
)
with (
@@ -222,6 +310,10 @@ async def test_function_call(
assert "Today's date is 2024-06-03." in mock_create.mock_calls[1][2]["system"]
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert (
+ result.response.speech["plain"]["speech"]
+ == "I have successfully called the function"
+ )
assert mock_create.mock_calls[1][2]["messages"][2] == {
"role": "user",
"content": [
@@ -275,39 +367,27 @@ async def test_function_exception(
for message in messages:
for content in message["content"]:
if not isinstance(content, str) and content["type"] == "tool_use":
- return Message(
- type="message",
- id="msg_1234567890ABCDEFGHIJKLMN",
- content=[
- TextBlock(
- type="text",
- text="There was an error calling the function",
+ return stream_generator(
+ create_messages(
+ create_content_block(
+ 0,
+ ["There was an error calling the function"],
)
- ],
- model="claude-3-5-sonnet-20240620",
- role="assistant",
- stop_reason="end_turn",
- stop_sequence=None,
- usage=Usage(input_tokens=8, output_tokens=12),
+ )
)
- return Message(
- type="message",
- id="msg_1234567890ABCDEFGHIJKLMN",
- content=[
- TextBlock(type="text", text="Certainly, calling it now!"),
- ToolUseBlock(
- type="tool_use",
- id="toolu_0123456789AbCdEfGhIjKlM",
- name="test_tool",
- input={"param1": "test_value"},
- ),
- ],
- model="claude-3-5-sonnet-20240620",
- role="assistant",
- stop_reason="tool_use",
- stop_sequence=None,
- usage=Usage(input_tokens=8, output_tokens=12),
+ return stream_generator(
+ create_messages(
+ [
+ *create_content_block(0, "Certainly, calling it now!"),
+ *create_tool_use_block(
+ 1,
+ "toolu_0123456789AbCdEfGhIjKlM",
+ "test_tool",
+ ['{"param1": "test_value"}'],
+ ),
+ ]
+ )
)
with patch(
@@ -324,6 +404,10 @@ async def test_function_exception(
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
+ assert (
+ result.response.speech["plain"]["speech"]
+ == "There was an error calling the function"
+ )
assert mock_create.mock_calls[1][2]["messages"][2] == {
"role": "user",
"content": [
@@ -376,15 +460,10 @@ async def test_assist_api_tools_conversion(
with patch(
"anthropic.resources.messages.AsyncMessages.create",
new_callable=AsyncMock,
- return_value=Message(
- type="message",
- id="msg_1234567890ABCDEFGHIJKLMN",
- content=[TextBlock(type="text", text="Hello, how can I help you?")],
- model="claude-3-5-sonnet-20240620",
- role="assistant",
- stop_reason="end_turn",
- stop_sequence=None,
- usage=Usage(input_tokens=8, output_tokens=12),
+ return_value=stream_generator(
+ create_messages(
+ create_content_block(0, "Hello, how can I help you?"),
+ ),
),
) as mock_create:
await conversation.async_converse(
@@ -425,28 +504,45 @@ async def test_conversation_id(
mock_init_component,
) -> None:
"""Test conversation ID is honored."""
- result = await conversation.async_converse(
- hass, "hello", None, None, agent_id="conversation.claude"
- )
- conversation_id = result.conversation_id
+ def create_stream_generator(*args, **kwargs) -> Any:
+ return stream_generator(
+ create_messages(
+ create_content_block(0, "Hello, how can I help you?"),
+ ),
+ )
- result = await conversation.async_converse(
- hass, "hello", conversation_id, None, agent_id="conversation.claude"
- )
+ with patch(
+ "anthropic.resources.messages.AsyncMessages.create",
+ new_callable=AsyncMock,
+ side_effect=create_stream_generator,
+ ):
+ result = await conversation.async_converse(
+ hass, "hello", "1234", Context(), agent_id="conversation.claude"
+ )
- assert result.conversation_id == conversation_id
+ result = await conversation.async_converse(
+ hass, "hello", None, None, agent_id="conversation.claude"
+ )
- unknown_id = ulid_util.ulid()
+ conversation_id = result.conversation_id
- result = await conversation.async_converse(
- hass, "hello", unknown_id, None, agent_id="conversation.claude"
- )
+ result = await conversation.async_converse(
+ hass, "hello", conversation_id, None, agent_id="conversation.claude"
+ )
- assert result.conversation_id != unknown_id
+ assert result.conversation_id == conversation_id
- result = await conversation.async_converse(
- hass, "hello", "koala", None, agent_id="conversation.claude"
- )
+ unknown_id = ulid_util.ulid()
- assert result.conversation_id == "koala"
+ result = await conversation.async_converse(
+ hass, "hello", unknown_id, None, agent_id="conversation.claude"
+ )
+
+ assert result.conversation_id != unknown_id
+
+ result = await conversation.async_converse(
+ hass, "hello", "koala", None, agent_id="conversation.claude"
+ )
+
+ assert result.conversation_id == "koala"
diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py
index 257961a5b32..f0a8f02fc50 100644
--- a/tests/components/assist_satellite/test_websocket_api.py
+++ b/tests/components/assist_satellite/test_websocket_api.py
@@ -313,6 +313,37 @@ async def test_get_configuration(
}
+async def test_get_configuration_not_implemented(
+ hass: HomeAssistant,
+ init_components: ConfigEntry,
+ entity: MockAssistSatellite,
+ hass_ws_client: WebSocketGenerator,
+) -> None:
+ """Test getting stub satellite configuration when the entity doesn't implement the method."""
+ ws_client = await hass_ws_client(hass)
+
+ with patch.object(
+ entity, "async_get_configuration", side_effect=NotImplementedError()
+ ):
+ await ws_client.send_json_auto_id(
+ {
+ "type": "assist_satellite/get_configuration",
+ "entity_id": ENTITY_ID,
+ }
+ )
+ msg = await ws_client.receive_json()
+ assert msg["success"]
+
+ # Stub configuration
+ assert msg["result"] == {
+ "active_wake_words": [],
+ "available_wake_words": [],
+ "max_active_wake_words": 1,
+ "pipeline_entity_id": None,
+ "vad_entity_id": None,
+ }
+
+
async def test_set_wake_words(
hass: HomeAssistant,
init_components: ConfigEntry,
diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py
index afdb5e47a2e..b21698bf365 100644
--- a/tests/components/backup/common.py
+++ b/tests/components/backup/common.py
@@ -18,10 +18,9 @@ from homeassistant.components.backup import (
)
from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component
-from tests.common import MockPlatform, mock_platform
+from tests.common import mock_platform
LOCAL_AGENT_ID = f"{DOMAIN}.local"
@@ -64,87 +63,37 @@ async def aiter_from_iter(iterable: Iterable) -> AsyncIterator:
yield i
-class BackupAgentTest(BackupAgent):
- """Test backup agent."""
+def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock:
+ """Create a mock backup agent."""
- domain = "test"
+ async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]:
+ """Mock download."""
+ if not await get_backup(backup_id):
+ raise BackupNotFound
+ return aiter_from_iter((backups_data.get(backup_id, b"backup data"),))
- def __init__(self, name: str, backups: list[AgentBackup] | None = None) -> None:
- """Initialize the backup agent."""
- self.name = name
- self.unique_id = name
- if backups is None:
- backups = [
- AgentBackup(
- addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
- backup_id="abc123",
- database_included=True,
- date="1970-01-01T00:00:00Z",
- extra_metadata={},
- folders=[Folder.MEDIA, Folder.SHARE],
- homeassistant_included=True,
- homeassistant_version="2024.12.0",
- name="Test",
- protected=False,
- size=13,
- )
- ]
+ async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None:
+ """Get a backup."""
+ return next((b for b in backups if b.backup_id == backup_id), None)
- self._backup_data: bytearray | None = None
- self._backups = {backup.backup_id: backup for backup in backups}
-
- async def async_download_backup(
- self,
- backup_id: str,
- **kwargs: Any,
- ) -> AsyncIterator[bytes]:
- """Download a backup file."""
- return AsyncMock(spec_set=["__aiter__"])
-
- async def async_upload_backup(
- self,
+ async def upload_backup(
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
**kwargs: Any,
) -> None:
"""Upload a backup."""
- self._backups[backup.backup_id] = backup
+ backups.append(backup)
backup_stream = await open_stream()
- self._backup_data = bytearray()
+ backup_data = bytearray()
async for chunk in backup_stream:
- self._backup_data += chunk
-
- async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
- """List backups."""
- return list(self._backups.values())
-
- async def async_get_backup(
- self,
- backup_id: str,
- **kwargs: Any,
- ) -> AgentBackup | None:
- """Return a backup."""
- return self._backups.get(backup_id)
-
- async def async_delete_backup(
- self,
- backup_id: str,
- **kwargs: Any,
- ) -> None:
- """Delete a backup file."""
-
-
-def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mock:
- """Create a mock backup agent."""
-
- async def get_backup(backup_id: str, **kwargs: Any) -> AgentBackup | None:
- """Get a backup."""
- return next((b for b in backups if b.backup_id == backup_id), None)
+ backup_data += chunk
+ backups_data[backup.backup_id] = backup_data
backups = backups or []
+ backups_data: dict[str, bytes] = {}
mock_agent = Mock(spec=BackupAgent)
- mock_agent.domain = "test"
+ mock_agent.domain = TEST_DOMAIN
mock_agent.name = name
mock_agent.unique_id = name
type(mock_agent).agent_id = BackupAgent.agent_id
@@ -152,7 +101,7 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo
spec_set=[BackupAgent.async_delete_backup]
)
mock_agent.async_download_backup = AsyncMock(
- side_effect=BackupNotFound, spec_set=[BackupAgent.async_download_backup]
+ side_effect=download_backup, spec_set=[BackupAgent.async_download_backup]
)
mock_agent.async_get_backup = AsyncMock(
side_effect=get_backup, spec_set=[BackupAgent.async_get_backup]
@@ -161,7 +110,8 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo
return_value=backups, spec_set=[BackupAgent.async_list_backups]
)
mock_agent.async_upload_backup = AsyncMock(
- spec_set=[BackupAgent.async_upload_backup]
+ side_effect=upload_backup,
+ spec_set=[BackupAgent.async_upload_backup],
)
return mock_agent
@@ -169,12 +119,12 @@ def mock_backup_agent(name: str, backups: list[AgentBackup] | None = None) -> Mo
async def setup_backup_integration(
hass: HomeAssistant,
with_hassio: bool = False,
- configuration: ConfigType | None = None,
*,
backups: dict[str, list[AgentBackup]] | None = None,
remote_agents: list[str] | None = None,
-) -> bool:
+) -> dict[str, Mock]:
"""Set up the Backup integration."""
+ backups = backups or {}
with (
patch("homeassistant.components.backup.is_hassio", return_value=with_hassio),
patch(
@@ -182,36 +132,34 @@ async def setup_backup_integration(
),
):
remote_agents = remote_agents or []
- platform = Mock(
- async_get_backup_agents=AsyncMock(
- return_value=[BackupAgentTest(agent, []) for agent in remote_agents]
- ),
- spec_set=BackupAgentPlatformProtocol,
- )
+ remote_agents_dict = {}
+ for agent in remote_agents:
+ if not agent.startswith(f"{TEST_DOMAIN}."):
+ raise ValueError(f"Invalid agent_id: {agent}")
+ name = agent.partition(".")[2]
+ remote_agents_dict[agent] = mock_backup_agent(name, backups.get(agent))
+ if remote_agents:
+ platform = Mock(
+ async_get_backup_agents=AsyncMock(
+ return_value=list(remote_agents_dict.values())
+ ),
+ spec_set=BackupAgentPlatformProtocol,
+ )
+ await setup_backup_platform(hass, domain=TEST_DOMAIN, platform=platform)
- mock_platform(hass, f"{TEST_DOMAIN}.backup", platform or MockPlatform())
- assert await async_setup_component(hass, TEST_DOMAIN, {})
-
- result = await async_setup_component(hass, DOMAIN, configuration or {})
+ assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
- if not backups:
- return result
- for agent_id, agent_backups in backups.items():
- if with_hassio and agent_id == LOCAL_AGENT_ID:
- continue
- agent = hass.data[DATA_MANAGER].backup_agents[agent_id]
+ if LOCAL_AGENT_ID not in backups or with_hassio:
+ return remote_agents_dict
- async def open_stream() -> AsyncIterator[bytes]:
- """Open a stream."""
- return aiter_from_iter((b"backup data",))
+ agent = hass.data[DATA_MANAGER].backup_agents[LOCAL_AGENT_ID]
- for backup in agent_backups:
- await agent.async_upload_backup(open_stream=open_stream, backup=backup)
- if agent_id == LOCAL_AGENT_ID:
- agent._loaded_backups = True
+ for backup in backups[LOCAL_AGENT_ID]:
+ await agent.async_upload_backup(open_stream=None, backup=backup)
+ agent._loaded_backups = True
- return result
+ return remote_agents_dict
async def setup_backup_platform(
diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr
index 2f063262f34..4452d191d5a 100644
--- a/tests/components/backup/snapshots/test_websocket.ambr
+++ b/tests/components/backup/snapshots/test_websocket.ambr
@@ -16,12 +16,12 @@
'result': dict({
'agents': list([
dict({
- 'agent_id': 'backup.local',
- 'name': 'local',
+ 'agent_id': 'test.remote',
+ 'name': 'remote',
}),
dict({
- 'agent_id': 'test.test',
- 'name': 'test',
+ 'agent_id': 'backup.local',
+ 'name': 'local',
}),
]),
}),
@@ -3457,7 +3457,7 @@
'id': 1,
'result': dict({
'agent_errors': dict({
- 'domain.test': 'The backup agent is unreachable.',
+ 'test.remote': 'The backup agent is unreachable.',
}),
}),
'success': True,
@@ -3480,15 +3480,17 @@
}),
]),
'agents': dict({
- 'domain.test': dict({
+ 'test.remote': dict({
'protected': False,
- 'size': 13,
+ 'size': 0,
}),
}),
'backup_id': 'abc123',
'database_included': True,
- 'date': '1970-01-01T00:00:00Z',
+ 'date': '1970-01-01T00:00:00.000Z',
'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
}),
'failed_agent_ids': list([
]),
@@ -3499,7 +3501,7 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'with_automatic_settings': None,
+ 'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
@@ -3518,7 +3520,7 @@
'id': 1,
'result': dict({
'agent_errors': dict({
- 'domain.test': 'The backup agent is unreachable.',
+ 'test.remote': 'The backup agent is unreachable.',
}),
}),
'success': True,
@@ -3541,15 +3543,17 @@
}),
]),
'agents': dict({
- 'domain.test': dict({
+ 'test.remote': dict({
'protected': False,
- 'size': 13,
+ 'size': 0,
}),
}),
'backup_id': 'abc123',
'database_included': True,
- 'date': '1970-01-01T00:00:00Z',
+ 'date': '1970-01-01T00:00:00.000Z',
'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
}),
'failed_agent_ids': list([
'test.remote',
@@ -3561,7 +3565,7 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'with_automatic_settings': None,
+ 'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
@@ -3602,15 +3606,17 @@
}),
]),
'agents': dict({
- 'domain.test': dict({
+ 'test.remote': dict({
'protected': False,
- 'size': 13,
+ 'size': 0,
}),
}),
'backup_id': 'abc123',
'database_included': True,
- 'date': '1970-01-01T00:00:00Z',
+ 'date': '1970-01-01T00:00:00.000Z',
'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
}),
'failed_agent_ids': list([
]),
@@ -3621,7 +3627,7 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'with_automatic_settings': None,
+ 'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
@@ -3662,15 +3668,17 @@
}),
]),
'agents': dict({
- 'domain.test': dict({
+ 'test.remote': dict({
'protected': False,
- 'size': 13,
+ 'size': 0,
}),
}),
'backup_id': 'abc123',
'database_included': True,
- 'date': '1970-01-01T00:00:00Z',
+ 'date': '1970-01-01T00:00:00.000Z',
'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
}),
'failed_agent_ids': list([
]),
@@ -3681,7 +3689,7 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'with_automatic_settings': None,
+ 'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
@@ -3700,7 +3708,7 @@
'id': 1,
'result': dict({
'agent_errors': dict({
- 'domain.test': 'Boom!',
+ 'test.remote': 'Boom!',
}),
}),
'success': True,
@@ -3723,15 +3731,17 @@
}),
]),
'agents': dict({
- 'domain.test': dict({
+ 'test.remote': dict({
'protected': False,
- 'size': 13,
+ 'size': 0,
}),
}),
'backup_id': 'abc123',
'database_included': True,
- 'date': '1970-01-01T00:00:00Z',
+ 'date': '1970-01-01T00:00:00.000Z',
'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
}),
'failed_agent_ids': list([
]),
@@ -3742,7 +3752,7 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'with_automatic_settings': None,
+ 'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
@@ -3761,7 +3771,7 @@
'id': 1,
'result': dict({
'agent_errors': dict({
- 'domain.test': 'Boom!',
+ 'test.remote': 'Boom!',
}),
}),
'success': True,
@@ -3784,15 +3794,17 @@
}),
]),
'agents': dict({
- 'domain.test': dict({
+ 'test.remote': dict({
'protected': False,
- 'size': 13,
+ 'size': 0,
}),
}),
'backup_id': 'abc123',
'database_included': True,
- 'date': '1970-01-01T00:00:00Z',
+ 'date': '1970-01-01T00:00:00.000Z',
'extra_metadata': dict({
+ 'instance_id': 'our_uuid',
+ 'with_automatic_settings': True,
}),
'failed_agent_ids': list([
'test.remote',
@@ -3804,7 +3816,7 @@
'homeassistant_included': True,
'homeassistant_version': '2024.12.0',
'name': 'Test',
- 'with_automatic_settings': None,
+ 'with_automatic_settings': True,
}),
]),
'last_attempted_automatic_backup': None,
@@ -3980,7 +3992,7 @@
'id': 1,
'result': dict({
'agent_errors': dict({
- 'domain.test': 'The backup agent is unreachable.',
+ 'test.remote': 'The backup agent is unreachable.',
}),
'backup': dict({
'addons': list([
@@ -4024,7 +4036,7 @@
'id': 1,
'result': dict({
'agent_errors': dict({
- 'domain.test': 'Oops',
+ 'test.remote': 'Oops',
}),
'backup': dict({
'addons': list([
@@ -4068,7 +4080,7 @@
'id': 1,
'result': dict({
'agent_errors': dict({
- 'domain.test': 'Boom!',
+ 'test.remote': 'Boom!',
}),
'backup': dict({
'addons': list([
@@ -4572,7 +4584,7 @@
'id': 1,
'result': dict({
'agent_errors': dict({
- 'domain.test': 'The backup agent is unreachable.',
+ 'test.remote': 'The backup agent is unreachable.',
}),
'backups': list([
dict({
@@ -4624,7 +4636,7 @@
'id': 1,
'result': dict({
'agent_errors': dict({
- 'domain.test': 'Oops',
+ 'test.remote': 'Oops',
}),
'backups': list([
dict({
@@ -4676,7 +4688,7 @@
'id': 1,
'result': dict({
'agent_errors': dict({
- 'domain.test': 'Boom!',
+ 'test.remote': 'Boom!',
}),
'backups': list([
dict({
diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py
index 9ebf3e8bd40..a03217beac2 100644
--- a/tests/components/backup/test_http.py
+++ b/tests/components/backup/test_http.py
@@ -18,20 +18,28 @@ from homeassistant.components.backup import (
BackupNotFound,
Folder,
)
-from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN
+from homeassistant.components.backup.const import DOMAIN
from homeassistant.core import HomeAssistant
-from .common import (
- TEST_BACKUP_ABC123,
- BackupAgentTest,
- aiter_from_iter,
- mock_backup_agent,
- setup_backup_integration,
-)
+from .common import TEST_BACKUP_ABC123, aiter_from_iter, setup_backup_integration
from tests.common import MockUser, get_fixture_path
from tests.typing import ClientSessionGenerator
+PROTECTED_BACKUP = AgentBackup(
+ addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
+ backup_id="c0cb53bd",
+ database_included=True,
+ date="1970-01-01T00:00:00Z",
+ extra_metadata={},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Test",
+ protected=True,
+ size=13,
+)
+
async def test_downloading_local_backup(
hass: HomeAssistant,
@@ -65,19 +73,16 @@ async def test_downloading_remote_backup(
hass_client: ClientSessionGenerator,
) -> None:
"""Test downloading a remote backup."""
+
await setup_backup_integration(
- hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test"]
+ hass, backups={"test.test": [TEST_BACKUP_ABC123]}, remote_agents=["test.test"]
)
client = await hass_client()
- with (
- patch.object(BackupAgentTest, "async_download_backup") as download_mock,
- ):
- download_mock.return_value.__aiter__.return_value = iter((b"backup data",))
- resp = await client.get("/api/backup/download/abc123?agent_id=test.test")
- assert resp.status == 200
- assert await resp.content.read() == b"backup data"
+ resp = await client.get("/api/backup/download/abc123?agent_id=test.test")
+ assert resp.status == 200
+ assert await resp.content.read() == b"backup data"
async def test_downloading_local_encrypted_backup_file_not_found(
@@ -119,32 +124,15 @@ async def test_downloading_remote_encrypted_backup(
) -> None:
"""Test downloading a local backup file."""
backup_path = get_fixture_path("test_backups/c0cb53bd.tar", DOMAIN)
- await setup_backup_integration(hass)
- mock_agent = mock_backup_agent(
- "test",
- [
- AgentBackup(
- addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
- backup_id="c0cb53bd",
- database_included=True,
- date="1970-01-01T00:00:00Z",
- extra_metadata={},
- folders=[Folder.MEDIA, Folder.SHARE],
- homeassistant_included=True,
- homeassistant_version="2024.12.0",
- name="Test",
- protected=True,
- size=13,
- )
- ],
+ mock_agents = await setup_backup_integration(
+ hass, remote_agents=["test.test"], backups={"test.test": [PROTECTED_BACKUP]}
)
- hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent
async def download_backup(backup_id: str, **kwargs: Any) -> AsyncIterator[bytes]:
return aiter_from_iter((backup_path.read_bytes(),))
- mock_agent.async_download_backup.side_effect = download_backup
- await _test_downloading_encrypted_backup(hass_client, "domain.test")
+ mock_agents["test.test"].async_download_backup.side_effect = download_backup
+ await _test_downloading_encrypted_backup(hass_client, "test.test")
@pytest.mark.parametrize(
@@ -161,31 +149,14 @@ async def test_downloading_remote_encrypted_backup_with_error(
status: int,
) -> None:
"""Test downloading a local backup file."""
- await setup_backup_integration(hass)
- mock_agent = mock_backup_agent(
- "test",
- [
- AgentBackup(
- addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
- backup_id="abc123",
- database_included=True,
- date="1970-01-01T00:00:00Z",
- extra_metadata={},
- folders=[Folder.MEDIA, Folder.SHARE],
- homeassistant_included=True,
- homeassistant_version="2024.12.0",
- name="Test",
- protected=True,
- size=13,
- )
- ],
+ mock_agents = await setup_backup_integration(
+ hass, remote_agents=["test.test"], backups={"test.test": [PROTECTED_BACKUP]}
)
- hass.data[DATA_MANAGER].backup_agents["domain.test"] = mock_agent
- mock_agent.async_download_backup.side_effect = error
+ mock_agents["test.test"].async_download_backup.side_effect = error
client = await hass_client()
resp = await client.get(
- "/api/backup/download/abc123?agent_id=domain.test&password=blah"
+ f"/api/backup/download/{PROTECTED_BACKUP.backup_id}?agent_id=test.test&password=blah"
)
assert resp.status == status
diff --git a/tests/components/backup/test_init.py b/tests/components/backup/test_init.py
index 925e2cb9b7a..8a0cc2b97c0 100644
--- a/tests/components/backup/test_init.py
+++ b/tests/components/backup/test_init.py
@@ -20,11 +20,7 @@ async def test_setup_with_hassio(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the setup of the integration with hassio enabled."""
- assert await setup_backup_integration(
- hass=hass,
- with_hassio=True,
- configuration={DOMAIN: {}},
- )
+ await setup_backup_integration(hass=hass, with_hassio=True)
manager = hass.data[DATA_MANAGER]
assert not manager.backup_agents
@@ -59,6 +55,7 @@ async def test_create_service(
)
+@pytest.mark.usefixtures("supervisor_client")
async def test_create_service_with_hassio(hass: HomeAssistant) -> None:
"""Test action backup.create does not exist with hassio."""
await setup_backup_integration(hass, with_hassio=True)
diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py
index bdcb9f068b6..b2b7e083a51 100644
--- a/tests/components/backup/test_manager.py
+++ b/tests/components/backup/test_manager.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
-from collections.abc import Generator
+from collections.abc import Callable, Generator
from dataclasses import replace
from io import StringIO
import json
@@ -27,11 +27,9 @@ import pytest
from homeassistant.components.backup import (
DOMAIN,
AgentBackup,
- BackupAgentPlatformProtocol,
BackupReaderWriterError,
Folder,
LocalBackupAgent,
- backup as local_backup_platform,
)
from homeassistant.components.backup.agent import BackupAgentError
from homeassistant.components.backup.const import DATA_MANAGER
@@ -50,7 +48,6 @@ from homeassistant.components.backup.util import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
-from homeassistant.setup import async_setup_component
from .common import (
LOCAL_AGENT_ID,
@@ -58,7 +55,8 @@ from .common import (
TEST_BACKUP_DEF456,
TEST_BACKUP_PATH_ABC123,
TEST_BACKUP_PATH_DEF456,
- BackupAgentTest,
+ mock_backup_agent,
+ setup_backup_integration,
setup_backup_platform,
)
@@ -110,8 +108,7 @@ async def test_create_backup_service(
mocked_tarfile: Mock,
) -> None:
"""Test create backup service."""
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
new_backup = NewBackup(backup_job_id="time-123")
backup_task = AsyncMock(
@@ -307,8 +304,7 @@ async def test_async_create_backup(
expected_writer_kwargs: dict[str, Any],
) -> None:
"""Test create backup."""
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
manager = hass.data[DATA_MANAGER]
new_backup = NewBackup(backup_job_id="time-123")
@@ -336,8 +332,7 @@ async def test_create_backup_when_busy(
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test generate backup with busy manager."""
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id(
@@ -385,8 +380,7 @@ async def test_create_backup_wrong_parameters(
expected_error: str,
) -> None:
"""Test create backup with wrong parameters."""
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
ws_client = await hass_ws_client(hass)
@@ -523,23 +517,7 @@ async def test_initiate_backup(
temp_file_unlink_call_count: int,
) -> None:
"""Test generate backup."""
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
- remote_agent = BackupAgentTest("remote", backups=[])
-
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+ await setup_backup_integration(hass, remote_agents=["test.remote"])
ws_client = await hass_ws_client(hass)
freezer.move_to("2025-01-30 13:42:12.345678")
@@ -693,7 +671,6 @@ async def test_initiate_backup_with_agent_error(
) -> None:
"""Test agent upload error during backup generation."""
agent_ids = [LOCAL_AGENT_ID, "test.remote"]
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id
backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id
backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id
@@ -771,22 +748,12 @@ async def test_initiate_backup_with_agent_error(
"with_automatic_settings": True,
},
]
- remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3])
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+ mock_agents = await setup_backup_integration(
+ hass,
+ remote_agents=["test.remote"],
+ backups={"test.remote": [backup_1, backup_2, backup_3]},
+ )
ws_client = await hass_ws_client(hass)
@@ -821,17 +788,8 @@ async def test_initiate_backup_with_agent_error(
result = await ws_client.receive_json()
assert result["success"] is True
- delete_backup = AsyncMock()
-
- with (
- patch("pathlib.Path.open", mock_open(read_data=b"test")),
- patch.object(
- remote_agent,
- "async_upload_backup",
- side_effect=exception,
- ),
- patch.object(remote_agent, "async_delete_backup", delete_backup),
- ):
+ mock_agents["test.remote"].async_upload_backup.side_effect = exception
+ with patch("pathlib.Path.open", mock_open(read_data=b"test")):
await ws_client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": agent_ids}
)
@@ -922,7 +880,7 @@ async def test_initiate_backup_with_agent_error(
]
# one of the two matching backups with the remote agent should have been deleted
- assert delete_backup.call_count == 1
+ assert mock_agents["test.remote"].async_delete_backup.call_count == 1
@pytest.mark.usefixtures("mock_backup_generation")
@@ -946,8 +904,7 @@ async def test_create_backup_success_clears_issue(
issues_after_create_backup: set[tuple[str, str]],
) -> None:
"""Test backup issue is cleared after backup is created."""
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
# Create a backup issue
ir.async_create_issue(
@@ -996,7 +953,7 @@ async def delayed_boom(*args, **kwargs) -> tuple[NewBackup, Any]:
"automatic_agents",
"create_backup_command",
"create_backup_side_effect",
- "agent_upload_side_effect",
+ "upload_side_effect",
"create_backup_result",
"issues_after_create_backup",
),
@@ -1115,26 +1072,12 @@ async def test_create_backup_failure_raises_issue(
automatic_agents: list[str],
create_backup_command: dict[str, Any],
create_backup_side_effect: Exception | None,
- agent_upload_side_effect: Exception | None,
+ upload_side_effect: Exception | None,
create_backup_result: bool,
issues_after_create_backup: dict[tuple[str, str], dict[str, Any]],
) -> None:
"""Test backup issue is cleared after backup is created."""
- remote_agent = BackupAgentTest("remote", backups=[])
-
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
-
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"])
ws_client = await hass_ws_client(hass)
@@ -1149,13 +1092,11 @@ async def test_create_backup_failure_raises_issue(
result = await ws_client.receive_json()
assert result["success"] is True
- with patch.object(
- remote_agent, "async_upload_backup", side_effect=agent_upload_side_effect
- ):
- await ws_client.send_json_auto_id(create_backup_command)
- result = await ws_client.receive_json()
- assert result["success"] == create_backup_result
- await hass.async_block_till_done()
+ mock_agents["test.remote"].async_upload_backup.side_effect = upload_side_effect
+ await ws_client.send_json_auto_id(create_backup_command)
+ result = await ws_client.receive_json()
+ assert result["success"] == create_backup_result
+ await hass.async_block_till_done()
issue_registry = ir.async_get(hass)
assert set(issue_registry.issues) == set(issues_after_create_backup)
@@ -1179,23 +1120,7 @@ async def test_initiate_backup_non_agent_upload_error(
) -> None:
"""Test an unknown or writer upload error during backup generation."""
agent_ids = [LOCAL_AGENT_ID, "test.remote"]
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
- remote_agent = BackupAgentTest("remote", backups=[])
-
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+ mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"])
ws_client = await hass_ws_client(hass)
@@ -1224,14 +1149,8 @@ async def test_initiate_backup_non_agent_upload_error(
result = await ws_client.receive_json()
assert result["success"] is True
- with (
- patch("pathlib.Path.open", mock_open(read_data=b"test")),
- patch.object(
- remote_agent,
- "async_upload_backup",
- side_effect=exception,
- ),
- ):
+ mock_agents["test.remote"].async_upload_backup.side_effect = exception
+ with patch("pathlib.Path.open", mock_open(read_data=b"test")):
await ws_client.send_json_auto_id(
{"type": "backup/generate", "agent_ids": agent_ids}
)
@@ -1297,23 +1216,8 @@ async def test_initiate_backup_with_task_error(
backup_task.set_exception(exception)
create_backup.return_value = (NewBackup(backup_job_id="abc123"), backup_task)
agent_ids = [LOCAL_AGENT_ID, "test.remote"]
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
- remote_agent = BackupAgentTest("remote", backups=[])
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+ await setup_backup_integration(hass, remote_agents=["test.remote"])
ws_client = await hass_ws_client(hass)
@@ -1408,22 +1312,8 @@ async def test_initiate_backup_file_error(
) -> None:
"""Test file error during generate backup."""
agent_ids = ["test.remote"]
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
- remote_agent = BackupAgentTest("remote", backups=[])
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+
+ await setup_backup_integration(hass, remote_agents=["test.remote"])
ws_client = await hass_ws_client(hass)
@@ -1513,34 +1403,29 @@ async def test_initiate_backup_file_error(
assert unlink_mock.call_count == unlink_call_count
-class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent):
- """Local backup agent."""
-
- def get_backup_path(self, backup_id: str) -> Path:
- """Return the local path to an existing backup."""
- return Path("test.tar")
-
- def get_new_backup_path(self, backup: AgentBackup) -> Path:
- """Return the local path to a new backup."""
- return Path("test.tar")
+def _mock_local_backup_agent(name: str) -> Mock:
+ local_agent = mock_backup_agent(name)
+ # This makes the local_agent pass isinstance checks for LocalBackupAgent
+ local_agent.mock_add_spec(LocalBackupAgent)
+ return local_agent
@pytest.mark.parametrize(
- ("agent_class", "num_local_agents"),
- [(LocalBackupAgentTest, 2), (BackupAgentTest, 1)],
+ ("agent_creator", "num_local_agents"),
+ [(_mock_local_backup_agent, 2), (mock_backup_agent, 1)],
)
async def test_loading_platform_with_listener(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
- agent_class: type[BackupAgentTest],
+ agent_creator: Callable[[str], Mock],
num_local_agents: int,
) -> None:
"""Test loading a backup agent platform which can be listened to."""
ws_client = await hass_ws_client(hass)
- assert await async_setup_component(hass, DOMAIN, {})
+ await setup_backup_integration(hass)
manager = hass.data[DATA_MANAGER]
- get_agents_mock = AsyncMock(return_value=[agent_class("remote1", backups=[])])
+ get_agents_mock = AsyncMock(return_value=[agent_creator("remote1")])
register_listener_mock = Mock()
await setup_backup_platform(
@@ -1565,7 +1450,7 @@ async def test_loading_platform_with_listener(
register_listener_mock.assert_called_once_with(hass, listener=ANY)
get_agents_mock.reset_mock()
- get_agents_mock.return_value = [agent_class("remote2", backups=[])]
+ get_agents_mock.return_value = [agent_creator("remote2")]
listener = register_listener_mock.call_args[1]["listener"]
listener()
@@ -1597,8 +1482,7 @@ async def test_not_loading_bad_platforms(
domain="test",
platform=platform_mock,
)
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
assert platform_mock.mock_calls == []
@@ -1609,7 +1493,7 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None:
async def _mock_step(hass: HomeAssistant) -> None:
raise HomeAssistantError("Test exception")
- remote_agent = BackupAgentTest("remote", backups=[])
+ remote_agent = mock_backup_agent("remote")
await setup_backup_platform(
hass,
domain="test",
@@ -1619,8 +1503,7 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None:
async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
),
)
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
with pytest.raises(BackupManagerError) as err:
await hass.services.async_call(
@@ -1639,7 +1522,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None:
async def _mock_step(hass: HomeAssistant) -> None:
raise HomeAssistantError("Test exception")
- remote_agent = BackupAgentTest("remote", backups=[])
+ remote_agent = mock_backup_agent("remote")
await setup_backup_platform(
hass,
domain="test",
@@ -1649,8 +1532,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None:
async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
),
)
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
with pytest.raises(BackupManagerError) as err:
await hass.services.async_call(
@@ -1678,7 +1560,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None:
2,
1,
["Test_1970-01-01_00.00_00000000.tar"],
- {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123},
+ {TEST_BACKUP_ABC123.backup_id: (TEST_BACKUP_ABC123, b"test")},
b"test",
0,
),
@@ -1696,7 +1578,7 @@ async def test_exception_platform_post(hass: HomeAssistant) -> None:
2,
0,
[],
- {TEST_BACKUP_ABC123.backup_id: TEST_BACKUP_ABC123},
+ {TEST_BACKUP_ABC123.backup_id: (TEST_BACKUP_ABC123, b"test")},
b"test",
1,
),
@@ -1714,17 +1596,7 @@ async def test_receive_backup(
temp_file_unlink_call_count: int,
) -> None:
"""Test receive backup and upload to the local and a remote agent."""
- remote_agent = BackupAgentTest("remote", backups=[])
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"])
client = await hass_client()
upload_data = "test"
@@ -1754,8 +1626,13 @@ async def test_receive_backup(
assert move_mock.call_count == move_call_count
for index, name in enumerate(move_path_names):
assert move_mock.call_args_list[index].args[1].name == name
- assert remote_agent._backups == remote_agent_backups
- assert remote_agent._backup_data == remote_agent_backup_data
+ remote_agent = mock_agents["test.remote"]
+ for backup_id, (backup, expected_backup_data) in remote_agent_backups.items():
+ assert await remote_agent.async_get_backup(backup_id) == backup
+ backup_data = bytearray()
+ async for chunk in await remote_agent.async_download_backup(backup_id):
+ backup_data += chunk
+ assert backup_data == expected_backup_data
assert unlink_mock.call_count == temp_file_unlink_call_count
@@ -1770,8 +1647,7 @@ async def test_receive_backup_busy_manager(
new_backup = NewBackup(backup_job_id="time-123")
backup_task: asyncio.Future[WrittenBackup] = asyncio.Future()
create_backup.return_value = (new_backup, backup_task)
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
client = await hass_client()
ws_client = await hass_ws_client(hass)
@@ -1833,7 +1709,6 @@ async def test_receive_backup_agent_error(
exception: Exception,
) -> None:
"""Test upload error during backup receive."""
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
backup_1 = replace(TEST_BACKUP_ABC123, backup_id="backup1") # matching instance id
backup_2 = replace(TEST_BACKUP_DEF456, backup_id="backup2") # other instance id
backup_3 = replace(TEST_BACKUP_ABC123, backup_id="backup3") # matching instance id
@@ -1911,22 +1786,12 @@ async def test_receive_backup_agent_error(
"with_automatic_settings": True,
},
]
- remote_agent = BackupAgentTest("remote", backups=[backup_1, backup_2, backup_3])
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+ mock_agents = await setup_backup_integration(
+ hass,
+ remote_agents=["test.remote"],
+ backups={"test.remote": [backup_1, backup_2, backup_3]},
+ )
client = await hass_client()
ws_client = await hass_ws_client(hass)
@@ -1962,13 +1827,11 @@ async def test_receive_backup_agent_error(
result = await ws_client.receive_json()
assert result["success"] is True
- delete_backup = AsyncMock()
upload_data = "test"
open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8"))
+ mock_agents["test.remote"].async_upload_backup.side_effect = exception
with (
- patch.object(remote_agent, "async_delete_backup", delete_backup),
- patch.object(remote_agent, "async_upload_backup", side_effect=exception),
patch("pathlib.Path.open", open_mock),
patch("shutil.move") as move_mock,
patch(
@@ -2050,7 +1913,7 @@ async def test_receive_backup_agent_error(
assert open_mock.call_count == 1
assert move_mock.call_count == 0
assert unlink_mock.call_count == 1
- assert delete_backup.call_count == 0
+ assert mock_agents["test.remote"].async_delete_backup.call_count == 0
@pytest.mark.usefixtures("mock_backup_generation")
@@ -2064,23 +1927,7 @@ async def test_receive_backup_non_agent_upload_error(
exception: Exception,
) -> None:
"""Test non agent upload error during backup receive."""
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
- remote_agent = BackupAgentTest("remote", backups=[])
-
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+ mock_agents = await setup_backup_integration(hass, remote_agents=["test.remote"])
client = await hass_client()
ws_client = await hass_ws_client(hass)
@@ -2113,8 +1960,8 @@ async def test_receive_backup_non_agent_upload_error(
upload_data = "test"
open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8"))
+ mock_agents["test.remote"].async_upload_backup.side_effect = exception
with (
- patch.object(remote_agent, "async_upload_backup", side_effect=exception),
patch("pathlib.Path.open", open_mock),
patch("shutil.move") as move_mock,
patch(
@@ -2192,22 +2039,7 @@ async def test_receive_backup_file_write_error(
close_exception: Exception | None,
) -> None:
"""Test file write error during backup receive."""
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
- remote_agent = BackupAgentTest("remote", backups=[])
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+ await setup_backup_integration(hass, remote_agents=["test.remote"])
client = await hass_client()
ws_client = await hass_ws_client(hass)
@@ -2303,22 +2135,7 @@ async def test_receive_backup_read_tar_error(
exception: Exception,
) -> None:
"""Test read tar error during backup receive."""
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
- remote_agent = BackupAgentTest("remote", backups=[])
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+ await setup_backup_integration(hass, remote_agents=["test.remote"])
client = await hass_client()
ws_client = await hass_ws_client(hass)
@@ -2483,22 +2300,7 @@ async def test_receive_backup_file_read_error(
response_status: int,
) -> None:
"""Test file read error during backup receive."""
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
- remote_agent = BackupAgentTest("remote", backups=[])
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+ await setup_backup_integration(hass, remote_agents=["test.remote"])
client = await hass_client()
ws_client = await hass_ws_client(hass)
@@ -2654,16 +2456,10 @@ async def test_restore_backup(
) -> None:
"""Test restore backup."""
password = password_param.get("password")
- remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
+ await setup_backup_integration(
hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
+ remote_agents=["test.remote"],
+ backups={"test.remote": [TEST_BACKUP_ABC123]},
)
ws_client = await hass_ws_client(hass)
@@ -2684,13 +2480,11 @@ async def test_restore_backup(
patch(
"homeassistant.components.backup.manager.validate_password"
) as validate_password_mock,
- patch.object(remote_agent, "async_download_backup") as download_mock,
patch(
"homeassistant.components.backup.backup.read_backup",
side_effect=mock_read_backup,
),
):
- download_mock.return_value.__aiter__.return_value = iter((b"backup data",))
await ws_client.send_json_auto_id(
{
"type": "backup/restore",
@@ -2761,16 +2555,10 @@ async def test_restore_backup_wrong_password(
) -> None:
"""Test restore backup wrong password."""
password = "hunter2"
- remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
+ await setup_backup_integration(
hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
+ remote_agents=["test.remote"],
+ backups={"test.remote": [TEST_BACKUP_ABC123]},
)
ws_client = await hass_ws_client(hass)
@@ -2791,13 +2579,11 @@ async def test_restore_backup_wrong_password(
patch(
"homeassistant.components.backup.manager.validate_password"
) as validate_password_mock,
- patch.object(remote_agent, "async_download_backup") as download_mock,
patch(
"homeassistant.components.backup.backup.read_backup",
side_effect=mock_read_backup,
),
):
- download_mock.return_value.__aiter__.return_value = iter((b"backup data",))
validate_password_mock.return_value = False
await ws_client.send_json_auto_id(
{
@@ -2871,8 +2657,7 @@ async def test_restore_backup_wrong_parameters(
expected_reason: str,
) -> None:
"""Test restore backup wrong parameters."""
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
ws_client = await hass_ws_client(hass)
@@ -2936,8 +2721,7 @@ async def test_restore_backup_when_busy(
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test restore backup with busy manager."""
- assert await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id(
@@ -2988,16 +2772,10 @@ async def test_restore_backup_agent_error(
expected_reason: str,
) -> None:
"""Test restore backup with agent error."""
- remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
+ mock_agents = await setup_backup_integration(
hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
+ remote_agents=["test.remote"],
+ backups={"test.remote": [TEST_BACKUP_ABC123]},
)
ws_client = await hass_ws_client(hass)
@@ -3009,19 +2787,17 @@ async def test_restore_backup_agent_error(
result = await ws_client.receive_json()
assert result["success"] is True
+ mock_agents["test.remote"].async_download_backup.side_effect = exception
with (
patch("pathlib.Path.open"),
patch("pathlib.Path.write_text") as mocked_write_text,
patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call,
- patch.object(
- remote_agent, "async_download_backup", side_effect=exception
- ) as download_mock,
):
await ws_client.send_json_auto_id(
{
"type": "backup/restore",
"backup_id": TEST_BACKUP_ABC123.backup_id,
- "agent_id": remote_agent.agent_id,
+ "agent_id": "test.remote",
}
)
@@ -3049,7 +2825,7 @@ async def test_restore_backup_agent_error(
assert result["error"]["code"] == error_code
assert result["error"]["message"] == error_message
- assert download_mock.call_count == 1
+ assert mock_agents["test.remote"].async_download_backup.call_count == 1
assert mocked_write_text.call_count == 0
assert mocked_service_call.call_count == 0
@@ -3128,16 +2904,10 @@ async def test_restore_backup_file_error(
validate_password_call_count: int,
) -> None:
"""Test restore backup with file error."""
- remote_agent = BackupAgentTest("remote", backups=[TEST_BACKUP_ABC123])
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
+ mock_agents = await setup_backup_integration(
hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
+ remote_agents=["test.remote"],
+ backups={"test.remote": [TEST_BACKUP_ABC123]},
)
ws_client = await hass_ws_client(hass)
@@ -3163,14 +2933,12 @@ async def test_restore_backup_file_error(
patch(
"homeassistant.components.backup.manager.validate_password"
) as validate_password_mock,
- patch.object(remote_agent, "async_download_backup") as download_mock,
):
- download_mock.return_value.__aiter__.return_value = iter((b"backup data",))
await ws_client.send_json_auto_id(
{
"type": "backup/restore",
"backup_id": TEST_BACKUP_ABC123.backup_id,
- "agent_id": remote_agent.agent_id,
+ "agent_id": "test.remote",
}
)
@@ -3198,7 +2966,7 @@ async def test_restore_backup_file_error(
assert result["error"]["code"] == "unknown_error"
assert result["error"]["message"] == "Unknown error"
- assert download_mock.call_count == 1
+ assert mock_agents["test.remote"].async_download_backup.call_count == 1
assert validate_password_mock.call_count == validate_password_call_count
assert open_mock.call_count == open_call_count
assert open_mock.return_value.write.call_count == write_call_count
@@ -3345,23 +3113,7 @@ async def test_initiate_backup_per_agent_encryption(
inner_tar_key: bytes | None,
) -> None:
"""Test generate backup where encryption is selectively set on agents."""
- local_agent = local_backup_platform.CoreLocalBackupAgent(hass)
- remote_agent = BackupAgentTest("remote", backups=[])
-
- with patch(
- "homeassistant.components.backup.backup.async_get_backup_agents"
- ) as core_get_backup_agents:
- core_get_backup_agents.return_value = [local_agent]
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
- )
+ await setup_backup_integration(hass, remote_agents=["test.remote"])
ws_client = await hass_ws_client(hass)
@@ -3511,8 +3263,7 @@ async def test_restore_progress_after_restart(
with patch(
"pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode()
):
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id({"type": "backup/info"})
@@ -3538,8 +3289,7 @@ async def test_restore_progress_after_restart_fail_to_remove(
"""Test restore backup progress after restart when failing to remove result file."""
with patch("pathlib.Path.unlink", side_effect=OSError("Boom!")):
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
+ await setup_backup_integration(hass)
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id({"type": "backup/info"})
diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py
index 773256bdd0b..8632fb1e957 100644
--- a/tests/components/backup/test_websocket.py
+++ b/tests/components/backup/test_websocket.py
@@ -11,7 +11,6 @@ from syrupy import SnapshotAssertion
from homeassistant.components.backup import (
AgentBackup,
BackupAgentError,
- BackupAgentPlatformProtocol,
BackupNotFound,
BackupReaderWriterError,
Folder,
@@ -28,15 +27,12 @@ from homeassistant.components.backup.manager import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.setup import async_setup_component
from .common import (
LOCAL_AGENT_ID,
TEST_BACKUP_ABC123,
TEST_BACKUP_DEF456,
- BackupAgentTest,
setup_backup_integration,
- setup_backup_platform,
)
from tests.common import async_fire_time_changed, async_mock_service
@@ -112,9 +108,9 @@ def mock_get_backups() -> Generator[AsyncMock]:
("remote_agents", "remote_backups"),
[
([], {}),
- (["remote"], {}),
- (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
- (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}),
+ (["test.remote"], {}),
+ (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
+ (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}),
],
)
async def test_info(
@@ -150,28 +146,30 @@ async def test_info_with_errors(
snapshot: SnapshotAssertion,
) -> None:
"""Test getting backup info with one unavailable agent."""
- await setup_backup_integration(
- hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}
+ mock_agents = await setup_backup_integration(
+ hass,
+ with_hassio=False,
+ backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]},
+ remote_agents=["test.remote"],
)
- hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test")
+ mock_agents["test.remote"].async_list_backups.side_effect = side_effect
client = await hass_ws_client(hass)
await hass.async_block_till_done()
- with patch.object(BackupAgentTest, "async_list_backups", side_effect=side_effect):
- await client.send_json_auto_id({"type": "backup/info"})
- assert await client.receive_json() == snapshot
+ await client.send_json_auto_id({"type": "backup/info"})
+ assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
("remote_agents", "backups"),
[
([], {}),
- (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}),
- (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
- (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}),
+ (["test.remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}),
+ (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
+ (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}),
(
- ["remote"],
+ ["test.remote"],
{
LOCAL_AGENT_ID: [TEST_BACKUP_ABC123],
"test.remote": [TEST_BACKUP_ABC123],
@@ -212,18 +210,18 @@ async def test_details_with_errors(
snapshot: SnapshotAssertion,
) -> None:
"""Test getting backup info with one unavailable agent."""
- await setup_backup_integration(
- hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}
+ mock_agents = await setup_backup_integration(
+ hass,
+ with_hassio=False,
+ backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]},
+ remote_agents=["test.remote"],
)
- hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test")
+ mock_agents["test.remote"].async_get_backup.side_effect = side_effect
client = await hass_ws_client(hass)
await hass.async_block_till_done()
- with (
- patch("pathlib.Path.exists", return_value=True),
- patch.object(BackupAgentTest, "async_get_backup", side_effect=side_effect),
- ):
+ with patch("pathlib.Path.exists", return_value=True):
await client.send_json_auto_id(
{"type": "backup/details", "backup_id": "abc123"}
)
@@ -234,11 +232,11 @@ async def test_details_with_errors(
("remote_agents", "backups"),
[
([], {}),
- (["remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}),
- (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
- (["remote"], {"test.remote": [TEST_BACKUP_DEF456]}),
+ (["test.remote"], {LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}),
+ (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
+ (["test.remote"], {"test.remote": [TEST_BACKUP_DEF456]}),
(
- ["remote"],
+ ["test.remote"],
{
LOCAL_AGENT_ID: [TEST_BACKUP_ABC123],
"test.remote": [TEST_BACKUP_ABC123],
@@ -304,17 +302,22 @@ async def test_delete_with_errors(
"version": store.STORAGE_VERSION,
"minor_version": store.STORAGE_VERSION_MINOR,
}
- await setup_backup_integration(
- hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]}
+ mock_agents = await setup_backup_integration(
+ hass,
+ with_hassio=False,
+ backups={
+ LOCAL_AGENT_ID: [TEST_BACKUP_ABC123],
+ "test.remote": [TEST_BACKUP_ABC123],
+ },
+ remote_agents=["test.remote"],
)
- hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test")
+ mock_agents["test.remote"].async_delete_backup.side_effect = side_effect
client = await hass_ws_client(hass)
await hass.async_block_till_done()
- with patch.object(BackupAgentTest, "async_delete_backup", side_effect=side_effect):
- await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"})
- assert await client.receive_json() == snapshot
+ await client.send_json_auto_id({"type": "backup/delete", "backup_id": "abc123"})
+ assert await client.receive_json() == snapshot
await client.send_json_auto_id({"type": "backup/info"})
assert await client.receive_json() == snapshot
@@ -326,22 +329,22 @@ async def test_agent_delete_backup(
snapshot: SnapshotAssertion,
) -> None:
"""Test deleting a backup file with a mock agent."""
- await setup_backup_integration(hass)
- hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")}
+ mock_agents = await setup_backup_integration(
+ hass, with_hassio=False, remote_agents=["test.remote"]
+ )
client = await hass_ws_client(hass)
await hass.async_block_till_done()
- with patch.object(BackupAgentTest, "async_delete_backup") as delete_mock:
- await client.send_json_auto_id(
- {
- "type": "backup/delete",
- "backup_id": "abc123",
- }
- )
- assert await client.receive_json() == snapshot
+ await client.send_json_auto_id(
+ {
+ "type": "backup/delete",
+ "backup_id": "abc123",
+ }
+ )
+ assert await client.receive_json() == snapshot
- assert delete_mock.call_args == call("abc123")
+ assert mock_agents["test.remote"].async_delete_backup.call_args == call("abc123")
@pytest.mark.parametrize(
@@ -588,17 +591,9 @@ async def test_generate_with_default_settings_calls_create(
client = await hass_ws_client(hass)
await hass.config.async_set_time_zone("Europe/Amsterdam")
freezer.move_to("2024-11-13T12:01:00+01:00")
- remote_agent = BackupAgentTest("remote", backups=[])
- await setup_backup_platform(
- hass,
- domain="test",
- platform=Mock(
- async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
- spec_set=BackupAgentPlatformProtocol,
- ),
+ mock_agents = await setup_backup_integration(
+ hass, with_hassio=False, remote_agents=["test.remote"]
)
- await async_setup_component(hass, DOMAIN, {})
- await hass.async_block_till_done()
await client.send_json_auto_id(
{"type": "backup/config/update", "create_backup": create_backup_settings}
@@ -623,15 +618,13 @@ async def test_generate_with_default_settings_calls_create(
is None
)
- with patch.object(remote_agent, "async_upload_backup", side_effect=side_effect):
- await client.send_json_auto_id(
- {"type": "backup/generate_with_automatic_settings"}
- )
- result = await client.receive_json()
- assert result["success"]
- assert result["result"] == {"backup_job_id": "abc123"}
+ mock_agents["test.remote"].async_upload_backup.side_effect = side_effect
+ await client.send_json_auto_id({"type": "backup/generate_with_automatic_settings"})
+ result = await client.receive_json()
+ assert result["success"]
+ assert result["result"] == {"backup_job_id": "abc123"}
- await hass.async_block_till_done()
+ await hass.async_block_till_done()
create_backup.assert_called_once_with(**expected_call_params)
@@ -688,8 +681,8 @@ async def test_restore_local_agent(
@pytest.mark.parametrize(
("remote_agents", "backups"),
[
- (["remote"], {}),
- (["remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
+ (["test.remote"], {}),
+ (["test.remote"], {"test.remote": [TEST_BACKUP_ABC123]}),
],
)
async def test_restore_remote_agent(
@@ -700,6 +693,7 @@ async def test_restore_remote_agent(
snapshot: SnapshotAssertion,
) -> None:
"""Test calling the restore command."""
+
await setup_backup_integration(
hass, with_hassio=False, backups=backups, remote_agents=remote_agents
)
@@ -891,8 +885,9 @@ async def test_agents_info(
snapshot: SnapshotAssertion,
) -> None:
"""Test getting backup agents info."""
- await setup_backup_integration(hass, with_hassio=False)
- hass.data[DATA_MANAGER].backup_agents["domain.test"] = BackupAgentTest("test")
+ await setup_backup_integration(
+ hass, with_hassio=False, remote_agents=["test.remote"]
+ )
client = await hass_ws_client(hass)
await hass.async_block_till_done()
@@ -1730,7 +1725,7 @@ async def test_config_schedule_logic(
await hass.config.async_set_time_zone("Europe/Amsterdam")
freezer.move_to("2024-11-11 12:00:00+01:00")
- await setup_backup_integration(hass, remote_agents=["test-agent"])
+ await setup_backup_integration(hass, remote_agents=["test.test-agent"])
await hass.async_block_till_done()
for command in commands:
@@ -1773,7 +1768,7 @@ async def test_config_schedule_logic(
"command",
"backups",
"get_backups_agent_errors",
- "agent_delete_backup_side_effects",
+ "delete_backup_side_effects",
"last_backup_time",
"next_time",
"backup_time",
@@ -2345,7 +2340,7 @@ async def test_config_retention_copies_logic(
command: dict[str, Any],
backups: dict[str, Any],
get_backups_agent_errors: dict[str, Exception],
- agent_delete_backup_side_effects: dict[str, Exception],
+ delete_backup_side_effects: dict[str, Exception],
last_backup_time: str,
next_time: str,
backup_time: str,
@@ -2392,14 +2387,13 @@ async def test_config_retention_copies_logic(
await hass.config.async_set_time_zone("Europe/Amsterdam")
freezer.move_to("2024-11-11 12:00:00+01:00")
- await setup_backup_integration(hass, remote_agents=["test-agent", "test-agent2"])
+ mock_agents = await setup_backup_integration(
+ hass, remote_agents=["test.test-agent", "test.test-agent2"]
+ )
await hass.async_block_till_done()
- manager = hass.data[DATA_MANAGER]
- for agent_id, agent in manager.backup_agents.items():
- agent.async_delete_backup = AsyncMock(
- side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True
- )
+ for agent_id, agent in mock_agents.items():
+ agent.async_delete_backup.side_effect = delete_backup_side_effects.get(agent_id)
await client.send_json_auto_id(command)
result = await client.receive_json()
@@ -2411,7 +2405,7 @@ async def test_config_retention_copies_logic(
await hass.async_block_till_done()
assert create_backup.call_count == backup_calls
assert get_backups.call_count == get_backups_calls
- for agent_id, agent in manager.backup_agents.items():
+ for agent_id, agent in mock_agents.items():
agent_delete_calls = delete_calls.get(agent_id, [])
assert agent.async_delete_backup.call_count == len(agent_delete_calls)
assert agent.async_delete_backup.call_args_list == agent_delete_calls
@@ -2671,13 +2665,11 @@ async def test_config_retention_copies_logic_manual_backup(
await hass.config.async_set_time_zone("Europe/Amsterdam")
freezer.move_to("2024-11-11 12:00:00+01:00")
- await setup_backup_integration(hass, remote_agents=["test-agent"])
+ mock_agents = await setup_backup_integration(
+ hass, remote_agents=["test.test-agent"]
+ )
await hass.async_block_till_done()
- manager = hass.data[DATA_MANAGER]
- for agent in manager.backup_agents.values():
- agent.async_delete_backup = AsyncMock(autospec=True)
-
await client.send_json_auto_id(config_command)
result = await client.receive_json()
assert result["success"]
@@ -2692,7 +2684,7 @@ async def test_config_retention_copies_logic_manual_backup(
assert create_backup.call_count == backup_calls
assert get_backups.call_count == get_backups_calls
- for agent_id, agent in manager.backup_agents.items():
+ for agent_id, agent in mock_agents.items():
agent_delete_calls = delete_calls.get(agent_id, [])
assert agent.async_delete_backup.call_count == len(agent_delete_calls)
assert agent.async_delete_backup.call_args_list == agent_delete_calls
@@ -2714,7 +2706,7 @@ async def test_config_retention_copies_logic_manual_backup(
"commands",
"backups",
"get_backups_agent_errors",
- "agent_delete_backup_side_effects",
+ "delete_backup_side_effects",
"last_backup_time",
"start_time",
"next_time",
@@ -3077,7 +3069,7 @@ async def test_config_retention_days_logic(
commands: list[dict[str, Any]],
backups: dict[str, Any],
get_backups_agent_errors: dict[str, Exception],
- agent_delete_backup_side_effects: dict[str, Exception],
+ delete_backup_side_effects: dict[str, Exception],
last_backup_time: str,
start_time: str,
next_time: str,
@@ -3120,14 +3112,13 @@ async def test_config_retention_days_logic(
await hass.config.async_set_time_zone("Europe/Amsterdam")
freezer.move_to(start_time)
- await setup_backup_integration(hass, remote_agents=["test-agent"])
+ mock_agents = await setup_backup_integration(
+ hass, remote_agents=["test.test-agent"]
+ )
await hass.async_block_till_done()
- manager = hass.data[DATA_MANAGER]
- for agent_id, agent in manager.backup_agents.items():
- agent.async_delete_backup = AsyncMock(
- side_effect=agent_delete_backup_side_effects.get(agent_id), autospec=True
- )
+ for agent_id, agent in mock_agents.items():
+ agent.async_delete_backup.side_effect = delete_backup_side_effects.get(agent_id)
for command in commands:
await client.send_json_auto_id(command)
@@ -3138,7 +3129,7 @@ async def test_config_retention_days_logic(
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert get_backups.call_count == get_backups_calls
- for agent_id, agent in manager.backup_agents.items():
+ for agent_id, agent in mock_agents.items():
agent_delete_calls = delete_calls.get(agent_id, [])
assert agent.async_delete_backup.call_count == len(agent_delete_calls)
assert agent.async_delete_backup.call_args_list == agent_delete_calls
@@ -3222,21 +3213,21 @@ async def test_can_decrypt_on_download_with_agent_error(
) -> None:
"""Test can decrypt on download."""
- await setup_backup_integration(
+ mock_agents = await setup_backup_integration(
hass,
with_hassio=False,
backups={"test.remote": [TEST_BACKUP_ABC123]},
- remote_agents=["remote"],
+ remote_agents=["test.remote"],
)
client = await hass_ws_client(hass)
- with patch.object(BackupAgentTest, "async_download_backup", side_effect=error):
- await client.send_json_auto_id(
- {
- "type": "backup/can_decrypt_on_download",
- "backup_id": TEST_BACKUP_ABC123.backup_id,
- "agent_id": "test.remote",
- "password": "hunter2",
- }
- )
- assert await client.receive_json() == snapshot
+ mock_agents["test.remote"].async_download_backup.side_effect = error
+ await client.send_json_auto_id(
+ {
+ "type": "backup/can_decrypt_on_download",
+ "backup_id": TEST_BACKUP_ABC123.backup_id,
+ "agent_id": "test.remote",
+ "password": "hunter2",
+ }
+ )
+ assert await client.receive_json() == snapshot
diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py
index 0bb8b2cd468..90f8fdc3d6e 100644
--- a/tests/components/balboa/conftest.py
+++ b/tests/components/balboa/conftest.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Callable, Generator
+from datetime import time
from unittest.mock import AsyncMock, MagicMock, patch
from pybalboa.enums import HeatMode, LowHighRange
@@ -48,7 +49,12 @@ def client_fixture() -> Generator[MagicMock]:
client.blowers = []
client.circulation_pump.state = 0
client.filter_cycle_1_running = False
+ client.filter_cycle_1_start = time(8, 0)
+ client.filter_cycle_1_end = time(9, 0)
client.filter_cycle_2_running = False
+ client.filter_cycle_2_enabled = True
+ client.filter_cycle_2_start = time(19, 0)
+ client.filter_cycle_2_end = time(21, 30)
client.temperature_unit = 1
client.temperature = 10
client.temperature_minimum = 10
diff --git a/tests/components/balboa/snapshots/test_switch.ambr b/tests/components/balboa/snapshots/test_switch.ambr
new file mode 100644
index 00000000000..ad63fcdf387
--- /dev/null
+++ b/tests/components/balboa/snapshots/test_switch.ambr
@@ -0,0 +1,48 @@
+# serializer version: 1
+# name: test_switches[switch.fakespa_filter_cycle_2_enabled-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'switch',
+ 'entity_category': ,
+ 'entity_id': 'switch.fakespa_filter_cycle_2_enabled',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Filter cycle 2 enabled',
+ 'platform': 'balboa',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'filter_cycle_2_enabled',
+ 'unique_id': 'FakeSpa-filter_cycle_2_enabled-c0ffee',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_switches[switch.fakespa_filter_cycle_2_enabled-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'FakeSpa Filter cycle 2 enabled',
+ }),
+ 'context': ,
+ 'entity_id': 'switch.fakespa_filter_cycle_2_enabled',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': 'on',
+ })
+# ---
diff --git a/tests/components/balboa/snapshots/test_time.ambr b/tests/components/balboa/snapshots/test_time.ambr
new file mode 100644
index 00000000000..6b27717e2d3
--- /dev/null
+++ b/tests/components/balboa/snapshots/test_time.ambr
@@ -0,0 +1,189 @@
+# serializer version: 1
+# name: test_times[time.fakespa_filter_cycle_1_end-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'time',
+ 'entity_category': ,
+ 'entity_id': 'time.fakespa_filter_cycle_1_end',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Filter cycle 1 end',
+ 'platform': 'balboa',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'filter_cycle_end',
+ 'unique_id': 'FakeSpa-filter_cycle_1_end-c0ffee',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_times[time.fakespa_filter_cycle_1_end-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'FakeSpa Filter cycle 1 end',
+ }),
+ 'context': ,
+ 'entity_id': 'time.fakespa_filter_cycle_1_end',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '09:00:00',
+ })
+# ---
+# name: test_times[time.fakespa_filter_cycle_1_start-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'time',
+ 'entity_category': ,
+ 'entity_id': 'time.fakespa_filter_cycle_1_start',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Filter cycle 1 start',
+ 'platform': 'balboa',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'filter_cycle_start',
+ 'unique_id': 'FakeSpa-filter_cycle_1_start-c0ffee',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_times[time.fakespa_filter_cycle_1_start-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'FakeSpa Filter cycle 1 start',
+ }),
+ 'context': ,
+ 'entity_id': 'time.fakespa_filter_cycle_1_start',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '08:00:00',
+ })
+# ---
+# name: test_times[time.fakespa_filter_cycle_2_end-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'time',
+ 'entity_category': ,
+ 'entity_id': 'time.fakespa_filter_cycle_2_end',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Filter cycle 2 end',
+ 'platform': 'balboa',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'filter_cycle_end',
+ 'unique_id': 'FakeSpa-filter_cycle_2_end-c0ffee',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_times[time.fakespa_filter_cycle_2_end-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'FakeSpa Filter cycle 2 end',
+ }),
+ 'context': ,
+ 'entity_id': 'time.fakespa_filter_cycle_2_end',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '21:30:00',
+ })
+# ---
+# name: test_times[time.fakespa_filter_cycle_2_start-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': None,
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'time',
+ 'entity_category': ,
+ 'entity_id': 'time.fakespa_filter_cycle_2_start',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Filter cycle 2 start',
+ 'platform': 'balboa',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'filter_cycle_start',
+ 'unique_id': 'FakeSpa-filter_cycle_2_start-c0ffee',
+ 'unit_of_measurement': None,
+ })
+# ---
+# name: test_times[time.fakespa_filter_cycle_2_start-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'FakeSpa Filter cycle 2 start',
+ }),
+ 'context': ,
+ 'entity_id': 'time.fakespa_filter_cycle_2_start',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '19:00:00',
+ })
+# ---
diff --git a/tests/components/balboa/test_switch.py b/tests/components/balboa/test_switch.py
new file mode 100644
index 00000000000..4b6bae172f4
--- /dev/null
+++ b/tests/components/balboa/test_switch.py
@@ -0,0 +1,55 @@
+"""Tests of the switches of the balboa integration."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+from syrupy import SnapshotAssertion
+
+from homeassistant.const import STATE_OFF, STATE_ON, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import init_integration
+
+from tests.common import snapshot_platform
+from tests.components.switch import common
+
+ENTITY_SWITCH = "switch.fakespa_filter_cycle_2_enabled"
+
+
+async def test_switches(
+ hass: HomeAssistant,
+ client: MagicMock,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test spa switches."""
+ with patch("homeassistant.components.balboa.PLATFORMS", [Platform.SWITCH]):
+ entry = await init_integration(hass)
+
+ await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
+
+
+async def test_switch(hass: HomeAssistant, client: MagicMock) -> None:
+ """Test spa filter cycle enabled switch."""
+ await init_integration(hass)
+
+ # check if the initial state is on
+ state = hass.states.get(ENTITY_SWITCH)
+ assert state.state == STATE_ON
+
+ # test calling turn off
+ await common.async_turn_off(hass, ENTITY_SWITCH)
+ client.configure_filter_cycle.assert_called_with(2, enabled=False)
+
+ setattr(client, "filter_cycle_2_enabled", False)
+ client.emit("")
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY_SWITCH)
+ assert state.state == STATE_OFF
+
+ # test calling turn on
+ await common.async_turn_on(hass, ENTITY_SWITCH)
+ client.configure_filter_cycle.assert_called_with(2, enabled=True)
diff --git a/tests/components/balboa/test_time.py b/tests/components/balboa/test_time.py
new file mode 100644
index 00000000000..21778d08e2d
--- /dev/null
+++ b/tests/components/balboa/test_time.py
@@ -0,0 +1,72 @@
+"""Tests of the times of the balboa integration."""
+
+from __future__ import annotations
+
+from datetime import time
+from unittest.mock import MagicMock, patch
+
+import pytest
+from syrupy import SnapshotAssertion
+
+from homeassistant.components.time import (
+ ATTR_TIME,
+ DOMAIN as TIME_DOMAIN,
+ SERVICE_SET_VALUE,
+)
+from homeassistant.const import ATTR_ENTITY_ID, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import init_integration
+
+from tests.common import snapshot_platform
+
+ENTITY_TIME = "time.fakespa_"
+
+
+async def test_times(
+ hass: HomeAssistant,
+ client: MagicMock,
+ entity_registry: er.EntityRegistry,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test spa times."""
+ with patch("homeassistant.components.balboa.PLATFORMS", [Platform.TIME]):
+ entry = await init_integration(hass)
+
+ await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
+
+
+@pytest.mark.parametrize(
+ ("filter_cycle", "period", "value"),
+ [
+ (1, "start", "08:00:00"),
+ (1, "end", "09:00:00"),
+ (2, "start", "19:00:00"),
+ (2, "end", "21:30:00"),
+ ],
+)
+async def test_time(
+ hass: HomeAssistant, client: MagicMock, filter_cycle: int, period: str, value: str
+) -> None:
+ """Test spa filter cycle time."""
+ await init_integration(hass)
+
+ time_entity = f"{ENTITY_TIME}filter_cycle_{filter_cycle}_{period}"
+
+ # check the expected state of the time entity
+ state = hass.states.get(time_entity)
+ assert state.state == value
+
+ new_time = time(hour=7, minute=0)
+
+ await hass.services.async_call(
+ TIME_DOMAIN,
+ SERVICE_SET_VALUE,
+ service_data={ATTR_TIME: new_time},
+ blocking=True,
+ target={ATTR_ENTITY_ID: time_entity},
+ )
+
+ # check we made a call with the right parameters
+ client.configure_filter_cycle.assert_called_with(filter_cycle, **{period: new_time})
diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr
index d7f9a045921..bc51f89f96d 100644
--- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr
+++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr
@@ -1,6 +1,22 @@
# serializer version: 1
# name: test_async_get_config_entry_diagnostics
dict({
+ 'PlayPause_event': dict({
+ 'attributes': dict({
+ 'device_class': 'button',
+ 'event_type': None,
+ 'event_types': list([
+ 'short_press_release',
+ 'long_press_timeout',
+ 'long_press_release',
+ 'very_long_press_timeout',
+ 'very_long_press_release',
+ ]),
+ 'friendly_name': 'Living room Balance Play / Pause',
+ }),
+ 'entity_id': 'event.beosound_balance_11111111_play_pause',
+ 'state': 'unknown',
+ }),
'config_entry': dict({
'data': dict({
'host': '192.168.0.1',
diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py
index 7c99648ace4..a9415a222a8 100644
--- a/tests/components/bang_olufsen/test_diagnostics.py
+++ b/tests/components/bang_olufsen/test_diagnostics.py
@@ -6,6 +6,9 @@ from syrupy import SnapshotAssertion
from syrupy.filters import props
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_registry import EntityRegistry
+
+from .const import TEST_BUTTON_EVENT_ENTITY_ID
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
@@ -14,6 +17,7 @@ from tests.typing import ClientSessionGenerator
async def test_async_get_config_entry_diagnostics(
hass: HomeAssistant,
+ entity_registry: EntityRegistry,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_mozart_client: AsyncMock,
@@ -23,6 +27,10 @@ async def test_async_get_config_entry_diagnostics(
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ # Enable an Event entity
+ entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None)
+ hass.config_entries.async_schedule_reload(mock_config_entry.entry_id)
+
result = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr
index 9b2f2e0eb33..b15cd08c23a 100644
--- a/tests/components/cloud/snapshots/test_http_api.ambr
+++ b/tests/components/cloud/snapshots/test_http_api.ambr
@@ -44,6 +44,17 @@
+ ## Full logs
+
+ Logs
+
+ ```logs
+ 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log
+ 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log
+ 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log
+ ```
+
+
'''
# ---
diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py
index 6e59b7d983e..18793cc00bb 100644
--- a/tests/components/cloud/test_backup.py
+++ b/tests/components/cloud/test_backup.py
@@ -3,12 +3,12 @@
from collections.abc import AsyncGenerator, Generator
from io import StringIO
from typing import Any
-from unittest.mock import Mock, PropertyMock, patch
+from unittest.mock import ANY, Mock, PropertyMock, patch
from aiohttp import ClientError
from hass_nabucasa import CloudError
from hass_nabucasa.api import CloudApiNonRetryableError
-from hass_nabucasa.files import FilesError
+from hass_nabucasa.files import FilesError, StorageType
import pytest
from homeassistant.components.backup import (
@@ -90,7 +90,26 @@ def mock_list_files() -> Generator[MagicMock]:
"size": 34519040,
"storage-type": "backup",
},
- }
+ },
+ {
+ "Key": "462e16810d6841228828d9dd2f9e341f.tar",
+ "LastModified": "2024-11-22T10:49:01.182Z",
+ "Size": 34519040,
+ "Metadata": {
+ "addons": [],
+ "backup_id": "23e64aed",
+ "date": "2024-11-22T11:48:48.727189+01:00",
+ "database_included": True,
+ "extra_metadata": {},
+ "folders": [],
+ "homeassistant_included": True,
+ "homeassistant_version": "2024.12.0.dev0",
+ "name": "Core 2024.12.0.dev0",
+ "protected": False,
+ "size": 34519040,
+ "storage-type": "backup",
+ },
+ },
]
yield list_files
@@ -148,7 +167,21 @@ async def test_agents_list_backups(
"name": "Core 2024.12.0.dev0",
"failed_agent_ids": [],
"with_automatic_settings": None,
- }
+ },
+ {
+ "addons": [],
+ "agents": {"cloud.cloud": {"protected": False, "size": 34519040}},
+ "backup_id": "23e64aed",
+ "date": "2024-11-22T11:48:48.727189+01:00",
+ "database_included": True,
+ "extra_metadata": {},
+ "folders": [],
+ "homeassistant_included": True,
+ "homeassistant_version": "2024.12.0.dev0",
+ "name": "Core 2024.12.0.dev0",
+ "failed_agent_ids": [],
+ "with_automatic_settings": None,
+ },
]
@@ -242,6 +275,10 @@ async def test_agents_download(
resp = await client.get(f"/api/backup/download/{backup_id}?agent_id=cloud.cloud")
assert resp.status == 200
assert await resp.content.read() == b"backup data"
+ cloud.files.download.assert_called_once_with(
+ filename="462e16810d6841228828d9dd2f9e341e.tar",
+ storage_type=StorageType.BACKUP,
+ )
@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
@@ -285,6 +322,7 @@ async def test_agents_upload(
) -> None:
"""Test agent upload backup."""
client = await hass_client()
+ backup_data = "test"
backup_id = "test-backup"
test_backup = AgentBackup(
addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
@@ -297,7 +335,7 @@ async def test_agents_upload(
homeassistant_version="2024.12.0",
name="Test",
protected=True,
- size=0,
+ size=len(backup_data),
)
with (
patch(
@@ -309,14 +347,21 @@ async def test_agents_upload(
),
patch("pathlib.Path.open") as mocked_open,
):
- mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
+ mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""])
fetch_backup.return_value = test_backup
resp = await client.post(
"/api/backup/upload?agent_id=cloud.cloud",
- data={"file": StringIO("test")},
+ data={"file": StringIO(backup_data)},
)
- assert len(cloud.files.upload.mock_calls) == 1
+ cloud.files.upload.assert_called_once_with(
+ storage_type=StorageType.BACKUP,
+ open_stream=ANY,
+ filename=f"{cloud.client.prefs.instance_id}.tar",
+ base64md5hash=ANY,
+ metadata=ANY,
+ size=ANY,
+ )
metadata = cloud.files.upload.mock_calls[-1].kwargs["metadata"]
assert metadata["backup_id"] == backup_id
@@ -336,6 +381,7 @@ async def test_agents_upload_fail(
) -> None:
"""Test agent upload backup fails."""
client = await hass_client()
+ backup_data = "test"
backup_id = "test-backup"
test_backup = AgentBackup(
addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
@@ -348,7 +394,7 @@ async def test_agents_upload_fail(
homeassistant_version="2024.12.0",
name="Test",
protected=True,
- size=0,
+ size=len(backup_data),
)
cloud.files.upload.side_effect = side_effect
@@ -366,11 +412,11 @@ async def test_agents_upload_fail(
patch("homeassistant.components.cloud.backup.random.randint", return_value=60),
patch("homeassistant.components.cloud.backup._RETRY_LIMIT", 2),
):
- mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
+ mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""])
fetch_backup.return_value = test_backup
resp = await client.post(
"/api/backup/upload?agent_id=cloud.cloud",
- data={"file": StringIO("test")},
+ data={"file": StringIO(backup_data)},
)
await hass.async_block_till_done()
@@ -409,6 +455,7 @@ async def test_agents_upload_fail_non_retryable(
) -> None:
"""Test agent upload backup fails with non-retryable error."""
client = await hass_client()
+ backup_data = "test"
backup_id = "test-backup"
test_backup = AgentBackup(
addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
@@ -435,12 +482,13 @@ async def test_agents_upload_fail_non_retryable(
return_value=test_backup,
),
patch("pathlib.Path.open") as mocked_open,
+ patch("homeassistant.components.cloud.backup.calculate_b64md5"),
):
- mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
+ mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""])
fetch_backup.return_value = test_backup
resp = await client.post(
"/api/backup/upload?agent_id=cloud.cloud",
- data={"file": StringIO("test")},
+ data={"file": StringIO(backup_data)},
)
await hass.async_block_till_done()
@@ -461,6 +509,7 @@ async def test_agents_upload_not_protected(
) -> None:
"""Test agent upload backup, when cloud user is logged in."""
client = await hass_client()
+ backup_data = "test"
backup_id = "test-backup"
test_backup = AgentBackup(
addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
@@ -473,7 +522,7 @@ async def test_agents_upload_not_protected(
homeassistant_version="2024.12.0",
name="Test",
protected=False,
- size=0,
+ size=len(backup_data),
)
with (
patch("pathlib.Path.open"),
@@ -484,7 +533,7 @@ async def test_agents_upload_not_protected(
):
resp = await client.post(
"/api/backup/upload?agent_id=cloud.cloud",
- data={"file": StringIO("test")},
+ data={"file": StringIO(backup_data)},
)
await hass.async_block_till_done()
@@ -496,10 +545,58 @@ async def test_agents_upload_not_protected(
assert stored_backup["failed_agent_ids"] == ["cloud.cloud"]
+@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
+async def test_agents_upload_wrong_size(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ caplog: pytest.LogCaptureFixture,
+ cloud: Mock,
+) -> None:
+ """Test agent upload backup with the wrong size."""
+ client = await hass_client()
+ backup_data = "test"
+ backup_id = "test-backup"
+ test_backup = AgentBackup(
+ addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
+ backup_id=backup_id,
+ database_included=True,
+ date="1970-01-01T00:00:00.000Z",
+ extra_metadata={},
+ folders=[Folder.MEDIA, Folder.SHARE],
+ homeassistant_included=True,
+ homeassistant_version="2024.12.0",
+ name="Test",
+ protected=True,
+ size=len(backup_data) - 1,
+ )
+ with (
+ patch(
+ "homeassistant.components.backup.manager.BackupManager.async_get_backup",
+ ) as fetch_backup,
+ patch(
+ "homeassistant.components.backup.manager.read_backup",
+ return_value=test_backup,
+ ),
+ patch("pathlib.Path.open") as mocked_open,
+ ):
+ mocked_open.return_value.read = Mock(side_effect=[backup_data.encode(), b""])
+ fetch_backup.return_value = test_backup
+ resp = await client.post(
+ "/api/backup/upload?agent_id=cloud.cloud",
+ data={"file": StringIO(backup_data)},
+ )
+
+ assert len(cloud.files.upload.mock_calls) == 0
+
+ assert resp.status == 201
+ assert "Upload failed for cloud.cloud" in caplog.text
+
+
@pytest.mark.usefixtures("cloud_logged_in", "mock_list_files")
async def test_agents_delete(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
+ cloud: Mock,
mock_delete_file: Mock,
) -> None:
"""Test agent delete backup."""
@@ -516,7 +613,11 @@ async def test_agents_delete(
assert response["success"]
assert response["result"] == {"agent_errors": {}}
- mock_delete_file.assert_called_once()
+ mock_delete_file.assert_called_once_with(
+ cloud,
+ filename="462e16810d6841228828d9dd2f9e341e.tar",
+ storage_type=StorageType.BACKUP,
+ )
@pytest.mark.parametrize("side_effect", [ClientError, CloudError])
diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py
index e4a526ceadd..ef4b93a8aab 100644
--- a/tests/components/cloud/test_http_api.py
+++ b/tests/components/cloud/test_http_api.py
@@ -2,12 +2,15 @@
from collections.abc import Callable, Coroutine
from copy import deepcopy
+import datetime
from http import HTTPStatus
import json
+import logging
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
import aiohttp
+from freezegun.api import FrozenDateTimeFactory
from hass_nabucasa import thingtalk
from hass_nabucasa.auth import (
InvalidTotpCode,
@@ -1869,15 +1872,18 @@ async def test_logout_view_dispatch_event(
assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"}
+@patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3)
async def test_download_support_package(
hass: HomeAssistant,
cloud: MagicMock,
set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
hass_client: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
+ freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
) -> None:
"""Test downloading a support package file."""
+
aioclient_mock.get("https://cloud.bla.com/status", text="")
aioclient_mock.get(
"https://cert-server/directory", exc=Exception("Unexpected exception")
@@ -1936,6 +1942,19 @@ async def test_download_support_package(
}
)
+ now = dt_util.utcnow()
+ # The logging is done with local time according to the system timezone. Set the
+ # fake time to 12:00 local time
+ tz = now.astimezone().tzinfo
+ freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz))
+ logging.getLogger("hass_nabucasa.iot").info(
+ "This message will be dropped since this test patches MAX_RECORDS"
+ )
+ logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log")
+ logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log")
+ logging.getLogger("homeassistant.components.cloud.client").error("Cloud log")
+ freezer.move_to(now) # Reset time otherwise hass_client auth fails
+
cloud_client = await hass_client()
with (
patch.object(hass.config, "config_dir", new="config"),
diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py
index 28161c0182c..a31836b598c 100644
--- a/tests/components/config/test_config_entries.py
+++ b/tests/components/config/test_config_entries.py
@@ -1192,7 +1192,7 @@ async def test_subentry_flow(hass: HomeAssistant, client) -> None:
async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None:
- """Test we can start a subentry reconfigure flow."""
+ """Test we can start and finish a subentry reconfigure flow."""
class TestFlow(core_ce.ConfigFlow):
class SubentryFlowHandler(core_ce.ConfigSubentryFlow):
@@ -1203,6 +1203,14 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None:
raise NotImplementedError
async def async_step_reconfigure(self, user_input=None):
+ if user_input is not None:
+ return self.async_update_and_abort(
+ self._get_reconfigure_entry(),
+ self._get_reconfigure_subentry(),
+ title="Test Entry",
+ data={"test": "blah"},
+ )
+
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema({vol.Required("enabled"): bool}),
@@ -1243,7 +1251,7 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None:
assert resp.status == HTTPStatus.OK
data = await resp.json()
- data.pop("flow_id")
+ flow_id = data.pop("flow_id")
assert data == {
"type": "form",
"handler": ["test1", "test"],
@@ -1255,6 +1263,87 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None:
"preview": None,
}
+ with mock_config_flow("test", TestFlow):
+ resp = await client.post(
+ f"/api/config/config_entries/subentries/flow/{flow_id}",
+ json={"enabled": True},
+ )
+ assert resp.status == HTTPStatus.OK
+
+ entries = hass.config_entries.async_entries("test")
+ assert len(entries) == 1
+
+ data = await resp.json()
+ data.pop("flow_id")
+ assert data == {
+ "handler": ["test1", "test"],
+ "reason": "reconfigure_successful",
+ "type": "abort",
+ "description_placeholders": None,
+ }
+
+ entry = hass.config_entries.async_entries()[0]
+ assert entry.subentries == {
+ "mock_id": core_ce.ConfigSubentry(
+ data={"test": "blah"},
+ subentry_id="mock_id",
+ subentry_type="test",
+ title="Test Entry",
+ unique_id=None,
+ ),
+ }
+
+
+async def test_subentry_does_not_support_reconfigure(
+ hass: HomeAssistant, client: TestClient
+) -> None:
+ """Test a subentry flow that does not support reconfigure step."""
+
+ class TestFlow(core_ce.ConfigFlow):
+ class SubentryFlowHandler(core_ce.ConfigSubentryFlow):
+ async def async_step_init(self, user_input=None):
+ raise NotImplementedError
+
+ async def async_step_user(self, user_input=None):
+ raise NotImplementedError
+
+ @classmethod
+ @callback
+ def async_get_supported_subentry_types(
+ cls, config_entry: core_ce.ConfigEntry
+ ) -> dict[str, type[core_ce.ConfigSubentryFlow]]:
+ return {"test": TestFlow.SubentryFlowHandler}
+
+ mock_integration(hass, MockModule("test"))
+ mock_platform(hass, "test.config_flow", None)
+ MockConfigEntry(
+ domain="test",
+ entry_id="test1",
+ source="bla",
+ subentries_data=[
+ core_ce.ConfigSubentryData(
+ data={},
+ subentry_id="mock_id",
+ subentry_type="test",
+ title="Title",
+ unique_id=None,
+ )
+ ],
+ ).add_to_hass(hass)
+ entry = hass.config_entries.async_entries()[0]
+
+ with mock_config_flow("test", TestFlow):
+ url = "/api/config/config_entries/subentries/flow"
+ resp = await client.post(
+ url, json={"handler": [entry.entry_id, "test"], "subentry_id": "mock_id"}
+ )
+
+ assert resp.status == HTTPStatus.BAD_REQUEST
+ response = await resp.json()
+ assert response == {
+ "message": "Handler SubentryFlowHandler doesn't support step reconfigure"
+ }
+
@pytest.mark.parametrize(
("endpoint", "method"),
diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py
index 54aa30b3fcf..d9f9917b9e0 100644
--- a/tests/components/conversation/test_default_agent.py
+++ b/tests/components/conversation/test_default_agent.py
@@ -3178,3 +3178,39 @@ async def test_state_names_are_not_translated(
mock_async_render.call_args.args[0]["state"].state
== weather.ATTR_CONDITION_PARTLYCLOUDY
)
+
+
+async def test_language_with_alternative_code(
+ hass: HomeAssistant, init_components
+) -> None:
+ """Test different codes for the same language."""
+ entity_ids: dict[str, str] = {}
+ for i, (lang_code, sentence, name) in enumerate(
+ (
+ ("no", "slå på lampen", "lampen"), # nb
+ ("no-NO", "slå på lampen", "lampen"), # nb
+ ("iw", "הדליקי את המנורה", "מנורה"), # he
+ )
+ ):
+ if not (entity_id := entity_ids.get(name)):
+ # Reuse entity id for the same name
+ entity_id = f"light.test{i}"
+ entity_ids[name] = entity_id
+
+ hass.states.async_set(entity_id, "off", attributes={ATTR_FRIENDLY_NAME: name})
+ calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
+ await hass.services.async_call(
+ "conversation",
+ "process",
+ {
+ conversation.ATTR_TEXT: sentence,
+ conversation.ATTR_LANGUAGE: lang_code,
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1, f"Failed for {lang_code}, {sentence}"
+ call = calls[0]
+ assert call.domain == LIGHT_DOMAIN
+ assert call.service == "turn_on"
+ assert call.data == {"entity_id": [entity_id]}
diff --git a/tests/components/eheimdigital/conftest.py b/tests/components/eheimdigital/conftest.py
index afb97b97569..ae1bc74df90 100644
--- a/tests/components/eheimdigital/conftest.py
+++ b/tests/components/eheimdigital/conftest.py
@@ -11,6 +11,7 @@ import pytest
from homeassistant.components.eheimdigital.const import DOMAIN
from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@@ -79,3 +80,15 @@ def eheimdigital_hub_mock(
}
eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock
yield eheimdigital_hub_mock
+
+
+async def init_integration(
+ hass: HomeAssistant, mock_config_entry: MockConfigEntry
+) -> None:
+ """Initialize the integration."""
+
+ mock_config_entry.add_to_hass(hass)
+ with patch(
+ "homeassistant.components.eheimdigital.coordinator.asyncio.Event", new=AsyncMock
+ ):
+ await hass.config_entries.async_setup(mock_config_entry.entry_id)
diff --git a/tests/components/eheimdigital/test_climate.py b/tests/components/eheimdigital/test_climate.py
index f1f29ce9d34..4abc33e449e 100644
--- a/tests/components/eheimdigital/test_climate.py
+++ b/tests/components/eheimdigital/test_climate.py
@@ -1,6 +1,6 @@
"""Tests for the climate module."""
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
from eheimdigital.types import (
EheimDeviceType,
@@ -31,6 +31,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
+from .conftest import init_integration
+
from tests.common import MockConfigEntry, snapshot_platform
@@ -45,7 +47,13 @@ async def test_setup_heater(
"""Test climate platform setup for heater."""
mock_config_entry.add_to_hass(hass)
- with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]):
+ with (
+ patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]),
+ patch(
+ "homeassistant.components.eheimdigital.coordinator.asyncio.Event",
+ new=AsyncMock,
+ ),
+ ):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
@@ -69,7 +77,13 @@ async def test_dynamic_new_devices(
eheimdigital_hub_mock.return_value.devices = {}
- with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]):
+ with (
+ patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.CLIMATE]),
+ patch(
+ "homeassistant.components.eheimdigital.coordinator.asyncio.Event",
+ new=AsyncMock,
+ ),
+ ):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert (
@@ -108,9 +122,7 @@ async def test_set_preset_mode(
heater_mode: HeaterMode,
) -> None:
"""Test setting a preset mode."""
- mock_config_entry.add_to_hass(hass)
-
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await init_integration(hass, mock_config_entry)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
@@ -146,9 +158,7 @@ async def test_set_temperature(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting a preset mode."""
- mock_config_entry.add_to_hass(hass)
-
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await init_integration(hass, mock_config_entry)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
@@ -189,9 +199,7 @@ async def test_set_hvac_mode(
active: bool,
) -> None:
"""Test setting a preset mode."""
- mock_config_entry.add_to_hass(hass)
-
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await init_integration(hass, mock_config_entry)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
@@ -231,9 +239,8 @@ async def test_state_update(
heater_mock.is_heating = False
heater_mock.operation_mode = HeaterMode.BIO
- mock_config_entry.add_to_hass(hass)
+ await init_integration(hass, mock_config_entry)
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:02", EheimDeviceType.VERSION_EHEIM_EXT_HEATER
)
diff --git a/tests/components/eheimdigital/test_init.py b/tests/components/eheimdigital/test_init.py
index 211a8b3b6fd..c64997ee372 100644
--- a/tests/components/eheimdigital/test_init.py
+++ b/tests/components/eheimdigital/test_init.py
@@ -8,6 +8,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
+from .conftest import init_integration
+
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
@@ -21,9 +23,8 @@ async def test_remove_device(
) -> None:
"""Test removing a device."""
assert await async_setup_component(hass, "config", {})
- mock_config_entry.add_to_hass(hass)
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await init_integration(hass, mock_config_entry)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E
diff --git a/tests/components/eheimdigital/test_light.py b/tests/components/eheimdigital/test_light.py
index da224979c43..81b63218085 100644
--- a/tests/components/eheimdigital/test_light.py
+++ b/tests/components/eheimdigital/test_light.py
@@ -1,7 +1,7 @@
"""Tests for the light module."""
from datetime import timedelta
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientError
from eheimdigital.types import EheimDeviceType, LightMode
@@ -26,6 +26,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util.color import value_to_brightness
+from .conftest import init_integration
+
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -51,7 +53,13 @@ async def test_setup_classic_led_ctrl(
classic_led_ctrl_mock.tankconfig = tankconfig
- with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]):
+ with (
+ patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]),
+ patch(
+ "homeassistant.components.eheimdigital.coordinator.asyncio.Event",
+ new=AsyncMock,
+ ),
+ ):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
@@ -75,7 +83,13 @@ async def test_dynamic_new_devices(
eheimdigital_hub_mock.return_value.devices = {}
- with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]):
+ with (
+ patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]),
+ patch(
+ "homeassistant.components.eheimdigital.coordinator.asyncio.Event",
+ new=AsyncMock,
+ ),
+ ):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert (
@@ -106,10 +120,8 @@ async def test_turn_off(
classic_led_ctrl_mock: MagicMock,
) -> None:
"""Test turning off the light."""
- mock_config_entry.add_to_hass(hass)
+ await init_integration(hass, mock_config_entry)
- with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]):
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
await mock_config_entry.runtime_data._async_device_found(
"00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E
)
@@ -143,10 +155,8 @@ async def test_turn_on_brightness(
expected_dim_value: int,
) -> None:
"""Test turning on the light with different brightness values."""
- mock_config_entry.add_to_hass(hass)
+ await init_integration(hass, mock_config_entry)
- with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]):
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E
)
@@ -173,12 +183,10 @@ async def test_turn_on_effect(
classic_led_ctrl_mock: MagicMock,
) -> None:
"""Test turning on the light with an effect value."""
- mock_config_entry.add_to_hass(hass)
-
classic_led_ctrl_mock.light_mode = LightMode.MAN_MODE
- with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]):
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
+ await init_integration(hass, mock_config_entry)
+
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E
)
@@ -204,10 +212,8 @@ async def test_state_update(
classic_led_ctrl_mock: MagicMock,
) -> None:
"""Test the light state update."""
- mock_config_entry.add_to_hass(hass)
+ await init_integration(hass, mock_config_entry)
- with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]):
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E
)
@@ -228,10 +234,8 @@ async def test_update_failed(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test an failed update."""
- mock_config_entry.add_to_hass(hass)
+ await init_integration(hass, mock_config_entry)
- with patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.LIGHT]):
- await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E
)
diff --git a/tests/components/elmax/__init__.py b/tests/components/elmax/__init__.py
index e1a6728f1f5..391c3ccbfb2 100644
--- a/tests/components/elmax/__init__.py
+++ b/tests/components/elmax/__init__.py
@@ -30,6 +30,7 @@ MOCK_PANEL_PIN = "000000"
MOCK_WRONG_PANEL_PIN = "000000"
MOCK_PASSWORD = "password"
MOCK_DIRECT_HOST = "1.1.1.1"
+MOCK_DIRECT_HOST_V6 = "fd00::be2:54:34:2"
MOCK_DIRECT_HOST_CHANGED = "2.2.2.2"
MOCK_DIRECT_PORT = 443
MOCK_DIRECT_SSL = True
diff --git a/tests/components/elmax/conftest.py b/tests/components/elmax/conftest.py
index f8cf33ffe1a..02f01036996 100644
--- a/tests/components/elmax/conftest.py
+++ b/tests/components/elmax/conftest.py
@@ -18,6 +18,7 @@ import respx
from . import (
MOCK_DIRECT_HOST,
+ MOCK_DIRECT_HOST_V6,
MOCK_DIRECT_PORT,
MOCK_DIRECT_SSL,
MOCK_PANEL_ID,
@@ -29,6 +30,7 @@ from tests.common import load_fixture
MOCK_DIRECT_BASE_URI = (
f"{'https' if MOCK_DIRECT_SSL else 'http'}://{MOCK_DIRECT_HOST}:{MOCK_DIRECT_PORT}"
)
+MOCK_DIRECT_BASE_URI_V6 = f"{'https' if MOCK_DIRECT_SSL else 'http'}://[{MOCK_DIRECT_HOST_V6}]:{MOCK_DIRECT_PORT}"
@pytest.fixture(autouse=True)
@@ -58,12 +60,16 @@ def httpx_mock_cloud_fixture() -> Generator[respx.MockRouter]:
yield respx_mock
+@pytest.fixture
+def base_uri() -> str:
+ """Configure the base-uri for the respx mock fixtures."""
+ return MOCK_DIRECT_BASE_URI
+
+
@pytest.fixture(autouse=True)
-def httpx_mock_direct_fixture() -> Generator[respx.MockRouter]:
+def httpx_mock_direct_fixture(base_uri: str) -> Generator[respx.MockRouter]:
"""Configure httpx fixture for direct Panel-API communication."""
- with respx.mock(
- base_url=MOCK_DIRECT_BASE_URI, assert_all_called=False
- ) as respx_mock:
+ with respx.mock(base_url=base_uri, assert_all_called=False) as respx_mock:
# Mock Login POST.
login_route = respx_mock.post(f"/api/v2/{ENDPOINT_LOGIN}", name="login")
diff --git a/tests/components/elmax/test_config_flow.py b/tests/components/elmax/test_config_flow.py
index be89ee4d5d6..379cfa98bbc 100644
--- a/tests/components/elmax/test_config_flow.py
+++ b/tests/components/elmax/test_config_flow.py
@@ -1,8 +1,10 @@
"""Tests for the Elmax config flow."""
+from ipaddress import IPv4Address, IPv6Address
from unittest.mock import patch
from elmax_api.exceptions import ElmaxBadLoginError, ElmaxBadPinError, ElmaxNetworkError
+import pytest
from homeassistant import config_entries
from homeassistant.components.elmax.const import (
@@ -28,6 +30,7 @@ from . import (
MOCK_DIRECT_CERT,
MOCK_DIRECT_HOST,
MOCK_DIRECT_HOST_CHANGED,
+ MOCK_DIRECT_HOST_V6,
MOCK_DIRECT_PORT,
MOCK_DIRECT_SSL,
MOCK_PANEL_ID,
@@ -37,12 +40,27 @@ from . import (
MOCK_USERNAME,
MOCK_WRONG_PANEL_PIN,
)
+from .conftest import MOCK_DIRECT_BASE_URI_V6
from tests.common import MockConfigEntry
MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo(
- ip_address=MOCK_DIRECT_HOST,
- ip_addresses=[MOCK_DIRECT_HOST],
+ ip_address=IPv4Address(address=MOCK_DIRECT_HOST),
+ ip_addresses=[IPv4Address(address=MOCK_DIRECT_HOST)],
+ hostname="VideoBox.local",
+ name="VideoBox",
+ port=443,
+ properties={
+ "idl": MOCK_PANEL_ID,
+ "idr": MOCK_PANEL_ID,
+ "v1": "PHANTOM64PRO_GSM 11.9.844",
+ "v2": "4.9.13",
+ },
+ type="_elmax-ssl._tcp",
+)
+MOCK_ZEROCONF_DISCOVERY_INFO_V6 = ZeroconfServiceInfo(
+ ip_address=IPv6Address(address=MOCK_DIRECT_HOST_V6),
+ ip_addresses=[IPv6Address(address=MOCK_DIRECT_HOST_V6)],
hostname="VideoBox.local",
name="VideoBox",
port=443,
@@ -55,8 +73,8 @@ MOCK_ZEROCONF_DISCOVERY_INFO = ZeroconfServiceInfo(
type="_elmax-ssl._tcp",
)
MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo(
- ip_address=MOCK_DIRECT_HOST_CHANGED,
- ip_addresses=[MOCK_DIRECT_HOST_CHANGED],
+ ip_address=IPv4Address(address=MOCK_DIRECT_HOST_CHANGED),
+ ip_addresses=[IPv4Address(address=MOCK_DIRECT_HOST_CHANGED)],
hostname="VideoBox.local",
name="VideoBox",
port=443,
@@ -69,8 +87,8 @@ MOCK_ZEROCONF_DISCOVERY_CHANGED_INFO = ZeroconfServiceInfo(
type="_elmax-ssl._tcp",
)
MOCK_ZEROCONF_DISCOVERY_INFO_NOT_SUPPORTED = ZeroconfServiceInfo(
- ip_address=MOCK_DIRECT_HOST,
- ip_addresses=[MOCK_DIRECT_HOST],
+ ip_address=IPv4Address(MOCK_DIRECT_HOST),
+ ip_addresses=[IPv4Address(MOCK_DIRECT_HOST)],
hostname="VideoBox.local",
name="VideoBox",
port=443,
@@ -194,6 +212,18 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None:
assert result["errors"] is None
+async def test_zeroconf_discovery_ipv6(hass: HomeAssistant) -> None:
+ """Test discovery of Elmax local api panel."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ data=MOCK_ZEROCONF_DISCOVERY_INFO_V6,
+ )
+ assert result["type"] is FlowResultType.FORM
+ assert result["step_id"] == "zeroconf_setup"
+ assert result["errors"] is None
+
+
async def test_zeroconf_setup_show_form(hass: HomeAssistant) -> None:
"""Test discovery shows a form when activated."""
result = await hass.config_entries.flow.async_init(
@@ -230,6 +260,27 @@ async def test_zeroconf_setup(hass: HomeAssistant) -> None:
assert result["type"] is FlowResultType.CREATE_ENTRY
+@pytest.mark.parametrize("base_uri", [MOCK_DIRECT_BASE_URI_V6])
+async def test_zeroconf_ipv6_setup(hass: HomeAssistant) -> None:
+ """Test the successful creation of config entry via discovery flow."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ data=MOCK_ZEROCONF_DISCOVERY_INFO_V6,
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {
+ CONF_ELMAX_PANEL_PIN: MOCK_PANEL_PIN,
+ CONF_ELMAX_MODE_DIRECT_SSL: MOCK_DIRECT_SSL,
+ },
+ )
+
+ await hass.async_block_till_done()
+ assert result["type"] is FlowResultType.CREATE_ENTRY
+
+
async def test_zeroconf_already_configured(hass: HomeAssistant) -> None:
"""Ensure local discovery aborts when same panel is already added to ha."""
MockConfigEntry(
diff --git a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json
index 05a6f265dfb..22aeca50ca0 100644
--- a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json
+++ b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json
@@ -93,7 +93,8 @@
"reserved_soc": 15.0,
"very_low_soc": 5,
"charge_from_grid": true,
- "date": "1695598084"
+ "date": "1695598084",
+ "opt_schedules": true
},
"single_rate": {
"rate": 0.0,
diff --git a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json
index 618b40027b8..52e812f979e 100644
--- a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json
+++ b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json
@@ -235,7 +235,8 @@
"reserved_soc": 0.0,
"very_low_soc": 5,
"charge_from_grid": true,
- "date": "1714749724"
+ "date": "1714749724",
+ "opt_schedules": true
},
"single_rate": {
"rate": 0.0,
diff --git a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json
index 8118630200f..30fbc8d0f4f 100644
--- a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json
+++ b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json
@@ -223,7 +223,8 @@
"reserved_soc": 0.0,
"very_low_soc": 5,
"charge_from_grid": true,
- "date": "1714749724"
+ "date": "1714749724",
+ "opt_schedules": true
},
"single_rate": {
"rate": 0.0,
diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json
index 7affc1bea0d..6cfbfed1e8e 100644
--- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json
+++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json
@@ -427,7 +427,8 @@
"reserved_soc": 15.0,
"very_low_soc": 5,
"charge_from_grid": true,
- "date": "1695598084"
+ "date": "1695598084",
+ "opt_schedules": true
},
"single_rate": {
"rate": 0.0,
diff --git a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json
index ff975b690ed..8c2767e33e5 100644
--- a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json
+++ b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json
@@ -242,7 +242,8 @@
"reserved_soc": 15.0,
"very_low_soc": 5,
"charge_from_grid": true,
- "date": "1695598084"
+ "date": "1695598084",
+ "opt_schedules": true
},
"single_rate": {
"rate": 0.0,
diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json
index 62df69c6d88..15cf2c173cb 100644
--- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json
+++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json
@@ -88,7 +88,8 @@
"reserved_soc": 15.0,
"very_low_soc": 5,
"charge_from_grid": true,
- "date": "1695598084"
+ "date": "1695598084",
+ "opt_schedules": true
},
"single_rate": {
"rate": 0.0,
diff --git a/tests/components/enphase_envoy/test_select.py b/tests/components/enphase_envoy/test_select.py
index e13492c7f54..a81a06a3441 100644
--- a/tests/components/enphase_envoy/test_select.py
+++ b/tests/components/enphase_envoy/test_select.py
@@ -2,6 +2,7 @@
from unittest.mock import AsyncMock, patch
+from pyenphase.exceptions import EnvoyError
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -17,6 +18,7 @@ from homeassistant.components.enphase_envoy.select import (
from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
@@ -157,6 +159,46 @@ async def test_select_relay_modes(
)
+@pytest.mark.parametrize(
+ ("mock_envoy", "relay", "target", "action"),
+ [("envoy_metered_batt_relay", "NC1", "generator_action", "powered")],
+ indirect=["mock_envoy"],
+)
+async def test_update_dry_contact_actions_with_error(
+ hass: HomeAssistant,
+ mock_envoy: AsyncMock,
+ config_entry: MockConfigEntry,
+ target: str,
+ relay: str,
+ action: str,
+) -> None:
+ """Test select platform update dry contact action with error return."""
+ with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]):
+ await setup_integration(hass, config_entry)
+
+ entity_base = f"{Platform.SELECT}."
+
+ assert (dry_contact := mock_envoy.data.dry_contact_settings[relay])
+ assert (name := dry_contact.load_name.lower().replace(" ", "_"))
+
+ test_entity = f"{entity_base}{name}_{target}"
+
+ mock_envoy.update_dry_contact.side_effect = EnvoyError("Test")
+ with pytest.raises(
+ HomeAssistantError,
+ match=f"Failed to execute async_select_option for {test_entity}, host",
+ ):
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {
+ ATTR_ENTITY_ID: test_entity,
+ ATTR_OPTION: action,
+ },
+ blocking=True,
+ )
+
+
@pytest.mark.parametrize(
("mock_envoy", "use_serial"),
[
@@ -197,6 +239,44 @@ async def test_select_storage_modes(
mock_envoy.set_storage_mode.assert_called_once_with(REVERSE_STORAGE_MODE_MAP[mode])
+@pytest.mark.parametrize(
+ ("mock_envoy", "use_serial"),
+ [
+ ("envoy_metered_batt_relay", "enpower_654321"),
+ ("envoy_eu_batt", "envoy_1234"),
+ ],
+ indirect=["mock_envoy"],
+)
+@pytest.mark.parametrize(("mode"), ["backup"])
+async def test_set_storage_modes_with_error(
+ hass: HomeAssistant,
+ mock_envoy: AsyncMock,
+ config_entry: MockConfigEntry,
+ use_serial: str,
+ mode: str,
+) -> None:
+ """Test select platform set storage mode with error return."""
+ with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]):
+ await setup_integration(hass, config_entry)
+
+ test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode"
+
+ mock_envoy.set_storage_mode.side_effect = EnvoyError("Test")
+ with pytest.raises(
+ HomeAssistantError,
+ match=f"Failed to execute async_select_option for {test_entity}, host",
+ ):
+ await hass.services.async_call(
+ SELECT_DOMAIN,
+ SERVICE_SELECT_OPTION,
+ {
+ ATTR_ENTITY_ID: test_entity,
+ ATTR_OPTION: mode,
+ },
+ blocking=True,
+ )
+
+
@pytest.mark.parametrize(
("mock_envoy", "use_serial"),
[
diff --git a/tests/components/geo_json_events/conftest.py b/tests/components/geo_json_events/conftest.py
index 11928e6f012..a4fff4563be 100644
--- a/tests/components/geo_json_events/conftest.py
+++ b/tests/components/geo_json_events/conftest.py
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
import pytest
-from homeassistant.components.geo_json_events import DOMAIN
+from homeassistant.components.geo_json_events.const import DOMAIN
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL
from tests.common import MockConfigEntry
diff --git a/tests/components/geo_json_events/test_config_flow.py b/tests/components/geo_json_events/test_config_flow.py
index fe21bccc7aa..9a52cb599b2 100644
--- a/tests/components/geo_json_events/test_config_flow.py
+++ b/tests/components/geo_json_events/test_config_flow.py
@@ -3,7 +3,7 @@
import pytest
from homeassistant import config_entries
-from homeassistant.components.geo_json_events import DOMAIN
+from homeassistant.components.geo_json_events.const import DOMAIN
from homeassistant.const import (
CONF_LATITUDE,
CONF_LOCATION,
diff --git a/tests/components/geo_json_events/test_init.py b/tests/components/geo_json_events/test_init.py
index e90e663d8b6..0553190395d 100644
--- a/tests/components/geo_json_events/test_init.py
+++ b/tests/components/geo_json_events/test_init.py
@@ -2,8 +2,8 @@
from unittest.mock import patch
-from homeassistant.components.geo_json_events.const import DOMAIN
from homeassistant.components.geo_location import DOMAIN as GEO_LOCATION_DOMAIN
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -24,11 +24,11 @@ async def test_component_unload_config_entry(
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert mock_feed_manager_update.call_count == 1
- assert hass.data[DOMAIN][config_entry.entry_id] is not None
+ assert config_entry.state is ConfigEntryState.LOADED
# Unload config entry.
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
- assert hass.data[DOMAIN].get(config_entry.entry_id) is None
+ assert config_entry.state is ConfigEntryState.NOT_LOADED
async def test_remove_orphaned_entities(
diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png
new file mode 100644
index 00000000000..5bb8c9d9f09
Binary files /dev/null and b/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png differ
diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.png
new file mode 100644
index 00000000000..8e9b046ee05
Binary files /dev/null and b/tests/components/habitica/__snapshots__/test_image/test_image_platform.png differ
diff --git a/tests/components/habitica/test_image.py b/tests/components/habitica/test_image.py
new file mode 100644
index 00000000000..17089f57bd7
--- /dev/null
+++ b/tests/components/habitica/test_image.py
@@ -0,0 +1,99 @@
+"""Tests for the Habitica image platform."""
+
+from collections.abc import Generator
+from datetime import timedelta
+from http import HTTPStatus
+from io import BytesIO
+import sys
+from unittest.mock import AsyncMock, patch
+
+from freezegun.api import FrozenDateTimeFactory
+from habiticalib import HabiticaUserResponse
+import pytest
+from syrupy.assertion import SnapshotAssertion
+from syrupy.extensions.image import PNGImageSnapshotExtension
+
+from homeassistant.components.habitica.const import DOMAIN
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture
+from tests.typing import ClientSessionGenerator
+
+
+@pytest.fixture(autouse=True)
+def image_only() -> Generator[None]:
+ """Enable only the image platform."""
+ with patch(
+ "homeassistant.components.habitica.PLATFORMS",
+ [Platform.IMAGE],
+ ):
+ yield
+
+
+@pytest.mark.skipif(
+ sys.platform != "linux", reason="linux only"
+) # Pillow output on win/mac is different
+async def test_image_platform(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ snapshot: SnapshotAssertion,
+ hass_client: ClientSessionGenerator,
+ habitica: AsyncMock,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test image platform."""
+ freezer.move_to("2024-09-20T22:00:00.000")
+ with patch(
+ "homeassistant.components.habitica.coordinator.BytesIO",
+ ) as avatar:
+ avatar.side_effect = [
+ BytesIO(
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdac\xfc\xcf\xc0\xf0\x1f\x00\x05\x05\x02\x00_\xc8\xf1\xd2\x00\x00\x00\x00IEND\xaeB`\x82"
+ ),
+ BytesIO(
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdacd`\xf8\xff\x1f\x00\x03\x07\x02\x000&\xc7a\x00\x00\x00\x00IEND\xaeB`\x82"
+ ),
+ ]
+
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.state is ConfigEntryState.LOADED
+
+ assert (state := hass.states.get("image.test_user_avatar"))
+ assert state.state == "2024-09-20T22:00:00+00:00"
+
+ access_token = state.attributes["access_token"]
+ assert (
+ state.attributes["entity_picture"]
+ == f"/api/image_proxy/image.test_user_avatar?token={access_token}"
+ )
+
+ client = await hass_client()
+ resp = await client.get(state.attributes["entity_picture"])
+ assert resp.status == HTTPStatus.OK
+
+ assert (await resp.read()) == snapshot(
+ extension_class=PNGImageSnapshotExtension
+ )
+
+ habitica.get_user.return_value = HabiticaUserResponse.from_json(
+ load_fixture("rogue_fixture.json", DOMAIN)
+ )
+
+ freezer.tick(timedelta(seconds=60))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ assert (state := hass.states.get("image.test_user_avatar"))
+ assert state.state == "2024-09-20T22:01:00+00:00"
+
+ resp = await client.get(state.attributes["entity_picture"])
+ assert resp.status == HTTPStatus.OK
+
+ assert (await resp.read()) == snapshot(
+ extension_class=PNGImageSnapshotExtension
+ )
diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py
index 39937a8355f..7bed05a0289 100644
--- a/tests/components/heos/conftest.py
+++ b/tests/components/heos/conftest.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from collections.abc import Iterator
+from collections.abc import Callable, Iterator
from unittest.mock import Mock, patch
from pyheos import (
@@ -130,16 +130,17 @@ def system_info_fixture() -> HeosSystem:
)
-@pytest.fixture(name="players")
-def players_fixture() -> dict[int, HeosPlayer]:
- """Create two mock HeosPlayers."""
- players = {}
- for i in (1, 2):
- player = HeosPlayer(
- player_id=i,
+@pytest.fixture(name="player_factory")
+def player_factory_fixture() -> Callable[[int, str, str], HeosPlayer]:
+ """Return a method that creates players."""
+
+ def factory(player_id: int, name: str, model: str) -> HeosPlayer:
+ """Create a player."""
+ return HeosPlayer(
+ player_id=player_id,
group_id=999,
- name="Test Player" if i == 1 else f"Test Player {i}",
- model="HEOS Drive HS2" if i == 1 else "Speaker",
+ name=name,
+ model=model,
serial="123456",
version="1.0.0",
supported_version=True,
@@ -147,26 +148,37 @@ def players_fixture() -> dict[int, HeosPlayer]:
is_muted=False,
available=True,
state=PlayState.STOP,
- ip_address=f"127.0.0.{i}",
+ ip_address=f"127.0.0.{player_id}",
network=NetworkType.WIRED,
shuffle=False,
repeat=RepeatType.OFF,
volume=25,
+ now_playing_media=HeosNowPlayingMedia(
+ type=MediaType.STATION,
+ song="Song",
+ station="Station Name",
+ album="Album",
+ artist="Artist",
+ image_url="http://",
+ album_id="1",
+ media_id="1",
+ queue_id=1,
+ source_id=10,
+ ),
)
- player.now_playing_media = HeosNowPlayingMedia(
- type=MediaType.STATION,
- song="Song",
- station="Station Name",
- album="Album",
- artist="Artist",
- image_url="http://",
- album_id="1",
- media_id="1",
- queue_id=1,
- source_id=10,
- )
- players[player.player_id] = player
- return players
+
+ return factory
+
+
+@pytest.fixture(name="players")
+def players_fixture(
+ player_factory: Callable[[int, str, str], HeosPlayer],
+) -> dict[int, HeosPlayer]:
+ """Create two mock HeosPlayers."""
+ return {
+ 1: player_factory(1, "Test Player", "HEOS Drive HS2"),
+ 2: player_factory(2, "Test Player 2", "Speaker"),
+ }
@pytest.fixture(name="group")
diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py
index 81acb7b3b8b..87cc8dd7dde 100644
--- a/tests/components/heos/test_init.py
+++ b/tests/components/heos/test_init.py
@@ -1,20 +1,32 @@
"""Tests for the init module."""
+from collections.abc import Callable
from typing import cast
from unittest.mock import Mock
-from pyheos import HeosError, HeosOptions, SignalHeosEvent, SignalType
+from pyheos import (
+ HeosError,
+ HeosOptions,
+ HeosPlayer,
+ PlayerUpdateResult,
+ SignalHeosEvent,
+ SignalType,
+ const,
+)
import pytest
from homeassistant.components.heos.const import DOMAIN
+from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.setup import async_setup_component
from . import MockHeos
from tests.common import MockConfigEntry
+from tests.typing import WebSocketGenerator
async def test_async_setup_entry_loads_platforms(
@@ -226,3 +238,91 @@ async def test_device_id_migration_both_present(
await hass.async_block_till_done(wait_background_tasks=True)
assert device_registry.async_get_device({(DOMAIN, 1)}) is None # type: ignore[arg-type]
assert device_registry.async_get_device({(DOMAIN, "1")}) is not None
+
+
+@pytest.mark.parametrize(
+ ("player_id", "expected_result"),
+ [("1", False), ("5", True)],
+ ids=("Present device", "Stale device"),
+)
+async def test_remove_config_entry_device(
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ device_registry: dr.DeviceRegistry,
+ hass_ws_client: WebSocketGenerator,
+ player_id: str,
+ expected_result: bool,
+) -> None:
+ """Test manually removing an stale device."""
+ assert await async_setup_component(hass, "config", {})
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+
+ device_entry = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, player_id)}
+ )
+
+ ws_client = await hass_ws_client(hass)
+ response = await ws_client.remove_device(device_entry.id, config_entry.entry_id)
+ assert response["success"] == expected_result
+
+
+async def test_reconnected_new_entities_created(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ config_entry: MockConfigEntry,
+ controller: MockHeos,
+ player_factory: Callable[[int, str, str], HeosPlayer],
+) -> None:
+ """Test new entities are created for new players after reconnecting."""
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+
+ # Assert initial entity doesn't exist
+ assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3")
+
+ # Create player
+ players = controller.players.copy()
+ players[3] = player_factory(3, "Test Player 3", "HEOS Link")
+ controller.mock_set_players(players)
+ controller.load_players.return_value = PlayerUpdateResult([3], [], {})
+
+ # Simulate reconnection
+ await controller.dispatcher.wait_send(
+ SignalType.HEOS_EVENT, SignalHeosEvent.CONNECTED
+ )
+ await hass.async_block_till_done()
+
+ # Assert new entity created
+ assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3")
+
+
+async def test_players_changed_new_entities_created(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ config_entry: MockConfigEntry,
+ controller: MockHeos,
+ player_factory: Callable[[int, str, str], HeosPlayer],
+) -> None:
+ """Test new entities are created for new players on change event."""
+ config_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+
+ # Assert initial entity doesn't exist
+ assert not entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3")
+
+ # Create player
+ players = controller.players.copy()
+ players[3] = player_factory(3, "Test Player 3", "HEOS Link")
+ controller.mock_set_players(players)
+
+ # Simulate players changed event
+ await controller.dispatcher.wait_send(
+ SignalType.CONTROLLER_EVENT,
+ const.EVENT_PLAYERS_CHANGED,
+ PlayerUpdateResult([3], [], {}),
+ )
+ await hass.async_block_till_done()
+
+ # Assert new entity created
+ assert entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "3")
diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py
index 4061d5ed863..7b74c2290c3 100644
--- a/tests/components/home_connect/conftest.py
+++ b/tests/components/home_connect/conftest.py
@@ -11,6 +11,7 @@ from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
ArrayOfEvents,
ArrayOfHomeAppliances,
+ ArrayOfOptions,
ArrayOfPrograms,
ArrayOfSettings,
ArrayOfStatus,
@@ -199,13 +200,13 @@ def _get_set_program_side_effect(
return set_program_side_effect
-def _get_set_key_value_side_effect(
- event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str
+def _get_set_setting_side_effect(
+ event_queue: asyncio.Queue[list[EventMessage]],
):
- """Set program options side effect."""
+ """Set settings side effect."""
- async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None:
- event_key = EventKey(kwargs[parameter_key])
+ async def set_settings_side_effect(ha_id: str, *_, **kwargs) -> None:
+ event_key = EventKey(kwargs["setting_key"])
await event_queue.put(
[
EventMessage(
@@ -227,7 +228,48 @@ def _get_set_key_value_side_effect(
]
)
- return set_key_value_side_effect
+ return set_settings_side_effect
+
+
+def _get_set_program_options_side_effect(
+ event_queue: asyncio.Queue[list[EventMessage]],
+):
+ """Set programs side effect."""
+
+ async def set_program_options_side_effect(ha_id: str, *_, **kwargs) -> None:
+ await event_queue.put(
+ [
+ EventMessage(
+ ha_id,
+ EventType.NOTIFY,
+ ArrayOfEvents(
+ [
+ Event(
+ key=EventKey(option.key),
+ raw_key=option.key.value,
+ timestamp=0,
+ level="",
+ handling="",
+ value=option.value,
+ )
+ for option in (
+ cast(ArrayOfOptions, kwargs["array_of_options"]).options
+ if "array_of_options" in kwargs
+ else [
+ Option(
+ kwargs["option_key"],
+ kwargs["value"],
+ unit=kwargs["unit"],
+ )
+ ]
+ )
+ ]
+ ),
+ ),
+ ]
+ )
+
+ return set_program_options_side_effect
async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms:
@@ -319,13 +361,19 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock:
),
)
mock.set_active_program_option = AsyncMock(
- side_effect=_get_set_key_value_side_effect(event_queue, "option_key"),
+ side_effect=_get_set_program_options_side_effect(event_queue),
+ )
+ mock.set_active_program_options = AsyncMock(
+ side_effect=_get_set_program_options_side_effect(event_queue),
)
mock.set_selected_program_option = AsyncMock(
- side_effect=_get_set_key_value_side_effect(event_queue, "option_key"),
+ side_effect=_get_set_program_options_side_effect(event_queue),
+ )
+ mock.set_selected_program_options = AsyncMock(
+ side_effect=_get_set_program_options_side_effect(event_queue),
)
mock.set_setting = AsyncMock(
- side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"),
+ side_effect=_get_set_setting_side_effect(event_queue),
)
mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect)
mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect)
@@ -363,7 +411,9 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock:
mock.stop_program = AsyncMock(side_effect=exception)
mock.set_selected_program = AsyncMock(side_effect=exception)
mock.set_active_program_option = AsyncMock(side_effect=exception)
+ mock.set_active_program_options = AsyncMock(side_effect=exception)
mock.set_selected_program_option = AsyncMock(side_effect=exception)
+ mock.set_selected_program_options = AsyncMock(side_effect=exception)
mock.set_setting = AsyncMock(side_effect=exception)
mock.get_settings = AsyncMock(side_effect=exception)
mock.get_setting = AsyncMock(side_effect=exception)
diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_init.ambr
new file mode 100644
index 00000000000..709621aaefb
--- /dev/null
+++ b/tests/components/home_connect/snapshots/test_init.ambr
@@ -0,0 +1,79 @@
+# serializer version: 1
+# name: test_set_program_and_options[service_call0-set_selected_program]
+ _Call(
+ tuple(
+ 'SIEMENS-HCS03WCH1-7BC6383CF794',
+ ),
+ dict({
+ 'options': list([
+ dict({
+ 'display_value': None,
+ 'key': ,
+ 'name': None,
+ 'unit': None,
+ 'value': 1800,
+ }),
+ ]),
+ 'program_key': ,
+ }),
+ )
+# ---
+# name: test_set_program_and_options[service_call1-start_program]
+ _Call(
+ tuple(
+ 'SIEMENS-HCS03WCH1-7BC6383CF794',
+ ),
+ dict({
+ 'options': list([
+ dict({
+ 'display_value': None,
+ 'key': ,
+ 'name': None,
+ 'unit': None,
+ 'value': 'ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal',
+ }),
+ ]),
+ 'program_key': ,
+ }),
+ )
+# ---
+# name: test_set_program_and_options[service_call2-set_active_program_options]
+ _Call(
+ tuple(
+ 'SIEMENS-HCS03WCH1-7BC6383CF794',
+ ),
+ dict({
+ 'array_of_options': dict({
+ 'options': list([
+ dict({
+ 'display_value': None,
+ 'key': ,
+ 'name': None,
+ 'unit': None,
+ 'value': 'ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent',
+ }),
+ ]),
+ }),
+ }),
+ )
+# ---
+# name: test_set_program_and_options[service_call3-set_selected_program_options]
+ _Call(
+ tuple(
+ 'SIEMENS-HCS03WCH1-7BC6383CF794',
+ ),
+ dict({
+ 'array_of_options': dict({
+ 'options': list([
+ dict({
+ 'display_value': None,
+ 'key': ,
+ 'name': None,
+ 'unit': None,
+ 'value': 35,
+ }),
+ ]),
+ }),
+ }),
+ )
+# ---
diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py
index 009c40b662d..5e309a7446e 100644
--- a/tests/components/home_connect/test_init.py
+++ b/tests/components/home_connect/test_init.py
@@ -1,6 +1,7 @@
"""Test the integration init functionality."""
from collections.abc import Awaitable, Callable
+from http import HTTPStatus
from typing import Any
from unittest.mock import MagicMock, patch
@@ -10,6 +11,7 @@ from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError
import pytest
import requests_mock
import respx
+from syrupy.assertion import SnapshotAssertion
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.home_connect.const import DOMAIN
@@ -22,6 +24,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr, entity_registry as er
+import homeassistant.helpers.issue_registry as ir
from script.hassfest.translations import RE_TRANSLATION_KEY
from .conftest import (
@@ -34,8 +37,9 @@ from .conftest import (
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
+from tests.typing import ClientSessionGenerator
-SERVICE_KV_CALL_PARAMS = [
+DEPRECATED_SERVICE_KV_CALL_PARAMS = [
{
"domain": DOMAIN,
"service": "set_option_active",
@@ -57,6 +61,10 @@ SERVICE_KV_CALL_PARAMS = [
},
"blocking": True,
},
+]
+
+SERVICE_KV_CALL_PARAMS = [
+ *DEPRECATED_SERVICE_KV_CALL_PARAMS,
{
"domain": DOMAIN,
"service": "change_setting",
@@ -125,6 +133,62 @@ SERVICE_APPLIANCE_METHOD_MAPPING = {
"start_program": "start_program",
}
+SERVICE_VALIDATION_ERROR_MAPPING = {
+ "set_option_active": r"Error.*setting.*options.*active.*program.*",
+ "set_option_selected": r"Error.*setting.*options.*selected.*program.*",
+ "change_setting": r"Error.*assigning.*value.*setting.*",
+ "pause_program": r"Error.*executing.*command.*",
+ "resume_program": r"Error.*executing.*command.*",
+ "select_program": r"Error.*selecting.*program.*",
+ "start_program": r"Error.*starting.*program.*",
+}
+
+
+SERVICES_SET_PROGRAM_AND_OPTIONS = [
+ {
+ "domain": DOMAIN,
+ "service": "set_program_and_options",
+ "service_data": {
+ "device_id": "DEVICE_ID",
+ "affects_to": "selected_program",
+ "program": "dishcare_dishwasher_program_eco_50",
+ "b_s_h_common_option_start_in_relative": 1800,
+ },
+ "blocking": True,
+ },
+ {
+ "domain": DOMAIN,
+ "service": "set_program_and_options",
+ "service_data": {
+ "device_id": "DEVICE_ID",
+ "affects_to": "active_program",
+ "program": "consumer_products_coffee_maker_program_beverage_coffee",
+ "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal",
+ },
+ "blocking": True,
+ },
+ {
+ "domain": DOMAIN,
+ "service": "set_program_and_options",
+ "service_data": {
+ "device_id": "DEVICE_ID",
+ "affects_to": "active_program",
+ "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent",
+ },
+ "blocking": True,
+ },
+ {
+ "domain": DOMAIN,
+ "service": "set_program_and_options",
+ "service_data": {
+ "device_id": "DEVICE_ID",
+ "affects_to": "selected_program",
+ "consumer_products_coffee_maker_option_fill_quantity": 35,
+ },
+ "blocking": True,
+ },
+]
+
async def test_entry_setup(
hass: HomeAssistant,
@@ -244,7 +308,7 @@ async def test_client_error(
"service_call",
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
)
-async def test_services(
+async def test_key_value_services(
service_call: dict[str, Any],
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
@@ -273,11 +337,188 @@ async def test_services(
)
+@pytest.mark.parametrize(
+ "service_call",
+ DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
+)
+async def test_programs_and_options_actions_deprecation(
+ service_call: dict[str, Any],
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ config_entry: MockConfigEntry,
+ integration_setup: Callable[[MagicMock], Awaitable[bool]],
+ setup_credentials: None,
+ client: MagicMock,
+ appliance_ha_id: str,
+ issue_registry: ir.IssueRegistry,
+ hass_client: ClientSessionGenerator,
+) -> None:
+ """Test deprecated service keys."""
+ issue_id = "deprecated_set_program_and_option_actions"
+ assert config_entry.state == ConfigEntryState.NOT_LOADED
+ assert await integration_setup(client)
+ assert config_entry.state == ConfigEntryState.LOADED
+
+ device_entry = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, appliance_ha_id)},
+ )
+
+ service_call["service_data"]["device_id"] = device_entry.id
+ await hass.services.async_call(**service_call)
+ await hass.async_block_till_done()
+
+ assert len(issue_registry.issues) == 1
+ issue = issue_registry.async_get_issue(DOMAIN, issue_id)
+ assert issue
+
+ _client = await hass_client()
+ resp = await _client.post(
+ "/api/repairs/issues/fix",
+ json={"handler": DOMAIN, "issue_id": issue.issue_id},
+ )
+ assert resp.status == HTTPStatus.OK
+ flow_id = (await resp.json())["flow_id"]
+ resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}")
+
+ assert not issue_registry.async_get_issue(DOMAIN, issue_id)
+ assert len(issue_registry.issues) == 0
+
+ await hass.services.async_call(**service_call)
+ await hass.async_block_till_done()
+
+ assert len(issue_registry.issues) == 1
+ assert issue_registry.async_get_issue(DOMAIN, issue_id)
+
+ await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Assert the issue is no longer present
+ assert not issue_registry.async_get_issue(DOMAIN, issue_id)
+ assert len(issue_registry.issues) == 0
+
+
+@pytest.mark.parametrize(
+ ("service_call", "called_method"),
+ zip(
+ SERVICES_SET_PROGRAM_AND_OPTIONS,
+ [
+ "set_selected_program",
+ "start_program",
+ "set_active_program_options",
+ "set_selected_program_options",
+ ],
+ strict=True,
+ ),
+)
+async def test_set_program_and_options(
+ service_call: dict[str, Any],
+ called_method: str,
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ config_entry: MockConfigEntry,
+ integration_setup: Callable[[MagicMock], Awaitable[bool]],
+ setup_credentials: None,
+ client: MagicMock,
+ appliance_ha_id: str,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test recognized options."""
+ assert config_entry.state == ConfigEntryState.NOT_LOADED
+ assert await integration_setup(client)
+ assert config_entry.state == ConfigEntryState.LOADED
+
+ device_entry = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, appliance_ha_id)},
+ )
+
+ service_call["service_data"]["device_id"] = device_entry.id
+ await hass.services.async_call(**service_call)
+ await hass.async_block_till_done()
+ method_mock: MagicMock = getattr(client, called_method)
+ assert method_mock.call_count == 1
+ assert method_mock.call_args == snapshot
+
+
+@pytest.mark.parametrize(
+ ("service_call", "error_regex"),
+ zip(
+ SERVICES_SET_PROGRAM_AND_OPTIONS,
+ [
+ r"Error.*selecting.*program.*",
+ r"Error.*starting.*program.*",
+ r"Error.*setting.*options.*active.*program.*",
+ r"Error.*setting.*options.*selected.*program.*",
+ ],
+ strict=True,
+ ),
+)
+async def test_set_program_and_options_exceptions(
+ service_call: dict[str, Any],
+ error_regex: str,
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ config_entry: MockConfigEntry,
+ integration_setup: Callable[[MagicMock], Awaitable[bool]],
+ setup_credentials: None,
+ client_with_exception: MagicMock,
+ appliance_ha_id: str,
+) -> None:
+ """Test recognized options."""
+ assert config_entry.state == ConfigEntryState.NOT_LOADED
+ assert await integration_setup(client_with_exception)
+ assert config_entry.state == ConfigEntryState.LOADED
+
+ device_entry = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, appliance_ha_id)},
+ )
+
+ service_call["service_data"]["device_id"] = device_entry.id
+ with pytest.raises(HomeAssistantError, match=error_regex):
+ await hass.services.async_call(**service_call)
+
+
+async def test_required_program_or_at_least_an_option(
+ hass: HomeAssistant,
+ device_registry: dr.DeviceRegistry,
+ config_entry: MockConfigEntry,
+ integration_setup: Callable[[MagicMock], Awaitable[bool]],
+ setup_credentials: None,
+ client: MagicMock,
+ appliance_ha_id: str,
+) -> None:
+ "Test that the set_program_and_options does raise an exception if no program nor options are set."
+
+ assert config_entry.state == ConfigEntryState.NOT_LOADED
+ assert await integration_setup(client)
+ assert config_entry.state == ConfigEntryState.LOADED
+
+ device_entry = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, appliance_ha_id)},
+ )
+
+ with pytest.raises(
+ ServiceValidationError,
+ ):
+ await hass.services.async_call(
+ DOMAIN,
+ "set_program_and_options",
+ {
+ "device_id": device_entry.id,
+ "affects_to": "selected_program",
+ },
+ True,
+ )
+
+
@pytest.mark.parametrize(
"service_call",
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
)
-async def test_services_exception(
+async def test_services_exception_device_id(
service_call: dict[str, Any],
hass: HomeAssistant,
config_entry: MockConfigEntry,
@@ -348,6 +589,40 @@ async def test_services_appliance_not_found(
await hass.services.async_call(**service_call)
+@pytest.mark.parametrize(
+ "service_call",
+ SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
+)
+async def test_services_exception(
+ service_call: dict[str, Any],
+ hass: HomeAssistant,
+ config_entry: MockConfigEntry,
+ integration_setup: Callable[[MagicMock], Awaitable[bool]],
+ setup_credentials: None,
+ client_with_exception: MagicMock,
+ appliance_ha_id: str,
+ device_registry: dr.DeviceRegistry,
+) -> None:
+ """Raise a ValueError when device id does not match."""
+ assert config_entry.state == ConfigEntryState.NOT_LOADED
+ assert await integration_setup(client_with_exception)
+ assert config_entry.state == ConfigEntryState.LOADED
+
+ device_entry = device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, appliance_ha_id)},
+ )
+
+ service_call["service_data"]["device_id"] = device_entry.id
+
+ service_name = service_call["service"]
+ with pytest.raises(
+ HomeAssistantError,
+ match=SERVICE_VALIDATION_ERROR_MAPPING[service_name],
+ ):
+ await hass.services.async_call(**service_call)
+
+
async def test_entity_migration(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py
index 047de3e452c..52739f16886 100644
--- a/tests/components/homeassistant_hardware/test_util.py
+++ b/tests/components/homeassistant_hardware/test_util.py
@@ -2,7 +2,12 @@
from unittest.mock import AsyncMock, MagicMock, patch
-from homeassistant.components.hassio import AddonError, AddonInfo, AddonState
+from homeassistant.components.hassio import (
+ AddonError,
+ AddonInfo,
+ AddonManager,
+ AddonState,
+)
from homeassistant.components.homeassistant_hardware.helpers import (
async_register_firmware_info_provider,
)
@@ -11,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.util import (
FirmwareInfo,
OwningAddon,
OwningIntegration,
+ get_otbr_addon_firmware_info,
guess_firmware_info,
)
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
@@ -247,3 +253,30 @@ async def test_firmware_info(hass: HomeAssistant) -> None:
)
assert (await firmware_info2.is_running(hass)) is False
+
+
+async def test_get_otbr_addon_firmware_info_failure(hass: HomeAssistant) -> None:
+ """Test getting OTBR addon firmware info failure due to bad API call."""
+
+ otbr_addon_manager = AsyncMock(spec_set=AddonManager)
+ otbr_addon_manager.async_get_addon_info.side_effect = AddonError()
+
+ assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None
+
+
+async def test_get_otbr_addon_firmware_info_failure_bad_options(
+ hass: HomeAssistant,
+) -> None:
+ """Test getting OTBR addon firmware info failure due to bad addon options."""
+
+ otbr_addon_manager = AsyncMock(spec_set=AddonManager)
+ otbr_addon_manager.async_get_addon_info.return_value = AddonInfo(
+ available=True,
+ hostname="core_some_addon_slug",
+ options={}, # `device` is missing
+ state=AddonState.RUNNING,
+ update_available=False,
+ version="1.0.0",
+ )
+
+ assert (await get_otbr_addon_firmware_info(hass, otbr_addon_manager)) is None
diff --git a/tests/components/homee/__init__.py b/tests/components/homee/__init__.py
index a5f8ae00d1e..432e2d68516 100644
--- a/tests/components/homee/__init__.py
+++ b/tests/components/homee/__init__.py
@@ -49,3 +49,12 @@ def build_mock_node(file: str) -> AsyncMock:
mock_node.get_attribute_by_type = attribute_by_type
return mock_node
+
+
+async def async_update_attribute_value(
+ hass: HomeAssistant, attribute: AsyncMock, value: float
+) -> None:
+ """Set the current_value of an attribute and notify hass."""
+ attribute.current_value = value
+ attribute.add_on_changed_listener.call_args_list[0][0][0](attribute)
+ await hass.async_block_till_done()
diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json
new file mode 100644
index 00000000000..f4a7f462218
--- /dev/null
+++ b/tests/components/homee/fixtures/sensors.json
@@ -0,0 +1,715 @@
+{
+ "id": 1,
+ "name": "Test MultiSensor",
+ "profile": 4010,
+ "image": "default",
+ "favorite": 0,
+ "order": 20,
+ "protocol": 1,
+ "routing": 0,
+ "state": 1,
+ "state_changed": 1709379826,
+ "added": 1676199446,
+ "history": 1,
+ "cube_type": 1,
+ "note": "",
+ "services": 5,
+ "phonetic_name": "",
+ "owner": 2,
+ "security": 0,
+ "attributes": [
+ {
+ "id": 1,
+ "node_id": 1,
+ "instance": 1,
+ "minimum": 0,
+ "maximum": 200000,
+ "current_value": 555.591,
+ "target_value": 555.591,
+ "last_value": 555.586,
+ "unit": "kWh",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 4,
+ "state": 1,
+ "last_changed": 1694175270,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 2,
+ "node_id": 1,
+ "instance": 2,
+ "minimum": 0,
+ "maximum": 200000,
+ "current_value": 1730.812,
+ "target_value": 1730.812,
+ "last_value": 1730.679,
+ "unit": "kWh",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 4,
+ "state": 1,
+ "last_changed": 1694175270,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 3,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 100,
+ "current_value": 100.0,
+ "target_value": 100.0,
+ "last_value": 100.0,
+ "unit": "%",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 8,
+ "state": 1,
+ "last_changed": 1709982926,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 4,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 100,
+ "current_value": 100.0,
+ "target_value": 100.0,
+ "last_value": 100.0,
+ "unit": "%",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 8,
+ "state": 1,
+ "last_changed": 1709982926,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 5,
+ "node_id": 1,
+ "instance": 1,
+ "minimum": 0,
+ "maximum": 65000,
+ "current_value": 175.0,
+ "target_value": 175.0,
+ "last_value": 66.0,
+ "unit": "lx",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 11,
+ "state": 1,
+ "last_changed": 1709982926,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 6,
+ "node_id": 1,
+ "instance": 2,
+ "minimum": 1,
+ "maximum": 100,
+ "current_value": 7.0,
+ "target_value": 7.0,
+ "last_value": 8.0,
+ "unit": "klx",
+ "step_value": 0.5,
+ "editable": 0,
+ "type": 11,
+ "state": 1,
+ "last_changed": 1700056686,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 7,
+ "node_id": 1,
+ "instance": 1,
+ "minimum": 0,
+ "maximum": 70,
+ "current_value": 0.249,
+ "target_value": 0.249,
+ "last_value": 0.249,
+ "unit": "A",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 193,
+ "state": 1,
+ "last_changed": 1694175269,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 8,
+ "node_id": 1,
+ "instance": 2,
+ "minimum": 0,
+ "maximum": 70,
+ "current_value": 0.812,
+ "target_value": 0.812,
+ "last_value": 0.252,
+ "unit": "A",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 193,
+ "state": 1,
+ "last_changed": 1694175269,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 9,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 100,
+ "current_value": 70.0,
+ "target_value": 0.0,
+ "last_value": 0.0,
+ "unit": "%",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 18,
+ "state": 1,
+ "last_changed": 1711796633,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 10,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 500,
+ "current_value": 500.0,
+ "target_value": 500.0,
+ "last_value": 500.0,
+ "unit": "lx",
+ "step_value": 2.0,
+ "editable": 0,
+ "type": 301,
+ "state": 1,
+ "last_changed": 1700056347,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 11,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": -40,
+ "maximum": 100,
+ "current_value": 44.12,
+ "target_value": 44.12,
+ "last_value": 44.27,
+ "unit": "°C",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 92,
+ "state": 1,
+ "last_changed": 1694176210,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 12,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 4095,
+ "current_value": 2000.0,
+ "target_value": 0.0,
+ "last_value": 1800.0,
+ "unit": "1/min",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 103,
+ "state": 1,
+ "last_changed": 1736106312,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 13,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 100,
+ "current_value": 47.0,
+ "target_value": 47.0,
+ "last_value": 47.0,
+ "unit": "%",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 96,
+ "state": 1,
+ "last_changed": 1736106312,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 14,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": -64,
+ "maximum": 63,
+ "current_value": 18.0,
+ "target_value": 18.0,
+ "last_value": 18.0,
+ "unit": "°C",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 98,
+ "state": 1,
+ "last_changed": 1736106312,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 15,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 4095,
+ "current_value": 0.0,
+ "target_value": 0.0,
+ "last_value": 0.0,
+ "unit": "1/min",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 102,
+ "state": 1,
+ "last_changed": 1736106312,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 16,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 99999,
+ "current_value": 2490.0,
+ "target_value": 2490.0,
+ "last_value": 2516.0,
+ "unit": "L",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 22,
+ "state": 1,
+ "last_changed": 1735964135,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 17,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 4,
+ "current_value": 4.0,
+ "target_value": 4.0,
+ "last_value": 4.0,
+ "unit": "n/a",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 33,
+ "state": 1,
+ "last_changed": 1735964135,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 18,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 196605,
+ "current_value": 5478.0,
+ "target_value": 5478.0,
+ "last_value": 5478.0,
+ "unit": "h",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 104,
+ "state": 1,
+ "last_changed": 1736105231,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 19,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 100,
+ "current_value": 33.0,
+ "target_value": 33.0,
+ "last_value": 32.0,
+ "unit": "%",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 95,
+ "state": 1,
+ "last_changed": 1736106312,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 20,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": -64,
+ "maximum": 63,
+ "current_value": 17.0,
+ "target_value": 17.0,
+ "last_value": 17.0,
+ "unit": "°C",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 97,
+ "state": 1,
+ "last_changed": 1736106312,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 21,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 100,
+ "current_value": 0.0,
+ "target_value": 0.0,
+ "last_value": 0.0,
+ "unit": "%",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 15,
+ "state": 1,
+ "last_changed": 1694176210,
+ "changed_by": 2,
+ "changed_by_id": 2,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 22,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 100,
+ "current_value": 51.0,
+ "target_value": 51.0,
+ "last_value": 51.0,
+ "unit": "%",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 7,
+ "state": 1,
+ "last_changed": 1709982925,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 23,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": -50,
+ "maximum": 125,
+ "current_value": 20.3,
+ "target_value": 20.3,
+ "last_value": 20.3,
+ "unit": "°C",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 5,
+ "state": 1,
+ "last_changed": 1709982925,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 24,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 600000,
+ "current_value": 3657.822,
+ "target_value": 3657.822,
+ "last_value": 3657.377,
+ "unit": "kWh",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 240,
+ "state": 1,
+ "last_changed": 1694175269,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 25,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 200,
+ "current_value": 2.223,
+ "target_value": 2.223,
+ "last_value": 2.21,
+ "unit": "A",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 272,
+ "state": 1,
+ "last_changed": 1694175269,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 26,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 80000,
+ "current_value": 195.384,
+ "target_value": 195.384,
+ "last_value": 248.412,
+ "unit": "W",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 239,
+ "state": 1,
+ "last_changed": 1694176076,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 27,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 420,
+ "current_value": 239.823,
+ "target_value": 239.823,
+ "last_value": 235.775,
+ "unit": "V",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 51,
+ "state": 1,
+ "last_changed": 1694175269,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 28,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 4,
+ "current_value": 0.0,
+ "target_value": 0.0,
+ "last_value": 3.0,
+ "unit": "n/a",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 135,
+ "state": 1,
+ "last_changed": 1687175680,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 29,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 15,
+ "current_value": 6.0,
+ "target_value": 6.0,
+ "last_value": 6.0,
+ "unit": "",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 173,
+ "state": 1,
+ "last_changed": 1709982926,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 30,
+ "node_id": 1,
+ "instance": 1,
+ "minimum": 0,
+ "maximum": 420,
+ "current_value": 239.823,
+ "target_value": 239.823,
+ "last_value": 239.559,
+ "unit": "V",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 195,
+ "state": 1,
+ "last_changed": 1694175269,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 31,
+ "node_id": 1,
+ "instance": 2,
+ "minimum": 0,
+ "maximum": 420,
+ "current_value": 236.867,
+ "target_value": 236.867,
+ "last_value": 237.634,
+ "unit": "V",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 195,
+ "state": 1,
+ "last_changed": 1694175269,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 32,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 25,
+ "current_value": 2.0,
+ "target_value": 2.0,
+ "last_value": 2.5,
+ "unit": "m/s",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 146,
+ "state": 1,
+ "last_changed": 1700056836,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ },
+ {
+ "id": 33,
+ "node_id": 1,
+ "instance": 0,
+ "minimum": 0,
+ "maximum": 2,
+ "current_value": 0.0,
+ "target_value": 0.0,
+ "last_value": 2.0,
+ "unit": "n/a",
+ "step_value": 1.0,
+ "editable": 0,
+ "type": 10,
+ "state": 1,
+ "last_changed": 1687175680,
+ "changed_by": 1,
+ "changed_by_id": 0,
+ "based_on": 1,
+ "data": "",
+ "name": ""
+ }
+ ]
+}
diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr
new file mode 100644
index 00000000000..3101723232e
--- /dev/null
+++ b/tests/components/homee/snapshots/test_sensor.ambr
@@ -0,0 +1,1811 @@
+# serializer version: 1
+# name: test_sensor_snapshot[sensor.test_multisensor_battery-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.test_multisensor_battery',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Battery',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'battery',
+ 'unique_id': '00055511EECC-1-3',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_battery-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'battery',
+ 'friendly_name': 'Test MultiSensor Battery',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_battery',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '100.0',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.test_multisensor_battery_2',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Battery',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'battery',
+ 'unique_id': '00055511EECC-1-4',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'battery',
+ 'friendly_name': 'Test MultiSensor Battery',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_battery_2',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '100.0',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_current_1-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_current_1',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Current 1',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'current_instance',
+ 'unique_id': '00055511EECC-1-7',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_current_1-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'current',
+ 'friendly_name': 'Test MultiSensor Current 1',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_current_1',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0.249',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_current_2-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_current_2',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Current 2',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'current_instance',
+ 'unique_id': '00055511EECC-1-8',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_current_2-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'current',
+ 'friendly_name': 'Test MultiSensor Current 2',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_current_2',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0.812',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_dawn-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_dawn',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Dawn',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'dawn',
+ 'unique_id': '00055511EECC-1-10',
+ 'unit_of_measurement': 'lx',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_dawn-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'illuminance',
+ 'friendly_name': 'Test MultiSensor Dawn',
+ 'state_class': ,
+ 'unit_of_measurement': 'lx',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_dawn',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '500.0',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_device_temperature-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_device_temperature',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Device temperature',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'device_temperature',
+ 'unique_id': '00055511EECC-1-11',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_device_temperature-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'temperature',
+ 'friendly_name': 'Test MultiSensor Device temperature',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_device_temperature',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '44.12',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_energy_1-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_energy_1',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Energy 1',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'energy_instance',
+ 'unique_id': '00055511EECC-1-1',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_energy_1-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'energy',
+ 'friendly_name': 'Test MultiSensor Energy 1',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_energy_1',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '555.591',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_energy_2-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_energy_2',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Energy 2',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'energy_instance',
+ 'unique_id': '00055511EECC-1-2',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_energy_2-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'energy',
+ 'friendly_name': 'Test MultiSensor Energy 2',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_energy_2',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '1730.812',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_exhaust_motor_speed-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.test_multisensor_exhaust_motor_speed',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Exhaust motor speed',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'exhaust_motor_revs',
+ 'unique_id': '00055511EECC-1-12',
+ 'unit_of_measurement': 'rpm',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_exhaust_motor_speed-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'Test MultiSensor Exhaust motor speed',
+ 'state_class': ,
+ 'unit_of_measurement': 'rpm',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_exhaust_motor_speed',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '2000.0',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_humidity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_humidity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Humidity',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'humidity',
+ 'unique_id': '00055511EECC-1-22',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_humidity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'humidity',
+ 'friendly_name': 'Test MultiSensor Humidity',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_humidity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '51.0',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_illuminance_1',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Illuminance 1',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'brightness_instance',
+ 'unique_id': '00055511EECC-1-5',
+ 'unit_of_measurement': 'lx',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_1-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'illuminance',
+ 'friendly_name': 'Test MultiSensor Illuminance 1',
+ 'state_class': ,
+ 'unit_of_measurement': 'lx',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_illuminance_1',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '175.0',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_illuminance_2',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Illuminance 2',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'brightness_instance',
+ 'unique_id': '00055511EECC-1-6',
+ 'unit_of_measurement': 'lx',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_illuminance_2-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'illuminance',
+ 'friendly_name': 'Test MultiSensor Illuminance 2',
+ 'state_class': ,
+ 'unit_of_measurement': 'lx',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_illuminance_2',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '7000.0',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_indoor_humidity-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_indoor_humidity',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Indoor humidity',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'indoor_humidity',
+ 'unique_id': '00055511EECC-1-13',
+ 'unit_of_measurement': '%',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_indoor_humidity-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'humidity',
+ 'friendly_name': 'Test MultiSensor Indoor humidity',
+ 'state_class': ,
+ 'unit_of_measurement': '%',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_indoor_humidity',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '47.0',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_indoor_temperature-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_indoor_temperature',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Indoor temperature',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'indoor_temperature',
+ 'unique_id': '00055511EECC-1-14',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_indoor_temperature-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'temperature',
+ 'friendly_name': 'Test MultiSensor Indoor temperature',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_indoor_temperature',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '18.0',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_intake_motor_speed-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': ,
+ 'entity_id': 'sensor.test_multisensor_intake_motor_speed',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': None,
+ 'original_icon': None,
+ 'original_name': 'Intake motor speed',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'intake_motor_revs',
+ 'unique_id': '00055511EECC-1-15',
+ 'unit_of_measurement': 'rpm',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_intake_motor_speed-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'friendly_name': 'Test MultiSensor Intake motor speed',
+ 'state_class': ,
+ 'unit_of_measurement': 'rpm',
+ }),
+ 'context': ,
+ 'entity_id': 'sensor.test_multisensor_intake_motor_speed',
+ 'last_changed': ,
+ 'last_reported': ,
+ 'last_updated': ,
+ 'state': '0.0',
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_level-entry]
+ EntityRegistryEntrySnapshot({
+ 'aliases': set({
+ }),
+ 'area_id': None,
+ 'capabilities': dict({
+ 'state_class': ,
+ }),
+ 'config_entry_id': ,
+ 'config_subentry_id': ,
+ 'device_class': None,
+ 'device_id': ,
+ 'disabled_by': None,
+ 'domain': 'sensor',
+ 'entity_category': None,
+ 'entity_id': 'sensor.test_multisensor_level',
+ 'has_entity_name': True,
+ 'hidden_by': None,
+ 'icon': None,
+ 'id': ,
+ 'labels': set({
+ }),
+ 'name': None,
+ 'options': dict({
+ }),
+ 'original_device_class': ,
+ 'original_icon': None,
+ 'original_name': 'Level',
+ 'platform': 'homee',
+ 'previous_unique_id': None,
+ 'supported_features': 0,
+ 'translation_key': 'level',
+ 'unique_id': '00055511EECC-1-16',
+ 'unit_of_measurement': ,
+ })
+# ---
+# name: test_sensor_snapshot[sensor.test_multisensor_level-state]
+ StateSnapshot({
+ 'attributes': ReadOnlyDict({
+ 'device_class': 'volume_storage',
+ 'friendly_name': 'Test MultiSensor Level',
+ 'state_class': ,
+ 'unit_of_measurement': ,
+ }),
+ 'context':