mirror of
https://github.com/home-assistant/core.git
synced 2026-05-06 08:36:42 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 522bfaff07 | |||
| 10084c8c0c | |||
| 7e8f5365ce | |||
| 65f9dcd7bf | |||
| 4c8f37fef6 | |||
| d1295fa260 | |||
| 9b2eea920f | |||
| c81c1cbb14 | |||
| 11ee05874a | |||
| 7d7c47b56e | |||
| dc4210595f | |||
| 7430366d9b | |||
| ae3bd54ca7 | |||
| e3ce7fb000 | |||
| 9286b517d3 | |||
| 4d62e4765d | |||
| ea55ef90a6 | |||
| 751765b97b | |||
| 11ed1fe20f | |||
| 9b5166769a | |||
| 70c2a323ce | |||
| 0ec5d6b273 | |||
| b1e8dc2ebb | |||
| e144804d28 | |||
| 8521a49986 | |||
| 3587f9613f | |||
| 2f1dd3a817 |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "sensereo",
|
||||
"name": "Sensereo",
|
||||
"iot_standards": ["matter"]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ from .coordinator import V2CConfigEntry, V2CUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"],
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Helper to check the configuration file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Plugin for checking imports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Plugin to enforce type hints on specific functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from astroid import nodes
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
|
||||
Generated
+6
-6
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+608
-66
File diff suppressed because it is too large
Load Diff
@@ -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({
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user