diff --git a/CODEOWNERS b/CODEOWNERS index 6146338ddc133..f6ffb7ad5cbb5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/brands/honeywell.json b/homeassistant/brands/honeywell.json index 37cd6d8ce732e..001db20de07af 100644 --- a/homeassistant/brands/honeywell.json +++ b/homeassistant/brands/honeywell.json @@ -1,5 +1,5 @@ { "domain": "honeywell", "name": "Honeywell", - "integrations": ["lyric", "evohome", "honeywell"] + "integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"] } diff --git a/homeassistant/components/battery/condition.py b/homeassistant/components/battery/condition.py index 7004d9f00fbf9..c89e9bd732160 100644 --- a/homeassistant/components/battery/condition.py +++ b/homeassistant/components/battery/condition.py @@ -30,19 +30,33 @@ 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, ), } diff --git a/homeassistant/components/battery/conditions.yaml b/homeassistant/components/battery/conditions.yaml index b03f9516bd944..39ffc09109f22 100644 --- a/homeassistant/components/battery/conditions.yaml +++ b/homeassistant/components/battery/conditions.yaml @@ -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: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 46059407294f8..cac4eadfe2548 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -35,6 +35,7 @@ MediaPlayerInfo, MediaPlayerSupportedFormat, NumberInfo, + RadioFrequencyInfo, SelectInfo, SensorInfo, SensorState, @@ -88,6 +89,7 @@ FanInfo: Platform.FAN, InfraredInfo: Platform.INFRARED, LightInfo: Platform.LIGHT, + RadioFrequencyInfo: Platform.RADIO_FREQUENCY, LockInfo: Platform.LOCK, MediaPlayerInfo: Platform.MEDIA_PLAYER, NumberInfo: Platform.NUMBER, diff --git a/homeassistant/components/esphome/radio_frequency.py b/homeassistant/components/esphome/radio_frequency.py new file mode 100644 index 0000000000000..7aaea22f53d80 --- /dev/null +++ b/homeassistant/components/esphome/radio_frequency.py @@ -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 + ), +) diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 0f0213ec984d9..70ffee8973fc3 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -11,7 +11,7 @@ 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 @@ 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 @@ 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( diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 032efb3f4ae9c..5050907c1d875 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -66,8 +66,6 @@ class MeshRoles(StrEnum): BUTTON_TYPE_WOL = "WakeOnLan" -UPTIME_DEVIATION = 5 - FRITZ_EXCEPTIONS = ( ConnectionError, FritzActionError, diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 41fa3fca056dc..4bd54a751d50f 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -28,7 +28,7 @@ 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 @@ 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 @@ class FritzDeviceSensorEntityDescription( 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 @@ class 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, ), diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 7adae8d87465e..92838755cd002 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -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//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 @@ def installed_version(self) -> str | None: """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 @@ async def async_install( **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.""" diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 27c63742f7b88..0fc5618c122fb 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -225,7 +225,7 @@ def update_entity_trigger( 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) diff --git a/homeassistant/components/honeywell_string_lights/__init__.py b/homeassistant/components/honeywell_string_lights/__init__.py new file mode 100644 index 0000000000000..f5c7b4b09a5d3 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/__init__.py @@ -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) diff --git a/homeassistant/components/honeywell_string_lights/config_flow.py b/homeassistant/components/honeywell_string_lights/config_flow.py new file mode 100644 index 0000000000000..f659a1403d4b2 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/config_flow.py @@ -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), + ), + } + ), + ) diff --git a/homeassistant/components/honeywell_string_lights/const.py b/homeassistant/components/honeywell_string_lights/const.py new file mode 100644 index 0000000000000..c55c712f6c7a5 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/const.py @@ -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" diff --git a/homeassistant/components/honeywell_string_lights/entity.py b/homeassistant/components/honeywell_string_lights/entity.py new file mode 100644 index 0000000000000..76363e1efa427 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/entity.py @@ -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 + ) diff --git a/homeassistant/components/honeywell_string_lights/light.py b/homeassistant/components/honeywell_string_lights/light.py new file mode 100644 index 0000000000000..d430e1f90e8c6 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/light.py @@ -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 + ) diff --git a/homeassistant/components/honeywell_string_lights/manifest.json b/homeassistant/components/honeywell_string_lights/manifest.json new file mode 100644 index 0000000000000..9924b71141463 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/manifest.json @@ -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"] +} diff --git a/homeassistant/components/honeywell_string_lights/quality_scale.yaml b/homeassistant/components/honeywell_string_lights/quality_scale.yaml new file mode 100644 index 0000000000000..54bcb3f12c1a0 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/honeywell_string_lights/strings.json b/homeassistant/components/honeywell_string_lights/strings.json new file mode 100644 index 0000000000000..a5c995ace0870 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/strings.json @@ -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." + } + } + } + } +} diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 3148b0d13c2ac..73ea6db503653 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -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.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 @@ 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 @@ def _calculate_precision_from_ratio( 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 @@ def state(self) -> Any: # 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 @@ def state(self) -> Any: 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 diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 26fde24059699..85dd700e5ef1a 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -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 @@ class SensorDeviceClass(StrEnum): SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorDeviceClass)) @@ -816,6 +831,7 @@ class SensorStateClass(StrEnum): 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}, diff --git a/homeassistant/components/sensor/helpers.py b/homeassistant/components/sensor/helpers.py index 12a5dcefdf8d0..c404c697da003 100644 --- a/homeassistant/components/sensor/helpers.py +++ b/homeassistant/components/sensor/helpers.py @@ -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 diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 59d57da280346..966e19439e38b 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -163,6 +163,9 @@ "timestamp": { "default": "mdi:clock" }, + "uptime": { + "default": "mdi:clock-start" + }, "volatile_organic_compounds": { "default": "mdi:molecule" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 33b56f1b0f1df..e51c139e8dea8 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -297,6 +297,9 @@ "timestamp": { "name": "Timestamp" }, + "uptime": { + "name": "Uptime" + }, "volatile_organic_compounds": { "name": "Volatile organic compounds" }, diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 334dab34cea73..685768f96a0fe 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -97,13 +97,17 @@ def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: 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 @@ def __init__( 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 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: + 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 @@ def __init__(self, node: Blind, config_entry_id: str) -> None: @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 diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 810338d54878d..fbd8bed90f3e3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -311,6 +311,7 @@ "homewizard", "homeworks", "honeywell", + "honeywell_string_lights", "hr_energy_qube", "html5", "huawei_lte", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 550fe74d22ae8..f7466b5891b28 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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" } } }, diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 6d53f674188b7..cb057fe263a65 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -16,6 +16,7 @@ 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 @@ def _check_all_match_state(self, states: list[State]) -> bool: 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 @@ class CustomCondition(EntityStateConditionBase): else ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL ) _states = states_set + _primary_entities_only = primary_entities_only return CustomCondition @@ -675,6 +684,8 @@ def is_valid_state(self, entity_state: State) -> bool: 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 @@ class CustomCondition(EntityNumericalConditionBase): _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, diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index dc8f52763c32e..87d03c0115274 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -403,12 +403,13 @@ def __init__( 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: diff --git a/requirements_all.txt b/requirements_all.txt index b89fb6f4be7c1..d25547077ef5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bf6b20f23063..17c1d0d087cf6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/alarm_control_panel/test_condition.py b/tests/components/alarm_control_panel/test_condition.py index 5ecbc088d99ba..22e215126ea53 100644 --- a/tests/components/alarm_control_panel/test_condition.py +++ b/tests/components/alarm_control_panel/test_condition.py @@ -16,6 +16,7 @@ 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"), diff --git a/tests/components/assist_satellite/test_condition.py b/tests/components/assist_satellite/test_condition.py index 26c43ec7db9c0..3594c3ba3e94d 100644 --- a/tests/components/assist_satellite/test_condition.py +++ b/tests/components/assist_satellite/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/battery/test_condition.py b/tests/components/battery/test_condition.py index 8c828c0add8c9..e0cb7d4be85e3 100644 --- a/tests/components/battery/test_condition.py +++ b/tests/components/battery/test_condition.py @@ -9,6 +9,7 @@ ATTR_UNIT_OF_MEASUREMENT, STATE_OFF, STATE_ON, + EntityCategory, ) from homeassistant.core import HomeAssistant @@ -17,6 +18,7 @@ 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 @@ @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"), diff --git a/tests/components/calendar/test_condition.py b/tests/components/calendar/test_condition.py index 05b7c71131493..2f49b982bc5d2 100644 --- a/tests/components/calendar/test_condition.py +++ b/tests/components/calendar/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/climate/test_condition.py b/tests/components/climate/test_condition.py index 13bf598241a20..b943a0005d9ed 100644 --- a/tests/components/climate/test_condition.py +++ b/tests/components/climate/test_condition.py @@ -22,6 +22,7 @@ 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"), diff --git a/tests/components/counter/test_condition.py b/tests/components/counter/test_condition.py index c25695edbfb6e..36d89889f86bf 100644 --- a/tests/components/counter/test_condition.py +++ b/tests/components/counter/test_condition.py @@ -11,6 +11,7 @@ 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"), diff --git a/tests/components/cover/test_condition.py b/tests/components/cover/test_condition.py index 2ee5f034e82e8..d33d8a1974fb9 100644 --- a/tests/components/cover/test_condition.py +++ b/tests/components/cover/test_condition.py @@ -13,6 +13,7 @@ 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"), diff --git a/tests/components/door/test_condition.py b/tests/components/door/test_condition.py index 7c267a8df8baa..1b8eb9d675a0a 100644 --- a/tests/components/door/test_condition.py +++ b/tests/components/door/test_condition.py @@ -13,6 +13,7 @@ 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 --- diff --git a/tests/components/esphome/test_radio_frequency.py b/tests/components/esphome/test_radio_frequency.py new file mode 100644 index 0000000000000..b6c4b82953bce --- /dev/null +++ b/tests/components/esphome/test_radio_frequency.py @@ -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 diff --git a/tests/components/fan/test_condition.py b/tests/components/fan/test_condition.py index 425af84766742..d3cde640af8b1 100644 --- a/tests/components/fan/test_condition.py +++ b/tests/components/fan/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 5d2ac1a440624..573529245a82d 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -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 diff --git a/tests/components/flume/conftest.py b/tests/components/flume/conftest.py index 6173db1e2b956..49d403a659b8d 100644 --- a/tests/components/flume/conftest.py +++ b/tests/components/flume/conftest.py @@ -41,6 +41,7 @@ "type": 2, # Sensor "location": { "name": "Sensor Location", + "tz": "America/New_York", }, "name": "Flume Sensor", "connected": True, diff --git a/tests/components/flume/snapshots/test_sensor.ambr b/tests/components/flume/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..17c7716e072c2 --- /dev/null +++ b/tests/components/flume/snapshots/test_sensor.ambr @@ -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': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': '24 hours', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# 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': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_24_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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': , + 'unit_of_measurement': 'gal/mo', + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_30_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': '60 minutes', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# 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': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_60_minutes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- +# name: test_sensors[sensor.flume_sensor_sensor_location_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.flume_sensor_sensor_location_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# 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': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current day', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# 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': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current_day', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current month', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# 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': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current week', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# 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': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.flume_sensor_sensor_location_current_week', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.5', + }) +# --- diff --git a/tests/components/flume/test_sensor.py b/tests/components/flume/test_sensor.py new file mode 100644 index 0000000000000..6d541de479fbe --- /dev/null +++ b/tests/components/flume/test_sensor.py @@ -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) diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr index d820dda43ee9e..48ed12dec8fec 100644 --- a/tests/components/fritz/snapshots/test_sensor.ambr +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -24,7 +24,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -349,7 +349,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -882,7 +882,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -1207,7 +1207,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -1740,7 +1740,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -2065,7 +2065,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -2598,7 +2598,7 @@ 'object_id_base': 'Connection uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , @@ -2981,7 +2981,7 @@ 'object_id_base': 'Last restart', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , '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': , diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index d00327994d633..94cb74f63a0e9 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -11,7 +11,7 @@ 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, }, }, } diff --git a/tests/components/garage_door/test_condition.py b/tests/components/garage_door/test_condition.py index f85aa719f1623..68b3993c6e995 100644 --- a/tests/components/garage_door/test_condition.py +++ b/tests/components/garage_door/test_condition.py @@ -13,6 +13,7 @@ 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 --- diff --git a/tests/components/gate/test_condition.py b/tests/components/gate/test_condition.py index 85d072fca715c..1ed3d1ecfedb4 100644 --- a/tests/components/gate/test_condition.py +++ b/tests/components/gate/test_condition.py @@ -13,6 +13,7 @@ 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"), diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 2ac4040efe54a..fefce2d43e438 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -1101,9 +1101,11 @@ async def check_progress( 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//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( diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index dc9fb1d34c27f..79f07c5285d3b 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -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() diff --git a/tests/components/honeywell_string_lights/__init__.py b/tests/components/honeywell_string_lights/__init__.py new file mode 100644 index 0000000000000..948d9ef3ec3e0 --- /dev/null +++ b/tests/components/honeywell_string_lights/__init__.py @@ -0,0 +1 @@ +"""Tests for the Honeywell String Lights integration.""" diff --git a/tests/components/honeywell_string_lights/conftest.py b/tests/components/honeywell_string_lights/conftest.py new file mode 100644 index 0000000000000..e164c7f4a0cde --- /dev/null +++ b/tests/components/honeywell_string_lights/conftest.py @@ -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 diff --git a/tests/components/honeywell_string_lights/test_config_flow.py b/tests/components/honeywell_string_lights/test_config_flow.py new file mode 100644 index 0000000000000..3826e2b50b748 --- /dev/null +++ b/tests/components/honeywell_string_lights/test_config_flow.py @@ -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" diff --git a/tests/components/honeywell_string_lights/test_light.py b/tests/components/honeywell_string_lights/test_light.py new file mode 100644 index 0000000000000..f2955f2db2e5d --- /dev/null +++ b/tests/components/honeywell_string_lights/test_light.py @@ -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 diff --git a/tests/components/humidifier/test_condition.py b/tests/components/humidifier/test_condition.py index b45f8882964ed..76d4acabaadaa 100644 --- a/tests/components/humidifier/test_condition.py +++ b/tests/components/humidifier/test_condition.py @@ -30,6 +30,7 @@ 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"), diff --git a/tests/components/humidity/test_condition.py b/tests/components/humidity/test_condition.py index f878dfe14a005..d62065853daf0 100644 --- a/tests/components/humidity/test_condition.py +++ b/tests/components/humidity/test_condition.py @@ -20,6 +20,7 @@ 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"), diff --git a/tests/components/illuminance/test_condition.py b/tests/components/illuminance/test_condition.py index d82a29581c3d0..614ea7146ffd0 100644 --- a/tests/components/illuminance/test_condition.py +++ b/tests/components/illuminance/test_condition.py @@ -17,6 +17,7 @@ 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"), diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index cfc5ec81064dc..49299a9537a47 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -2216,6 +2216,7 @@ 'options': list([ 'date', 'timestamp', + 'uptime', 'absolute_humidity', 'apparent_power', 'aqi', diff --git a/tests/components/lawn_mower/test_condition.py b/tests/components/lawn_mower/test_condition.py index 25bdf62d8fa82..27ab7503a0002 100644 --- a/tests/components/lawn_mower/test_condition.py +++ b/tests/components/lawn_mower/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/light/test_condition.py b/tests/components/light/test_condition.py index 6851527aee23f..e52d9b60f62e9 100644 --- a/tests/components/light/test_condition.py +++ b/tests/components/light/test_condition.py @@ -13,6 +13,7 @@ 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"), diff --git a/tests/components/lock/test_condition.py b/tests/components/lock/test_condition.py index 73d5162097494..d2a009b5a5ced 100644 --- a/tests/components/lock/test_condition.py +++ b/tests/components/lock/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/media_player/test_condition.py b/tests/components/media_player/test_condition.py index 2dded050bfd62..55ed770ea0d7b 100644 --- a/tests/components/media_player/test_condition.py +++ b/tests/components/media_player/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/moisture/test_condition.py b/tests/components/moisture/test_condition.py index 65d7e7c76d07e..7c636a7c90bc4 100644 --- a/tests/components/moisture/test_condition.py +++ b/tests/components/moisture/test_condition.py @@ -17,6 +17,7 @@ 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"), diff --git a/tests/components/motion/test_condition.py b/tests/components/motion/test_condition.py index b4b3c717e0336..dda997d91183f 100644 --- a/tests/components/motion/test_condition.py +++ b/tests/components/motion/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/occupancy/test_condition.py b/tests/components/occupancy/test_condition.py index f4753d06acd6c..1e06a88bfb994 100644 --- a/tests/components/occupancy/test_condition.py +++ b/tests/components/occupancy/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/power/test_condition.py b/tests/components/power/test_condition.py index e5bff95dff50e..3e9b7cf4a575b 100644 --- a/tests/components/power/test_condition.py +++ b/tests/components/power/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/radio_frequency/conftest.py b/tests/components/radio_frequency/conftest.py index 69538e3e18f58..e4e651204e6bd 100644 --- a/tests/components/radio_frequency/conftest.py +++ b/tests/components/radio_frequency/conftest.py @@ -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 @@ def __init__( 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 @@ def supported_frequency_ranges(self) -> list[tuple[int, int]]: 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 diff --git a/tests/components/radio_frequency/test_init.py b/tests/components/radio_frequency/test_init.py index 35e9129f4549f..f8c42c198a45c 100644 --- a/tests/components/radio_frequency/test_init.py +++ b/tests/components/radio_frequency/test_init.py @@ -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 diff --git a/tests/components/remote/test_condition.py b/tests/components/remote/test_condition.py index b3052de5bd715..04c187f0dd68c 100644 --- a/tests/components/remote/test_condition.py +++ b/tests/components/remote/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/schedule/test_condition.py b/tests/components/schedule/test_condition.py index e9eb1fcf61cc4..107d83183779e 100644 --- a/tests/components/schedule/test_condition.py +++ b/tests/components/schedule/test_condition.py @@ -13,6 +13,7 @@ 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"), diff --git a/tests/components/select/test_condition.py b/tests/components/select/test_condition.py index edd97c41ee269..1d97b13113a7c 100644 --- a/tests/components/select/test_condition.py +++ b/tests/components/select/test_condition.py @@ -16,6 +16,7 @@ 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"), diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 4dedababad1ce..3721e7be22065 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -95,6 +95,7 @@ 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, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 67f07e3293a36..59aeb8cc8d535 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -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 diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 8b407ac5576c0..d796dd1158a66 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -103,6 +103,7 @@ async def test_get_triggers( SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } expected_triggers = [ { diff --git a/tests/components/sensor/test_helpers.py b/tests/components/sensor/test_helpers.py index e197579fa6674..2fc89f2585577 100644 --- a/tests/components/sensor/test_helpers.py +++ b/tests/components/sensor/test_helpers.py @@ -6,10 +6,15 @@ 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 diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index e9ae2ba4f7520..3a03a608ce880 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -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 @@ 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 = { diff --git a/tests/components/siren/test_condition.py b/tests/components/siren/test_condition.py index 8da90f57b97d7..3399208cf7a9f 100644 --- a/tests/components/siren/test_condition.py +++ b/tests/components/siren/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/switch/test_condition.py b/tests/components/switch/test_condition.py index 16154bc027e35..7a7cdbc4aa412 100644 --- a/tests/components/switch/test_condition.py +++ b/tests/components/switch/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/temperature/test_condition.py b/tests/components/temperature/test_condition.py index 96199ea2c881e..fc78895e75892 100644 --- a/tests/components/temperature/test_condition.py +++ b/tests/components/temperature/test_condition.py @@ -14,6 +14,7 @@ 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"), diff --git a/tests/components/text/test_condition.py b/tests/components/text/test_condition.py index 292a5e0b5bc01..ebe761e4421c3 100644 --- a/tests/components/text/test_condition.py +++ b/tests/components/text/test_condition.py @@ -21,6 +21,7 @@ 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", diff --git a/tests/components/timer/test_condition.py b/tests/components/timer/test_condition.py index 3a60edca4c0c6..781c088cf0319 100644 --- a/tests/components/timer/test_condition.py +++ b/tests/components/timer/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/todo/test_condition.py b/tests/components/todo/test_condition.py index 26a0ef33566fb..9723d1cc2a063 100644 --- a/tests/components/todo/test_condition.py +++ b/tests/components/todo/test_condition.py @@ -11,6 +11,7 @@ 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"), diff --git a/tests/components/update/test_condition.py b/tests/components/update/test_condition.py index e8e839d3a14e0..6344828f8a1a1 100644 --- a/tests/components/update/test_condition.py +++ b/tests/components/update/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/vacuum/test_condition.py b/tests/components/vacuum/test_condition.py index 7d3abfc38117e..366a882e117a7 100644 --- a/tests/components/vacuum/test_condition.py +++ b/tests/components/vacuum/test_condition.py @@ -12,6 +12,7 @@ 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"), diff --git a/tests/components/valve/test_condition.py b/tests/components/valve/test_condition.py index 5ec78a9022963..20b52236f5f37 100644 --- a/tests/components/valve/test_condition.py +++ b/tests/components/valve/test_condition.py @@ -13,6 +13,7 @@ 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"), diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 2c4cd51a97b77..fb7018bb6ef5b 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -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 diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py index a2620aac31dc4..483fbca5593e3 100644 --- a/tests/components/velux/test_cover.py +++ b/tests/components/velux/test_cover.py @@ -33,6 +33,7 @@ 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 diff --git a/tests/components/water_heater/test_condition.py b/tests/components/water_heater/test_condition.py index f4965ec70b3ea..0ddf476a58cc1 100644 --- a/tests/components/water_heater/test_condition.py +++ b/tests/components/water_heater/test_condition.py @@ -26,6 +26,7 @@ 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"), diff --git a/tests/components/window/test_condition.py b/tests/components/window/test_condition.py index 5e64d10b0e632..65bbe3f73f1e9 100644 --- a/tests/components/window/test_condition.py +++ b/tests/components/window/test_condition.py @@ -13,6 +13,7 @@ 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 --- diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index be995e3524878..cec1e5976e286 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -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( diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index 08f6c7de8197e..0bbe521eef000 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -296,14 +296,20 @@ def some_other_key(self) -> dict[str, Any] | 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 @@ def state(self) -> bool | None: "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