Compare commits

..

27 Commits

Author SHA1 Message Date
Erik 522bfaff07 Add method _should_include to EntityConditionBase 2026-05-06 08:00:13 +02:00
Erik Montnemery 10084c8c0c Add trigger timer.time_remaining (#169763) 2026-05-05 23:54:49 -04:00
Erik Montnemery 7e8f5365ce Add method _should_include to EntityTriggerBase (#169837) 2026-05-06 00:50:22 +02:00
Erik Montnemery 65f9dcd7bf Improve condition test helper docstrings (#169871) 2026-05-06 00:32:37 +02:00
epenet 4c8f37fef6 Bump tuya-device-handlers to 0.0.19 (#169848) 2026-05-05 22:23:14 +02:00
Erik Montnemery d1295fa260 Validate yaml matches implementation in automation options_supported tests (#169798) 2026-05-05 22:20:28 +02:00
Diogo Gomes 9b2eea920f Add V2C LED lights (#169778)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 22:19:59 +02:00
Petro31 c81c1cbb14 Remove legacy weather template entities (#169734) 2026-05-05 22:18:46 +02:00
Erik Montnemery 11ee05874a Improve trigger test helper docstrings (#169869) 2026-05-05 22:11:08 +02:00
puddly 7d7c47b56e Bump serialx to 1.7.0 (#169867) 2026-05-05 21:06:30 +02:00
epenet dc4210595f Fix flaky test_set_scan_interval_via_platform (#169856)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:49:15 +02:00
Freekers 7430366d9b Enable web search support for gpt-5-nano (#169710) 2026-05-05 20:47:52 +03:00
Crocmagnon ae3bd54ca7 switchbot: remove unwanted future annotations import preventing build on all new PRs (#169863) 2026-05-05 19:40:27 +02:00
Glenn Waters e3ce7fb000 Bump elkm1-lib to 2.2.15 (#169843)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 18:50:17 +02:00
epenet 9286b517d3 Add ruff rule to prevent __future__ annotations (#169852)
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 18:42:10 +02:00
elgris 4d62e4765d Add a number entity to set display time offset (in minutes) for Switchbot Meter CO2 devices. (#169603)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 17:45:47 +02:00
Michael Hansen ea55ef90a6 Bump intents to 2026.5.5 (#169855) 2026-05-05 18:22:22 +03:00
epenet 751765b97b Cleanup from __future__ import annotations (#169850) 2026-05-05 16:35:21 +02:00
Denis Shulyaka 11ed1fe20f Return the requested format for OpenAI TTS (#169839)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 10:28:20 -04:00
Joost Lekkerkerker 9b5166769a Add Sensereo matter brand (#169836) 2026-05-05 10:18:01 -04:00
Joost Lekkerkerker 70c2a323ce Add Zunzunbee Zigbee brand (#169838) 2026-05-05 10:17:49 -04:00
Ronald van der Meer 0ec5d6b273 Add API version to Duco diagnostics for support triage (#169802) 2026-05-05 15:48:43 +02:00
Robert Resch b1e8dc2ebb Remove show_advanced_options in Ecovacs and always show all options (#169831) 2026-05-05 15:42:08 +02:00
Artur Pragacz e144804d28 Fix async_unload teardown race in scripts (#169562) 2026-05-05 15:03:37 +02:00
cengelen 8521a49986 Bump growatt server to 2.1.0 (#169495)
Co-authored-by: Copilot <copilot@github.com>
2026-05-05 14:11:50 +02:00
Raj Laud 3587f9613f Bump victron-ble-ha-parser to 0.7.0 (#169736)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 13:57:19 +02:00
Jan Bouwhuis 2f1dd3a817 Deprecate MQTT protocol versions 3.x and migrate to version 5 (#169759)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-05 13:43:18 +02:00
127 changed files with 3216 additions and 2384 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "sensereo",
"name": "Sensereo",
"iot_standards": ["matter"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "zunzunbee",
"name": "Zunzunbee",
"iot_standards": ["zigbee"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.4.1"]
"requirements": ["serialx==1.7.0"]
}
@@ -899,12 +899,13 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script or conditions as they will
# be reused.
await self._async_disable()
return
self.action_script.async_unload()
await self._async_disable(stop_actions=False)
await self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
from broadlink.exceptions import BroadlinkException
from broadlink.remote import pulses_to_data as _bl_pulses_to_data
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -43,8 +43,8 @@ async def async_setup_entry(
async_add_entities([BroadlinkInfraredEntity(device)])
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEmitterEntity):
"""Broadlink infrared emitter entity."""
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEntity):
"""Broadlink infrared transmitter entity."""
_attr_has_entity_name = True
_attr_translation_key = "infrared_emitter"
+16 -5
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -65,6 +65,20 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
return self._hass.config.units.temperature_unit
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -88,10 +102,7 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity": ClimateTargetHumidityCondition,
"target_temperature": ClimateTargetTemperatureCondition,
}
+31 -10
View File
@@ -8,14 +8,15 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
@@ -75,6 +76,32 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
"""Trigger for climate target temperature value crossing a threshold."""
class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for climate target humidity triggers."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class ClimateTargetHumidityChangedTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for climate target humidity value changes."""
class ClimateTargetHumidityCrossedThresholdTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for climate target humidity value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
@@ -83,14 +110,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
}
@@ -13,6 +13,9 @@ from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .coordinator import DucoConfigEntry
# MAC addresses and serial numbers are redacted because a Duco installer or
# manufacturer could cross-reference them against an installation registry to
# identify the physical location of the device.
TO_REDACT = {
CONF_HOST,
"mac",
@@ -31,9 +34,15 @@ async def async_get_config_entry_diagnostics(
coordinator = entry.runtime_data
board = asdict(coordinator.board_info)
# `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage.
board.pop("time")
if board["public_api_version"] is None:
board.pop("public_api_version")
if board["software_version"] is None:
board.pop("software_version")
try:
api_info_obj = await coordinator.client.async_get_api_info()
lan_info = await coordinator.client.async_get_lan_info()
duco_diags = await coordinator.client.async_get_diagnostics()
write_remaining = await coordinator.client.async_get_write_req_remaining()
@@ -43,10 +52,15 @@ async def async_get_config_entry_diagnostics(
translation_key="connection_error",
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
api_info["reported_api_version"] = api_info_obj.reported_api_version
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"api_info": api_info,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node)
@@ -137,10 +137,6 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if not self.show_advanced_options:
return await self.async_step_auth()
if user_input:
self._mode = user_input[CONF_MODE]
return await self.async_step_auth()
+1 -1
View File
@@ -16,5 +16,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["elkm1_lib"],
"requirements": ["elkm1-lib==2.2.13"]
"requirements": ["elkm1-lib==2.2.15"]
}
+3 -1
View File
@@ -199,7 +199,9 @@ class ElkSetting(ElkSensor):
_element: Setting
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
self._attr_native_value = self._element.value
self._attr_native_value = (
None if self._element.value is None else str(self._element.value)
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
+3 -5
View File
@@ -5,7 +5,7 @@ import logging
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.core import callback
from .entity import (
@@ -19,10 +19,8 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
class EsphomeInfraredEntity(
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
):
"""ESPHome infrared emitter entity using native API."""
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
"""ESPHome infrared entity using native API."""
@callback
def _on_device_update(self) -> None:
@@ -1,7 +1,5 @@
"""DataUpdateCoordinator for Fluss+ integration."""
from __future__ import annotations
import asyncio
from typing import Any
@@ -596,7 +596,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_charge_times(settings_data=self.data)
return self.api.sph_read_ac_charge_times(
self.device_id, settings_data=self.data
)
async def read_ac_discharge_times(self) -> dict:
"""Read AC discharge time settings from SPH device cache."""
@@ -609,4 +611,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self.data:
await self.async_refresh()
return self.api.sph_read_ac_discharge_times(settings_data=self.data)
return self.api.sph_read_ac_discharge_times(
self.device_id, settings_data=self.data
)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"quality_scale": "silver",
"requirements": ["growattServer==1.9.0"]
"requirements": ["growattServer==2.1.0"]
}
+14 -169
View File
@@ -1,20 +1,15 @@
"""Provides functionality to interact with infrared devices."""
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from enum import StrEnum
import logging
from typing import final
from infrared_protocols import Command as InfraredCommand
from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import EntityDescription
@@ -28,30 +23,15 @@ from .const import DOMAIN
__all__ = [
"DOMAIN",
"InfraredEmitterEntity",
"InfraredEmitterEntityDescription",
"InfraredReceivedSignal",
"InfraredReceiverEntity",
"InfraredReceiverEntityDescription",
"InfraredEntity",
"InfraredEntityDescription",
"async_get_emitters",
"async_get_receivers",
"async_send_command",
"async_subscribe_receiver",
]
class InfraredDeviceClass(StrEnum):
"""Device class for infrared entities."""
RECEIVER = "receiver"
EMITTER = "emitter"
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[
EntityComponent[InfraredEmitterEntity | InfraredReceiverEntity]
] = HassKey(DOMAIN)
DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
@@ -60,9 +40,9 @@ SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the infrared domain."""
component = hass.data[DATA_COMPONENT] = EntityComponent[
InfraredEmitterEntity | InfraredReceiverEntity
](_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)
return True
@@ -85,25 +65,7 @@ def async_get_emitters(hass: HomeAssistant) -> list[str]:
if component is None:
return []
return [
entity.entity_id
for entity in component.entities
if isinstance(entity, InfraredEmitterEntity)
]
@callback
def async_get_receivers(hass: HomeAssistant) -> list[str]:
"""Get all infrared receiver entity IDs."""
component = hass.data.get(DATA_COMPONENT)
if component is None:
return []
return [
entity.entity_id
for entity in component.entities
if isinstance(entity, InfraredReceiverEntity)
]
return [entity.entity_id for entity in component.entities]
async def async_send_command(
@@ -127,7 +89,7 @@ async def async_send_command(
ent_reg = er.async_get(hass)
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
entity = component.get_entity(entity_id)
if entity is None or not isinstance(entity, InfraredEmitterEntity):
if entity is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
@@ -140,62 +102,14 @@ async def async_send_command(
await entity.async_send_command_internal(command)
@callback
def async_subscribe_receiver(
hass: HomeAssistant,
entity_id_or_uuid: str,
signal_callback: Callable[[InfraredReceivedSignal], None],
) -> CALLBACK_TYPE:
"""Subscribe to IR signals from a specific receiver entity.
Raises:
HomeAssistantError: If the receiver entity is not found.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
ent_reg = er.async_get(hass)
try:
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
except vol.Invalid as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="receiver_not_found",
translation_placeholders={"entity_id": entity_id_or_uuid},
) from err
entity = component.get_entity(entity_id)
if entity is None or not isinstance(entity, InfraredReceiverEntity):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="receiver_not_found",
translation_placeholders={"entity_id": entity_id},
)
return entity.async_subscribe_received_signal(signal_callback)
class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared entities."""
@dataclass(frozen=True, slots=True)
class InfraredReceivedSignal:
"""Represents a received IR signal."""
class InfraredEntity(RestoreEntity):
"""Base class for infrared transmitter entities."""
timings: list[int]
modulation: int | None = None
class InfraredEmitterEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared emitter entities."""
class InfraredEmitterEntity(RestoreEntity):
"""Base class for infrared emitter entities."""
entity_description: InfraredEmitterEntityDescription
_attr_device_class: InfraredDeviceClass = InfraredDeviceClass.EMITTER
entity_description: InfraredEntityDescription
_attr_should_poll = False
_attr_state: None = None
@@ -235,72 +149,3 @@ class InfraredEmitterEntity(RestoreEntity):
Raises:
HomeAssistantError: If transmission fails.
"""
class InfraredReceiverEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared receiver entities."""
class InfraredReceiverEntity(RestoreEntity):
"""Base class for infrared receiver entities."""
entity_description: InfraredReceiverEntityDescription
_attr_device_class: InfraredDeviceClass = InfraredDeviceClass.RECEIVER
_attr_should_poll = False
_attr_state: None = None
__last_signal_received: str | None = None
@cached_property
def __signal_callbacks(self) -> set[Callable[[InfraredReceivedSignal], None]]:
"""Subscriber callback set, lazily initialized on first access."""
return set()
@property
@final
def state(self) -> str | None:
"""Return the entity state."""
return self.__last_signal_received
@final
async def async_internal_added_to_hass(self) -> None:
"""Call when the infrared entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
self.__last_signal_received = state.state
@final
def _handle_received_signal(self, signal: InfraredReceivedSignal) -> None:
"""Handle a received IR signal.
Should not be overridden. To be called by platform implementations when a
signal is received.
"""
self.__last_signal_received = dt_util.utcnow().isoformat(
timespec="milliseconds"
)
self.async_write_ha_state()
for signal_callback in tuple(self.__signal_callbacks):
try:
signal_callback(signal)
except Exception:
_LOGGER.exception("Error in signal callback for %s", self.entity_id)
@callback
def async_subscribe_received_signal(
self,
signal_callback: Callable[[InfraredReceivedSignal], None],
) -> CALLBACK_TYPE:
"""Subscribe to received IR signals.
Returns a callable to unsubscribe.
"""
callbacks = self.__signal_callbacks
callbacks.add(signal_callback)
@callback
def remove_callback() -> None:
callbacks.discard(signal_callback)
return remove_callback
@@ -2,9 +2,6 @@
"entity_component": {
"_": {
"default": "mdi:led-on"
},
"receiver": {
"default": "mdi:led-off"
}
}
}
@@ -1,21 +1,10 @@
{
"entity_component": {
"_": {
"name": "Infrared emitter"
},
"receiver": {
"name": "Infrared receiver"
}
},
"exceptions": {
"component_not_loaded": {
"message": "Infrared component not loaded"
},
"entity_not_found": {
"message": "Infrared entity `{entity_id}` not found"
},
"receiver_not_found": {
"message": "Infrared receiver entity `{entity_id}` not found"
}
}
}
@@ -77,10 +77,9 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None:
existing_intents = hass.data[DOMAIN]
for intent_type, conf in existing_intents.items():
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_stop()
conf[CONF_ACTION].async_unload()
intent.async_remove(hass, intent_type)
if isinstance(conf.get(CONF_ACTION), script.Script):
await conf[CONF_ACTION].async_unload()
if not new_config or DOMAIN not in new_config:
hass.data[DOMAIN] = {}
@@ -55,7 +55,6 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
Platform.FAN,
Platform.EVENT,
Platform.IMAGE,
Platform.INFRARED,
Platform.LAWN_MOWER,
@@ -9,7 +9,6 @@ from homeassistant import data_entry_flow
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
async_get_receivers,
)
from homeassistant.config_entries import (
ConfigEntry,
@@ -23,7 +22,7 @@ from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from .const import CONF_INFRARED_ENTITY_ID, CONF_INFRARED_RECEIVER_ENTITY_ID, DOMAIN
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
CONF_BOOLEAN = "bool"
CONF_INT = "int"
@@ -179,33 +178,25 @@ class InfraredFanSubentryFlowHandler(ConfigSubentryFlow):
) -> SubentryFlowResult:
"""User flow to add an infrared fan."""
entities = async_get_emitters(self.hass)
if not entities:
return self.async_abort(reason="no_emitters")
if user_input is not None:
title = user_input.pop("name")
return self.async_create_entry(data=user_input, title=title)
emitter_entities = async_get_emitters(self.hass)
if not emitter_entities:
return self.async_abort(reason="no_emitters")
schema_dict: dict[vol.Marker, Any] = {
vol.Required("name"): str,
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=emitter_entities,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("name"): str,
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=entities,
)
),
}
),
}
receiver_entities = async_get_receivers(self.hass)
if receiver_entities:
schema_dict[vol.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID)] = (
EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=receiver_entities,
)
)
)
return self.async_show_form(step_id="user", data_schema=vol.Schema(schema_dict))
)
@@ -6,7 +6,6 @@ from homeassistant.util.hass_dict import HassKey
DOMAIN = "kitchen_sink"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)
@@ -1,126 +0,0 @@
"""Demo platform that offers a fake infrared receiver event entity."""
from homeassistant.components.event import EventEntity
from homeassistant.components.infrared import (
InfraredReceivedSignal,
async_subscribe_receiver,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
callback,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_INFRARED_RECEIVER_ENTITY_ID, DOMAIN
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo infrared event platform."""
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "infrared_fan":
continue
if subentry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID) is None:
continue
async_add_entities(
[
DemoInfraredEvent(
subentry_id=subentry_id,
device_name=subentry.title,
infrared_receiver_entity_id=subentry.data[
CONF_INFRARED_RECEIVER_ENTITY_ID
],
)
],
config_subentry_id=subentry_id,
)
class DemoInfraredEvent(EventEntity):
"""Representation of a demo infrared event entity."""
_attr_has_entity_name = True
_attr_name = "Received IR Event"
_attr_should_poll = False
_attr_event_types = ["unknown"]
def __init__(
self, subentry_id: str, device_name: str, infrared_receiver_entity_id: str
) -> None:
"""Initialize the demo infrared event entity."""
self._receiver_entity_id = infrared_receiver_entity_id
self._attr_unique_id = subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry_id)}, name=device_name
)
async def async_added_to_hass(self) -> None:
"""Subscribe to the IR receiver when added to hass."""
await super().async_added_to_hass()
@callback
def _handle_signal(signal: InfraredReceivedSignal) -> None:
"""Handle a received IR signal."""
self._trigger_event("unknown", {"raw_code": signal.timings})
self.async_write_ha_state()
remove_signal_subscription: CALLBACK_TYPE | None = None
@callback
def _async_unsubscribe_receiver() -> None:
"""Unsubscribe from the current IR receiver."""
nonlocal remove_signal_subscription
if remove_signal_subscription is None:
return
remove_signal_subscription()
remove_signal_subscription = None
@callback
def _async_update_receiver_subscription(write_state: bool = True) -> None:
"""Update the IR receiver subscription when availability changes."""
nonlocal remove_signal_subscription
ir_state = self.hass.states.get(self._receiver_entity_id)
receiver_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
if not receiver_available:
_async_unsubscribe_receiver()
elif remove_signal_subscription is None:
remove_signal_subscription = async_subscribe_receiver(
self.hass, self._receiver_entity_id, _handle_signal
)
if self._attr_available == receiver_available:
return
self._attr_available = receiver_available
if write_state:
self.async_write_ha_state()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
_async_update_receiver_subscription()
_async_update_receiver_subscription(write_state=False)
self.async_on_remove(_async_unsubscribe_receiver)
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._receiver_entity_id], _async_ir_state_changed
)
)
@@ -3,27 +3,16 @@
import infrared_protocols
from homeassistant.components import persistent_notification
from homeassistant.components.infrared import (
InfraredEmitterEntity,
InfraredReceivedSignal,
InfraredReceiverEntity,
)
from homeassistant.components.infrared import InfraredEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
PARALLEL_UPDATES = 0
INFRARED_COMMAND_SIGNAL = f"{DOMAIN}_infrared_command_signal"
async def async_setup_entry(
hass: HomeAssistant,
@@ -33,60 +22,37 @@ async def async_setup_entry(
"""Set up the demo infrared platform."""
async_add_entities(
[
DemoInfraredEmitter(
unique_id="ir_emitter",
entity_name="Infrared Emitter",
),
DemoInfraredReceiver(
unique_id="ir_receiver",
entity_name="Infrared Receiver",
DemoInfrared(
unique_id="ir_transmitter",
device_name="IR Blaster",
entity_name="Infrared Transmitter",
),
]
)
# pylint: disable=hass-enforce-class-module
class DemoInfraredEntityBase(Entity):
class DemoInfrared(InfraredEntity):
"""Representation of a demo infrared entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, unique_id: str, entity_name: str) -> None:
def __init__(
self,
unique_id: str,
device_name: str,
entity_name: str,
) -> None:
"""Initialize the demo infrared entity."""
super().__init__()
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, "infrared")}, name="IR Blaster"
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
self._attr_name = entity_name
class DemoInfraredEmitter(DemoInfraredEntityBase, InfraredEmitterEntity):
"""Representation of a demo infrared emitter entity."""
async def async_send_command(self, command: infrared_protocols.Command) -> None:
"""Send an IR command."""
raw_timings = command.get_raw_timings()
persistent_notification.async_create(
self.hass, str(raw_timings), title="Infrared Command Sent"
)
async_dispatcher_send(self.hass, INFRARED_COMMAND_SIGNAL, raw_timings)
class DemoInfraredReceiver(DemoInfraredEntityBase, InfraredReceiverEntity):
"""Representation of a demo infrared receiver entity."""
@callback
def _on_dispatcher_signal(self, raw_timings: list[int]) -> None:
"""Handle received infrared command signal."""
self._handle_received_signal(InfraredReceivedSignal(timings=raw_timings))
async def async_added_to_hass(self) -> None:
"""Called when entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass, INFRARED_COMMAND_SIGNAL, self._on_dispatcher_signal
)
self.hass, str(command.get_raw_timings()), title="Infrared Command"
)
@@ -35,7 +35,7 @@
},
"infrared_fan": {
"abort": {
"no_emitters": "No infrared emitter entities found. Please set up an infrared device first."
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"entry_type": "Infrared fan",
"initiate_flow": {
@@ -44,11 +44,10 @@
"step": {
"user": {
"data": {
"infrared_entity_id": "Infrared emitter",
"infrared_receiver_entity_id": "Infrared receiver",
"infrared_entity_id": "Infrared transmitter",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "Select an infrared emitter to control the fan."
"description": "Select an infrared transmitter to control the fan."
}
}
}
@@ -33,27 +33,7 @@ class _MediaPlayerMutedStateTriggerBase(EntityTriggerBase):
excluded from the check - otherwise an "all" check would never
pass when there are media players without volume support.
"""
return state.state not in self._excluded_states and self._has_volume_attributes(
state
)
def check_all_match(self, entity_ids: set[str]) -> bool:
"""Check if all mutable entity states match."""
return all(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and self._should_include(state)
)
def count_matches(self, entity_ids: set[str]) -> int:
"""Count matching mutable entities."""
return sum(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and self._should_include(state)
)
return super()._should_include(state) and self._has_volume_attributes(state)
def is_muted(self, state: State) -> bool:
"""Check if the media player is muted."""
@@ -1,7 +1,5 @@
"""Mitsubishi Comfort integration for Home Assistant."""
from __future__ import annotations
import asyncio
import logging
@@ -1,7 +1,5 @@
"""Climate entity for Mitsubishi Comfort integration."""
from __future__ import annotations
from typing import Any
from mitsubishi_comfort import FanSpeed, IndoorUnit, Mode, VaneDirection
@@ -1,7 +1,5 @@
"""Config flow for Mitsubishi Comfort integration."""
from __future__ import annotations
import logging
from typing import Any
@@ -1,7 +1,5 @@
"""DataUpdateCoordinator for Mitsubishi Comfort devices."""
from __future__ import annotations
import logging
from mitsubishi_comfort import IndoorUnit, KumoStation
@@ -1,7 +1,5 @@
"""Base entity for Mitsubishi Comfort integration."""
from __future__ import annotations
from mitsubishi_comfort import IndoorUnit, KumoStation
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+29 -1
View File
@@ -11,7 +11,12 @@ import voluptuous as vol
from homeassistant import config as conf_util
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM, SERVICE_RELOAD
from homeassistant.const import (
CONF_DISCOVERY,
CONF_PLATFORM,
CONF_PROTOCOL,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import (
ConfigValidationError,
@@ -27,6 +32,7 @@ from homeassistant.helpers import (
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
@@ -73,12 +79,14 @@ from .const import (
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_PREFIX,
DEFAULT_PROTOCOL,
DEFAULT_QOS,
DEFAULT_RETAIN,
DOMAIN,
ENTITY_PLATFORMS,
ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
PROTOCOL_311,
TEMPLATE_ERRORS,
Platform,
)
@@ -424,6 +432,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load a config entry."""
mqtt_data: MqttData
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL:
broker: str = entry.data[CONF_BROKER]
async_create_issue(
hass,
DOMAIN,
"protocol_5_migration",
issue_domain=DOMAIN,
is_fixable=True,
breaks_in_ha_version="2027.1.0",
severity=IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol",
data={
"entry_id": entry.entry_id,
"broker": broker,
"protocol": protocol,
},
translation_placeholders={"broker": broker, "protocol": protocol},
translation_key="protocol_5_migration",
)
async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
"""Set up the MQTT client."""
# Fetch configuration
+9 -3
View File
@@ -63,7 +63,6 @@ from .const import (
DEFAULT_ENCODING,
DEFAULT_KEEPALIVE,
DEFAULT_PORT,
DEFAULT_PROTOCOL,
DEFAULT_QOS,
DEFAULT_TRANSPORT,
DEFAULT_WILL,
@@ -74,6 +73,7 @@ from .const import (
MQTT_PROCESSED_SUBSCRIPTIONS,
PROTOCOL_5,
PROTOCOL_31,
PROTOCOL_311,
TRANSPORT_WEBSOCKETS,
)
from .models import (
@@ -331,7 +331,10 @@ class MqttClientSetup:
config = self._config
clean_session: bool | None = None
if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31:
# If no protocol setting is set in the config entry data
# we assume the config was migrated from YAML, and the
# protocol version is defaulting to legacy version 3.1.1.
if (protocol := config.get(CONF_PROTOCOL, PROTOCOL_311)) == PROTOCOL_31:
proto = mqtt.MQTTv31
clean_session = True
elif protocol == PROTOCOL_5:
@@ -420,7 +423,10 @@ class MQTT:
self.loop = hass.loop
self.config_entry = config_entry
self.conf = conf
self.is_mqttv5 = conf.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_5
# If no protocol setting is set in the config entry data
# we assume the config was migrated from YAML, and the
# protocol version is defaulting to legacy version 3.1.1.
self.is_mqttv5 = conf.get(CONF_PROTOCOL, PROTOCOL_311) == PROTOCOL_5
self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict(
set
@@ -4073,6 +4073,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
config: dict[str, Any] = {
CONF_BROKER: addon_discovery_config[CONF_HOST],
CONF_PORT: addon_discovery_config[CONF_PORT],
CONF_PROTOCOL: DEFAULT_PROTOCOL,
CONF_USERNAME: addon_discovery_config.get(CONF_USERNAME),
CONF_PASSWORD: addon_discovery_config.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
@@ -4301,6 +4302,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
data: dict[str, Any] = self._hassio_discovery.copy()
data[CONF_BROKER] = data.pop(CONF_HOST)
data[CONF_PROTOCOL] = DEFAULT_PROTOCOL
can_connect = await self.hass.async_add_executor_job(
try_connection,
data,
@@ -4312,6 +4314,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
data={
CONF_BROKER: data[CONF_BROKER],
CONF_PORT: data[CONF_PORT],
CONF_PROTOCOL: DEFAULT_PROTOCOL,
CONF_USERNAME: data.get(CONF_USERNAME),
CONF_PASSWORD: data.get(CONF_PASSWORD),
CONF_DISCOVERY: DEFAULT_DISCOVERY,
@@ -5178,6 +5181,8 @@ async def async_get_broker_settings( # noqa: C901
) -> bool:
"""Additional validation on broker settings for better error messages."""
if CONF_PROTOCOL not in validated_user_input:
validated_user_input[CONF_PROTOCOL] = DEFAULT_PROTOCOL
# Get current certificate settings from config entry
certificate: str | None = (
"auto"
+2 -2
View File
@@ -347,14 +347,14 @@ REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT"
PROTOCOL_31 = "3.1"
PROTOCOL_311 = "3.1.1"
PROTOCOL_5 = "5"
SUPPORTED_PROTOCOLS = [PROTOCOL_31, PROTOCOL_311, PROTOCOL_5]
SUPPORTED_PROTOCOLS = [PROTOCOL_5, PROTOCOL_311, PROTOCOL_31]
TRANSPORT_TCP = "tcp"
TRANSPORT_WEBSOCKETS = "websockets"
DEFAULT_PORT = 1883
DEFAULT_KEEPALIVE = 60
DEFAULT_PROTOCOL = PROTOCOL_311
DEFAULT_PROTOCOL = PROTOCOL_5
DEFAULT_TRANSPORT = TRANSPORT_TCP
DEFAULT_BIRTH = {
+63 -8
View File
@@ -6,10 +6,16 @@ import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.const import CONF_PORT, CONF_PROTOCOL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
from .config_flow import try_connection
from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5
URL_MQTT_BROKER_CONFIGURATION = (
"https://www.home-assistant.io/integrations/mqtt/#broker-configuration"
)
class MQTTDeviceEntryMigration(RepairsFlow):
@@ -50,6 +56,55 @@ class MQTTDeviceEntryMigration(RepairsFlow):
)
class MQTTProtocolV5Migration(RepairsFlow):
"""Handler to migrate to MQTT protocol version 5."""
def __init__(self, entry_id: str, broker: str, protocol: str) -> None:
"""Initialize the flow."""
self.entry_id = entry_id
self.broker = broker
self.protocol = protocol
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 await self.async_step_confirm()
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."""
if user_input is not None:
entry = self.hass.config_entries.async_get_entry(self.entry_id)
if TYPE_CHECKING:
assert entry is not None
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Try the connection with protocol version 5
if await self.hass.async_add_executor_job(
try_connection,
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
):
self.hass.config_entries.async_update_entry(entry, data=new_entry_data)
return self.async_create_entry(data={})
return self.async_abort(
reason="mqtt_broker_migration_to_v5_failed",
description_placeholders={
"broker": self.broker,
"protocol": self.protocol,
"url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION,
},
)
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders={"broker": self.broker, "protocol": self.protocol},
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
@@ -58,13 +113,13 @@ async def async_create_fix_flow(
"""Create flow."""
if TYPE_CHECKING:
assert data is not None
entry_id = data["entry_id"]
subentry_id = data["subentry_id"]
name = data["name"]
if TYPE_CHECKING:
assert isinstance(entry_id, str)
assert isinstance(subentry_id, str)
assert isinstance(name, str)
entry_id: str = data["entry_id"] # type: ignore[assignment]
if issue_id == "protocol_5_migration":
broker: str = data["broker"] # type: ignore[assignment]
protocol: str = data["protocol"] # type: ignore[assignment]
return MQTTProtocolV5Migration(entry_id, broker, protocol)
subentry_id: str = data["subentry_id"] # type: ignore[assignment]
name: str = data["name"] # type: ignore[assignment]
return MQTTDeviceEntryMigration(
entry_id=entry_id,
subentry_id=subentry_id,
@@ -1120,6 +1120,20 @@
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/config/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue.",
"title": "Invalid config found for MQTT {domain} item"
},
"protocol_5_migration": {
"fix_flow": {
"abort": {
"mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue."
},
"step": {
"confirm": {
"description": "Home Assistant is migrating to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.",
"title": "MQTT protocol change required"
}
}
},
"title": "Deprecated MQTT protocol {protocol} in use"
},
"subentry_migration_discovery": {
"fix_flow": {
"step": {
@@ -72,7 +72,6 @@ UNSUPPORTED_MODELS: list[str] = [
]
UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [
"gpt-5-nano",
"gpt-3.5",
"gpt-4-turbo",
"gpt-4.1-nano",
@@ -2,7 +2,7 @@
from collections.abc import Mapping
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from openai import OpenAIError
from propcache.api import cached_property
@@ -164,14 +164,15 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity):
client = self.entry.runtime_data
response_format = options[ATTR_PREFERRED_FORMAT]
if response_format not in self._supported_formats:
# common aliases
if response_format == "ogg":
response_format = "opus"
elif response_format == "raw":
response_format = "pcm"
else:
response_format = self.default_options[ATTR_PREFERRED_FORMAT]
if response_format in ("ogg", "oga"):
codec: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "opus"
elif response_format == "raw":
response_format = codec = "pcm"
elif response_format not in self._supported_formats:
response_format = self.default_options[ATTR_PREFERRED_FORMAT]
codec = response_format
else:
codec = response_format
try:
async with client.audio.speech.with_streaming_response.create(
@@ -180,7 +181,7 @@ class OpenAITTSEntity(TextToSpeechEntity, OpenAIBaseLLMEntity):
input=message,
instructions=str(options.get(CONF_PROMPT)),
speed=options.get(CONF_TTS_SPEED, RECOMMENDED_TTS_SPEED),
response_format=response_format,
response_format=codec,
) as response:
response_data = bytearray()
async for chunk in response.iter_bytes():
@@ -1,7 +1,5 @@
"""Services for the Overkiz integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.cover import (
+6 -6
View File
@@ -766,14 +766,14 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Stop script and remove service when it will be removed from HA."""
await self.script.async_stop()
if not self.registry_entry or self.registry_entry.entity_id == self.entity_id:
# Entity ID not changed, unload the script as it will not be reused.
self.script.async_unload()
# remove service
self.hass.services.async_remove(DOMAIN, self._attr_unique_id)
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script as it will be reused.
await self.script.async_stop()
return
await self.script.async_unload()
@websocket_api.websocket_command({"type": "script/config", "entity_id": str})
def websocket_config(
@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/serial",
"iot_class": "local_polling",
"requirements": ["serialx==1.4.1"]
"requirements": ["serialx==1.7.0"]
}
+3 -3
View File
@@ -3,7 +3,7 @@
from pysmlight.exceptions import SmlightError
from pysmlight.models import IRPayload
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,8 +27,8 @@ async def async_setup_entry(
async_add_entities([SmInfraredEntity(coordinator)])
class SmInfraredEntity(SmEntity, InfraredEmitterEntity):
"""Representation of a SLZB-Ultima infrared emitter."""
class SmInfraredEntity(SmEntity, InfraredEntity):
"""Representation of a SLZB-Ultima infrared."""
_attr_translation_key = "infrared_emitter"
@@ -56,6 +56,7 @@ PLATFORMS_BY_TYPE = {
SupportedModels.HYGROMETER.value: [Platform.SENSOR],
SupportedModels.HYGROMETER_CO2.value: [
Platform.BUTTON,
Platform.NUMBER,
Platform.SENSOR,
Platform.SELECT,
],
@@ -0,0 +1,77 @@
"""Number platform for SwitchBot devices."""
from datetime import timedelta
import logging
import switchbot
from switchbot import SwitchbotOperationError
from switchbot.devices.meter_pro import MAX_TIME_OFFSET
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity, exception_handler
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(days=7)
_LOGGER = logging.getLogger(__name__)
_SECONDS_IN_MINUTE = 60
_MAX_TIME_OFFSET_MINUTES = MAX_TIME_OFFSET // _SECONDS_IN_MINUTE
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot number platform."""
coordinator = entry.runtime_data
if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2):
async_add_entities(
[SwitchBotMeterProCO2DisplayTimeOffsetNumber(coordinator)], True
)
class SwitchBotMeterProCO2DisplayTimeOffsetNumber(SwitchbotEntity, NumberEntity):
"""Number entity to set the time offset for Meter Pro CO2 devices."""
_device: switchbot.SwitchbotMeterProCO2
_attr_device_class = NumberDeviceClass.DURATION
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "display_time_offset"
_attr_native_min_value = -_MAX_TIME_OFFSET_MINUTES
_attr_native_max_value = _MAX_TIME_OFFSET_MINUTES
_attr_native_step = 1.0
_attr_native_unit_of_measurement = UnitOfTime.MINUTES
_attr_should_poll = True
_attr_entity_registry_enabled_default = False
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
"""Initialize the number entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.base_unique_id}_display_time_offset"
@exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set the time offset."""
_LOGGER.debug("Setting time offset to %s minutes for %s", value, self._address)
offset_minutes = round(value)
offset_seconds = offset_minutes * _SECONDS_IN_MINUTE
await self._device.set_time_offset(offset_seconds)
self._attr_native_value = offset_minutes
self.async_write_ha_state()
async def async_update(self) -> None:
"""Fetch the latest time offset from the device."""
try:
offset_seconds = await self._device.get_time_offset()
except SwitchbotOperationError:
_LOGGER.debug(
"Failed to update time offset for %s", self._address, exc_info=True
)
return
self._attr_native_value = round(offset_seconds / _SECONDS_IN_MINUTE)
@@ -272,6 +272,11 @@
}
}
},
"number": {
"display_time_offset": {
"name": "Display time offset"
}
},
"select": {
"time_format": {
"name": "Time format",
+16 -33
View File
@@ -25,19 +25,12 @@ from homeassistant.components.weather import (
ATTR_CONDITION_WINDY_VARIANT,
DOMAIN as WEATHER_DOMAIN,
ENTITY_ID_FORMAT,
PLATFORM_SCHEMA as WEATHER_PLATFORM_SCHEMA,
Forecast,
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_NAME,
CONF_TEMPERATURE_UNIT,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import (
@@ -230,18 +223,6 @@ WEATHER_MODERN_YAML_SCHEMA = WEATHER_COMMON_MODERN_SCHEMA.extend(
make_template_entity_common_modern_schema(WEATHER_DOMAIN, DEFAULT_NAME).schema
)
PLATFORM_SCHEMA = (
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
.extend(WEATHER_COMMON_LEGACY_SCHEMA.schema)
.extend(WEATHER_PLATFORM_SCHEMA.schema)
)
WEATHER_CONFIG_ENTRY_SCHEMA = WEATHER_COMMON_MODERN_SCHEMA.extend(
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema
)
@@ -257,21 +238,23 @@ async def async_setup_platform(
# Rewrite the configuration options to modern keys.
if discovery_info is None:
# Legacy
config = rewrite_legacy_to_modern_config(hass, config, LEGACY_FIELDS)
else:
# Modern and Trigger
entity_configs: list[ConfigType] = discovery_info["entities"]
modified_entity_configs = []
for entity_config in entity_configs:
entity_config = rewrite_legacy_to_modern_config(
hass, entity_config, LEGACY_FIELDS
)
_LOGGER.warning(
"Template weather entities can only be configured under template:"
)
return
modified_entity_configs.append(entity_config)
# Modern and Trigger
entity_configs: list[ConfigType] = discovery_info["entities"]
modified_entity_configs = []
for entity_config in entity_configs:
entity_config = rewrite_legacy_to_modern_config(
hass, entity_config, LEGACY_FIELDS
)
if modified_entity_configs:
discovery_info["entities"] = modified_entity_configs
modified_entity_configs.append(entity_config)
if modified_entity_configs:
discovery_info["entities"] = modified_entity_configs
await async_setup_template_platform(
hass,
@@ -45,6 +45,9 @@
},
"started": {
"trigger": "mdi:timer-play"
},
"time_remaining": {
"trigger": "mdi:timer-alert-outline"
}
}
}
@@ -183,6 +183,15 @@
}
},
"name": "Timer started"
},
"time_remaining": {
"description": "Triggers when one or more timers reach a specific remaining time.",
"fields": {
"remaining": {
"name": "Time remaining"
}
},
"name": "Timer time remaining"
}
}
}
+155 -4
View File
@@ -1,10 +1,160 @@
"""Provides triggers for timers."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from datetime import datetime, timedelta
from typing import cast, override
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, CONF_OPTIONS
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec, filter_by_domain_specs
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
Trigger,
TriggerActionRunner,
TriggerConfig,
make_entity_target_state_trigger,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from . import ATTR_FINISHES_AT, ATTR_LAST_TRANSITION, DOMAIN, STATUS_ACTIVE
CONF_REMAINING = "remaining"
TIME_REMAINING_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_REMAINING): cv.positive_time_period_dict,
},
}
)
class TimeRemainingTrigger(Trigger):
"""Trigger when a timer has a specific amount of time remaining."""
_domain_specs: dict[str, DomainSpec] = {DOMAIN: DomainSpec()}
_schema = TIME_REMAINING_TRIGGER_SCHEMA
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, cls._schema(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the time remaining trigger."""
super().__init__(hass, config)
assert config.target is not None
self._target = config.target
options = config.options or {}
self._remaining: timedelta = options[CONF_REMAINING]
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities to timer domain."""
return filter_by_domain_specs(self._hass, self._domain_specs, entities)
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
scheduled: dict[str, CALLBACK_TYPE] = {}
@callback
def schedule_for_state(
entity_id: str,
to_state: State | None,
context: Context | None,
) -> None:
"""Schedule a fire for an active timer state, if applicable."""
if to_state is None:
return
if to_state.state != STATUS_ACTIVE:
return
finishes_at_str = to_state.attributes.get(ATTR_FINISHES_AT)
if finishes_at_str is None:
return
finishes_at = dt_util.parse_datetime(finishes_at_str)
if finishes_at is None:
return
fire_at = finishes_at - self._remaining
if fire_at <= dt_util.utcnow():
return
@callback
def fire_trigger(now: datetime) -> None:
"""Fire the trigger."""
scheduled.pop(entity_id, None)
run_action(
{
ATTR_ENTITY_ID: entity_id,
"to_state": to_state,
"remaining": self._remaining,
},
f"time remaining of {entity_id}",
context,
)
scheduled[entity_id] = async_track_point_in_utc_time(
self._hass, fire_trigger, fire_at
)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and schedule trigger."""
event = target_state_change_data.state_change_event
entity_id: str = event.data["entity_id"]
to_state = event.data["new_state"]
# Cancel any previously scheduled callback for this entity
if entity_id in scheduled:
scheduled.pop(entity_id)()
schedule_for_state(entity_id, to_state, event.context)
@callback
def on_entities_update(added: set[str], removed: set[str]) -> None:
"""Handle changes to the tracked entity set."""
for entity_id in removed:
if entity_id in scheduled:
scheduled.pop(entity_id)()
for entity_id in added:
state = self._hass.states.get(entity_id)
schedule_for_state(entity_id, state, state.context if state else None)
unsub = async_track_target_selector_state_change_event(
self._hass,
self._target,
state_change_listener,
self.entity_filter,
on_entities_update,
)
@callback
def async_remove() -> None:
"""Remove state listeners."""
unsub()
for cancel in scheduled.values():
cancel()
scheduled.clear()
return async_remove
from . import ATTR_LAST_TRANSITION, DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"cancelled": make_entity_target_state_trigger(
@@ -22,6 +172,7 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_LAST_TRANSITION)}, "started"
),
"time_remaining": TimeRemainingTrigger,
}
@@ -20,3 +20,13 @@ finished: *trigger_common
paused: *trigger_common
restarted: *trigger_common
started: *trigger_common
time_remaining:
target:
entity:
domain: timer
fields:
remaining:
required: true
selector:
duration:
+1 -1
View File
@@ -44,7 +44,7 @@
"iot_class": "cloud_push",
"loggers": ["tuya_sharing"],
"requirements": [
"tuya-device-handlers==0.0.18",
"tuya-device-handlers==0.0.19",
"tuya-device-sharing-sdk==0.2.8"
]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.4.1"]
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.0"]
}
+1
View File
@@ -10,6 +10,7 @@ from .coordinator import V2CConfigEntry, V2CUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.LIGHT,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
+8
View File
@@ -1,5 +1,13 @@
{
"entity": {
"light": {
"light_led": {
"default": "mdi:led-on"
},
"logo_led": {
"default": "mdi:led-on"
}
},
"sensor": {
"battery_power": {
"default": "mdi:home-battery"
+126
View File
@@ -0,0 +1,126 @@
"""Light platform for V2C EVSE LEDs."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from pytrydan import Trydan, TrydanData
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ColorMode,
LightEntity,
LightEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .coordinator import V2CConfigEntry, V2CUpdateCoordinator
from .entity import V2CBaseEntity
LED_ON_VALUE = 100
LED_OFF_VALUE = 0
BRIGHTNESS_SCALE = (LED_OFF_VALUE, LED_ON_VALUE)
@dataclass(frozen=True, kw_only=True)
class V2CLightEntityDescription(LightEntityDescription):
"""Describes V2C EVSE light entity."""
supports_brightness: bool = False
value_fn: Callable[[TrydanData], int | None]
update_fn: Callable[[Trydan, int], Coroutine[Any, Any, None]]
TRYDAN_LIGHTS = (
V2CLightEntityDescription(
key="light_led",
translation_key="light_led",
entity_registry_enabled_default=False,
value_fn=lambda evse_data: evse_data.light_led,
update_fn=lambda evse, value: evse.light_led(value),
),
V2CLightEntityDescription(
key="logo_led",
translation_key="logo_led",
supports_brightness=True,
value_fn=lambda evse_data: evse_data.logo_led,
update_fn=lambda evse, value: evse.logo_led(value),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: V2CConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up V2C Trydan light platform."""
coordinator = config_entry.runtime_data
data = coordinator.data
assert data is not None
async_add_entities(
V2CLightEntity(
coordinator,
description,
config_entry.entry_id,
)
for description in TRYDAN_LIGHTS
if description.value_fn(data) is not None
)
class V2CLightEntity(V2CBaseEntity, LightEntity):
"""Representation of V2C EVSE LED light entity."""
entity_description: V2CLightEntityDescription
def __init__(
self,
coordinator: V2CUpdateCoordinator,
description: V2CLightEntityDescription,
entry_id: str,
) -> None:
"""Initialize the V2C light entity."""
super().__init__(coordinator, description)
self._attr_unique_id = f"{entry_id}_{description.key}"
self._attr_color_mode = (
ColorMode.BRIGHTNESS if description.supports_brightness else ColorMode.ONOFF
)
self._attr_supported_color_modes = {self._attr_color_mode}
@property
def brightness(self) -> int | None:
"""Return the light brightness."""
if not self.entity_description.supports_brightness:
return None
value = self.entity_description.value_fn(self.data)
if value is None:
return None
return value_to_brightness(BRIGHTNESS_SCALE, value)
@property
def is_on(self) -> bool | None:
"""Return true if the light is on."""
value = self.entity_description.value_fn(self.data)
if value is None:
return None
return value > 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the LED."""
value = LED_ON_VALUE
if self.entity_description.supports_brightness:
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
value = round(brightness_to_value(BRIGHTNESS_SCALE, brightness))
if brightness:
value = max(value, 1)
await self.entity_description.update_fn(self.coordinator.evse, value)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the LED."""
await self.entity_description.update_fn(self.coordinator.evse, LED_OFF_VALUE)
await self.coordinator.async_request_refresh()
@@ -30,6 +30,14 @@
"name": "Ready"
}
},
"light": {
"light_led": {
"name": "Light LED"
},
"logo_led": {
"name": "Logo LED"
}
},
"number": {
"intensity": {
"name": "Intensity"
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["victron-ble-ha-parser==0.6.3"]
"requirements": ["victron-ble-ha-parser==0.7.0"]
}
@@ -127,11 +127,11 @@ class WolSwitch(SwitchEntity):
"""Clean up script when removing from Home Assistant."""
if self._off_script is None:
return
await self._off_script.async_stop()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script as it will be reused.
await self._off_script.async_stop()
return
self._off_script.async_unload()
await self._off_script.async_unload()
def turn_off(self, **kwargs: Any) -> None:
"""Turn the device off if an off action is present."""
@@ -1075,7 +1075,7 @@ async def handle_execute_script(
)
return
finally:
script_obj.async_unload()
await script_obj.async_unload()
connection.send_result(
msg["id"],
{
+12
View File
@@ -6146,6 +6146,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"sensereo": {
"name": "Sensereo",
"iot_standards": [
"matter"
]
},
"sensibo": {
"name": "Sensibo",
"integration_type": "hub",
@@ -8242,6 +8248,12 @@
"zwave"
]
},
"zunzunbee": {
"name": "Zunzunbee",
"iot_standards": [
"zigbee"
]
},
"zwave_js": {
"name": "Z-Wave",
"integration_type": "hub",
-2
View File
@@ -1,7 +1,5 @@
"""Helper to check the configuration file."""
from __future__ import annotations
from collections import OrderedDict
import logging
import os
+15 -2
View File
@@ -437,6 +437,9 @@ class EntityConditionBase(Condition):
"""Base class for entity conditions."""
_domain_specs: Mapping[str, DomainSpec]
_excluded_states: Final[frozenset[str]] = frozenset(
{STATE_UNAVAILABLE, STATE_UNKNOWN}
)
_schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
# When True, indirect target expansion (via device/area/floor) skips
# entities with an entity_category.
@@ -501,7 +504,7 @@ class EntityConditionBase(Condition):
"""
if (
_state is not None
and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and self._should_include(_state)
and self.is_valid_state(_state)
):
# Only record the time if not already tracked, to avoid
@@ -566,6 +569,16 @@ class EntityConditionBase(Condition):
return entity_state.state
return entity_state.attributes.get(domain_spec.value_source)
def _should_include(self, _state: State) -> bool:
"""Check if an entity should participate in any/all checks.
The default implementation excludes only entities whose state.state
is in `_excluded_states` (unavailable / unknown). Subclasses can
override to also exclude entities that lack the optional capability
the condition relies on.
"""
return _state.state not in self._excluded_states
@abc.abstractmethod
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected state(s)."""
@@ -622,7 +635,7 @@ class EntityConditionBase(Condition):
_state
for entity_id in filtered_entity_ids
if (_state := self._hass.states.get(entity_id))
and _state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and self._should_include(_state)
]
return self._matcher(entity_states)
+28 -15
View File
@@ -1519,7 +1519,7 @@ class Script:
if self._unloaded:
return
try:
self.async_unload()
self._async_unload()
except Exception:
_LOGGER.exception("Error while unloading script")
@@ -1787,11 +1787,16 @@ class Script:
started_action: Callable[..., Any] | None = None,
) -> ScriptRunResult | None:
"""Run script."""
# Prevent running an unloaded script
if self._unloaded:
raise RuntimeError(
f"Cannot run script '{self.name}' after it has been unloaded"
)
if DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED in self._hass.data:
self._log("Home Assistant is shutting down, starting script blocked")
return None
# The fences above rely on there being no await between these checks
# and the _runs.append below, so that setting either flag is
# sufficient to block new runs from being added.
if context is None:
self._log(
@@ -1799,11 +1804,6 @@ class Script:
)
context = Context()
# Prevent spawning new script runs when Home Assistant is shutting down
if DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED in self._hass.data:
self._log("Home Assistant is shutting down, starting script blocked")
return None
# Prevent spawning new script runs if not allowed by script mode
if self.is_running:
if self.script_mode == SCRIPT_MODE_SINGLE:
@@ -1913,7 +1913,20 @@ class Script:
return
await asyncio.shield(create_eager_task(self._async_stop(aws, update_state)))
def async_unload(self) -> None:
async def async_unload(self) -> None:
"""Unload the script, stopping any in-flight runs first.
Blocks new runs immediately, stops any in-flight runs, then cleans
up all resources.
"""
if self._unloaded:
return
# Set the flag before stopping so async_run rejects new runs.
self._unloaded = True
await self.async_stop()
self._async_unload()
def _async_unload(self) -> None:
"""Unload the script, cleaning up all resources.
Unloads cached conditions, and recursively unloads sub-scripts.
@@ -1935,31 +1948,31 @@ class Script:
self._condition_cache.clear()
for sub_script in self._repeat_script.values():
sub_script.async_unload()
sub_script._async_unload() # noqa: SLF001
self._repeat_script.clear()
# Conditions in _choose_data and _if_data are the same objects as in
# _condition_cache, so they're already unloaded above. Only unload scripts.
for choose_data in self._choose_data.values():
for _conditions, sub_script in choose_data["choices"]:
sub_script.async_unload()
sub_script._async_unload() # noqa: SLF001
if choose_data["default"] is not None:
choose_data["default"].async_unload()
choose_data["default"]._async_unload() # noqa: SLF001
self._choose_data.clear()
for if_data in self._if_data.values():
if_data["if_then"].async_unload()
if_data["if_then"]._async_unload() # noqa: SLF001
if if_data["if_else"] is not None:
if_data["if_else"].async_unload()
if_data["if_else"]._async_unload() # noqa: SLF001
self._if_data.clear()
for scripts in self._parallel_scripts.values():
for sub_script in scripts:
sub_script.async_unload()
sub_script._async_unload() # noqa: SLF001
self._parallel_scripts.clear()
for sub_script in self._sequence_scripts.values():
sub_script.async_unload()
sub_script._async_unload() # noqa: SLF001
self._sequence_scripts.clear()
async def _async_get_condition(self, config: ConfigType) -> ConditionChecker:
+46 -26
View File
@@ -397,23 +397,36 @@ class EntityTriggerBase(Trigger):
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
def check_all_match(self, entity_ids: set[str]) -> bool:
"""Check if all entity states match."""
return all(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and state.state not in self._excluded_states
)
def _should_include(self, state: State) -> bool:
"""Check if an entity should participate in all/count checks.
def count_matches(self, entity_ids: set[str]) -> int:
"""Count the number of entity states that match."""
return sum(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
and state.state not in self._excluded_states
)
The default implementation excludes only entities whose state.state
is in `_excluded_states` (unavailable / unknown). Subclasses can
override to also exclude entities that lack the optional capability
the trigger relies on (e.g. a missing volume_level attribute).
"""
return state.state not in self._excluded_states
def count_matches(self, entity_ids: set[str]) -> tuple[int, int]:
"""Return (matches, included) for the entity set.
`matches` is the number of entities that pass `_should_include` AND
`is_valid_state`. `included` is the number that pass
`_should_include` (i.e. are visible to the all/count check at all).
Callers can use the pair to distinguish vacuous truth
(`included == 0`) from a genuine all-match
(`matches == included > 0`).
"""
matches = 0
included = 0
for entity_id in entity_ids:
state = self._hass.states.get(entity_id)
if state is None or not self._should_include(state):
continue
included += 1
if self.is_valid_state(state):
matches += 1
return matches, included
@override
async def async_attach_runner(
@@ -445,14 +458,20 @@ class EntityTriggerBase(Trigger):
For behavior first/last, checks the combined state.
"""
if behavior == BEHAVIOR_LAST:
return self.check_all_match(
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids
)
# Require at least one included entity to avoid keeping
# the timer alive when every targeted entity has been
# filtered out since it started — a vacuous all-match
# (`included == 0`) would otherwise let the action fire
# after `for:` even though no entity still matches.
return included > 0 and matches == included
if behavior == BEHAVIOR_FIRST:
return (
self.count_matches(target_state_change_data.targeted_entity_ids)
>= 1
matches, _included = self.count_matches(
target_state_change_data.targeted_entity_ids
)
return matches >= 1
# Behavior any: check the individual entity's state
if not to_state:
return False
@@ -470,18 +489,19 @@ class EntityTriggerBase(Trigger):
return
if behavior == BEHAVIOR_LAST:
if not self.check_all_match(
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids
):
)
if matches != included:
return
elif behavior == BEHAVIOR_FIRST:
# Note: It's enough to test for exactly 1 match here because if there
# were previously 2 matches the transition would not be valid and we
# would have returned already.
if (
self.count_matches(target_state_change_data.targeted_entity_ids)
!= 1
):
matches, _ = self.count_matches(
target_state_change_data.targeted_entity_ids
)
if matches != 1:
return
@callback
+2 -2
View File
@@ -40,7 +40,7 @@ hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260429.2
home-assistant-intents==2026.3.24
home-assistant-intents==2026.5.5
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
@@ -63,7 +63,7 @@ PyTurboJPEG==1.8.3
PyYAML==6.0.3
requests==2.33.1
securetar==2026.4.1
serialx==1.4.1
serialx==1.7.0
SQLAlchemy==2.0.49
standard-aifc==3.13.0
standard-telnetlib==3.13.0
@@ -1,7 +1,5 @@
"""Plugin for logger invocations."""
from __future__ import annotations
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
-2
View File
@@ -1,7 +1,5 @@
"""Plugin to check decorators."""
from __future__ import annotations
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
@@ -1,7 +1,5 @@
"""Plugin for checking if class is in correct module."""
from __future__ import annotations
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
@@ -9,8 +9,6 @@ IP/hostname-based unique IDs were the #1 unique ID review pattern, found in
16.2% of new-integration PRs across 1,100+ analyzed PRs.
"""
from __future__ import annotations
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
@@ -8,8 +8,6 @@ device capabilities, and data freshness needs.
Found in 3.5% of new-integration PRs across 1,100+ analyzed PRs, April 2026.
"""
from __future__ import annotations
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
@@ -1,7 +1,5 @@
"""Plugin for checking preferred coding of μ is used."""
from __future__ import annotations
from typing import Any
from astroid import nodes
@@ -10,8 +10,6 @@ pull requests reviewed (69 occurrences), making it a high-value target for
automated enforcement.
"""
from __future__ import annotations
from pathlib import Path
from astroid import nodes
@@ -1,7 +1,5 @@
"""Plugin for checking sorted platforms list."""
from __future__ import annotations
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
@@ -1,7 +1,5 @@
"""Plugin for checking super calls."""
from __future__ import annotations
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.interfaces import INFERENCE
@@ -1,7 +1,5 @@
"""Plugin to enforce type hints on specific functions."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import re
-2
View File
@@ -1,7 +1,5 @@
"""Plugin for checking imports."""
from __future__ import annotations
from dataclasses import dataclass
import re
-2
View File
@@ -1,7 +1,5 @@
"""Plugin to enforce type hints on specific functions."""
from __future__ import annotations
import re
from astroid import nodes
-2
View File
@@ -1,7 +1,5 @@
"""Plugin for logger invocations."""
from __future__ import annotations
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
+1
View File
@@ -895,6 +895,7 @@ mark-parentheses = false
"async_timeout".msg = "use asyncio.timeout instead"
"pytz".msg = "use zoneinfo instead"
"tests".msg = "You should not import tests"
"__future__.annotations".msg = "It should not be needed because Home Assistant requires Python 3.14+"
[tool.ruff.lint.isort]
force-sort-within-sections = true
+1 -1
View File
@@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.3.24
home-assistant-intents==2026.5.5
httpx==0.28.1
ifaddr==0.2.0
infrared-protocols==2.1.0
+6 -6
View File
@@ -892,7 +892,7 @@ elgato==5.1.2
eliqonline==1.2.2
# homeassistant.components.elkm1
elkm1-lib==2.2.13
elkm1-lib==2.2.15
# homeassistant.components.inels
elkoep-aio-mqtt==0.1.0b4
@@ -1168,7 +1168,7 @@ greenwavereality==0.5.1
gridnet==5.0.1
# homeassistant.components.growatt_server
growattServer==1.9.0
growattServer==2.1.0
# homeassistant.components.google_sheets
gspread==5.5.0
@@ -1251,7 +1251,7 @@ holidays==0.95
home-assistant-frontend==20260429.2
# homeassistant.components.conversation
home-assistant-intents==2026.3.24
home-assistant-intents==2026.5.5
# homeassistant.components.homekit
homekit-audio-proxy==1.2.1
@@ -2951,7 +2951,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.4.1
serialx==1.7.0
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
@@ -3178,7 +3178,7 @@ ttls==1.8.3
ttn_client==1.3.0
# homeassistant.components.tuya
tuya-device-handlers==0.0.18
tuya-device-handlers==0.0.19
# homeassistant.components.tuya
tuya-device-sharing-sdk==0.2.8
@@ -3255,7 +3255,7 @@ venstarcolortouch==0.21
viaggiatreno_ha==0.2.4
# homeassistant.components.victron_ble
victron-ble-ha-parser==0.6.3
victron-ble-ha-parser==0.7.0
# homeassistant.components.victron_gx
victron-mqtt==2026.4.17
+6 -6
View File
@@ -792,7 +792,7 @@ elevenlabs==2.3.0
elgato==5.1.2
# homeassistant.components.elkm1
elkm1-lib==2.2.13
elkm1-lib==2.2.15
# homeassistant.components.inels
elkoep-aio-mqtt==0.1.0b4
@@ -1041,7 +1041,7 @@ greenplanet-energy-api==0.1.10
gridnet==5.0.1
# homeassistant.components.growatt_server
growattServer==1.9.0
growattServer==2.1.0
# homeassistant.components.google_sheets
gspread==5.5.0
@@ -1115,7 +1115,7 @@ holidays==0.95
home-assistant-frontend==20260429.2
# homeassistant.components.conversation
home-assistant-intents==2026.3.24
home-assistant-intents==2026.5.5
# homeassistant.components.homekit
homekit-audio-proxy==1.2.1
@@ -2517,7 +2517,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.4.1
serialx==1.7.0
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
@@ -2699,7 +2699,7 @@ ttls==1.8.3
ttn_client==1.3.0
# homeassistant.components.tuya
tuya-device-handlers==0.0.18
tuya-device-handlers==0.0.19
# homeassistant.components.tuya
tuya-device-sharing-sdk==0.2.8
@@ -2767,7 +2767,7 @@ velbus-aio==2026.4.1
venstarcolortouch==0.21
# homeassistant.components.victron_ble
victron-ble-ha-parser==0.6.3
victron-ble-ha-parser==0.7.0
# homeassistant.components.victron_gx
victron-mqtt==2026.4.17
@@ -332,6 +332,7 @@ async def test_climate_attribute_condition_behavior_all(
"climate.target_humidity",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_condition_above_below_any(
"climate.target_temperature",
@@ -376,6 +377,7 @@ async def test_climate_numerical_condition_behavior_any(
"climate.target_humidity",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_condition_above_below_all(
"climate.target_temperature",
+16 -4
View File
@@ -218,7 +218,10 @@ async def test_climate_state_trigger_behavior_any(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_changed_trigger_states(
"climate.target_humidity_changed", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_changed",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_changed_trigger_states(
"climate.target_temperature_changed",
@@ -227,7 +230,10 @@ async def test_climate_state_trigger_behavior_any(
threshold_unit=UnitOfTemperature.CELSIUS,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
@@ -342,7 +348,10 @@ async def test_climate_state_trigger_behavior_first(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
@@ -457,7 +466,10 @@ async def test_climate_state_trigger_behavior_last(
("trigger", "trigger_options", "states"),
[
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
"climate.target_humidity_crossed_threshold",
HVACMode.AUTO,
ATTR_HUMIDITY,
attribute_required=True,
),
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold",
File diff suppressed because it is too large Load Diff
+23
View File
@@ -4,6 +4,8 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from duco.models import (
ApiEndpointInfo,
ApiInfo,
BoardInfo,
DiagComponent,
DiagStatus,
@@ -49,6 +51,25 @@ def mock_board_info() -> BoardInfo:
serial_duco_box="GHI789",
serial_duco_comm="JKL012",
time=1700000000,
public_api_version="2.5",
software_version="1.2.3",
)
@pytest.fixture
def mock_api_info() -> ApiInfo:
"""Return mock API info."""
return ApiInfo(
api_version="2.5",
reported_api_version="2.5.1",
endpoints=[
ApiEndpointInfo(
url="/info",
query_parameters=["module", "submodule"],
methods=["GET"],
modules=["General", "Diag"],
)
],
)
@@ -180,6 +201,7 @@ def mock_nodes() -> list[Node]:
@pytest.fixture
def mock_duco_client(
mock_api_info: ApiInfo,
mock_board_info: BoardInfo,
mock_lan_info: LanInfo,
mock_nodes: list[Node],
@@ -202,6 +224,7 @@ def mock_duco_client(
),
):
client = mock_class.return_value
client.async_get_api_info.return_value = mock_api_info
client.async_get_board_info.return_value = mock_board_info
client.async_get_lan_info.return_value = mock_lan_info
client.async_get_nodes.return_value = mock_nodes
@@ -1,15 +1,19 @@
# serializer version: 1
# name: test_diagnostics
dict({
'api_info': dict({
'public_api_version': '2.5',
'reported_api_version': '2.5.1',
}),
'board_info': dict({
'box_name': 'SILENT_CONNECT',
'box_sub_type_name': 'Eu',
'public_api_version': None,
'public_api_version': '2.5',
'serial_board_box': '**REDACTED**',
'serial_board_comm': '**REDACTED**',
'serial_duco_box': '**REDACTED**',
'serial_duco_comm': '**REDACTED**',
'software_version': None,
'software_version': '1.2.3',
}),
'duco_diagnostics': list([
dict({
+52 -2
View File
@@ -1,9 +1,11 @@
"""Tests for the Duco diagnostics."""
from dataclasses import replace
from http import HTTPStatus
from unittest.mock import AsyncMock
from duco.exceptions import DucoConnectionError
from duco.models import ApiInfo
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -24,7 +26,7 @@ async def test_diagnostics(
mock_duco_client: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
"""Test that the full diagnostics payload matches the snapshot."""
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
@@ -34,7 +36,12 @@ async def test_diagnostics(
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"failing_method",
["async_get_lan_info", "async_get_diagnostics", "async_get_write_req_remaining"],
[
"async_get_api_info",
"async_get_lan_info",
"async_get_diagnostics",
"async_get_write_req_remaining",
],
)
async def test_diagnostics_connection_error(
hass: HomeAssistant,
@@ -54,3 +61,46 @@ async def test_diagnostics_connection_error(
f"/api/diagnostics/config_entry/{mock_config_entry.entry_id}"
)
assert response.status == HTTPStatus.INTERNAL_SERVER_ERROR
async def test_diagnostics_without_optional_board_metadata(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
) -> None:
"""Test that None board fields are omitted from the diagnostics payload."""
# BoardInfo is a frozen dataclass, so the mock must be updated before
# integration setup — the coordinator stores board_info during async_setup.
mock_duco_client.async_get_board_info.return_value = replace(
mock_duco_client.async_get_board_info.return_value,
public_api_version=None,
software_version=None,
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
assert "public_api_version" not in diagnostics["board_info"]
assert "software_version" not in diagnostics["board_info"]
@pytest.mark.usefixtures("init_integration")
async def test_diagnostics_without_optional_api_metadata(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
) -> None:
"""Test diagnostics when optional API metadata is absent."""
mock_duco_client.async_get_api_info.return_value = ApiInfo(api_version="2.5")
diagnostics = await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
)
assert diagnostics["api_info"] == {"public_api_version": "2.5"}
+13 -60
View File
@@ -1,6 +1,6 @@
"""Test Ecovacs config flow."""
from collections.abc import Awaitable, Callable
from collections.abc import Callable
from dataclasses import dataclass, field
import ssl
from typing import Any
@@ -50,26 +50,6 @@ async def _test_user_flow(
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert not result["errors"]
return await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=user_input.auth,
)
async def _test_user_flow_show_advanced_options(
hass: HomeAssistant,
user_input: _TestFnUserInput,
) -> dict[str, Any]:
"""Test config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER, "show_advanced_options": True},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
@@ -90,37 +70,29 @@ async def _test_user_flow_show_advanced_options(
@pytest.mark.parametrize(
("test_fn", "test_fn_user_input", "entry_data"),
("test_fn_user_input", "entry_data"),
[
(
_test_user_flow_show_advanced_options,
_TestFnUserInput(VALID_ENTRY_DATA_CLOUD),
VALID_ENTRY_DATA_CLOUD,
),
(
_test_user_flow_show_advanced_options,
_TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED),
VALID_ENTRY_DATA_SELF_HOSTED,
),
(
_test_user_flow,
_TestFnUserInput(VALID_ENTRY_DATA_CLOUD),
VALID_ENTRY_DATA_CLOUD,
),
],
ids=["advanced_cloud", "advanced_self_hosted", "cloud"],
ids=["cloud", "self_hosted"],
)
async def test_user_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_authenticator_authenticate: AsyncMock,
mock_mqtt_client: Mock,
test_fn: Callable[[HomeAssistant, _TestFnUserInput], Awaitable[dict[str, Any]]],
test_fn_user_input: _TestFnUserInput,
entry_data: dict[str, Any],
) -> None:
"""Test the user config flow."""
result = await test_fn(hass, test_fn_user_input)
result = await _test_user_flow(hass, test_fn_user_input)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == entry_data[CONF_USERNAME]
assert result["data"] == entry_data
@@ -156,25 +128,18 @@ def _cannot_connect_error(user_input: dict[str, Any]) -> str:
ids=["cannot_connect", "invalid_auth", "unknown"],
)
@pytest.mark.parametrize(
("test_fn", "test_fn_user_input", "entry_data"),
("test_fn_user_input", "entry_data"),
[
(
_test_user_flow_show_advanced_options,
_TestFnUserInput(VALID_ENTRY_DATA_CLOUD),
VALID_ENTRY_DATA_CLOUD,
),
(
_test_user_flow_show_advanced_options,
_TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED),
VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT,
),
(
_test_user_flow,
_TestFnUserInput(VALID_ENTRY_DATA_CLOUD),
VALID_ENTRY_DATA_CLOUD,
),
],
ids=["advanced_cloud", "advanced_self_hosted", "cloud"],
ids=["cloud", "self_hosted"],
)
async def test_user_flow_raise_error(
hass: HomeAssistant,
@@ -185,7 +150,6 @@ async def test_user_flow_raise_error(
reason_rest: str,
side_effect_mqtt: Exception,
errors_mqtt: Callable[[dict[str, Any]], str],
test_fn: Callable[[HomeAssistant, _TestFnUserInput], Awaitable[dict[str, Any]]],
test_fn_user_input: _TestFnUserInput,
entry_data: dict[str, Any],
) -> None:
@@ -194,7 +158,7 @@ async def test_user_flow_raise_error(
# Authenticator raises error
mock_authenticator_authenticate.side_effect = side_effect_rest
result = await test_fn(hass, test_fn_user_input)
result = await _test_user_flow(hass, test_fn_user_input)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "auth"
assert result["errors"] == {"base": reason_rest}
@@ -240,7 +204,7 @@ async def test_user_flow_self_hosted_error(
) -> None:
"""Test handling selfhosted errors and custom ssl context."""
result = await _test_user_flow_show_advanced_options(
result = await _test_user_flow(
hass,
_TestFnUserInput(
VALID_ENTRY_DATA_SELF_HOSTED
@@ -289,32 +253,21 @@ async def test_user_flow_self_hosted_error(
@pytest.mark.parametrize(
("test_fn", "test_fn_user_input"),
("test_fn_user_input"),
[
(
_test_user_flow_show_advanced_options,
_TestFnUserInput(VALID_ENTRY_DATA_CLOUD),
),
(
_test_user_flow_show_advanced_options,
_TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED),
),
(
_test_user_flow,
_TestFnUserInput(VALID_ENTRY_DATA_CLOUD),
),
_TestFnUserInput(VALID_ENTRY_DATA_CLOUD),
_TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED),
],
ids=["advanced_cloud", "advanced_self_hosted", "cloud"],
ids=["cloud", "self_hosted"],
)
async def test_already_exists(
hass: HomeAssistant,
test_fn: Callable[[HomeAssistant, _TestFnUserInput], Awaitable[dict[str, Any]]],
test_fn_user_input: _TestFnUserInput,
) -> None:
"""Test we don't allow duplicated config entries."""
MockConfigEntry(domain=DOMAIN, data=test_fn_user_input.auth).add_to_hass(hass)
result = await test_fn(
result = await _test_user_flow(
hass,
test_fn_user_input,
)
@@ -371,8 +371,9 @@ async def test_token_auth_api_error(
result["flow_id"], {"next_step_id": "token_auth"}
)
error = growattServer.GrowattV1ApiError("API error")
error.error_code = error_code
error = growattServer.GrowattV1ApiError(
message="API error", error_code=error_code, error_msg="API error"
)
mock_growatt_v1_api.plant_list.side_effect = error
result = await hass.config_entries.flow.async_configure(
@@ -882,9 +883,11 @@ async def test_reauth_token_success(
def _make_no_privilege_error() -> growattServer.GrowattV1ApiError:
error = growattServer.GrowattV1ApiError("No privilege access")
error.error_code = V1_API_ERROR_NO_PRIVILEGE
return error
return growattServer.GrowattV1ApiError(
message="No privilege access",
error_code=growattServer.GrowattV1ApiErrorCode.NO_PRIVILEGE,
error_msg="No privilege access",
)
@pytest.mark.parametrize(
@@ -942,8 +945,11 @@ async def test_reauth_token_non_auth_api_error(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
error = growattServer.GrowattV1ApiError("Rate limit exceeded")
error.error_code = V1_API_ERROR_RATE_LIMITED
error = growattServer.GrowattV1ApiError(
message="Rate limit exceeded",
error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED,
error_msg="Rate limit exceeded",
)
mock_growatt_v1_api.plant_list.side_effect = error
result = await hass.config_entries.flow.async_configure(
result["flow_id"], FIXTURE_USER_INPUT_TOKEN
+27 -10
View File
@@ -19,8 +19,7 @@ from homeassistant.components.growatt_server.const import (
DEFAULT_PLANT_ID,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
V1_API_ERROR_NO_PRIVILEGE,
V1_API_ERROR_RATE_LIMITED,
V1_API_ERROR_WRONG_DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
@@ -67,7 +66,14 @@ async def test_device_info(
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(growattServer.GrowattV1ApiError("API Error"), ConfigEntryState.SETUP_ERROR),
(
growattServer.GrowattV1ApiError(
message="API Error",
error_code=V1_API_ERROR_WRONG_DOMAIN,
error_msg="Invalid JSON",
),
ConfigEntryState.SETUP_ERROR,
),
(
json.decoder.JSONDecodeError("Invalid JSON", "", 0),
ConfigEntryState.SETUP_ERROR,
@@ -102,7 +108,9 @@ async def test_coordinator_update_failed(
# Cause coordinator update to fail
mock_growatt_v1_api.min_detail.side_effect = growattServer.GrowattV1ApiError(
"Connection timeout"
message="Rate limited",
error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED,
error_msg="Too many requests",
)
# Trigger coordinator refresh
@@ -148,8 +156,11 @@ async def test_coordinator_total_non_auth_api_error(
"""Test total coordinator handles non-auth V1 API errors as UpdateFailed."""
assert mock_config_entry.state is ConfigEntryState.LOADED
error = growattServer.GrowattV1ApiError("Rate limited")
error.error_code = V1_API_ERROR_RATE_LIMITED
error = growattServer.GrowattV1ApiError(
message="Rate limited",
error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED,
error_msg="Too many requests",
)
mock_growatt_v1_api.plant_energy_overview.side_effect = error
freezer.tick(timedelta(minutes=5))
@@ -168,8 +179,11 @@ async def test_setup_auth_failed_on_permission_denied(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that error 10011 (no privilege) from device_list triggers reauth during setup."""
error = growattServer.GrowattV1ApiError("Permission denied")
error.error_code = V1_API_ERROR_NO_PRIVILEGE
error = growattServer.GrowattV1ApiError(
message="Permission denied",
error_code=growattServer.GrowattV1ApiErrorCode.NO_PRIVILEGE,
error_msg="dummy error",
)
mock_growatt_v1_api.device_list.side_effect = error
await setup_integration(hass, mock_config_entry)
@@ -194,8 +208,11 @@ async def test_coordinator_auth_failed_triggers_reauth(
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
error = growattServer.GrowattV1ApiError("Permission denied")
error.error_code = V1_API_ERROR_NO_PRIVILEGE
error = growattServer.GrowattV1ApiError(
message="Permission denied",
error_code=growattServer.GrowattV1ApiErrorCode.NO_PRIVILEGE,
error_msg="dummy error",
)
mock_growatt_v1_api.min_detail.side_effect = error
freezer.tick(timedelta(minutes=5))
@@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from growattServer import GrowattV1ApiError
from growattServer import GrowattV1ApiError, GrowattV1ApiErrorCode
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -52,7 +52,11 @@ async def test_set_number_value_api_error(
) -> None:
"""Test handling API error when setting number value."""
# Mock API to raise error
mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError("API Error")
mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError(
message="API Error",
error_code=GrowattV1ApiErrorCode.NO_PRIVILEGE,
error_msg="API Error",
)
with pytest.raises(HomeAssistantError) as excinfo:
await hass.services.async_call(
@@ -56,7 +56,9 @@ async def test_sph_sensor_unavailable_on_coordinator_error(
assert state.state != STATE_UNAVAILABLE
mock_growatt_v1_api.sph_detail.side_effect = growattServer.GrowattV1ApiError(
"Connection timeout"
message="Rate limited",
error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED,
error_msg="Too many requests",
)
freezer.tick(timedelta(minutes=5))
@@ -175,7 +177,9 @@ async def test_sensor_unavailable_on_coordinator_error(
# Cause coordinator update to fail
mock_growatt_v1_api.min_detail.side_effect = growattServer.GrowattV1ApiError(
"Connection timeout"
message="Rate limited",
error_code=growattServer.GrowattV1ApiErrorCode.RATE_LIMITED,
error_msg="Too many requests",
)
# Trigger coordinator refresh
@@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
from growattServer import GrowattV1ApiError
from growattServer import GrowattV1ApiError, GrowattV1ApiErrorCode
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -89,7 +89,11 @@ async def test_switch_service_call_api_error(
) -> None:
"""Test handling API error when calling switch services."""
# Mock API to raise error
mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError("API Error")
mock_growatt_v1_api.min_write_parameter.side_effect = GrowattV1ApiError(
message="API Error",
error_code=GrowattV1ApiErrorCode.NO_PRIVILEGE,
error_msg="API Error",
)
with pytest.raises(HomeAssistantError) as excinfo:
await hass.services.async_call(
+7 -28
View File
@@ -3,10 +3,7 @@
from infrared_protocols import Command as InfraredCommand
import pytest
from homeassistant.components.infrared import (
InfraredEmitterEntity,
InfraredReceiverEntity,
)
from homeassistant.components.infrared import InfraredEntity
from homeassistant.components.infrared.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -19,15 +16,14 @@ async def init_integration(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
class MockInfraredEmitterEntity(InfraredEmitterEntity):
"""Mock infrared emitter entity for testing."""
class MockInfraredEntity(InfraredEntity):
"""Mock infrared entity for testing."""
_attr_has_entity_name = True
_attr_name = "Test IR emitter"
_attr_name = "Test IR transmitter"
def __init__(self, unique_id: str) -> None:
"""Initialize mock entity."""
super().__init__()
self._attr_unique_id = unique_id
self.send_command_calls: list[InfraredCommand] = []
@@ -37,23 +33,6 @@ class MockInfraredEmitterEntity(InfraredEmitterEntity):
@pytest.fixture
def mock_infrared_emitter_entity() -> MockInfraredEmitterEntity:
"""Return a mock infrared emitter entity."""
return MockInfraredEmitterEntity("test_ir_emitter")
class MockInfraredReceiverEntity(InfraredReceiverEntity):
"""Mock infrared receiver entity for testing."""
_attr_has_entity_name = True
_attr_name = "Test IR receiver"
def __init__(self, unique_id: str) -> None:
"""Initialize mock receiver entity."""
self._attr_unique_id = unique_id
@pytest.fixture
def mock_infrared_receiver_entity() -> MockInfraredReceiverEntity:
"""Return a mock infrared receiver entity."""
return MockInfraredReceiverEntity("test_ir_receiver")
def mock_infrared_entity() -> MockInfraredEntity:
"""Return a mock infrared entity."""
return MockInfraredEntity("test_ir_transmitter")
+44 -226
View File
@@ -1,6 +1,6 @@
"""Tests for the Infrared integration setup."""
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
from infrared_protocols import NECCommand
@@ -9,11 +9,8 @@ import pytest
from homeassistant.components.infrared import (
DATA_COMPONENT,
DOMAIN,
InfraredReceivedSignal,
async_get_emitters,
async_get_receivers,
async_send_command,
async_subscribe_receiver,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
@@ -21,79 +18,57 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .conftest import MockInfraredEmitterEntity, MockInfraredReceiverEntity
from .conftest import MockInfraredEntity
from tests.common import mock_restore_cache
TEST_COMMAND = NECCommand(address=0x04FB, command=0x08F7, modulation=38000)
async def test_get_entities_component_not_loaded(hass: HomeAssistant) -> None:
"""Test getting entities when the component is not loaded."""
async def test_get_entities_integration_setup(hass: HomeAssistant) -> None:
"""Test getting entities when the integration is not setup."""
assert async_get_emitters(hass) == []
assert async_get_receivers(hass) == []
@pytest.mark.usefixtures("init_integration")
async def test_get_entities_empty(hass: HomeAssistant) -> None:
"""Test getting entities when none are registered."""
assert async_get_emitters(hass) == []
assert async_get_receivers(hass) == []
@pytest.mark.usefixtures("init_integration")
async def test_get_entities_filters_by_type(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
async def test_infrared_entity_initial_state(
hass: HomeAssistant, mock_infrared_entity: MockInfraredEntity
) -> None:
"""Test get_emitters/get_receivers return only entities of the matching type."""
"""Test infrared entity has no state before any command is sent."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities(
[mock_infrared_emitter_entity, mock_infrared_receiver_entity]
)
await component.async_add_entities([mock_infrared_entity])
assert async_get_emitters(hass) == [mock_infrared_emitter_entity.entity_id]
assert async_get_receivers(hass) == [mock_infrared_receiver_entity.entity_id]
@pytest.mark.usefixtures("init_integration")
async def test_infrared_entities_initial_state(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test infrared entities have no state before any command is sent."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities(
[mock_infrared_emitter_entity, mock_infrared_receiver_entity]
)
assert (emitter_state := hass.states.get("infrared.test_ir_emitter")) is not None
assert emitter_state.state == STATE_UNKNOWN
assert (receiver_state := hass.states.get("infrared.test_ir_receiver")) is not None
assert receiver_state.state == STATE_UNKNOWN
state = hass.states.get("infrared.test_ir_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_success(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_infrared_entity: MockInfraredEntity,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sending command via async_send_command helper."""
# Add the mock entity to the component
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_emitter_entity])
await component.async_add_entities([mock_infrared_entity])
# Freeze time so we can verify the state update
now = dt_util.utcnow()
freezer.move_to(now)
await async_send_command(hass, mock_infrared_emitter_entity.entity_id, TEST_COMMAND)
command = NECCommand(address=0x04FB, command=0x08F7, modulation=38000)
await async_send_command(hass, mock_infrared_entity.entity_id, command)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
assert mock_infrared_emitter_entity.send_command_calls[0] is TEST_COMMAND
assert len(mock_infrared_entity.send_command_calls) == 1
assert mock_infrared_entity.send_command_calls[0] is command
state = hass.states.get("infrared.test_ir_emitter")
state = hass.states.get("infrared.test_ir_transmitter")
assert state is not None
assert state.state == now.isoformat(timespec="milliseconds")
@@ -101,26 +76,27 @@ async def test_async_send_command_success(
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_error_does_not_update_state(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_infrared_entity: MockInfraredEntity,
) -> None:
"""Test that state is not updated when async_send_command raises an error."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_emitter_entity])
await component.async_add_entities([mock_infrared_entity])
state = hass.states.get("infrared.test_ir_emitter")
state = hass.states.get("infrared.test_ir_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
mock_infrared_emitter_entity.async_send_command = AsyncMock(
command = NECCommand(address=0x04FB, command=0x08F7, modulation=38000)
mock_infrared_entity.async_send_command = AsyncMock(
side_effect=HomeAssistantError("Transmission failed")
)
with pytest.raises(HomeAssistantError, match="Transmission failed"):
await async_send_command(
hass, mock_infrared_emitter_entity.entity_id, TEST_COMMAND
)
await async_send_command(hass, mock_infrared_entity.entity_id, command)
state = hass.states.get("infrared.test_ir_emitter")
# Verify state was not updated after the error
state = hass.states.get("infrared.test_ir_transmitter")
assert state is not None
assert state.state == STATE_UNKNOWN
@@ -128,35 +104,25 @@ async def test_async_send_command_error_does_not_update_state(
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_entity_not_found(hass: HomeAssistant) -> None:
"""Test async_send_command raises error when entity not found."""
command = NECCommand(
address=0x04FB, command=0x08F7, modulation=38000, repeat_count=1
)
with pytest.raises(
HomeAssistantError,
match="Infrared entity `infrared.nonexistent_entity` not found",
):
await async_send_command(hass, "infrared.nonexistent_entity", TEST_COMMAND)
@pytest.mark.usefixtures("init_integration")
async def test_async_send_command_rejects_receiver(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test async_send_command rejects a receiver entity."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_receiver_entity])
with pytest.raises(
HomeAssistantError,
match=f"Infrared entity `{mock_infrared_receiver_entity.entity_id}` not found",
):
await async_send_command(
hass, mock_infrared_receiver_entity.entity_id, TEST_COMMAND
)
await async_send_command(hass, "infrared.nonexistent_entity", command)
async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> None:
"""Test async_send_command raises error when component not loaded."""
command = NECCommand(
address=0x04FB, command=0x08F7, modulation=38000, repeat_count=1
)
with pytest.raises(HomeAssistantError, match="component_not_loaded"):
await async_send_command(hass, "infrared.some_entity", TEST_COMMAND)
await async_send_command(hass, "infrared.some_entity", command)
@pytest.mark.parametrize(
@@ -168,167 +134,19 @@ async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> N
)
async def test_infrared_entity_state_restore(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
mock_infrared_entity: MockInfraredEntity,
restored_value: str,
expected_state: str,
) -> None:
"""Test infrared entity state restore."""
mock_restore_cache(
hass,
[
State("infrared.test_ir_emitter", restored_value),
State("infrared.test_ir_receiver", restored_value),
],
)
mock_restore_cache(hass, [State("infrared.test_ir_transmitter", restored_value)])
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
component = hass.data[DATA_COMPONENT]
await component.async_add_entities(
[mock_infrared_emitter_entity, mock_infrared_receiver_entity]
)
await component.async_add_entities([mock_infrared_entity])
assert (emitter_state := hass.states.get("infrared.test_ir_emitter")) is not None
assert emitter_state.state == expected_state
assert (receiver_state := hass.states.get("infrared.test_ir_receiver")) is not None
assert receiver_state.state == expected_state
@pytest.mark.usefixtures("init_integration")
async def test_async_subscribe_receiver_success(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test subscribing to a receiver via async_subscribe_receiver helper."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_receiver_entity])
now = dt_util.utcnow()
freezer.move_to(now)
signal_callback = Mock()
unsubscribe = async_subscribe_receiver(
hass, mock_infrared_receiver_entity.entity_id, signal_callback
)
signal = InfraredReceivedSignal(timings=[100, 200, 300], modulation=38000)
mock_infrared_receiver_entity._handle_received_signal(signal)
assert signal_callback.call_count == 1
assert signal_callback.call_args[0][0] is signal
state = hass.states.get("infrared.test_ir_receiver")
state = hass.states.get("infrared.test_ir_transmitter")
assert state is not None
assert state.state == now.isoformat(timespec="milliseconds")
unsubscribe()
mock_infrared_receiver_entity._handle_received_signal(signal)
assert signal_callback.call_count == 1
@pytest.mark.usefixtures("init_integration")
async def test_handle_received_signal_isolates_callback_errors(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a failing subscriber does not prevent other subscribers from running."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_receiver_entity])
failing_callback = Mock(side_effect=RuntimeError("boom"))
working_callback = Mock()
async_subscribe_receiver(
hass, mock_infrared_receiver_entity.entity_id, failing_callback
)
async_subscribe_receiver(
hass, mock_infrared_receiver_entity.entity_id, working_callback
)
signal = InfraredReceivedSignal(timings=[100, 200, 300])
mock_infrared_receiver_entity._handle_received_signal(signal)
failing_callback.assert_called_once_with(signal)
working_callback.assert_called_once_with(signal)
assert "Error in signal callback" in caplog.text
@pytest.mark.usefixtures("init_integration")
async def test_handle_received_signal_unsubscribe_during_dispatch(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test a subscriber can unsubscribe itself during dispatch without error."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_receiver_entity])
other_callback = Mock()
def unsubscribing_callback(signal: InfraredReceivedSignal) -> None:
unsubscribe()
self_unsub_mock = Mock(side_effect=unsubscribing_callback)
unsubscribe = async_subscribe_receiver(
hass, mock_infrared_receiver_entity.entity_id, self_unsub_mock
)
async_subscribe_receiver(
hass, mock_infrared_receiver_entity.entity_id, other_callback
)
signal = InfraredReceivedSignal(timings=[100, 200, 300])
mock_infrared_receiver_entity._handle_received_signal(signal)
self_unsub_mock.assert_called_once_with(signal)
other_callback.assert_called_once_with(signal)
mock_infrared_receiver_entity._handle_received_signal(signal)
self_unsub_mock.assert_called_once_with(signal)
assert other_callback.call_count == 2
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"entity_id_or_uuid",
["infrared.nonexistent_entity", "invalid-id"],
)
async def test_async_subscribe_receiver_not_found(
hass: HomeAssistant, entity_id_or_uuid: str
) -> None:
"""Test async_subscribe_receiver raises when the entity is missing or invalid."""
with pytest.raises(
HomeAssistantError,
match=f"Infrared receiver entity `{entity_id_or_uuid}` not found",
):
async_subscribe_receiver(hass, entity_id_or_uuid, lambda _: None)
@pytest.mark.usefixtures("init_integration")
async def test_async_subscribe_receiver_rejects_emitter(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test async_subscribe_receiver rejects an emitter entity."""
component = hass.data[DATA_COMPONENT]
await component.async_add_entities([mock_infrared_emitter_entity])
with pytest.raises(
HomeAssistantError,
match=(
f"Infrared receiver entity `{mock_infrared_emitter_entity.entity_id}`"
" not found"
),
):
async_subscribe_receiver(
hass, mock_infrared_emitter_entity.entity_id, lambda _: None
)
async def test_async_subscribe_receiver_component_not_loaded(
hass: HomeAssistant,
) -> None:
"""Test async_subscribe_receiver raises error when component not loaded."""
with pytest.raises(HomeAssistantError, match="component_not_loaded"):
async_subscribe_receiver(hass, "infrared.some_entity", lambda _: None)
assert state.state == expected_state
@@ -14,8 +14,7 @@ from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
ENTITY_IR_EMITTER = "infrared.ir_blaster_infrared_emitter"
ENTITY_IR_RECEIVER = "infrared.ir_blaster_infrared_receiver"
ENTITY_IR_TRANSMITTER = "infrared.ir_blaster_infrared_transmitter"
@pytest.fixture
@@ -228,8 +227,7 @@ async def test_infrared_fan_subentry_flow(hass: HomeAssistant) -> None:
result["flow_id"],
user_input={
"name": "Living Room Fan",
"infrared_entity_id": ENTITY_IR_EMITTER,
"infrared_receiver_entity_id": ENTITY_IR_RECEIVER,
"infrared_entity_id": ENTITY_IR_TRANSMITTER,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -239,10 +237,7 @@ async def test_infrared_fan_subentry_flow(hass: HomeAssistant) -> None:
if s.subentry_type == "infrared_fan"
][0]
assert config_entry.subentries[subentry_id] == config_entries.ConfigSubentry(
data={
"infrared_entity_id": ENTITY_IR_EMITTER,
"infrared_receiver_entity_id": ENTITY_IR_RECEIVER,
},
data={"infrared_entity_id": ENTITY_IR_TRANSMITTER},
subentry_id=subentry_id,
subentry_type="infrared_fan",
title="Living Room Fan",
+11 -19
View File
@@ -1,23 +1,19 @@
"""The tests for the kitchen_sink infrared platform."""
from unittest.mock import Mock, patch
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import infrared_protocols
import pytest
from homeassistant.components.infrared import (
async_send_command,
async_subscribe_receiver,
)
from homeassistant.components.infrared import async_send_command
from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.const import Platform
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
ENTITY_IR_EMITTER = "infrared.ir_blaster_infrared_emitter"
ENTITY_IR_RECEIVER = "infrared.ir_blaster_infrared_receiver"
ENTITY_IR_TRANSMITTER = "infrared.ir_blaster_infrared_transmitter"
@pytest.fixture
@@ -37,12 +33,13 @@ async def setup_comp(hass: HomeAssistant, infrared_only: None) -> None:
await hass.async_block_till_done()
async def test_send_receive(
async def test_send_command(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test the receiver picks up commands sent by the emitter via dispatcher."""
signal_callback = Mock()
async_subscribe_receiver(hass, ENTITY_IR_RECEIVER, signal_callback)
"""Test sending an infrared command."""
state = hass.states.get(ENTITY_IR_TRANSMITTER)
assert state
assert state.state == STATE_UNKNOWN
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
assert now is not None
@@ -51,13 +48,8 @@ async def test_send_receive(
command = infrared_protocols.NECCommand(
address=0x04, command=0x08, modulation=38000
)
await async_send_command(hass, ENTITY_IR_EMITTER, command)
await hass.async_block_till_done()
await async_send_command(hass, ENTITY_IR_TRANSMITTER, command)
state = hass.states.get(ENTITY_IR_RECEIVER)
state = hass.states.get(ENTITY_IR_TRANSMITTER)
assert state
assert state.state == now.isoformat(timespec="milliseconds")
assert signal_callback.call_count == 1
received_signal = signal_callback.call_args[0][0]
assert received_signal.timings == command.get_raw_timings()

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