Compare commits

...

13 Commits

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