mirror of
https://github.com/home-assistant/core.git
synced 2026-05-12 15:04:07 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75413dfc11 | |||
| 0633400725 | |||
| 758a851b0d | |||
| ead2ff214f | |||
| db91c0eaee | |||
| 7203f61e7a | |||
| ed99a9c7d9 | |||
| d8a389afe0 | |||
| 6cbbc2185a | |||
| f660ddddea | |||
| 47579a9ac7 | |||
| c65c502e2f | |||
| 13e28210aa |
Generated
+2
@@ -758,6 +758,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/homewizard/ @DCSBL
|
||||
/homeassistant/components/honeywell/ @rdfurman @mkmer
|
||||
/tests/components/honeywell/ @rdfurman @mkmer
|
||||
/homeassistant/components/honeywell_string_lights/ @balloob
|
||||
/tests/components/honeywell_string_lights/ @balloob
|
||||
/homeassistant/components/hr_energy_qube/ @MattieGit
|
||||
/tests/components/hr_energy_qube/ @MattieGit
|
||||
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "honeywell",
|
||||
"name": "Honeywell",
|
||||
"integrations": ["lyric", "evohome", "honeywell"]
|
||||
"integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"]
|
||||
}
|
||||
|
||||
@@ -30,19 +30,33 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS = {
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_low": make_entity_state_condition(
|
||||
BATTERY_DOMAIN_SPECS, STATE_ON, support_duration=True
|
||||
BATTERY_DOMAIN_SPECS,
|
||||
STATE_ON,
|
||||
support_duration=True,
|
||||
primary_entities_only=False,
|
||||
),
|
||||
"is_not_low": make_entity_state_condition(
|
||||
BATTERY_DOMAIN_SPECS, STATE_OFF, support_duration=True
|
||||
BATTERY_DOMAIN_SPECS,
|
||||
STATE_OFF,
|
||||
support_duration=True,
|
||||
primary_entities_only=False,
|
||||
),
|
||||
"is_charging": make_entity_state_condition(
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, support_duration=True
|
||||
BATTERY_CHARGING_DOMAIN_SPECS,
|
||||
STATE_ON,
|
||||
support_duration=True,
|
||||
primary_entities_only=False,
|
||||
),
|
||||
"is_not_charging": make_entity_state_condition(
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, support_duration=True
|
||||
BATTERY_CHARGING_DOMAIN_SPECS,
|
||||
STATE_OFF,
|
||||
support_duration=True,
|
||||
primary_entities_only=False,
|
||||
),
|
||||
"is_level": make_entity_numerical_condition(
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS,
|
||||
PERCENTAGE,
|
||||
primary_entities_only=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery
|
||||
primary_entities_only: false
|
||||
fields:
|
||||
behavior: &condition_behavior
|
||||
required: true
|
||||
@@ -42,6 +43,7 @@ is_charging:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery_charging
|
||||
primary_entities_only: false
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
for: *condition_for
|
||||
@@ -51,6 +53,7 @@ is_not_charging:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery_charging
|
||||
primary_entities_only: false
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
for: *condition_for
|
||||
@@ -60,6 +63,7 @@ is_level:
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: battery
|
||||
primary_entities_only: false
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
threshold:
|
||||
|
||||
@@ -32,19 +32,27 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = {
|
||||
}
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON),
|
||||
"not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF),
|
||||
"low": make_entity_target_state_trigger(
|
||||
BATTERY_LOW_DOMAIN_SPECS, STATE_ON, primary_entities_only=False
|
||||
),
|
||||
"not_low": make_entity_target_state_trigger(
|
||||
BATTERY_LOW_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False
|
||||
),
|
||||
"started_charging": make_entity_target_state_trigger(
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, primary_entities_only=False
|
||||
),
|
||||
"stopped_charging": make_entity_target_state_trigger(
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False
|
||||
),
|
||||
"level_changed": make_entity_numerical_state_changed_trigger(
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS,
|
||||
valid_unit="%",
|
||||
primary_entities_only=False,
|
||||
),
|
||||
"level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS,
|
||||
valid_unit="%",
|
||||
primary_entities_only=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -33,16 +33,19 @@
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery
|
||||
primary_entities_only: false
|
||||
|
||||
.trigger_target_charging: &trigger_target_charging
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: battery_charging
|
||||
primary_entities_only: false
|
||||
|
||||
.trigger_target_percentage: &trigger_target_percentage
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: battery
|
||||
primary_entities_only: false
|
||||
|
||||
low:
|
||||
fields:
|
||||
|
||||
@@ -35,6 +35,7 @@ from aioesphomeapi import (
|
||||
MediaPlayerInfo,
|
||||
MediaPlayerSupportedFormat,
|
||||
NumberInfo,
|
||||
RadioFrequencyInfo,
|
||||
SelectInfo,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
@@ -88,6 +89,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
|
||||
FanInfo: Platform.FAN,
|
||||
InfraredInfo: Platform.INFRARED,
|
||||
LightInfo: Platform.LIGHT,
|
||||
RadioFrequencyInfo: Platform.RADIO_FREQUENCY,
|
||||
LockInfo: Platform.LOCK,
|
||||
MediaPlayerInfo: Platform.MEDIA_PLAYER,
|
||||
NumberInfo: Platform.NUMBER,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Radio Frequency platform for ESPHome."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
|
||||
from aioesphomeapi import (
|
||||
EntityState,
|
||||
RadioFrequencyCapability,
|
||||
RadioFrequencyInfo,
|
||||
RadioFrequencyModulation,
|
||||
)
|
||||
from rf_protocols import ModulationType, RadioFrequencyCommand
|
||||
|
||||
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .entity import (
|
||||
EsphomeEntity,
|
||||
convert_api_error_ha_error,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
MODULATION_TYPE_TO_ESPHOME: dict[ModulationType, RadioFrequencyModulation] = {
|
||||
ModulationType.OOK: RadioFrequencyModulation.OOK,
|
||||
}
|
||||
|
||||
|
||||
class EsphomeRadioFrequencyEntity(
|
||||
EsphomeEntity[RadioFrequencyInfo, EntityState], RadioFrequencyTransmitterEntity
|
||||
):
|
||||
"""ESPHome radio frequency entity using native API."""
|
||||
|
||||
@property
|
||||
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
|
||||
"""Return supported frequency ranges from device info."""
|
||||
return [(self._static_info.frequency_min, self._static_info.frequency_max)]
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
self.async_write_ha_state()
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
|
||||
"""Send an RF command."""
|
||||
timings = command.get_raw_timings()
|
||||
_LOGGER.debug("Sending RF command: %s", timings)
|
||||
|
||||
self._client.radio_frequency_transmit_raw_timings(
|
||||
self._static_info.key,
|
||||
frequency=command.frequency,
|
||||
timings=timings,
|
||||
modulation=MODULATION_TYPE_TO_ESPHOME[command.modulation],
|
||||
# In ESPHome, repeat_count is total number of times to send the command, while in rf_protocols
|
||||
# it's the number of additional times to send it, so we need to add 1 here.
|
||||
repeat_count=command.repeat_count + 1,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
platform_async_setup_entry,
|
||||
info_type=RadioFrequencyInfo,
|
||||
entity_type=EsphomeRadioFrequencyEntity,
|
||||
state_type=EntityState,
|
||||
info_filter=lambda info: bool(
|
||||
info.capabilities & RadioFrequencyCapability.TRANSMITTER
|
||||
),
|
||||
)
|
||||
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfVolume
|
||||
from homeassistant.const import UnitOfVolume, UnitOfVolumeFlowRate
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -34,7 +34,8 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = (
|
||||
key="current_interval",
|
||||
translation_key="current_interval",
|
||||
suggested_display_precision=2,
|
||||
native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/m",
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_MINUTE,
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
@@ -65,14 +66,16 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = (
|
||||
key="last_60_min",
|
||||
translation_key="last_60_min",
|
||||
suggested_display_precision=2,
|
||||
native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/h",
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="last_24_hrs",
|
||||
translation_key="last_24_hrs",
|
||||
suggested_display_precision=2,
|
||||
native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/d",
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_DAY,
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
||||
@@ -66,8 +66,6 @@ SWITCH_TYPE_WIFINETWORK = "WiFiNetwork"
|
||||
|
||||
BUTTON_TYPE_WOL = "WakeOnLan"
|
||||
|
||||
UPTIME_DEVIATION = 5
|
||||
|
||||
FRITZ_EXCEPTIONS = (
|
||||
ConnectionError,
|
||||
FritzActionError,
|
||||
|
||||
@@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import DSL_CONNECTION, UPTIME_DEVIATION
|
||||
from .const import DSL_CONNECTION
|
||||
from .coordinator import FritzConfigEntry
|
||||
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
|
||||
from .models import ConnectionInfo
|
||||
@@ -39,31 +39,18 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime:
|
||||
"""Calculate uptime with deviation."""
|
||||
delta_uptime = utcnow() - timedelta(seconds=seconds_uptime)
|
||||
|
||||
if (
|
||||
not last_value
|
||||
or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION
|
||||
):
|
||||
return delta_uptime
|
||||
|
||||
return last_value
|
||||
|
||||
|
||||
def _retrieve_device_uptime_state(
|
||||
status: FritzStatus, last_value: datetime
|
||||
status: FritzStatus, last_value: datetime | None
|
||||
) -> datetime:
|
||||
"""Return uptime from device."""
|
||||
return _uptime_calculation(status.device_uptime, last_value)
|
||||
return utcnow() - timedelta(seconds=status.device_uptime)
|
||||
|
||||
|
||||
def _retrieve_connection_uptime_state(
|
||||
status: FritzStatus, last_value: datetime | None
|
||||
) -> datetime:
|
||||
"""Return uptime from connection."""
|
||||
return _uptime_calculation(status.connection_uptime, last_value)
|
||||
return utcnow() - timedelta(seconds=status.connection_uptime)
|
||||
|
||||
|
||||
def _retrieve_external_ip_state(status: FritzStatus, last_value: str) -> str:
|
||||
@@ -200,7 +187,7 @@ CONNECTION_SENSOR_TYPES: tuple[FritzConnectionSensorEntityDescription, ...] = (
|
||||
FritzConnectionSensorEntityDescription(
|
||||
key="connection_uptime",
|
||||
translation_key="connection_uptime",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=_retrieve_connection_uptime_state,
|
||||
),
|
||||
@@ -308,7 +295,7 @@ DEVICE_SENSOR_TYPES: tuple[FritzDeviceSensorEntityDescription, ...] = (
|
||||
FritzDeviceSensorEntityDescription(
|
||||
key="device_uptime",
|
||||
translation_key="device_uptime",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=_retrieve_device_uptime_state,
|
||||
),
|
||||
|
||||
@@ -87,7 +87,18 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
||||
"""Update entity to handle updates for the Supervisor add-ons."""
|
||||
"""Update entity to handle updates for the Supervisor add-ons.
|
||||
|
||||
The ``addon_manager_update`` job emits a ``done=True`` WS event as soon as
|
||||
Supervisor finishes the container work, a few milliseconds before the
|
||||
``/store/addons/<slug>/update`` HTTP call returns. If we clear
|
||||
``_attr_in_progress`` on that event while the coordinator data still
|
||||
carries the pre-update version, the UI briefly flips back to
|
||||
"Update available" before ``async_install`` can refresh. ``_update_ongoing``
|
||||
survives both the WS done event and the base ``UpdateEntity`` reset, so
|
||||
the installing state remains until the coordinator confirms a new
|
||||
``installed_version``.
|
||||
"""
|
||||
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL
|
||||
@@ -95,6 +106,8 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
||||
| UpdateEntityFeature.RELEASE_NOTES
|
||||
| UpdateEntityFeature.PROGRESS
|
||||
)
|
||||
_update_ongoing: bool = False
|
||||
_version_before_update: str | None = None
|
||||
|
||||
@property
|
||||
def _addon_data(self) -> dict:
|
||||
@@ -121,6 +134,13 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
||||
"""Version installed and in use."""
|
||||
return self._addon_data[ATTR_VERSION]
|
||||
|
||||
@property
|
||||
def in_progress(self) -> bool | None:
|
||||
"""Return combined progress from the update job and refresh phase."""
|
||||
if self._update_ongoing:
|
||||
return True
|
||||
return self._attr_in_progress
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the icon of the add-on if any."""
|
||||
@@ -154,13 +174,34 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
self._version_before_update = self.installed_version
|
||||
self._update_ongoing = True
|
||||
self._attr_in_progress = True
|
||||
self.async_write_ha_state()
|
||||
await update_addon(
|
||||
self.hass, self._addon_slug, backup, self.title, self.installed_version
|
||||
)
|
||||
try:
|
||||
await update_addon(
|
||||
self.hass, self._addon_slug, backup, self.title, self.installed_version
|
||||
)
|
||||
except HomeAssistantError:
|
||||
self._update_ongoing = False
|
||||
self._version_before_update = None
|
||||
self._attr_in_progress = False
|
||||
self._attr_update_percentage = None
|
||||
self.async_write_ha_state()
|
||||
raise
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Clear the ongoing flag once the installed version has changed."""
|
||||
if (
|
||||
self._update_ongoing
|
||||
and self.installed_version != self._version_before_update
|
||||
):
|
||||
self._update_ongoing = False
|
||||
self._version_before_update = None
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@callback
|
||||
def _update_job_changed(self, job: Job) -> None:
|
||||
"""Process update for this entity's update job."""
|
||||
|
||||
@@ -225,7 +225,7 @@ async def async_attach_trigger( # noqa: C901
|
||||
elif (
|
||||
new_state.domain == "sensor"
|
||||
and new_state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
== sensor.SensorDeviceClass.TIMESTAMP
|
||||
in (sensor.SensorDeviceClass.TIMESTAMP, sensor.SensorDeviceClass.UPTIME)
|
||||
and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
|
||||
):
|
||||
trigger_dt = dt_util.parse_datetime(new_state.state)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""The Honeywell String Lights integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Honeywell String Lights from a config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Config flow for the Honeywell String Lights integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols import RadioFrequencyCommand
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.radio_frequency import async_get_transmitters
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
|
||||
from .const import CONF_TRANSMITTER, DOMAIN
|
||||
from .light import COMMANDS
|
||||
|
||||
|
||||
class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Honeywell String Lights."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
sample_command: RadioFrequencyCommand = await self.hass.async_add_executor_job(
|
||||
COMMANDS.load_command, "turn_on"
|
||||
)
|
||||
try:
|
||||
transmitters = async_get_transmitters(
|
||||
self.hass, sample_command.frequency, sample_command.modulation
|
||||
)
|
||||
except HomeAssistantError:
|
||||
return self.async_abort(reason="no_transmitters")
|
||||
|
||||
if not transmitters:
|
||||
return self.async_abort(reason="no_compatible_transmitters")
|
||||
|
||||
if user_input is not None:
|
||||
registry = er.async_get(self.hass)
|
||||
entity_entry = registry.async_get(user_input[CONF_TRANSMITTER])
|
||||
assert entity_entry is not None
|
||||
await self.async_set_unique_id(entity_entry.id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title="Honeywell String Lights",
|
||||
data={CONF_TRANSMITTER: entity_entry.id},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TRANSMITTER): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(include_entities=transmitters),
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Constants for the Honeywell String Lights integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "honeywell_string_lights"
|
||||
|
||||
CONF_TRANSMITTER: Final = "transmitter"
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Common entity for Honeywell String Lights integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import Event, EventStateChangedData, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .const import CONF_TRANSMITTER, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HoneywellStringLightsEntity(Entity):
|
||||
"""Honeywell String Lights base entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._transmitter = entry.data[CONF_TRANSMITTER]
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="Honeywell",
|
||||
model="String Lights",
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to transmitter entity state changes."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
transmitter_entity_id = er.async_validate_entity_id(
|
||||
er.async_get(self.hass), self._transmitter
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_transmitter_state_changed(
|
||||
event: Event[EventStateChangedData],
|
||||
) -> None:
|
||||
"""Handle transmitter entity state changes."""
|
||||
new_state = event.data["new_state"]
|
||||
transmitter_available = (
|
||||
new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
if transmitter_available != self.available:
|
||||
_LOGGER.info(
|
||||
"Transmitter %s used by %s is %s",
|
||||
transmitter_entity_id,
|
||||
self.entity_id,
|
||||
"available" if transmitter_available else "unavailable",
|
||||
)
|
||||
|
||||
self._attr_available = transmitter_available
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[transmitter_entity_id],
|
||||
_async_transmitter_state_changed,
|
||||
)
|
||||
)
|
||||
|
||||
# Set initial availability based on current transmitter entity state
|
||||
transmitter_state = self.hass.states.get(transmitter_entity_id)
|
||||
self._attr_available = (
|
||||
transmitter_state is not None
|
||||
and transmitter_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Light platform for Honeywell String Lights."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols import get_codes
|
||||
|
||||
from homeassistant.components.light import ColorMode, LightEntity
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .entity import HoneywellStringLightsEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
COMMANDS = get_codes("honeywell/string_lights")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Honeywell String Lights light platform."""
|
||||
async_add_entities([HoneywellStringLight(config_entry)])
|
||||
|
||||
|
||||
class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEntity):
|
||||
"""Representation of a Honeywell String Lights set controlled via RF."""
|
||||
|
||||
_attr_assumed_state = True
|
||||
_attr_color_mode = ColorMode.ONOFF
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last known state."""
|
||||
await super().async_added_to_hass()
|
||||
if (last_state := await self.async_get_last_state()) is not None:
|
||||
self._attr_is_on = last_state.state == STATE_ON
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the light."""
|
||||
await self._async_send_command("turn_on")
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the light."""
|
||||
await self._async_send_command("turn_off")
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_send_command(self, name: str) -> None:
|
||||
"""Load the named command and send it via the configured transmitter."""
|
||||
command = await COMMANDS.async_load_command(name)
|
||||
await async_send_command(
|
||||
self.hass, self._transmitter, command, context=self._context
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "honeywell_string_lights",
|
||||
"name": "Honeywell String Lights",
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["radio_frequency"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/honeywell_string_lights",
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["rf-protocols==2.1.0"]
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not register custom service actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration transmits RF commands and does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not register custom service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use runtime data.
|
||||
test-before-configure:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is a one-way broadcast with no device to contact.
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is a one-way broadcast with no device to contact.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration has no options.
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is a one-way broadcast; the light uses assumed state.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is a one-way broadcast; the light uses assumed state.
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not authenticate.
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF devices cannot be discovered.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is one-way; there is no data update.
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry represents a single static device.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
The single entity represents the primary device function.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: |
|
||||
Light entities do not have device classes.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
The single entity represents the primary device function.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
The entity uses the device name.
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
Light uses the default icon for its state.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
No known repairable issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry represents a single static device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use a web session.
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.",
|
||||
"no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"transmitter": "Radio frequency transmitter"
|
||||
},
|
||||
"data_description": {
|
||||
"transmitter": "The radio frequency transmitter used to control the Honeywell String Lights."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
@@ -32,6 +32,7 @@ from homeassistant.helpers.typing import UNDEFINED, ConfigType, StateType, Undef
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from .const import ( # noqa: F401
|
||||
AMBIGUOUS_UNITS,
|
||||
@@ -63,6 +64,8 @@ ENTITY_ID_FORMAT: Final = DOMAIN + ".{}"
|
||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
SCAN_INTERVAL: Final = timedelta(seconds=30)
|
||||
UPTIME_DEFAULT_TOLERANCE_SECONDS: Final = 60
|
||||
UPTIME_MIN_TOLERANCE_SECONDS: Final = 5
|
||||
|
||||
__all__ = [
|
||||
"ATTR_LAST_RESET",
|
||||
@@ -180,6 +183,9 @@ TEMPERATURE_UNITS = {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT}
|
||||
class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Base class for sensor entities."""
|
||||
|
||||
# Allow per-entity override of drift tolerance
|
||||
_attr_uptime_drift_tolerance: int = UPTIME_DEFAULT_TOLERANCE_SECONDS
|
||||
|
||||
_entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS})
|
||||
|
||||
entity_description: SensorEntityDescription
|
||||
@@ -201,6 +207,19 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_sensor_option_display_precision: int | None = None
|
||||
_sensor_option_unit_of_measurement: str | None | UndefinedType = UNDEFINED
|
||||
_invalid_suggested_unit_of_measurement_reported = False
|
||||
_get_uptime: Callable[[datetime], datetime] | None = None
|
||||
|
||||
def _normalize_uptime(self, current_uptime: datetime) -> datetime:
|
||||
"""Normalize uptime to suppress small drift between updates."""
|
||||
if self._get_uptime is None:
|
||||
drift_tolerance = max(
|
||||
self._attr_uptime_drift_tolerance, UPTIME_MIN_TOLERANCE_SECONDS
|
||||
)
|
||||
self._get_uptime = ignore_variance(
|
||||
func=lambda value: value,
|
||||
ignored_variance=timedelta(seconds=drift_tolerance),
|
||||
)
|
||||
return self._get_uptime(current_uptime)
|
||||
|
||||
@callback
|
||||
def add_to_platform_start(
|
||||
@@ -610,10 +629,14 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
# Checks below only apply if there is a value
|
||||
if value is None:
|
||||
if device_class is SensorDeviceClass.UPTIME:
|
||||
# Reset baseline so the first uptime after unavailable is not
|
||||
# compared against a stale value.
|
||||
self._get_uptime = None
|
||||
return None
|
||||
|
||||
# Received a datetime
|
||||
if device_class is SensorDeviceClass.TIMESTAMP:
|
||||
if device_class in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME):
|
||||
try:
|
||||
# We cast the value, to avoid using isinstance, but satisfy
|
||||
# typechecking. The errors are guarded in this try.
|
||||
@@ -627,10 +650,13 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
if value.tzinfo != UTC:
|
||||
value = value.astimezone(UTC)
|
||||
|
||||
if device_class is SensorDeviceClass.UPTIME:
|
||||
value = self._normalize_uptime(value)
|
||||
|
||||
return value.isoformat(timespec="seconds")
|
||||
except (AttributeError, OverflowError, TypeError) as err:
|
||||
raise ValueError(
|
||||
f"Invalid datetime: {self.entity_id} has timestamp device class "
|
||||
f"Invalid datetime: {self.entity_id} has {device_class.value} device class "
|
||||
f"but provides state {value}:{type(value)} resulting in '{err}'"
|
||||
) from err
|
||||
|
||||
|
||||
@@ -117,6 +117,20 @@ class SensorDeviceClass(StrEnum):
|
||||
ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601
|
||||
"""
|
||||
|
||||
UPTIME = "uptime"
|
||||
"""Uptime.
|
||||
|
||||
Represents the point in time when a device or service last restarted.
|
||||
|
||||
Small drift between updates is automatically suppressed in
|
||||
`SensorEntity.state` to avoid unnecessary state changes caused by clock
|
||||
jitter.
|
||||
|
||||
Unit of measurement: `None`
|
||||
|
||||
ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601
|
||||
"""
|
||||
|
||||
# Numerical device classes, these should be aligned with NumberDeviceClass
|
||||
ABSOLUTE_HUMIDITY = "absolute_humidity"
|
||||
"""Absolute humidity.
|
||||
@@ -516,6 +530,7 @@ NON_NUMERIC_DEVICE_CLASSES = {
|
||||
SensorDeviceClass.DATE,
|
||||
SensorDeviceClass.ENUM,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
SensorDeviceClass.UPTIME,
|
||||
}
|
||||
|
||||
DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorDeviceClass))
|
||||
@@ -816,6 +831,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
|
||||
SensorDeviceClass.TEMPERATURE: {SensorStateClass.MEASUREMENT},
|
||||
SensorDeviceClass.TEMPERATURE_DELTA: {SensorStateClass.MEASUREMENT},
|
||||
SensorDeviceClass.TIMESTAMP: set(),
|
||||
SensorDeviceClass.UPTIME: set(),
|
||||
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {SensorStateClass.MEASUREMENT},
|
||||
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {SensorStateClass.MEASUREMENT},
|
||||
SensorDeviceClass.VOLTAGE: {SensorStateClass.MEASUREMENT},
|
||||
|
||||
@@ -18,7 +18,7 @@ def async_parse_date_datetime(
|
||||
value: str, entity_id: str, device_class: SensorDeviceClass | str | None
|
||||
) -> datetime | date | None:
|
||||
"""Parse datetime string to a data or datetime."""
|
||||
if device_class == SensorDeviceClass.TIMESTAMP:
|
||||
if device_class in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME):
|
||||
if (parsed_timestamp := dt_util.parse_datetime(value)) is None:
|
||||
_LOGGER.warning("%s rendered invalid timestamp: %s", entity_id, value)
|
||||
return None
|
||||
|
||||
@@ -163,6 +163,9 @@
|
||||
"timestamp": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"uptime": {
|
||||
"default": "mdi:clock-start"
|
||||
},
|
||||
"volatile_organic_compounds": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
|
||||
@@ -297,6 +297,9 @@
|
||||
"timestamp": {
|
||||
"name": "Timestamp"
|
||||
},
|
||||
"uptime": {
|
||||
"name": "Uptime"
|
||||
},
|
||||
"volatile_organic_compounds": {
|
||||
"name": "Volatile organic compounds"
|
||||
},
|
||||
|
||||
@@ -97,13 +97,17 @@ class VeluxCover(VeluxEntity, CoverEntity):
|
||||
self._attr_device_class = CoverDeviceClass.SHUTTER
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int:
|
||||
def current_cover_position(self) -> int | None:
|
||||
"""Return the current position of the cover."""
|
||||
if not self.node.position.known:
|
||||
return None
|
||||
return 100 - self.node.position.position_percent
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
if not self.node.position.known:
|
||||
return None
|
||||
return self.node.position.closed
|
||||
|
||||
@property
|
||||
@@ -168,22 +172,29 @@ class VeluxDualRollerShutter(VeluxCover):
|
||||
self.part = part
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int:
|
||||
"""Return the current position of the cover."""
|
||||
def _part_position(self) -> Position:
|
||||
"""Return the pyvlx Position for this part of the shutter."""
|
||||
if self.part == VeluxDualRollerPart.UPPER:
|
||||
return 100 - self.node.position_upper_curtain.position_percent
|
||||
return self.node.position_upper_curtain
|
||||
if self.part == VeluxDualRollerPart.LOWER:
|
||||
return 100 - self.node.position_lower_curtain.position_percent
|
||||
return 100 - self.node.position.position_percent
|
||||
return self.node.position_lower_curtain
|
||||
return self.node.position
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
def current_cover_position(self) -> int | None:
|
||||
"""Return the current position of the cover."""
|
||||
position = self._part_position
|
||||
if not position.known:
|
||||
return None
|
||||
return 100 - position.position_percent
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
if self.part == VeluxDualRollerPart.UPPER:
|
||||
return self.node.position_upper_curtain.closed
|
||||
if self.part == VeluxDualRollerPart.LOWER:
|
||||
return self.node.position_lower_curtain.closed
|
||||
return self.node.position.closed
|
||||
position = self._part_position
|
||||
if not position.known:
|
||||
return None
|
||||
return position.closed
|
||||
|
||||
@wrap_pyvlx_call_exceptions
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
@@ -227,6 +238,8 @@ class VeluxBlind(VeluxCover):
|
||||
@property
|
||||
def current_cover_tilt_position(self) -> int | None:
|
||||
"""Return the current tilt position of the cover."""
|
||||
if not self.node.orientation.known:
|
||||
return None
|
||||
return 100 - self.node.orientation.position_percent
|
||||
|
||||
@wrap_pyvlx_call_exceptions
|
||||
|
||||
Generated
+1
@@ -311,6 +311,7 @@ FLOWS = {
|
||||
"homewizard",
|
||||
"homeworks",
|
||||
"honeywell",
|
||||
"honeywell_string_lights",
|
||||
"hr_energy_qube",
|
||||
"html5",
|
||||
"huawei_lte",
|
||||
|
||||
@@ -2975,6 +2975,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"name": "Honeywell Total Connect Comfort (US)"
|
||||
},
|
||||
"honeywell_string_lights": {
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "assumed_state",
|
||||
"name": "Honeywell String Lights"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import sys
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ClassVar,
|
||||
Final,
|
||||
Literal,
|
||||
Never,
|
||||
@@ -443,6 +444,9 @@ class EntityConditionBase(Condition):
|
||||
|
||||
_domain_specs: Mapping[str, DomainSpec]
|
||||
_schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
|
||||
# When True, indirect target expansion (via device/area/floor) skips
|
||||
# entities with an entity_category.
|
||||
_primary_entities_only: ClassVar[bool] = True
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
@@ -506,7 +510,10 @@ class EntityConditionBase(Condition):
|
||||
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
|
||||
"""Test state condition."""
|
||||
targeted_entities = async_extract_referenced_entity_ids(
|
||||
self._hass, self._target_selection, expand_group=False
|
||||
self._hass,
|
||||
self._target_selection,
|
||||
expand_group=False,
|
||||
primary_entities_only=self._primary_entities_only,
|
||||
)
|
||||
referenced_entity_ids = targeted_entities.referenced.union(
|
||||
targeted_entities.indirectly_referenced
|
||||
@@ -545,6 +552,7 @@ def make_entity_state_condition(
|
||||
states: str | bool | set[str | bool],
|
||||
*,
|
||||
support_duration: bool = False,
|
||||
primary_entities_only: bool = True,
|
||||
) -> type[EntityStateConditionBase]:
|
||||
"""Create a condition for entity state changes to specific state(s).
|
||||
|
||||
@@ -568,6 +576,7 @@ def make_entity_state_condition(
|
||||
else ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL
|
||||
)
|
||||
_states = states_set
|
||||
_primary_entities_only = primary_entities_only
|
||||
|
||||
return CustomCondition
|
||||
|
||||
@@ -675,6 +684,8 @@ class EntityNumericalConditionBase(EntityConditionBase):
|
||||
def make_entity_numerical_condition(
|
||||
domain_specs: Mapping[str, DomainSpec] | str,
|
||||
valid_unit: str | None | UndefinedType = UNDEFINED,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
) -> type[EntityNumericalConditionBase]:
|
||||
"""Create a condition for numerical state comparisons."""
|
||||
specs = _normalize_domain_specs(domain_specs)
|
||||
@@ -684,6 +695,7 @@ def make_entity_numerical_condition(
|
||||
|
||||
_domain_specs = specs
|
||||
_valid_unit = valid_unit
|
||||
_primary_entities_only = primary_entities_only
|
||||
|
||||
return CustomCondition
|
||||
|
||||
@@ -1476,7 +1488,7 @@ def time(
|
||||
after = datetime.strptime(after_entity.state, "%H:%M:%S").time()
|
||||
elif (
|
||||
after_entity.attributes.get(ATTR_DEVICE_CLASS)
|
||||
== SensorDeviceClass.TIMESTAMP
|
||||
in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME)
|
||||
) and after_entity.state not in (
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
@@ -1506,7 +1518,7 @@ def time(
|
||||
return False
|
||||
elif (
|
||||
before_entity.attributes.get(ATTR_DEVICE_CLASS)
|
||||
== SensorDeviceClass.TIMESTAMP
|
||||
in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME)
|
||||
) and before_entity.state not in (
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
|
||||
@@ -1878,6 +1878,7 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False):
|
||||
|
||||
entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig]
|
||||
device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig]
|
||||
primary_entities_only: bool
|
||||
|
||||
|
||||
@SELECTORS.register("target")
|
||||
@@ -1899,6 +1900,7 @@ class TargetSelector(Selector[TargetSelectorConfig]):
|
||||
cv.ensure_list,
|
||||
[DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA],
|
||||
),
|
||||
vol.Optional("primary_entities_only"): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import logging
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ClassVar,
|
||||
Final,
|
||||
Literal,
|
||||
Protocol,
|
||||
@@ -357,6 +358,9 @@ class EntityTriggerBase(Trigger):
|
||||
{STATE_UNAVAILABLE, STATE_UNKNOWN}
|
||||
)
|
||||
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST
|
||||
# When True, indirect target expansion (via device/area/floor) skips
|
||||
# entities with an entity_category.
|
||||
_primary_entities_only: ClassVar[bool] = True
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
@@ -519,7 +523,11 @@ class EntityTriggerBase(Trigger):
|
||||
)
|
||||
|
||||
unsub = async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, self.entity_filter
|
||||
self._hass,
|
||||
self._target,
|
||||
state_change_listener,
|
||||
self.entity_filter,
|
||||
primary_entities_only=self._primary_entities_only,
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -892,6 +900,8 @@ def _normalize_domain_specs(
|
||||
def make_entity_target_state_trigger(
|
||||
domain_specs: Mapping[str, DomainSpec] | str,
|
||||
to_states: str | set[str],
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
) -> type[EntityTargetStateTriggerBase]:
|
||||
"""Create a trigger for entity state changes to specific state(s).
|
||||
|
||||
@@ -910,6 +920,7 @@ def make_entity_target_state_trigger(
|
||||
|
||||
_domain_specs = specs
|
||||
_to_states = to_states_set
|
||||
_primary_entities_only = primary_entities_only
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
@@ -961,6 +972,8 @@ def make_entity_origin_state_trigger(
|
||||
def make_entity_numerical_state_changed_trigger(
|
||||
domain_specs: Mapping[str, DomainSpec],
|
||||
valid_unit: str | None | UndefinedType = UNDEFINED,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
) -> type[EntityNumericalStateChangedTriggerBase]:
|
||||
"""Create a trigger for numerical state value change."""
|
||||
|
||||
@@ -969,6 +982,7 @@ def make_entity_numerical_state_changed_trigger(
|
||||
|
||||
_domain_specs = domain_specs
|
||||
_valid_unit = valid_unit
|
||||
_primary_entities_only = primary_entities_only
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
@@ -976,6 +990,8 @@ def make_entity_numerical_state_changed_trigger(
|
||||
def make_entity_numerical_state_crossed_threshold_trigger(
|
||||
domain_specs: Mapping[str, DomainSpec],
|
||||
valid_unit: str | None | UndefinedType = UNDEFINED,
|
||||
*,
|
||||
primary_entities_only: bool = True,
|
||||
) -> type[EntityNumericalStateCrossedThresholdTriggerBase]:
|
||||
"""Create a trigger for numerical state value crossing a threshold."""
|
||||
|
||||
@@ -984,6 +1000,7 @@ def make_entity_numerical_state_crossed_threshold_trigger(
|
||||
|
||||
_domain_specs = domain_specs
|
||||
_valid_unit = valid_unit
|
||||
_primary_entities_only = primary_entities_only
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
@@ -403,12 +403,13 @@ class ManualTriggerSensorEntity(ManualTriggerEntity, SensorEntity):
|
||||
def _set_native_value_with_possible_timestamp(self, value: Any) -> None:
|
||||
"""Set native value with possible timestamp.
|
||||
|
||||
If self.device_class is `date` or `timestamp`,
|
||||
If self.device_class is `date`, `timestamp`, or `uptime`,
|
||||
it will try to parse the value to a date/datetime object.
|
||||
"""
|
||||
if self.device_class not in (
|
||||
SensorDeviceClass.DATE,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
SensorDeviceClass.UPTIME,
|
||||
):
|
||||
self._attr_native_value = value
|
||||
elif value is not None:
|
||||
|
||||
Generated
+1
@@ -2840,6 +2840,7 @@ renson-endura-delta==1.7.2
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.19.1
|
||||
|
||||
# homeassistant.components.honeywell_string_lights
|
||||
# homeassistant.components.radio_frequency
|
||||
rf-protocols==2.1.0
|
||||
|
||||
|
||||
Generated
+1
@@ -2424,6 +2424,7 @@ renson-endura-delta==1.7.2
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.19.1
|
||||
|
||||
# homeassistant.components.honeywell_string_lights
|
||||
# homeassistant.components.radio_frequency
|
||||
rf-protocols==2.1.0
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -49,6 +50,36 @@ async def test_alarm_control_panel_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("alarm_control_panel.is_armed", {}, True, False),
|
||||
("alarm_control_panel.is_armed_away", {}, True, True),
|
||||
("alarm_control_panel.is_armed_home", {}, True, True),
|
||||
("alarm_control_panel.is_armed_night", {}, True, True),
|
||||
("alarm_control_panel.is_armed_vacation", {}, True, True),
|
||||
("alarm_control_panel.is_disarmed", {}, True, True),
|
||||
("alarm_control_panel.is_triggered", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_alarm_control_panel_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that alarm_control_panel conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -42,6 +43,33 @@ async def test_assist_satellite_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("assist_satellite.is_idle", {}, True, True),
|
||||
("assist_satellite.is_listening", {}, True, True),
|
||||
("assist_satellite.is_processing", {}, True, True),
|
||||
("assist_satellite.is_responding", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_assist_satellite_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that assist_satellite conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -17,6 +18,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_numerical_condition_above_below_all,
|
||||
@@ -31,13 +33,17 @@ _BATTERY_UNIT_ATTRS = {ATTR_UNIT_OF_MEASUREMENT: "%"}
|
||||
@pytest.fixture
|
||||
async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple binary sensor entities associated with different targets."""
|
||||
return await target_entities(hass, "binary_sensor")
|
||||
return await target_entities(
|
||||
hass, "binary_sensor", entity_category=EntityCategory.DIAGNOSTIC
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple sensor entities associated with different targets."""
|
||||
return await target_entities(hass, "sensor")
|
||||
return await target_entities(
|
||||
hass, "sensor", entity_category=EntityCategory.DIAGNOSTIC
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -57,6 +63,33 @@ async def test_battery_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("battery.is_low", {}, True, True),
|
||||
("battery.is_not_low", {}, True, True),
|
||||
("battery.is_charging", {}, True, True),
|
||||
("battery.is_not_charging", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_battery_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that battery conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -31,13 +32,17 @@ from tests.components.common import (
|
||||
@pytest.fixture
|
||||
async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple binary sensor entities associated with different targets."""
|
||||
return await target_entities(hass, "binary_sensor")
|
||||
return await target_entities(
|
||||
hass, "binary_sensor", entity_category=EntityCategory.DIAGNOSTIC
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple sensor entities associated with different targets."""
|
||||
return await target_entities(hass, "sensor")
|
||||
return await target_entities(
|
||||
hass, "sensor", entity_category=EntityCategory.DIAGNOSTIC
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -38,6 +39,30 @@ async def test_calendar_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("calendar.is_event_active", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_calendar_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that calendar conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -22,6 +22,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
assert_numerical_condition_unit_conversion,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
@@ -59,6 +60,34 @@ async def test_climate_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("climate.is_off", {}, True, True),
|
||||
("climate.is_on", {}, True, False),
|
||||
("climate.is_cooling", {}, True, False),
|
||||
("climate.is_drying", {}, True, False),
|
||||
("climate.is_heating", {}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_climate_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that climate conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.const import (
|
||||
CONF_TARGET,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.helpers import (
|
||||
@@ -48,13 +49,22 @@ from tests.common import MockConfigEntry, mock_device_registry
|
||||
|
||||
|
||||
async def target_entities(
|
||||
hass: HomeAssistant, domain: str, *, domain_excluded: str | None = None
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
*,
|
||||
domain_excluded: str | None = None,
|
||||
entity_category: EntityCategory | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Create multiple entities associated with different targets.
|
||||
|
||||
If `domain_excluded` is provided, entities in excluded_entities will have this
|
||||
domain, otherwise they will have the same domain as included_entities.
|
||||
|
||||
If `entity_category` is provided, all created registry entities (i.e. the
|
||||
area-, device-, and label-associated entities) are created with that
|
||||
entity category. Standalone entities are referenced directly by entity_id
|
||||
and are unaffected.
|
||||
|
||||
Returns a dict with the following keys:
|
||||
- included_entities: List of entity_ids meant to be targeted.
|
||||
- excluded_entities: List of entity_ids not meant to be targeted.
|
||||
@@ -89,6 +99,7 @@ async def target_entities(
|
||||
platform="test",
|
||||
unique_id=f"{domain}_area",
|
||||
suggested_object_id=f"area_{domain}",
|
||||
entity_category=entity_category,
|
||||
)
|
||||
entity_reg.async_update_entity(entity_area.entity_id, area_id=area.id)
|
||||
entity_area_excluded = entity_reg.async_get_or_create(
|
||||
@@ -96,6 +107,7 @@ async def target_entities(
|
||||
platform="test",
|
||||
unique_id=f"{domain_excluded}_area_excluded",
|
||||
suggested_object_id=f"area_{domain_excluded}_excluded",
|
||||
entity_category=entity_category,
|
||||
)
|
||||
entity_reg.async_update_entity(entity_area_excluded.entity_id, area_id=area.id)
|
||||
|
||||
@@ -106,6 +118,7 @@ async def target_entities(
|
||||
unique_id=f"{domain}_device",
|
||||
suggested_object_id=f"device_{domain}",
|
||||
device_id=device.id,
|
||||
entity_category=entity_category,
|
||||
)
|
||||
entity_reg.async_get_or_create(
|
||||
domain=domain,
|
||||
@@ -113,6 +126,7 @@ async def target_entities(
|
||||
unique_id=f"{domain}_device2",
|
||||
suggested_object_id=f"device2_{domain}",
|
||||
device_id=device.id,
|
||||
entity_category=entity_category,
|
||||
)
|
||||
entity_reg.async_get_or_create(
|
||||
domain=domain_excluded,
|
||||
@@ -120,6 +134,7 @@ async def target_entities(
|
||||
unique_id=f"{domain_excluded}_device_excluded",
|
||||
suggested_object_id=f"device_{domain_excluded}_excluded",
|
||||
device_id=device.id,
|
||||
entity_category=entity_category,
|
||||
)
|
||||
|
||||
# Entities associated with label
|
||||
@@ -128,6 +143,7 @@ async def target_entities(
|
||||
platform="test",
|
||||
unique_id=f"{domain}_label",
|
||||
suggested_object_id=f"label_{domain}",
|
||||
entity_category=entity_category,
|
||||
)
|
||||
entity_reg.async_update_entity(entity_label.entity_id, labels={label.label_id})
|
||||
entity_label_excluded = entity_reg.async_get_or_create(
|
||||
@@ -135,6 +151,7 @@ async def target_entities(
|
||||
platform="test",
|
||||
unique_id=f"{domain_excluded}_label_excluded",
|
||||
suggested_object_id=f"label_{domain_excluded}_excluded",
|
||||
entity_category=entity_category,
|
||||
)
|
||||
entity_reg.async_update_entity(
|
||||
entity_label_excluded.entity_id, labels={label.label_id}
|
||||
|
||||
@@ -11,6 +11,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -31,6 +32,33 @@ async def test_counter_condition_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, "counter.is_value")
|
||||
|
||||
|
||||
_PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("counter.is_value", _PLAIN_THRESHOLD, True, False),
|
||||
],
|
||||
)
|
||||
async def test_counter_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that counter conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -13,6 +13,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -50,6 +51,39 @@ async def test_cover_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("cover.awning_is_closed", {}, True, False),
|
||||
("cover.awning_is_open", {}, True, False),
|
||||
("cover.blind_is_closed", {}, True, False),
|
||||
("cover.blind_is_open", {}, True, False),
|
||||
("cover.curtain_is_closed", {}, True, False),
|
||||
("cover.curtain_is_open", {}, True, False),
|
||||
("cover.shade_is_closed", {}, True, False),
|
||||
("cover.shade_is_open", {}, True, False),
|
||||
("cover.shutter_is_closed", {}, True, False),
|
||||
("cover.shutter_is_open", {}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_cover_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that cover conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -13,6 +13,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -47,6 +48,31 @@ async def test_door_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("door.is_closed", {}, True, False),
|
||||
("door.is_open", {}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_door_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that door conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
# --- binary_sensor tests ---
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Test ESPHome radio frequency platform."""
|
||||
|
||||
from aioesphomeapi import (
|
||||
APIClient,
|
||||
APIConnectionError,
|
||||
RadioFrequencyCapability,
|
||||
RadioFrequencyInfo,
|
||||
RadioFrequencyModulation,
|
||||
)
|
||||
import pytest
|
||||
from rf_protocols import ModulationType, OOKCommand
|
||||
|
||||
from homeassistant.components import radio_frequency
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .conftest import MockESPHomeDevice, MockESPHomeDeviceType
|
||||
|
||||
ENTITY_ID = "radio_frequency.test_rf"
|
||||
|
||||
|
||||
async def _mock_rf_device(
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
mock_client: APIClient,
|
||||
capabilities: RadioFrequencyCapability = RadioFrequencyCapability.TRANSMITTER,
|
||||
frequency_min: int = 433_000_000,
|
||||
frequency_max: int = 434_000_000,
|
||||
supported_modulations: int = 1,
|
||||
) -> MockESPHomeDevice:
|
||||
entity_info = [
|
||||
RadioFrequencyInfo(
|
||||
object_id="rf",
|
||||
key=1,
|
||||
name="RF",
|
||||
capabilities=capabilities,
|
||||
frequency_min=frequency_min,
|
||||
frequency_max=frequency_max,
|
||||
supported_modulations=supported_modulations,
|
||||
)
|
||||
]
|
||||
return await mock_esphome_device(
|
||||
mock_client=mock_client, entity_info=entity_info, states=[]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capabilities", "entity_created"),
|
||||
[
|
||||
(RadioFrequencyCapability.TRANSMITTER, True),
|
||||
(RadioFrequencyCapability.RECEIVER, False),
|
||||
(
|
||||
RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER,
|
||||
True,
|
||||
),
|
||||
(RadioFrequencyCapability(0), False),
|
||||
],
|
||||
)
|
||||
async def test_radio_frequency_entity_transmitter(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
capabilities: RadioFrequencyCapability,
|
||||
entity_created: bool,
|
||||
) -> None:
|
||||
"""Test radio frequency entity with transmitter capability is created."""
|
||||
await _mock_rf_device(mock_esphome_device, mock_client, capabilities)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert (state is not None) == entity_created
|
||||
|
||||
|
||||
async def test_radio_frequency_multiple_entities_mixed_capabilities(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test multiple radio frequency entities with mixed capabilities."""
|
||||
entity_info = [
|
||||
RadioFrequencyInfo(
|
||||
object_id="rf_transmitter",
|
||||
key=1,
|
||||
name="RF Transmitter",
|
||||
capabilities=RadioFrequencyCapability.TRANSMITTER,
|
||||
),
|
||||
RadioFrequencyInfo(
|
||||
object_id="rf_receiver",
|
||||
key=2,
|
||||
name="RF Receiver",
|
||||
capabilities=RadioFrequencyCapability.RECEIVER,
|
||||
),
|
||||
RadioFrequencyInfo(
|
||||
object_id="rf_transceiver",
|
||||
key=3,
|
||||
name="RF Transceiver",
|
||||
capabilities=(
|
||||
RadioFrequencyCapability.TRANSMITTER | RadioFrequencyCapability.RECEIVER
|
||||
),
|
||||
),
|
||||
]
|
||||
await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=[],
|
||||
)
|
||||
|
||||
# Only transmitter and transceiver should be created
|
||||
assert hass.states.get("radio_frequency.test_rf_transmitter") is not None
|
||||
assert hass.states.get("radio_frequency.test_rf_receiver") is None
|
||||
assert hass.states.get("radio_frequency.test_rf_transceiver") is not None
|
||||
|
||||
|
||||
async def test_radio_frequency_send_command_success(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sending RF command successfully."""
|
||||
await _mock_rf_device(mock_esphome_device, mock_client)
|
||||
|
||||
command = OOKCommand(
|
||||
frequency=433_920_000,
|
||||
timings=[350, -1050, 350, -350],
|
||||
)
|
||||
await radio_frequency.async_send_command(hass, ENTITY_ID, command)
|
||||
|
||||
mock_client.radio_frequency_transmit_raw_timings.assert_called_once()
|
||||
call_args = mock_client.radio_frequency_transmit_raw_timings.call_args
|
||||
assert call_args[0][0] == 1 # key
|
||||
assert call_args[1]["frequency"] == 433_920_000
|
||||
assert call_args[1]["modulation"] == RadioFrequencyModulation.OOK
|
||||
assert call_args[1]["repeat_count"] == 1
|
||||
assert call_args[1]["device_id"] == 0
|
||||
assert call_args[1]["timings"] == [350, -1050, 350, -350]
|
||||
|
||||
|
||||
async def test_radio_frequency_send_command_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sending RF command with APIConnectionError raises HomeAssistantError."""
|
||||
await _mock_rf_device(mock_esphome_device, mock_client)
|
||||
|
||||
mock_client.radio_frequency_transmit_raw_timings.side_effect = APIConnectionError(
|
||||
"Connection lost"
|
||||
)
|
||||
|
||||
command = OOKCommand(
|
||||
frequency=433_920_000,
|
||||
timings=[350, -1050],
|
||||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError) as exc_info:
|
||||
await radio_frequency.async_send_command(hass, ENTITY_ID, command)
|
||||
assert exc_info.value.translation_domain == "esphome"
|
||||
assert exc_info.value.translation_key == "error_communicating_with_device"
|
||||
|
||||
|
||||
async def test_radio_frequency_entity_availability(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test radio frequency entity becomes available after device reconnects."""
|
||||
mock_device = await _mock_rf_device(mock_esphome_device, mock_client)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
await mock_device.mock_disconnect(False)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
await mock_device.mock_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_radio_frequency_supported_frequency_ranges(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test supported frequency ranges are exposed from device info."""
|
||||
await _mock_rf_device(
|
||||
mock_esphome_device,
|
||||
mock_client,
|
||||
frequency_min=433_000_000,
|
||||
frequency_max=434_000_000,
|
||||
)
|
||||
|
||||
transmitters = radio_frequency.async_get_transmitters(
|
||||
hass, 433_920_000, ModulationType.OOK
|
||||
)
|
||||
assert len(transmitters) == 1
|
||||
|
||||
transmitters = radio_frequency.async_get_transmitters(
|
||||
hass, 868_000_000, ModulationType.OOK
|
||||
)
|
||||
assert len(transmitters) == 0
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -39,6 +40,31 @@ async def test_fan_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("fan.is_off", {}, True, True),
|
||||
("fan.is_on", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_fan_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that fan conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -85,6 +85,7 @@ async def test_storage_data_writing(
|
||||
assert await async_setup_config_entry(
|
||||
hass, VALID_CONFIG_DEFAULT, return_value=feed_one_event
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# one new event
|
||||
assert len(events) == 1
|
||||
|
||||
@@ -41,6 +41,7 @@ SENSOR_DEVICE = {
|
||||
"type": 2, # Sensor
|
||||
"location": {
|
||||
"name": "Sensor Location",
|
||||
"tz": "America/New_York",
|
||||
},
|
||||
"name": "Flume Sensor",
|
||||
"connected": True,
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
# serializer version: 1
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_24_hours-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_24_hours',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': '24 hours',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME_FLOW_RATE: 'volume_flow_rate'>,
|
||||
'original_icon': None,
|
||||
'original_name': '24 hours',
|
||||
'platform': 'flume',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'last_24_hrs',
|
||||
'unique_id': 'last_24_hrs_1234',
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.GALLONS_PER_DAY: 'gal/d'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_24_hours-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Flume API',
|
||||
'device_class': 'volume_flow_rate',
|
||||
'friendly_name': 'Flume Sensor Sensor Location 24 hours',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.GALLONS_PER_DAY: 'gal/d'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_24_hours',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '20.4',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_30_days-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_30_days',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': '30 days',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': '30 days',
|
||||
'platform': 'flume',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'last_30_days',
|
||||
'unique_id': 'last_30_days_1234',
|
||||
'unit_of_measurement': 'gal/mo',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_30_days-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Flume API',
|
||||
'friendly_name': 'Flume Sensor Sensor Location 30 days',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': 'gal/mo',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_30_days',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '150.8',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_60_minutes-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_60_minutes',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': '60 minutes',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME_FLOW_RATE: 'volume_flow_rate'>,
|
||||
'original_icon': None,
|
||||
'original_name': '60 minutes',
|
||||
'platform': 'flume',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'last_60_min',
|
||||
'unique_id': 'last_60_min_1234',
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.GALLONS_PER_HOUR: 'gal/h'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_60_minutes-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Flume API',
|
||||
'device_class': 'volume_flow_rate',
|
||||
'friendly_name': 'Flume Sensor Sensor Location 60 minutes',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.GALLONS_PER_HOUR: 'gal/h'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_60_minutes',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '5.5',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_current-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_current',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Current',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME_FLOW_RATE: 'volume_flow_rate'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Current',
|
||||
'platform': 'flume',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'current_interval',
|
||||
'unique_id': 'current_interval_1234',
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 'gal/min'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_current-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Flume API',
|
||||
'device_class': 'volume_flow_rate',
|
||||
'friendly_name': 'Flume Sensor Sensor Location Current',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.GALLONS_PER_MINUTE: 'gal/min'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_current',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1.23',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_current_day-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_current_day',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Current day',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Current day',
|
||||
'platform': 'flume',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'today',
|
||||
'unique_id': 'today_1234',
|
||||
'unit_of_measurement': <UnitOfVolume.GALLONS: 'gal'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_current_day-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Flume API',
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Flume Sensor Sensor Location Current day',
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
'unit_of_measurement': <UnitOfVolume.GALLONS: 'gal'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_current_day',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '10.2',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_current_month-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_current_month',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Current month',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Current month',
|
||||
'platform': 'flume',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'month_to_date',
|
||||
'unique_id': 'month_to_date_1234',
|
||||
'unit_of_measurement': <UnitOfVolume.GALLONS: 'gal'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_current_month-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Flume API',
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Flume Sensor Sensor Location Current month',
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
'unit_of_measurement': <UnitOfVolume.GALLONS: 'gal'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_current_month',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '100.1',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_current_week-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_current_week',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Current week',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Current week',
|
||||
'platform': 'flume',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'week_to_date',
|
||||
'unique_id': 'week_to_date_1234',
|
||||
'unit_of_measurement': <UnitOfVolume.GALLONS: 'gal'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.flume_sensor_sensor_location_current_week-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by Flume API',
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Flume Sensor Sensor Location Current week',
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
'unit_of_measurement': <UnitOfVolume.GALLONS: 'gal'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.flume_sensor_sensor_location_current_week',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '50.5',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Test the flume sensor."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def platforms_fixture():
|
||||
"""Return the platforms to be loaded for this test."""
|
||||
with patch("homeassistant.components.flume.PLATFORMS", [Platform.SENSOR]):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("access_token", "device_list")
|
||||
async def test_sensors(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test sensors."""
|
||||
hass.config.units = US_CUSTOMARY_SYSTEM
|
||||
|
||||
flume_values = {
|
||||
"current_interval": 1.23,
|
||||
"month_to_date": 100.1,
|
||||
"week_to_date": 50.5,
|
||||
"today": 10.2,
|
||||
"last_60_min": 5.5,
|
||||
"last_24_hrs": 20.4,
|
||||
"last_30_days": 150.8,
|
||||
}
|
||||
|
||||
with patch("homeassistant.components.flume.sensor.FlumeData") as mock_flume_data:
|
||||
mock_flume_data.return_value.values = flume_values
|
||||
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
|
||||
@@ -24,7 +24,7 @@
|
||||
'object_id_base': 'Connection uptime',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Connection uptime',
|
||||
'platform': 'fritz',
|
||||
@@ -39,7 +39,7 @@
|
||||
# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_connection_uptime-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'timestamp',
|
||||
'device_class': 'uptime',
|
||||
'friendly_name': 'Mock Title Connection uptime',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -349,7 +349,7 @@
|
||||
'object_id_base': 'Last restart',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Last restart',
|
||||
'platform': 'fritz',
|
||||
@@ -364,7 +364,7 @@
|
||||
# name: test_sensor_cpu_temp_not_supported[None-return_values1][sensor.mock_title_last_restart-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'timestamp',
|
||||
'device_class': 'uptime',
|
||||
'friendly_name': 'Mock Title Last restart',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -882,7 +882,7 @@
|
||||
'object_id_base': 'Connection uptime',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Connection uptime',
|
||||
'platform': 'fritz',
|
||||
@@ -897,7 +897,7 @@
|
||||
# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_connection_uptime-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'timestamp',
|
||||
'device_class': 'uptime',
|
||||
'friendly_name': 'Mock Title Connection uptime',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -1207,7 +1207,7 @@
|
||||
'object_id_base': 'Last restart',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Last restart',
|
||||
'platform': 'fritz',
|
||||
@@ -1222,7 +1222,7 @@
|
||||
# name: test_sensor_cpu_temp_not_supported[None-return_values2][sensor.mock_title_last_restart-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'timestamp',
|
||||
'device_class': 'uptime',
|
||||
'friendly_name': 'Mock Title Last restart',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -1740,7 +1740,7 @@
|
||||
'object_id_base': 'Connection uptime',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Connection uptime',
|
||||
'platform': 'fritz',
|
||||
@@ -1755,7 +1755,7 @@
|
||||
# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_connection_uptime-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'timestamp',
|
||||
'device_class': 'uptime',
|
||||
'friendly_name': 'Mock Title Connection uptime',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -2065,7 +2065,7 @@
|
||||
'object_id_base': 'Last restart',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Last restart',
|
||||
'platform': 'fritz',
|
||||
@@ -2080,7 +2080,7 @@
|
||||
# name: test_sensor_cpu_temp_not_supported[side_effect0-None][sensor.mock_title_last_restart-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'timestamp',
|
||||
'device_class': 'uptime',
|
||||
'friendly_name': 'Mock Title Last restart',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -2598,7 +2598,7 @@
|
||||
'object_id_base': 'Connection uptime',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Connection uptime',
|
||||
'platform': 'fritz',
|
||||
@@ -2613,7 +2613,7 @@
|
||||
# name: test_sensor_setup[sensor.mock_title_connection_uptime-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'timestamp',
|
||||
'device_class': 'uptime',
|
||||
'friendly_name': 'Mock Title Connection uptime',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -2981,7 +2981,7 @@
|
||||
'object_id_base': 'Last restart',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Last restart',
|
||||
'platform': 'fritz',
|
||||
@@ -2996,7 +2996,7 @@
|
||||
# name: test_sensor_setup[sensor.mock_title_last_restart-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'timestamp',
|
||||
'device_class': 'uptime',
|
||||
'friendly_name': 'Mock Title Last restart',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -11,7 +11,7 @@ import pytest
|
||||
from requests.exceptions import RequestException
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL, UPTIME_DEVIATION
|
||||
from homeassistant.components.fritz.const import DOMAIN, SCAN_INTERVAL
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -95,13 +95,13 @@ async def test_sensor_uptime_spike(
|
||||
assert (state := hass.states.get(entity_id))
|
||||
assert state.state == "2026-01-16T06:00:21+00:00"
|
||||
|
||||
# Simulate uptime spike by setting uptime to a value between
|
||||
# the previous one and a delta smaller than UPTIME_DEVIATION
|
||||
# Simulate uptime spike by setting uptime to a value that shifts
|
||||
# the resulting timestamp only by 1 second.
|
||||
base_uptime = MOCK_FB_SERVICES["DeviceInfo1"]["GetInfo"]["NewUpTime"]
|
||||
update_uptime = {
|
||||
"DeviceInfo1": {
|
||||
"GetInfo": {
|
||||
"NewUpTime": base_uptime + SCAN_INTERVAL - UPTIME_DEVIATION + 1,
|
||||
"NewUpTime": base_uptime + SCAN_INTERVAL + 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -47,6 +48,31 @@ async def test_garage_door_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("garage_door.is_closed", {}, True, False),
|
||||
("garage_door.is_open", {}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_garage_door_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that garage_door conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
# --- binary_sensor tests ---
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -41,6 +42,31 @@ async def test_gate_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("gate.is_closed", {}, True, False),
|
||||
("gate.is_open", {}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_gate_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that gate conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -1101,9 +1101,11 @@ async def test_update_addon_sets_progress_immediately(
|
||||
|
||||
|
||||
async def test_update_addon_resets_progress_on_error(
|
||||
hass: HomeAssistant, supervisor_client: AsyncMock
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
supervisor_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test addon update resets in_progress to False when update fails."""
|
||||
"""Test addon update resets in_progress and update_percentage on failure."""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
@@ -1118,11 +1120,48 @@ async def test_update_addon_resets_progress_on_error(
|
||||
|
||||
state = hass.states.get("update.test_update")
|
||||
assert state.attributes.get("in_progress") is False
|
||||
assert state.attributes.get("update_percentage") is None
|
||||
|
||||
ws = await hass_ws_client(hass)
|
||||
job_uuid = uuid4().hex
|
||||
|
||||
async def fake_update_addon_error(
|
||||
_hass: HomeAssistant,
|
||||
_addon: str,
|
||||
_backup: bool,
|
||||
_addon_name: str | None,
|
||||
_installed_version: str | None,
|
||||
) -> None:
|
||||
"""Report some progress, then fail - as a mid-pull network error would."""
|
||||
await ws.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {
|
||||
"uuid": job_uuid,
|
||||
"created": "2025-09-29T00:00:00.000000+00:00",
|
||||
"name": "addon_manager_update",
|
||||
"reference": "test",
|
||||
"progress": 42,
|
||||
"done": False,
|
||||
"stage": None,
|
||||
"extra": {"total": 1234567890},
|
||||
"errors": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = await ws.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
raise HomeAssistantError
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.hassio.update.update_addon",
|
||||
side_effect=HomeAssistantError,
|
||||
side_effect=fake_update_addon_error,
|
||||
),
|
||||
pytest.raises(HomeAssistantError),
|
||||
):
|
||||
@@ -1137,6 +1176,163 @@ async def test_update_addon_resets_progress_on_error(
|
||||
assert state.attributes.get("in_progress") is False, (
|
||||
"in_progress should be reset to False after error"
|
||||
)
|
||||
assert state.attributes.get("update_percentage") is None, (
|
||||
"update_percentage should be reset to None after error"
|
||||
)
|
||||
|
||||
|
||||
def _bump_addon_to(
|
||||
addons_list: AsyncMock,
|
||||
addon_installed: AsyncMock,
|
||||
version: str,
|
||||
version_latest: str,
|
||||
) -> None:
|
||||
"""Rewrite the addon fixtures to report a post-update version."""
|
||||
current = addons_list.return_value
|
||||
addons_list.return_value = [
|
||||
replace(
|
||||
current[0],
|
||||
version=version,
|
||||
version_latest=version_latest,
|
||||
update_available=version != version_latest,
|
||||
),
|
||||
*current[1:],
|
||||
]
|
||||
|
||||
def _updated_info(slug: str):
|
||||
addon = Mock(
|
||||
spec=InstalledAddonComplete,
|
||||
to_dict=addon_installed.return_value.to_dict,
|
||||
**addon_installed.return_value.to_dict(),
|
||||
)
|
||||
addon.name = "test"
|
||||
addon.slug = "test"
|
||||
addon.version = version
|
||||
addon.version_latest = version_latest
|
||||
addon.update_available = version != version_latest
|
||||
addon.state = AddonState.STARTED
|
||||
addon.url = "https://github.com/home-assistant/addons/test"
|
||||
addon.auto_update = True
|
||||
return addon
|
||||
|
||||
addon_installed.side_effect = _updated_info
|
||||
|
||||
|
||||
async def test_update_addon_stays_in_progress_until_refresh(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
update_addon: AsyncMock,
|
||||
addon_installed: AsyncMock,
|
||||
addons_list: AsyncMock,
|
||||
) -> None:
|
||||
"""Test addon update entity stays in progress until coordinator refresh.
|
||||
|
||||
Supervisor emits the ``addon_manager_update`` job ``done=True`` WS event a
|
||||
few milliseconds before ``/store/addons/<slug>/update`` returns. Without
|
||||
the ``_update_ongoing`` guard, ``_attr_in_progress`` is cleared while the
|
||||
coordinator still holds the pre-update version and the UI briefly flips
|
||||
back to "Update available".
|
||||
"""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "update.test_update"
|
||||
assert hass.states.get(entity_id).state == "on"
|
||||
|
||||
ws = await hass_ws_client(hass)
|
||||
job_uuid = uuid4().hex
|
||||
in_progress_after_done: list[bool | None] = []
|
||||
|
||||
async def fake_update_addon(slug: str, _options: StoreAddonUpdate) -> None:
|
||||
"""Mimic Supervisor: fire done=True on WS, then return HTTP response."""
|
||||
await ws.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {
|
||||
"uuid": job_uuid,
|
||||
"created": "2025-09-29T00:00:00.000000+00:00",
|
||||
"name": "addon_manager_update",
|
||||
"reference": "test",
|
||||
"progress": 100,
|
||||
"done": True,
|
||||
"stage": None,
|
||||
"extra": {"total": 1234567890},
|
||||
"errors": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = await ws.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
in_progress_after_done.append(
|
||||
hass.states.get(entity_id).attributes.get("in_progress")
|
||||
)
|
||||
_bump_addon_to(addons_list, addon_installed, "2.0.1", "2.0.1")
|
||||
|
||||
update_addon.side_effect = fake_update_addon
|
||||
|
||||
await hass.services.async_call(
|
||||
"update", "install", {"entity_id": entity_id}, blocking=True
|
||||
)
|
||||
|
||||
# The done=True WS event fired mid-install must not drop in_progress; the
|
||||
# coordinator data at that instant still carries the pre-update version.
|
||||
assert in_progress_after_done == [True]
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.attributes.get("in_progress") is False
|
||||
assert state.state == "off"
|
||||
|
||||
|
||||
async def test_update_addon_completes_on_any_version_change(
|
||||
hass: HomeAssistant,
|
||||
update_addon: AsyncMock,
|
||||
addon_installed: AsyncMock,
|
||||
addons_list: AsyncMock,
|
||||
) -> None:
|
||||
"""Test completion when installed version changes from the pre-install one.
|
||||
|
||||
If a newer upstream release appears between install start and the refresh,
|
||||
``installed_version`` will not equal ``latest_version`` but will differ
|
||||
from the pre-install version. The ongoing flag must still clear.
|
||||
"""
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "update.test_update"
|
||||
|
||||
async def fake_update_addon(slug: str, _options: StoreAddonUpdate) -> None:
|
||||
_bump_addon_to(addons_list, addon_installed, "2.0.1", "2.0.2")
|
||||
|
||||
update_addon.side_effect = fake_update_addon
|
||||
|
||||
await hass.services.async_call(
|
||||
"update", "install", {"entity_id": entity_id}, blocking=True
|
||||
)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.attributes.get("in_progress") is False
|
||||
assert state.state == "on"
|
||||
|
||||
|
||||
async def test_update_supervisor(
|
||||
|
||||
@@ -521,11 +521,16 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("at_sensor"), ["sensor.next_alarm", "{{ 'sensor.next_alarm' }}"]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"device_class",
|
||||
[SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME],
|
||||
)
|
||||
async def test_if_fires_using_at_sensor(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
service_calls: list[ServiceCall],
|
||||
at_sensor: str,
|
||||
device_class: SensorDeviceClass,
|
||||
) -> None:
|
||||
"""Test for firing at sensor time."""
|
||||
now = dt_util.now()
|
||||
@@ -535,7 +540,7 @@ async def test_if_fires_using_at_sensor(
|
||||
hass.states.async_set(
|
||||
"sensor.next_alarm",
|
||||
trigger_dt.isoformat(),
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
|
||||
{ATTR_DEVICE_CLASS: device_class},
|
||||
)
|
||||
|
||||
time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1)
|
||||
@@ -572,7 +577,7 @@ async def test_if_fires_using_at_sensor(
|
||||
hass.states.async_set(
|
||||
"sensor.next_alarm",
|
||||
trigger_dt.isoformat(),
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
|
||||
{ATTR_DEVICE_CLASS: device_class},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -589,13 +594,13 @@ async def test_if_fires_using_at_sensor(
|
||||
hass.states.async_set(
|
||||
"sensor.next_alarm",
|
||||
trigger_dt.isoformat(),
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
|
||||
{ATTR_DEVICE_CLASS: device_class},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(
|
||||
"sensor.next_alarm",
|
||||
broken,
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
|
||||
{ATTR_DEVICE_CLASS: device_class},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -609,7 +614,7 @@ async def test_if_fires_using_at_sensor(
|
||||
hass.states.async_set(
|
||||
"sensor.next_alarm",
|
||||
trigger_dt.isoformat(),
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
|
||||
{ATTR_DEVICE_CLASS: device_class},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(
|
||||
@@ -633,12 +638,17 @@ async def test_if_fires_using_at_sensor(
|
||||
({"minutes": 5}, timedelta(minutes=5)),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"device_class",
|
||||
[SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME],
|
||||
)
|
||||
async def test_if_fires_using_at_sensor_with_offset(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
freezer: FrozenDateTimeFactory,
|
||||
offset: str | dict[str, int],
|
||||
delta: timedelta,
|
||||
device_class: SensorDeviceClass,
|
||||
) -> None:
|
||||
"""Test for firing at sensor time."""
|
||||
now = dt_util.now()
|
||||
@@ -649,7 +659,7 @@ async def test_if_fires_using_at_sensor_with_offset(
|
||||
hass.states.async_set(
|
||||
"sensor.next_alarm",
|
||||
start_dt.isoformat(),
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
|
||||
{ATTR_DEVICE_CLASS: device_class},
|
||||
)
|
||||
|
||||
time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1)
|
||||
@@ -693,7 +703,7 @@ async def test_if_fires_using_at_sensor_with_offset(
|
||||
hass.states.async_set(
|
||||
"sensor.next_alarm",
|
||||
start_dt.isoformat(),
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
|
||||
{ATTR_DEVICE_CLASS: device_class},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Honeywell String Lights integration."""
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Common fixtures for the Honeywell String Lights tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.honeywell_string_lights.const import (
|
||||
CONF_TRANSMITTER,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.radio_frequency.conftest import (
|
||||
MockRadioFrequencyEntity,
|
||||
init_integration, # noqa: F401
|
||||
mock_rf_entity, # noqa: F401
|
||||
)
|
||||
|
||||
TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_rf_entity: MockRadioFrequencyEntity, # noqa: F811
|
||||
) -> MockConfigEntry:
|
||||
"""Return a mock config entry for Honeywell String Lights."""
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID)
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Honeywell String Lights",
|
||||
data={CONF_TRANSMITTER: entity_entry.id},
|
||||
unique_id=entity_entry.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_string_lights(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Honeywell String Lights integration."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Test the Honeywell String Lights config flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.honeywell_string_lights.const import (
|
||||
CONF_TRANSMITTER,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.radio_frequency import DATA_COMPONENT, DOMAIN as RF_DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import TRANSMITTER_ENTITY_ID
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity
|
||||
|
||||
|
||||
async def test_user_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test the user config flow creates an entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID},
|
||||
)
|
||||
|
||||
entity_entry = entity_registry.async_get(TRANSMITTER_ENTITY_ID)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Honeywell String Lights"
|
||||
assert result["data"] == {CONF_TRANSMITTER: entity_entry.id}
|
||||
assert result["result"].unique_id == entity_entry.id
|
||||
|
||||
|
||||
async def test_unique_id_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test aborting when the same transmitter is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_TRANSMITTER: TRANSMITTER_ENTITY_ID},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_no_transmitters(hass: HomeAssistant) -> None:
|
||||
"""Test the flow aborts when no RF transmitters are registered at all."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_transmitters"
|
||||
|
||||
|
||||
async def test_no_compatible_transmitters(hass: HomeAssistant) -> None:
|
||||
"""Test aborting when transmitters exist but none support 433.92 MHz OOK."""
|
||||
assert await async_setup_component(hass, RF_DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
incompatible = MockRadioFrequencyEntity(
|
||||
"incompatible", frequency_ranges=[(868_000_000, 869_000_000)]
|
||||
)
|
||||
await hass.data[DATA_COMPONENT].async_add_entities([incompatible])
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_compatible_transmitters"
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for the Honeywell String Lights light platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.honeywell_string_lights.light import COMMANDS
|
||||
from homeassistant.components.light import (
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ASSUMED_STATE,
|
||||
ATTR_ENTITY_ID,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, State
|
||||
|
||||
from tests.common import MockConfigEntry, mock_restore_cache
|
||||
from tests.components.radio_frequency.conftest import MockRadioFrequencyEntity
|
||||
|
||||
ENTITY_ID = "light.honeywell_string_lights"
|
||||
|
||||
|
||||
async def test_turn_on_off_sends_commands(
|
||||
hass: HomeAssistant,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
init_string_lights: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test turning the light on and off sends the correct RF commands."""
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.attributes[ATTR_ASSUMED_STATE] is True
|
||||
|
||||
context = Context()
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
context=context,
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
assert state.context is context
|
||||
assert len(mock_rf_entity.send_command_calls) == 1
|
||||
command = mock_rf_entity.send_command_calls[0]
|
||||
assert command.command is COMMANDS.load_command("turn_on")
|
||||
assert command.context is context
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
context=context,
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_OFF
|
||||
assert state.context is context
|
||||
assert len(mock_rf_entity.send_command_calls) == 2
|
||||
command = mock_rf_entity.send_command_calls[1]
|
||||
assert command.command is COMMANDS.load_command("turn_off")
|
||||
assert command.context is context
|
||||
|
||||
|
||||
async def test_restore_state(
|
||||
hass: HomeAssistant,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the light restores its previous on state."""
|
||||
mock_restore_cache(hass, [State(ENTITY_ID, STATE_ON)])
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant, init_string_lights: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test unloading the config entry removes the entity."""
|
||||
assert hass.states.get(ENTITY_ID) is not None
|
||||
|
||||
assert await hass.config_entries.async_unload(init_string_lights.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
@@ -30,6 +30,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_numerical_attribute_condition_above_below_all,
|
||||
@@ -63,6 +64,33 @@ async def test_humidifier_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("humidifier.is_off", {}, True, True),
|
||||
("humidifier.is_on", {}, True, True),
|
||||
("humidifier.is_drying", {}, True, False),
|
||||
("humidifier.is_humidifying", {}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_humidifier_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that humidifier conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -20,6 +20,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_numerical_attribute_condition_above_below_all,
|
||||
parametrize_numerical_attribute_condition_above_below_any,
|
||||
parametrize_numerical_condition_above_below_all,
|
||||
@@ -68,6 +69,33 @@ async def test_humidity_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
_PLAIN_THRESHOLD = {"threshold": {"type": "above", "value": {"number": 50}}}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("humidity.is_value", _PLAIN_THRESHOLD, True, False),
|
||||
],
|
||||
)
|
||||
async def test_humidity_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that humidity conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -17,6 +17,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_numerical_condition_above_below_all,
|
||||
@@ -55,6 +56,31 @@ async def test_illuminance_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("illuminance.is_detected", {}, True, True),
|
||||
("illuminance.is_not_detected", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_illuminance_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that illuminance conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -2216,6 +2216,7 @@
|
||||
'options': list([
|
||||
'date',
|
||||
'timestamp',
|
||||
'uptime',
|
||||
'absolute_humidity',
|
||||
'apparent_power',
|
||||
'aqi',
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -43,6 +44,34 @@ async def test_lawn_mower_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("lawn_mower.is_docked", {}, True, True),
|
||||
("lawn_mower.is_encountering_an_error", {}, True, True),
|
||||
("lawn_mower.is_mowing", {}, True, True),
|
||||
("lawn_mower.is_paused", {}, True, True),
|
||||
("lawn_mower.is_returning", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_lawn_mower_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that lawn_mower conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -13,6 +13,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -151,6 +152,31 @@ async def test_light_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("light.is_off", {}, True, True),
|
||||
("light.is_on", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_light_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that light conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -42,6 +43,33 @@ async def test_lock_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("lock.is_jammed", {}, True, True),
|
||||
("lock.is_locked", {}, True, True),
|
||||
("lock.is_open", {}, True, True),
|
||||
("lock.is_unlocked", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_lock_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that lock conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -43,6 +44,34 @@ async def test_media_player_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("media_player.is_off", {}, True, True),
|
||||
("media_player.is_on", {}, True, False),
|
||||
("media_player.is_not_playing", {}, True, False),
|
||||
("media_player.is_paused", {}, True, True),
|
||||
("media_player.is_playing", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_media_player_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that media_player conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -17,6 +17,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_numerical_condition_above_below_all,
|
||||
@@ -55,6 +56,31 @@ async def test_moisture_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("moisture.is_detected", {}, True, True),
|
||||
("moisture.is_not_detected", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_moisture_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that moisture conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -40,6 +41,31 @@ async def test_motion_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("motion.is_detected", {}, True, True),
|
||||
("motion.is_not_detected", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_motion_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that motion conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -40,6 +41,31 @@ async def test_occupancy_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("occupancy.is_detected", {}, True, True),
|
||||
("occupancy.is_not_detected", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_occupancy_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that occupancy conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
assert_numerical_condition_unit_conversion,
|
||||
parametrize_numerical_condition_above_below_all,
|
||||
parametrize_numerical_condition_above_below_any,
|
||||
@@ -37,6 +38,38 @@ async def test_power_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
_WATT_THRESHOLD = {
|
||||
"threshold": {
|
||||
"type": "above",
|
||||
"value": {"number": 50, "unit_of_measurement": "W"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("power.is_value", _WATT_THRESHOLD, True, False),
|
||||
],
|
||||
)
|
||||
async def test_power_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that power conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Common fixtures for the Radio Frequency tests."""
|
||||
|
||||
from typing import override
|
||||
from typing import NamedTuple, override
|
||||
|
||||
import pytest
|
||||
from rf_protocols import ModulationType, RadioFrequencyCommand
|
||||
@@ -21,6 +21,13 @@ async def init_integration(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
class MockCommand(NamedTuple):
|
||||
"""Data structure to store calls to async_send_command."""
|
||||
|
||||
command: RadioFrequencyCommand
|
||||
context: object | None
|
||||
|
||||
|
||||
class MockRadioFrequencyCommand(RadioFrequencyCommand):
|
||||
"""Mock RF command for testing."""
|
||||
|
||||
@@ -60,7 +67,7 @@ class MockRadioFrequencyEntity(RadioFrequencyTransmitterEntity):
|
||||
if frequency_ranges is None
|
||||
else frequency_ranges
|
||||
)
|
||||
self.send_command_calls: list[RadioFrequencyCommand] = []
|
||||
self.send_command_calls: list[MockCommand] = []
|
||||
|
||||
@property
|
||||
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
|
||||
@@ -69,7 +76,9 @@ class MockRadioFrequencyEntity(RadioFrequencyTransmitterEntity):
|
||||
|
||||
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
|
||||
"""Mock send command."""
|
||||
self.send_command_calls.append(command)
|
||||
self.send_command_calls.append(
|
||||
MockCommand(command=command, context=self._context)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -80,7 +80,7 @@ async def test_async_send_command_success(
|
||||
await async_send_command(hass, ENTITY_ID, command)
|
||||
|
||||
assert len(mock_rf_entity.send_command_calls) == 1
|
||||
assert mock_rf_entity.send_command_calls[0] is command
|
||||
assert mock_rf_entity.send_command_calls[0].command is command
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -39,6 +40,31 @@ async def test_remote_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("remote.is_off", {}, True, True),
|
||||
("remote.is_on", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_remote_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that remote conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -13,6 +13,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -40,6 +41,31 @@ async def test_schedule_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("schedule.is_off", {}, True, True),
|
||||
("schedule.is_on", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_schedule_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that schedule conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -16,6 +16,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -47,6 +48,30 @@ async def test_select_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("select.is_option_selected", {"option": ["option_a"]}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_select_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that select conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -95,6 +95,7 @@ UNITS_OF_MEASUREMENT = {
|
||||
SensorDeviceClass.TEMPERATURE: UnitOfTemperature.CELSIUS,
|
||||
SensorDeviceClass.TEMPERATURE_DELTA: UnitOfTemperature.CELSIUS,
|
||||
SensorDeviceClass.TIMESTAMP: None,
|
||||
SensorDeviceClass.UPTIME: None,
|
||||
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: CONCENTRATION_PARTS_PER_MILLION,
|
||||
SensorDeviceClass.VOLTAGE: UnitOfElectricPotential.VOLT,
|
||||
|
||||
@@ -101,6 +101,7 @@ async def test_get_conditions(
|
||||
SensorDeviceClass.DATE,
|
||||
SensorDeviceClass.ENUM,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
SensorDeviceClass.UPTIME,
|
||||
}
|
||||
expected_conditions = [
|
||||
{
|
||||
@@ -202,6 +203,7 @@ async def test_get_conditions_no_state(
|
||||
SensorDeviceClass.DATE, # No condition
|
||||
SensorDeviceClass.ENUM, # No condition
|
||||
SensorDeviceClass.TIMESTAMP, # No condition
|
||||
SensorDeviceClass.UPTIME, # No condition
|
||||
SensorDeviceClass.AQI, # No unit of measurement
|
||||
SensorDeviceClass.PH, # No unit of measurement
|
||||
SensorDeviceClass.MONETARY, # No unit of measurement
|
||||
|
||||
@@ -103,6 +103,7 @@ async def test_get_triggers(
|
||||
SensorDeviceClass.DATE,
|
||||
SensorDeviceClass.ENUM,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
SensorDeviceClass.UPTIME,
|
||||
}
|
||||
expected_triggers = [
|
||||
{
|
||||
|
||||
@@ -6,10 +6,15 @@ from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.components.sensor.helpers import async_parse_date_datetime
|
||||
|
||||
|
||||
def test_async_parse_datetime(caplog: pytest.LogCaptureFixture) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
"device_class",
|
||||
[SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME],
|
||||
)
|
||||
def test_async_parse_datetime(
|
||||
caplog: pytest.LogCaptureFixture, device_class: SensorDeviceClass
|
||||
) -> None:
|
||||
"""Test async_parse_date_datetime."""
|
||||
entity_id = "sensor.timestamp"
|
||||
device_class = SensorDeviceClass.TIMESTAMP
|
||||
assert (
|
||||
async_parse_date_datetime(
|
||||
"2021-12-12 12:12Z", entity_id, device_class
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import math
|
||||
from typing import Any
|
||||
@@ -23,6 +23,7 @@ from homeassistant.components.sensor import (
|
||||
DEVICE_CLASS_UNITS,
|
||||
DOMAIN,
|
||||
NON_NUMERIC_DEVICE_CLASSES,
|
||||
UPTIME_DEFAULT_TOLERANCE_SECONDS,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
@@ -283,6 +284,44 @@ async def test_datetime_conversion(
|
||||
assert state.state == test_timestamp.isoformat()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("drift_tolerance", [UPTIME_DEFAULT_TOLERANCE_SECONDS, 10])
|
||||
async def test_uptime_device_class_auto_normalizes_drift(
|
||||
hass: HomeAssistant, drift_tolerance
|
||||
) -> None:
|
||||
"""Test uptime device class suppresses small drift automatically."""
|
||||
initial_uptime = datetime(2026, 2, 14, 9, 30, tzinfo=UTC)
|
||||
entity = MockSensor(
|
||||
name="Test",
|
||||
native_value=initial_uptime,
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
)
|
||||
entity._attr_uptime_drift_tolerance = drift_tolerance
|
||||
setup_test_component_platform(hass, sensor.DOMAIN, [entity])
|
||||
|
||||
assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (state := hass.states.get(entity.entity_id))
|
||||
assert state.state == initial_uptime.isoformat(timespec="seconds")
|
||||
|
||||
entity._values["native_value"] = initial_uptime + timedelta(
|
||||
seconds=drift_tolerance - 1
|
||||
)
|
||||
entity.async_write_ha_state()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (state := hass.states.get(entity.entity_id))
|
||||
assert state.state == initial_uptime.isoformat(timespec="seconds")
|
||||
|
||||
updated_uptime = initial_uptime + timedelta(seconds=drift_tolerance + 1)
|
||||
entity._values["native_value"] = updated_uptime
|
||||
entity.async_write_ha_state()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (state := hass.states.get(entity.entity_id))
|
||||
assert state.state == updated_uptime.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
async def test_a_sensor_with_a_non_numeric_device_class(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
@@ -2200,6 +2239,7 @@ async def test_invalid_enumeration_entity_without_device_class(
|
||||
SensorDeviceClass.DATE,
|
||||
SensorDeviceClass.ENUM,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
SensorDeviceClass.UPTIME,
|
||||
],
|
||||
)
|
||||
async def test_non_numeric_device_class_with_unit_of_measurement(
|
||||
@@ -2554,6 +2594,7 @@ async def test_device_classes_with_invalid_state_class(
|
||||
(SensorDeviceClass.ENUM, None, None, None, False),
|
||||
(SensorDeviceClass.DATE, None, None, None, False),
|
||||
(SensorDeviceClass.TIMESTAMP, None, None, None, False),
|
||||
(SensorDeviceClass.UPTIME, None, None, None, False),
|
||||
("custom", None, None, None, False),
|
||||
(SensorDeviceClass.POWER, None, "V", None, True),
|
||||
(None, SensorStateClass.MEASUREMENT, None, None, True),
|
||||
@@ -3097,6 +3138,7 @@ def test_device_class_units_are_complete() -> None:
|
||||
SensorDeviceClass.ENUM,
|
||||
SensorDeviceClass.MONETARY,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
SensorDeviceClass.UPTIME,
|
||||
}
|
||||
unit_device_classes = {
|
||||
device_class.value for device_class in SensorDeviceClass
|
||||
@@ -3126,6 +3168,7 @@ def test_device_class_converters_are_complete() -> None:
|
||||
SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
SensorDeviceClass.SOUND_PRESSURE,
|
||||
SensorDeviceClass.TIMESTAMP,
|
||||
SensorDeviceClass.UPTIME,
|
||||
SensorDeviceClass.WIND_DIRECTION,
|
||||
}
|
||||
converter_device_classes = {
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -39,6 +40,31 @@ async def test_siren_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("siren.is_off", {}, True, True),
|
||||
("siren.is_on", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_siren_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that siren conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -46,6 +47,31 @@ async def test_switch_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("switch.is_off", {}, True, True),
|
||||
("switch.is_on", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_switch_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that switch conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -14,6 +14,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
assert_numerical_condition_unit_conversion,
|
||||
parametrize_numerical_attribute_condition_above_below_all,
|
||||
parametrize_numerical_attribute_condition_above_below_any,
|
||||
@@ -61,6 +62,38 @@ async def test_temperature_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
_CELSIUS_THRESHOLD = {
|
||||
"threshold": {
|
||||
"type": "above",
|
||||
"value": {"number": 20, "unit_of_measurement": "\u00b0C"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("temperature.is_value", _CELSIUS_THRESHOLD, True, False),
|
||||
],
|
||||
)
|
||||
async def test_temperature_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that temperature conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -21,6 +21,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -48,6 +49,30 @@ async def test_text_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("text.is_equal_to", {"value": "hello"}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_text_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that text conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
CONDITION_STATES_ANY = [
|
||||
*parametrize_condition_states_any(
|
||||
condition="text.is_equal_to",
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -40,6 +41,32 @@ async def test_timer_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("timer.is_active", {}, True, True),
|
||||
("timer.is_paused", {}, True, True),
|
||||
("timer.is_idle", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_timer_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that timer conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -11,6 +11,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -38,6 +39,30 @@ async def test_todo_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("todo.all_completed", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_todo_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that todo conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -39,6 +40,31 @@ async def test_update_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("update.is_available", {}, True, True),
|
||||
("update.is_not_available", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_update_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that update conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -12,6 +12,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
other_states,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -43,6 +44,34 @@ async def test_vacuum_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("vacuum.is_cleaning", {}, True, True),
|
||||
("vacuum.is_docked", {}, True, True),
|
||||
("vacuum.is_encountering_an_error", {}, True, True),
|
||||
("vacuum.is_paused", {}, True, True),
|
||||
("vacuum.is_returning", {}, True, True),
|
||||
],
|
||||
)
|
||||
async def test_vacuum_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that vacuum conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -13,6 +13,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_target_entities,
|
||||
@@ -40,6 +41,31 @@ async def test_valve_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("valve.is_open", {}, True, False),
|
||||
("valve.is_closed", {}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_valve_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that valve conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -74,7 +74,7 @@ def mock_window() -> AsyncMock:
|
||||
window.device_updated_cbs = []
|
||||
window.is_opening = False
|
||||
window.is_closing = False
|
||||
window.position = MagicMock(position_percent=30, closed=False)
|
||||
window.position = MagicMock(position_percent=30, closed=False, known=True)
|
||||
window.wink = AsyncMock()
|
||||
window.pyvlx = MagicMock()
|
||||
return window
|
||||
@@ -89,9 +89,13 @@ def mock_dual_roller_shutter() -> AsyncMock:
|
||||
cover.serial_number = "987654321"
|
||||
cover.is_opening = False
|
||||
cover.is_closing = False
|
||||
cover.position_upper_curtain = MagicMock(position_percent=30, closed=False)
|
||||
cover.position_lower_curtain = MagicMock(position_percent=30, closed=False)
|
||||
cover.position = MagicMock(position_percent=30, closed=False)
|
||||
cover.position_upper_curtain = MagicMock(
|
||||
position_percent=30, closed=False, known=True
|
||||
)
|
||||
cover.position_lower_curtain = MagicMock(
|
||||
position_percent=30, closed=False, known=True
|
||||
)
|
||||
cover.position = MagicMock(position_percent=30, closed=False, known=True)
|
||||
cover.pyvlx = MagicMock()
|
||||
return cover
|
||||
|
||||
@@ -104,11 +108,11 @@ def mock_blind() -> AsyncMock:
|
||||
blind.name = "Test Blind"
|
||||
blind.serial_number = "4711"
|
||||
# Standard cover position (used by current_cover_position)
|
||||
blind.position = MagicMock(position_percent=40, closed=False)
|
||||
blind.position = MagicMock(position_percent=40, closed=False, known=True)
|
||||
blind.is_opening = False
|
||||
blind.is_closing = False
|
||||
# Orientation/tilt-related attributes and methods
|
||||
blind.orientation = MagicMock(position_percent=25)
|
||||
blind.orientation = MagicMock(position_percent=25, known=True)
|
||||
blind.open_orientation = AsyncMock()
|
||||
blind.close_orientation = AsyncMock()
|
||||
blind.stop_orientation = AsyncMock()
|
||||
@@ -175,9 +179,13 @@ def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock:
|
||||
cover.serial_number = f"serial_{request.param.__name__}"
|
||||
cover.is_opening = False
|
||||
cover.is_closing = False
|
||||
cover.position = MagicMock(position_percent=30, closed=False)
|
||||
cover.position_upper_curtain = MagicMock(position_percent=30, closed=False)
|
||||
cover.position_lower_curtain = MagicMock(position_percent=30, closed=False)
|
||||
cover.position = MagicMock(position_percent=30, closed=False, known=True)
|
||||
cover.position_upper_curtain = MagicMock(
|
||||
position_percent=30, closed=False, known=True
|
||||
)
|
||||
cover.position_lower_curtain = MagicMock(
|
||||
position_percent=30, closed=False, known=True
|
||||
)
|
||||
cover.pyvlx = MagicMock()
|
||||
return cover
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from homeassistant.const import (
|
||||
STATE_CLOSING,
|
||||
STATE_OPEN,
|
||||
STATE_OPENING,
|
||||
STATE_UNKNOWN,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -475,6 +476,77 @@ async def test_non_blind_has_no_tilt_position(
|
||||
assert "current_tilt_position" not in state.attributes
|
||||
|
||||
|
||||
# Unknown position tests
|
||||
|
||||
|
||||
async def test_window_unknown_position(
|
||||
hass: HomeAssistant, mock_window: AsyncMock
|
||||
) -> None:
|
||||
"""When the device position is not known, state and position must be unknown."""
|
||||
|
||||
entity_id = "cover.test_window"
|
||||
|
||||
mock_window.position.known = False
|
||||
await update_callback_entity(hass, mock_window)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.attributes.get("current_position") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("unknown_attr", "unknown_entity_id"),
|
||||
[
|
||||
("position", "cover.test_dual_roller_shutter"),
|
||||
("position_upper_curtain", "cover.test_dual_roller_shutter_upper_shutter"),
|
||||
("position_lower_curtain", "cover.test_dual_roller_shutter_lower_shutter"),
|
||||
],
|
||||
)
|
||||
async def test_dual_roller_shutter_unknown_position(
|
||||
hass: HomeAssistant,
|
||||
mock_dual_roller_shutter: AsyncMock,
|
||||
unknown_attr: str,
|
||||
unknown_entity_id: str,
|
||||
) -> None:
|
||||
"""Each part falls back to unknown independently when only its position is unknown."""
|
||||
|
||||
all_entity_ids = {
|
||||
"cover.test_dual_roller_shutter",
|
||||
"cover.test_dual_roller_shutter_upper_shutter",
|
||||
"cover.test_dual_roller_shutter_lower_shutter",
|
||||
}
|
||||
|
||||
getattr(mock_dual_roller_shutter, unknown_attr).known = False
|
||||
await update_callback_entity(hass, mock_dual_roller_shutter)
|
||||
|
||||
state = hass.states.get(unknown_entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.attributes.get("current_position") is None
|
||||
|
||||
for entity_id in all_entity_ids - {unknown_entity_id}:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNKNOWN
|
||||
assert state.attributes.get("current_position") == 70
|
||||
|
||||
|
||||
async def test_blind_unknown_tilt_position(
|
||||
hass: HomeAssistant, mock_blind: AsyncMock
|
||||
) -> None:
|
||||
"""Tilt position must be None when the orientation is not known."""
|
||||
|
||||
entity_id = "cover.test_blind"
|
||||
|
||||
mock_blind.orientation.known = False
|
||||
await update_callback_entity(hass, mock_blind)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.attributes.get("current_tilt_position") is None
|
||||
|
||||
|
||||
# Exception handling tests
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
assert_numerical_condition_unit_conversion,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -71,6 +72,31 @@ async def test_water_heater_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("water_heater.is_off", {}, True, True),
|
||||
("water_heater.is_on", {}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_water_heater_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that water_heater conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
|
||||
@@ -13,6 +13,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_all,
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_condition_options_supported,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
@@ -47,6 +48,31 @@ async def test_window_conditions_gated_by_labs_flag(
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_key", "base_options", "supports_behavior", "supports_duration"),
|
||||
[
|
||||
("window.is_closed", {}, True, False),
|
||||
("window.is_open", {}, True, False),
|
||||
],
|
||||
)
|
||||
async def test_window_condition_options_validation(
|
||||
hass: HomeAssistant,
|
||||
condition_key: str,
|
||||
base_options: dict[str, Any] | None,
|
||||
supports_behavior: bool,
|
||||
supports_duration: bool,
|
||||
) -> None:
|
||||
"""Test that window conditions support the expected options."""
|
||||
await assert_condition_options_supported(
|
||||
hass,
|
||||
condition_key,
|
||||
base_options,
|
||||
supports_behavior=supports_behavior,
|
||||
supports_duration=supports_duration,
|
||||
)
|
||||
|
||||
|
||||
# --- binary_sensor tests ---
|
||||
|
||||
|
||||
|
||||
@@ -1146,6 +1146,11 @@ async def test_time_using_sensor(hass: HomeAssistant) -> None:
|
||||
"2020-06-01 01:00:00.000000+00:00", # 6 pm local time
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP},
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.uptime_am",
|
||||
"2021-06-03 13:00:00.000000+00:00", # 6 am local time
|
||||
{ATTR_DEVICE_CLASS: SensorDeviceClass.UPTIME},
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.no_device_class",
|
||||
"2020-06-01 01:00:00.000000+00:00",
|
||||
@@ -1168,6 +1173,7 @@ async def test_time_using_sensor(hass: HomeAssistant) -> None:
|
||||
return_value=dt_util.now().replace(hour=9),
|
||||
):
|
||||
assert condition.time(hass, after="sensor.am", before="sensor.pm")
|
||||
assert condition.time(hass, after="sensor.uptime_am", before="sensor.pm")
|
||||
assert not condition.time(hass, after="sensor.pm", before="sensor.am")
|
||||
|
||||
with patch(
|
||||
|
||||
@@ -1185,6 +1185,24 @@ def test_serial_port_selector_schema(
|
||||
(),
|
||||
(),
|
||||
),
|
||||
(
|
||||
{"primary_entities_only": True},
|
||||
(),
|
||||
(),
|
||||
),
|
||||
(
|
||||
{"primary_entities_only": False},
|
||||
(),
|
||||
(),
|
||||
),
|
||||
(
|
||||
{
|
||||
"entity": {"domain": "light"},
|
||||
"primary_entities_only": True,
|
||||
},
|
||||
(),
|
||||
(),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_target_selector_schema(schema, valid_selections, invalid_selections) -> None:
|
||||
|
||||
@@ -296,14 +296,20 @@ async def test_trigger_template_complex(hass: HomeAssistant) -> None:
|
||||
assert entity.some_other_key == {"test_key": "test_data"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"device_class",
|
||||
[SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME],
|
||||
)
|
||||
async def test_manual_trigger_sensor_entity_with_date(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
device_class: SensorDeviceClass,
|
||||
) -> None:
|
||||
"""Test manual trigger template entity when availability template isn't used."""
|
||||
config = {
|
||||
CONF_NAME: template.Template("test_entity", hass),
|
||||
CONF_STATE: template.Template("{{ as_datetime(value) }}", hass),
|
||||
CONF_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP,
|
||||
CONF_DEVICE_CLASS: device_class,
|
||||
}
|
||||
|
||||
class TestEntity(ManualTriggerSensorEntity):
|
||||
@@ -328,4 +334,4 @@ async def test_manual_trigger_sensor_entity_with_date(
|
||||
"2025-01-01T00:00:00+00:00", entity.entity_id, entity.device_class
|
||||
)
|
||||
assert entity.state == "2025-01-01T00:00:00+00:00"
|
||||
assert entity.device_class == SensorDeviceClass.TIMESTAMP
|
||||
assert entity.device_class == device_class
|
||||
|
||||
Reference in New Issue
Block a user