Compare commits

...

15 Commits

Author SHA1 Message Date
jbouwh dcfae0c7ed Allow to set translation_domain for SelectSelector and create helpers for sensor/switch device_class and sensor state_class 2025-06-18 17:14:28 +00:00
Abílio Costa 9adf493acd Use non-autospec mock for Reolink's init tests (#146991) 2025-06-18 17:58:50 +01:00
Michael Hansen a29d5fb56c tts_output is optional in run-start (#147092) 2025-06-18 12:08:53 -04:00
Petro31 bcb87cf812 Support variables, icon, and picture for all compatible template platforms (#145893)
* Fix template entity variables in blueprints

* add picture and icon tests

* add variable test for all platforms

* apply comments

* Update all test names
2025-06-18 16:49:46 +02:00
Jan Bouwhuis d01758cea8 Ensure mqtt sensor has a valid native unit of measurement (#146722) 2025-06-18 15:48:38 +02:00
Joakim Sørensen 5487bfe1d9 Bump hass-nabucasa from 0.101.0 to 0.102.0 (#147087) 2025-06-18 15:47:01 +02:00
Simone Chemelli fec65f40fc Bump aioamazondevices to 3.1.12 (#147055)
* Bump aioamazondevices to 3.1.10

* bump to 3.1.12
2025-06-18 10:20:51 +02:00
Guido Schmitz 596951ea9f Cleanup devolo Home Control tests (#147051) 2025-06-18 09:24:09 +02:00
Norbert Rittel 75d6b885cf Fix typo in state name references of homee (#146905)
Fix typo in state references

Replace wrong semicolons with colon.
2025-06-18 09:23:37 +02:00
Guido Schmitz 3fad76dfa1 Use missed typed ConfigEntry in devolo Home Control (#147049) 2025-06-18 09:22:37 +02:00
Pete Sage 43d8a151ab Remove internals from Sonos test_init.py (#147063)
* fix: test init

* fix: revert

* fix: revert

* fix: revert

* fix: revert

* fix: simplify
2025-06-18 09:21:21 +02:00
starkillerOG 07110e288d If no Reolink HTTP api available, do not set configuration_url (#146684)
* If no http api available, do not set configuration_url

* Add tests
2025-06-18 09:16:08 +02:00
Jan-Philipp Benecke ba2aac4614 Bump aiowebdav2 to 0.4.6 (#147054) 2025-06-18 09:15:27 +02:00
msw 3449dae7a2 Capitalize "Ice Bites" and switch to "Cubed ice" (#147060) (#147061) 2025-06-18 09:14:45 +02:00
G Johansson b8cd3f3635 Bump holidays lib to 0.75 (#147043) 2025-06-18 10:11:01 +03:00
69 changed files with 1253 additions and 935 deletions
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==3.1.4"]
"requirements": ["aioamazondevices==3.1.12"]
}
+1 -1
View File
@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==0.101.0"],
"requirements": ["hass-nabucasa==0.102.0"],
"single_config_entry": true
}
@@ -91,7 +91,9 @@ async def async_unload_entry(
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
hass: HomeAssistant,
config_entry: DevoloHomeControlConfigEntry,
device_entry: DeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
return True
@@ -332,7 +332,7 @@ class EsphomeAssistSatellite(
}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
assert event.data is not None
if tts_output := event.data["tts_output"]:
if tts_output := event.data.get("tts_output"):
path = tts_output["url"]
url = async_process_play_media_url(self.hass, path)
data_to_send = {"url": url}
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.74", "babel==2.15.0"]
"requirements": ["holidays==0.75", "babel==2.15.0"]
}
+4 -4
View File
@@ -177,9 +177,9 @@
"state_attributes": {
"event_type": {
"state": {
"upper": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::upper%]",
"lower": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::lower%]",
"released": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::released%]"
"upper": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::upper%]",
"lower": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::lower%]",
"released": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]"
}
}
}
@@ -189,7 +189,7 @@
"state_attributes": {
"event_type": {
"state": {
"release": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::released%]",
"release": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]",
"up": "Up",
"down": "Down",
"stop": "Stop",
+10 -24
View File
@@ -41,7 +41,10 @@ from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
STATE_CLASS_UNITS,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.components.sensor.helpers import (
create_sensor_device_class_select_selector,
create_sensor_state_class_select_selector,
)
from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.config_entries import (
@@ -412,15 +415,6 @@ SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
}
)
# Sensor specific selectors
SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class_sensor",
sort=True,
)
)
BINARY_SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in BinarySensorDeviceClass],
@@ -445,19 +439,9 @@ COVER_DEVICE_CLASS_SELECTOR = SelectSelector(
sort=True,
)
)
SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorStateClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key=CONF_STATE_CLASS,
)
)
OPTIONS_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[],
custom_value=True,
multiple=True,
)
SelectSelectorConfig(options=[], custom_value=True, multiple=True)
)
SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector(
NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9)
@@ -783,10 +767,12 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
Platform.NOTIFY.value: {},
Platform.SENSOR.value: {
CONF_DEVICE_CLASS: PlatformField(
selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False
selector=create_sensor_device_class_select_selector(),
required=False,
),
CONF_STATE_CLASS: PlatformField(
selector=SENSOR_STATE_CLASS_SELECTOR, required=False
selector=create_sensor_state_class_select_selector(),
required=False,
),
CONF_UNIT_OF_MEASUREMENT: PlatformField(
selector=unit_of_measurement_selector,
+3 -40
View File
@@ -35,7 +35,6 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
from homeassistant.util import dt as dt_util
@@ -48,7 +47,6 @@ from .const import (
CONF_OPTIONS,
CONF_STATE_TOPIC,
CONF_SUGGESTED_DISPLAY_PRECISION,
DOMAIN,
PAYLOAD_NONE,
)
from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper
@@ -138,12 +136,9 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT
device_class in DEVICE_CLASS_UNITS
and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class]
):
_LOGGER.warning(
"The unit of measurement `%s` is not valid "
"together with device class `%s`. "
"this will stop working in HA Core 2025.7.0",
unit_of_measurement,
device_class,
raise vol.Invalid(
f"The unit of measurement `{unit_of_measurement}` is not valid "
f"together with device class `{device_class}`",
)
return config
@@ -194,40 +189,8 @@ class MqttSensor(MqttEntity, RestoreSensor):
None
)
@callback
def async_check_uom(self) -> None:
"""Check if the unit of measurement is valid with the device class."""
if (
self._discovery_data is not None
or self.device_class is None
or self.native_unit_of_measurement is None
):
return
if (
self.device_class in DEVICE_CLASS_UNITS
and self.native_unit_of_measurement
not in DEVICE_CLASS_UNITS[self.device_class]
):
async_create_issue(
self.hass,
DOMAIN,
self.entity_id,
issue_domain=sensor.DOMAIN,
is_fixable=False,
severity=IssueSeverity.WARNING,
learn_more_url=URL_DOCS_SUPPORTED_SENSOR_UOM,
translation_placeholders={
"uom": self.native_unit_of_measurement,
"device_class": self.device_class.value,
"entity_id": self.entity_id,
},
translation_key="invalid_unit_of_measurement",
breaks_in_ha_version="2025.7.0",
)
async def mqtt_async_added_to_hass(self) -> None:
"""Restore state for entities with expire_after set."""
self.async_check_uom()
last_state: State | None
last_sensor_data: SensorExtraStoredData | None
if (
@@ -3,10 +3,6 @@
"invalid_platform_config": {
"title": "Invalid config found for MQTT {domain} item",
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue."
},
"invalid_unit_of_measurement": {
"title": "Sensor with invalid unit of measurement",
"description": "Manual configured Sensor entity **{entity_id}** has a configured unit of measurement **{uom}** which is not valid with configured device class **{device_class}**. Make sure a valid unit of measurement is configured or remove the device class, and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue."
}
},
"config": {
@@ -821,66 +817,6 @@
"window": "[%key:component::cover::entity_component::window::name%]"
}
},
"device_class_sensor": {
"options": {
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"date": "[%key:component::sensor::entity_component::date::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"enum": "Enumeration",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
"device_class_switch": {
"options": {
"outlet": "[%key:component::switch::entity_component::outlet::name%]",
@@ -920,14 +856,6 @@
"custom": "Custom"
}
},
"state_class": {
"options": {
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]",
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
},
"supported_color_modes": {
"options": {
"onoff": "[%key:component::light::entity_component::_::state_attributes::color_mode::state::onoff%]",
+10 -2
View File
@@ -75,7 +75,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
)
http_s = "https" if self._host.api.use_https else "http"
self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}"
if self._host.api.baichuan_only:
self._conf_url = None
else:
self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}"
self._dev_id = self._host.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._dev_id)},
@@ -184,6 +187,11 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
if mac := self._host.api.baichuan.mac_address(dev_ch):
connections.add((CONNECTION_NETWORK_MAC, mac))
if self._conf_url is None:
conf_url = None
else:
conf_url = f"{self._conf_url}/?ch={dev_ch}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._dev_id)},
connections=connections,
@@ -195,7 +203,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
hw_version=self._host.api.camera_hardware_version(dev_ch),
sw_version=self._host.api.camera_sw_version(dev_ch),
serial_number=self._host.api.camera_uid(dev_ch),
configuration_url=f"{self._conf_url}/?ch={dev_ch}",
configuration_url=conf_url,
)
@property
+34 -1
View File
@@ -6,9 +6,14 @@ from datetime import date, datetime
import logging
from homeassistant.core import callback
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.util import dt as dt_util
from . import SensorDeviceClass
from . import DOMAIN, SensorDeviceClass, SensorStateClass
_LOGGER = logging.getLogger(__name__)
@@ -37,3 +42,31 @@ def async_parse_date_datetime(
_LOGGER.warning("%s rendered invalid date %s", entity_id, value)
return None
@callback
def create_sensor_device_class_select_selector() -> SelectSelector:
"""Create sensor device class select selector."""
return SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class",
translation_domain=DOMAIN,
sort=True,
)
)
@callback
def create_sensor_state_class_select_selector() -> SelectSelector:
"""Create sensor state class select selector."""
return SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SensorStateClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="state_class",
translation_domain=DOMAIN,
sort=True,
)
)
@@ -327,5 +327,74 @@
"title": "The unit of {statistic_id} has changed",
"description": ""
}
},
"selector": {
"device_class": {
"options": {
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"date": "[%key:component::sensor::entity_component::date::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"enum": "Enumeration",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
"state_class": {
"options": {
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
}
}
}
@@ -605,10 +605,10 @@
"name": "Wrinkle prevent"
},
"ice_maker": {
"name": "Ice cubes"
"name": "Cubed ice"
},
"ice_maker_2": {
"name": "Ice bites"
"name": "Ice Bites"
},
"sabbath_mode": {
"name": "Sabbath mode"
@@ -0,0 +1,25 @@
"""Helpers for switch entities."""
from homeassistant.core import callback
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from . import DOMAIN, SwitchDeviceClass
@callback
def create_switch_device_class_select_selector() -> SelectSelector:
"""Create sensor device class select selector."""
return SelectSelector(
SelectSelectorConfig(
options=[device_class.value for device_class in SwitchDeviceClass],
mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class",
translation_domain=DOMAIN,
sort=True,
)
)
@@ -52,5 +52,13 @@
"name": "[%key:common::action::toggle%]",
"description": "Toggles a switch on/off."
}
},
"selector": {
"device_class": {
"options": {
"outlet": "[%key:component::switch::entity_component::outlet::name%]",
"switch": "[%key:component::switch::title%]"
}
}
}
}
@@ -41,13 +41,12 @@ from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN
from .const import CONF_OBJECT_ID, DOMAIN
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
@@ -105,15 +104,10 @@ ALARM_CONTROL_PANEL_SCHEMA = vol.All(
CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name
): cv.enum(TemplateCodeFormat),
vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_NAME): cv.template,
vol.Optional(CONF_PICTURE): cv.template,
vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema),
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
)
@@ -419,9 +413,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane
unique_id: str | None,
) -> None:
"""Initialize the panel."""
TemplateEntity.__init__(
self, hass, config=config, fallback_name=None, unique_id=unique_id
)
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
AbstractTemplateAlarmControlPanel.__init__(self, config)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
+7 -17
View File
@@ -26,29 +26,19 @@ from homeassistant.helpers.entity_platform import (
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_PRESS, DOMAIN
from .template_entity import (
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
)
from .template_entity import TemplateEntity, make_template_entity_common_modern_schema
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Template Button"
DEFAULT_OPTIMISTIC = False
BUTTON_SCHEMA = (
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema)
)
BUTTON_SCHEMA = vol.Schema(
{
vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
}
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
CONFIG_BUTTON_SCHEMA = vol.Schema(
{
+4 -12
View File
@@ -37,14 +37,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN
from .const import CONF_OBJECT_ID, DOMAIN
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
@@ -100,21 +99,16 @@ COVER_SCHEMA = vol.All(
vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA,
vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
vol.Optional(CONF_OPTIMISTIC): cv.boolean,
vol.Optional(CONF_PICTURE): cv.template,
vol.Optional(CONF_POSITION): cv.template,
vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean,
vol.Optional(CONF_TILT): cv.template,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA,
}
)
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema),
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema),
cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION),
)
@@ -463,9 +457,7 @@ class CoverTemplate(TemplateEntity, AbstractTemplateCover):
unique_id,
) -> None:
"""Initialize the Template cover."""
TemplateEntity.__init__(
self, hass, config=config, fallback_name=None, unique_id=unique_id
)
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
AbstractTemplateCover.__init__(self, config)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
+4 -12
View File
@@ -37,14 +37,13 @@ from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN
from .const import CONF_OBJECT_ID, DOMAIN
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
@@ -85,12 +84,10 @@ FAN_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_DIRECTION): cv.template,
vol.Optional(CONF_NAME): cv.template,
vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_OSCILLATING): cv.template,
vol.Optional(CONF_PERCENTAGE): cv.template,
vol.Optional(CONF_PICTURE): cv.template,
vol.Optional(CONF_PRESET_MODE): cv.template,
vol.Optional(CONF_PRESET_MODES): cv.ensure_list,
vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA,
@@ -99,11 +96,8 @@ FAN_SCHEMA = vol.All(
vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int),
vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema),
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
)
LEGACY_FAN_SCHEMA = vol.All(
@@ -488,9 +482,7 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan):
unique_id,
) -> None:
"""Initialize the fan."""
TemplateEntity.__init__(
self, hass, config=config, fallback_name=None, unique_id=unique_id
)
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
AbstractTemplateFan.__init__(self, config)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
+5 -2
View File
@@ -29,7 +29,10 @@ from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator
from .const import CONF_PICTURE
from .template_entity import TemplateEntity, make_template_entity_common_schema
from .template_entity import (
TemplateEntity,
make_template_entity_common_modern_attributes_schema,
)
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -43,7 +46,7 @@ IMAGE_SCHEMA = vol.Schema(
vol.Required(CONF_URL): cv.template,
vol.Optional(CONF_VERIFY_SSL, default=True): bool,
}
).extend(make_template_entity_common_schema(DEFAULT_NAME).schema)
).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema)
IMAGE_CONFIG_SCHEMA = vol.Schema(
+28 -38
View File
@@ -49,14 +49,13 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import color as color_util
from . import TriggerUpdateCoordinator
from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN
from .const import CONF_OBJECT_ID, DOMAIN
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
@@ -124,38 +123,31 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
DEFAULT_NAME = "Template Light"
LIGHT_SCHEMA = (
vol.Schema(
{
vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA,
vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template,
vol.Inclusive(CONF_EFFECT, "effect"): cv.template,
vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_HS): cv.template,
vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_LEVEL): cv.template,
vol.Optional(CONF_MAX_MIREDS): cv.template,
vol.Optional(CONF_MIN_MIREDS): cv.template,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
vol.Optional(CONF_PICTURE): cv.template,
vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_RGB): cv.template,
vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_RGBW): cv.template,
vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_RGBWW): cv.template,
vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template,
vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_TEMPERATURE): cv.template,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
}
)
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema)
)
LIGHT_SCHEMA = vol.Schema(
{
vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA,
vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template,
vol.Inclusive(CONF_EFFECT, "effect"): cv.template,
vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_HS): cv.template,
vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_LEVEL): cv.template,
vol.Optional(CONF_MAX_MIREDS): cv.template,
vol.Optional(CONF_MIN_MIREDS): cv.template,
vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_RGB): cv.template,
vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_RGBW): cv.template,
vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_RGBWW): cv.template,
vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template,
vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_TEMPERATURE): cv.template,
vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
}
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
LEGACY_LIGHT_SCHEMA = vol.All(
cv.deprecated(CONF_ENTITY_ID),
@@ -955,9 +947,7 @@ class LightTemplate(TemplateEntity, AbstractTemplateLight):
unique_id: str | None,
) -> None:
"""Initialize the light."""
TemplateEntity.__init__(
self, hass, config=config, fallback_name=None, unique_id=unique_id
)
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
AbstractTemplateLight.__init__(self, config)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
+3 -10
View File
@@ -31,10 +31,9 @@ from .const import CONF_PICTURE, DOMAIN
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
@@ -57,17 +56,13 @@ LOCK_SCHEMA = vol.All(
{
vol.Optional(CONF_CODE_FORMAT): cv.template,
vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_NAME): cv.template,
vol.Optional(CONF_OPEN): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
vol.Optional(CONF_PICTURE): cv.template,
vol.Required(CONF_STATE): cv.template,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA,
}
)
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema),
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
)
@@ -313,9 +308,7 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock):
unique_id: str | None,
) -> None:
"""Initialize the lock."""
TemplateEntity.__init__(
self, hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id
)
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
AbstractTemplateLock.__init__(self, config)
name = self._attr_name
if TYPE_CHECKING:
+12 -22
View File
@@ -35,11 +35,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN
from .template_entity import (
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
)
from .template_entity import TemplateEntity, make_template_entity_common_modern_schema
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -49,23 +45,17 @@ CONF_SET_VALUE = "set_value"
DEFAULT_NAME = "Template Number"
DEFAULT_OPTIMISTIC = False
NUMBER_SCHEMA = (
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
vol.Required(CONF_STATE): cv.template,
vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA,
vol.Required(CONF_STEP): cv.template,
vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template,
vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema)
)
NUMBER_SCHEMA = vol.Schema(
{
vol.Required(CONF_STATE): cv.template,
vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA,
vol.Required(CONF_STEP): cv.template,
vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template,
vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
}
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
NUMBER_CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.template,
+9 -19
View File
@@ -32,11 +32,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import DOMAIN
from .template_entity import (
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
)
from .template_entity import TemplateEntity, make_template_entity_common_modern_schema
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@@ -47,20 +43,14 @@ CONF_SELECT_OPTION = "select_option"
DEFAULT_NAME = "Template Select"
DEFAULT_OPTIMISTIC = False
SELECT_SCHEMA = (
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
vol.Required(CONF_STATE): cv.template,
vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
vol.Required(ATTR_OPTIONS): cv.template,
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema)
)
SELECT_SCHEMA = vol.Schema(
{
vol.Required(CONF_STATE): cv.template,
vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA,
vol.Required(ATTR_OPTIONS): cv.template,
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
}
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
SELECT_CONFIG_SCHEMA = vol.Schema(
+10 -18
View File
@@ -40,13 +40,12 @@ from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import CONF_OBJECT_ID, CONF_PICTURE, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN
from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
@@ -60,20 +59,13 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
DEFAULT_NAME = "Template Switch"
SWITCH_SCHEMA = (
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
vol.Optional(CONF_STATE): cv.template,
vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA,
vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_PICTURE): cv.template,
}
)
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema)
)
SWITCH_SCHEMA = vol.Schema(
{
vol.Optional(CONF_STATE): cv.template,
vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA,
vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA,
}
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
LEGACY_SWITCH_SCHEMA = vol.All(
cv.deprecated(ATTR_ENTITY_ID),
@@ -228,7 +220,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
unique_id: str | None,
) -> None:
"""Initialize the Template switch."""
super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id)
super().__init__(hass, config=config, unique_id=unique_id)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
@@ -94,16 +94,24 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = (
)
def make_template_entity_common_schema(default_name: str) -> vol.Schema:
def make_template_entity_common_modern_schema(
default_name: str,
) -> vol.Schema:
"""Return a schema with default name."""
return (
vol.Schema(
{
vol.Optional(CONF_AVAILABILITY): cv.template,
}
)
.extend(make_template_entity_base_schema(default_name).schema)
.extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema)
return vol.Schema(
{
vol.Optional(CONF_AVAILABILITY): cv.template,
vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
}
).extend(make_template_entity_base_schema(default_name).schema)
def make_template_entity_common_modern_attributes_schema(
default_name: str,
) -> vol.Schema:
"""Return a schema with default name."""
return make_template_entity_common_modern_schema(default_name).extend(
TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema
)
+6 -15
View File
@@ -38,16 +38,14 @@ from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN
from .const import CONF_OBJECT_ID, DOMAIN
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA,
TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
make_template_entity_common_modern_attributes_schema,
rewrite_common_legacy_to_modern_conf,
)
@@ -60,6 +58,8 @@ CONF_FAN_SPEED_LIST = "fan_speeds"
CONF_FAN_SPEED = "fan_speed"
CONF_FAN_SPEED_TEMPLATE = "fan_speed_template"
DEFAULT_NAME = "Template Vacuum"
ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}"
_VALID_STATES = [
VacuumActivity.CLEANING,
@@ -80,13 +80,9 @@ VACUUM_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_BATTERY_LEVEL): cv.template,
vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list,
vol.Optional(CONF_FAN_SPEED): cv.template,
vol.Optional(CONF_NAME): cv.template,
vol.Optional(CONF_PICTURE): cv.template,
vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA,
vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA,
vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA,
@@ -95,10 +91,7 @@ VACUUM_SCHEMA = vol.All(
vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA,
vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA,
}
)
.extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
.extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema),
).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema)
)
LEGACY_VACUUM_SCHEMA = vol.All(
@@ -353,9 +346,7 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum):
unique_id,
) -> None:
"""Initialize the vacuum."""
TemplateEntity.__init__(
self, hass, config=config, fallback_name=None, unique_id=unique_id
)
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
AbstractTemplateVacuum.__init__(self, config)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
+23 -20
View File
@@ -32,7 +32,6 @@ from homeassistant.components.weather import (
WeatherEntityFeature,
)
from homeassistant.const import (
CONF_NAME,
CONF_TEMPERATURE_UNIT,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
@@ -53,7 +52,11 @@ from homeassistant.util.unit_conversion import (
)
from .coordinator import TriggerUpdateCoordinator
from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf
from .template_entity import (
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
CHECK_FORECAST_KEYS = (
@@ -104,33 +107,33 @@ CONF_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template"
CONF_DEW_POINT_TEMPLATE = "dew_point_template"
CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template"
DEFAULT_NAME = "Template Weather"
WEATHER_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.template,
vol.Required(CONF_CONDITION_TEMPLATE): cv.template,
vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template,
vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template,
vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template,
vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template,
vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template,
vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template,
vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template,
vol.Optional(CONF_OZONE_TEMPLATE): cv.template,
vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template,
vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template,
vol.Required(CONF_CONDITION_TEMPLATE): cv.template,
vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template,
vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template,
vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template,
vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template,
vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS),
vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS),
vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS),
vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS),
vol.Optional(CONF_OZONE_TEMPLATE): cv.template,
vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS),
vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template,
vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS),
vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template,
vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS),
vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template,
vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS),
vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template,
vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template,
vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template,
vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template,
vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template,
vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template,
vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS),
}
)
).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema)
PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aiowebdav2"],
"quality_scale": "bronze",
"requirements": ["aiowebdav2==0.4.5"]
"requirements": ["aiowebdav2==0.4.6"]
}
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.74"]
"requirements": ["holidays==0.75"]
}
+2
View File
@@ -1167,6 +1167,7 @@ class SelectSelectorConfig(BaseSelectorConfig, total=False):
custom_value: bool
mode: SelectSelectorMode
translation_key: str
translation_domain: str
sort: bool
@@ -1185,6 +1186,7 @@ class SelectSelector(Selector[SelectSelectorConfig]):
vol.Coerce(SelectSelectorMode), lambda val: val.value
),
vol.Optional("translation_key"): cv.string,
vol.Optional("translation_domain"): cv.string,
vol.Optional("sort", default=False): cv.boolean,
}
)
+1 -1
View File
@@ -36,7 +36,7 @@ fnv-hash-fast==1.5.0
go2rtc-client==0.2.1
ha-ffmpeg==3.2.2
habluetooth==3.49.0
hass-nabucasa==0.101.0
hass-nabucasa==0.102.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250531.3
+1 -1
View File
@@ -53,7 +53,7 @@ dependencies = [
"ha-ffmpeg==3.2.2",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
"hass-nabucasa==0.101.0",
"hass-nabucasa==0.102.0",
# hassil is indirectly imported from onboarding via the import chain
# onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs
# to be setup in stage 0, but we don't want to also promote cloud with all its
+1 -1
View File
@@ -24,7 +24,7 @@ ciso8601==2.3.2
cronsim==2.6
fnv-hash-fast==1.5.0
ha-ffmpeg==3.2.2
hass-nabucasa==0.101.0
hass-nabucasa==0.102.0
hassil==2.2.3
httpx==0.28.1
home-assistant-bluetooth==1.13.1
+4 -4
View File
@@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12
aioairzone==1.0.0
# homeassistant.components.alexa_devices
aioamazondevices==3.1.4
aioamazondevices==3.1.12
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -429,7 +429,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webdav
aiowebdav2==0.4.5
aiowebdav2==0.4.6
# homeassistant.components.webostv
aiowebostv==0.7.3
@@ -1124,7 +1124,7 @@ habiticalib==0.4.0
habluetooth==3.49.0
# homeassistant.components.cloud
hass-nabucasa==0.101.0
hass-nabucasa==0.102.0
# homeassistant.components.splunk
hass-splunk==0.1.1
@@ -1161,7 +1161,7 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.74
holidays==0.75
# homeassistant.components.frontend
home-assistant-frontend==20250531.3
+4 -4
View File
@@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12
aioairzone==1.0.0
# homeassistant.components.alexa_devices
aioamazondevices==3.1.4
aioamazondevices==3.1.12
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -411,7 +411,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webdav
aiowebdav2==0.4.5
aiowebdav2==0.4.6
# homeassistant.components.webostv
aiowebostv==0.7.3
@@ -982,7 +982,7 @@ habiticalib==0.4.0
habluetooth==3.49.0
# homeassistant.components.cloud
hass-nabucasa==0.101.0
hass-nabucasa==0.102.0
# homeassistant.components.conversation
hassil==2.2.3
@@ -1007,7 +1007,7 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.74
holidays==0.75
# homeassistant.components.frontend
home-assistant-frontend==20250531.3
@@ -2,7 +2,6 @@
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
@@ -19,7 +18,6 @@ from .mocks import (
)
@pytest.mark.usefixtures("mock_zeroconf")
async def test_binary_sensor(
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
) -> None:
@@ -58,7 +56,6 @@ async def test_binary_sensor(
)
@pytest.mark.usefixtures("mock_zeroconf")
async def test_remote_control(
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
) -> None:
@@ -99,7 +96,6 @@ async def test_remote_control(
)
@pytest.mark.usefixtures("mock_zeroconf")
async def test_disabled(hass: HomeAssistant) -> None:
"""Test setup of a disabled device."""
entry = configure_integration(hass)
@@ -113,7 +109,6 @@ async def test_disabled(hass: HomeAssistant) -> None:
assert hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door") is None
@pytest.mark.usefixtures("mock_zeroconf")
async def test_remove_from_hass(hass: HomeAssistant) -> None:
"""Test removing entity."""
entry = configure_integration(hass)
@@ -66,44 +66,6 @@ async def test_form_already_configured(hass: HomeAssistant) -> None:
assert result["reason"] == "already_configured"
async def test_form_advanced_options(hass: HomeAssistant) -> None:
"""Test if we get the advanced options if user has enabled it."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER, "show_advanced_options": True},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
with (
patch(
"homeassistant.components.devolo_home_control.async_setup_entry",
return_value=True,
) as mock_setup_entry,
patch(
"homeassistant.components.devolo_home_control.Mydevolo.uuid",
return_value="123456",
),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "devolo Home Control"
assert result2["data"] == {
"username": "test-username",
"password": "test-password",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_zeroconf(hass: HomeAssistant) -> None:
"""Test that the zeroconf confirmation form is served."""
result = await hass.config_entries.flow.async_init(
@@ -1,7 +1,5 @@
"""Tests for the devolo Home Control diagnostics."""
from __future__ import annotations
from unittest.mock import patch
from syrupy.assertion import SnapshotAssertion
@@ -19,7 +19,6 @@ from .mocks import HomeControlMock, HomeControlMockBinarySensor
from tests.typing import WebSocketGenerator
@pytest.mark.usefixtures("mock_zeroconf")
async def test_setup_entry(hass: HomeAssistant) -> None:
"""Test setup entry."""
entry = configure_integration(hass)
@@ -44,7 +43,6 @@ async def test_setup_entry_maintenance(hass: HomeAssistant) -> None:
assert entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("mock_zeroconf")
async def test_setup_gateway_offline(hass: HomeAssistant) -> None:
"""Test setup entry fails on gateway offline."""
entry = configure_integration(hass)
@@ -2,7 +2,6 @@
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN
@@ -14,7 +13,6 @@ from . import configure_integration
from .mocks import HomeControlMock, HomeControlMockSiren
@pytest.mark.usefixtures("mock_zeroconf")
async def test_siren(
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
) -> None:
@@ -45,7 +43,6 @@ async def test_siren(
assert hass.states.get(f"{SIREN_DOMAIN}.test").state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("mock_zeroconf")
async def test_siren_switching(
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
) -> None:
@@ -98,7 +95,6 @@ async def test_siren_switching(
property_set.assert_called_once_with(0)
@pytest.mark.usefixtures("mock_zeroconf")
async def test_siren_change_default_tone(
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
) -> None:
@@ -130,7 +126,6 @@ async def test_siren_change_default_tone(
property_set.assert_called_once_with(2)
@pytest.mark.usefixtures("mock_zeroconf")
async def test_remove_from_hass(hass: HomeAssistant) -> None:
"""Test removing entity."""
entry = configure_integration(hass)
+4 -34
View File
@@ -898,42 +898,12 @@ async def test_invalid_unit_of_measurement(
"The unit of measurement `ppm` is not valid together with device class `energy`"
in caplog.text
)
# A repair issue was logged
# A repair issue was logged for the failing YAML config
assert len(events) == 1
assert events[0].data["issue_id"] == "sensor.test"
# Assert the sensor works
async_fire_mqtt_message(hass, "test-topic", "100")
await hass.async_block_till_done()
assert events[0].data["domain"] == mqtt.DOMAIN
# Assert the sensor is not created
state = hass.states.get("sensor.test")
assert state is not None
assert state.state == "100"
caplog.clear()
discovery_payload = {
"name": "bla",
"state_topic": "test-topic2",
"device_class": "temperature",
"unit_of_measurement": "C",
}
# Now discover an other invalid sensor
async_fire_mqtt_message(
hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload)
)
await hass.async_block_till_done()
assert (
"The unit of measurement `C` is not valid together with device class `temperature`"
in caplog.text
)
# Assert the sensor works
async_fire_mqtt_message(hass, "test-topic2", "21")
await hass.async_block_till_done()
state = hass.states.get("sensor.bla")
assert state is not None
assert state.state == "21"
# No new issue was registered for the discovered entity
assert len(events) == 1
assert state is None
@pytest.mark.parametrize(
+28
View File
@@ -65,11 +65,14 @@ def mock_setup_entry() -> Generator[AsyncMock]:
def _init_host_mock(host_mock: MagicMock) -> None:
host_mock.get_host_data = AsyncMock(return_value=None)
host_mock.get_states = AsyncMock(return_value=None)
host_mock.get_state = AsyncMock()
host_mock.check_new_firmware = AsyncMock(return_value=False)
host_mock.subscribe = AsyncMock()
host_mock.unsubscribe = AsyncMock(return_value=True)
host_mock.logout = AsyncMock(return_value=True)
host_mock.reboot = AsyncMock()
host_mock.set_ptz_command = AsyncMock()
host_mock.get_motion_state_all_ch = AsyncMock(return_value=False)
host_mock.is_nvr = True
host_mock.is_hub = False
host_mock.mac_address = TEST_MAC
@@ -138,8 +141,10 @@ def _init_host_mock(host_mock: MagicMock) -> None:
# Disable tcp push by default for tests
host_mock.baichuan.port = TEST_BC_PORT
host_mock.baichuan.events_active = False
host_mock.baichuan.subscribe_events = AsyncMock()
host_mock.baichuan.unsubscribe_events = AsyncMock()
host_mock.baichuan.check_subscribe_events = AsyncMock()
host_mock.baichuan.get_privacy_mode = AsyncMock()
host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM
host_mock.baichuan.privacy_mode.return_value = False
host_mock.baichuan.day_night_state.return_value = "day"
@@ -242,3 +247,26 @@ def test_chime(reolink_connect: MagicMock) -> None:
reolink_connect.chime_list = [TEST_CHIME]
reolink_connect.chime.return_value = TEST_CHIME
return TEST_CHIME
@pytest.fixture
def reolink_chime(reolink_host: MagicMock) -> None:
"""Mock a reolink chime."""
TEST_CHIME = Chime(
host=reolink_host,
dev_id=12345678,
channel=0,
)
TEST_CHIME.name = "Test chime"
TEST_CHIME.volume = 3
TEST_CHIME.connect_state = 2
TEST_CHIME.led_state = True
TEST_CHIME.event_info = {
"md": {"switch": 0, "musicId": 0},
"people": {"switch": 0, "musicId": 1},
"visitor": {"switch": 1, "musicId": 2},
}
reolink_host.chime_list = [TEST_CHIME]
reolink_host.chime.return_value = TEST_CHIME
return TEST_CHIME
+1
View File
@@ -115,6 +115,7 @@ async def test_webhook_callback(
assert hass.states.get(entity_id).state == STATE_OFF
# test webhook callback success all channels
reolink_connect.get_motion_state_all_ch.return_value = True
reolink_connect.motion_detected.return_value = True
reolink_connect.ONVIF_event_callback.return_value = None
await client.post(f"/api/webhook/{webhook_id}")
+109 -128
View File
@@ -69,7 +69,7 @@ from .conftest import (
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import WebSocketGenerator
pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms")
pytestmark = pytest.mark.usefixtures("reolink_host", "reolink_platforms")
CHIME_MODEL = "Reolink Chime"
@@ -116,15 +116,14 @@ async def test_wait(*args, **key_args) -> None:
)
async def test_failures_parametrized(
hass: HomeAssistant,
reolink_connect: MagicMock,
reolink_host: MagicMock,
config_entry: MockConfigEntry,
attr: str,
value: Any,
expected: ConfigEntryState,
) -> None:
"""Test outcomes when changing errors."""
original = getattr(reolink_connect, attr)
setattr(reolink_connect, attr, value)
setattr(reolink_host, attr, value)
assert await hass.config_entries.async_setup(config_entry.entry_id) is (
expected is ConfigEntryState.LOADED
)
@@ -132,17 +131,15 @@ async def test_failures_parametrized(
assert config_entry.state == expected
setattr(reolink_connect, attr, original)
async def test_firmware_error_twice(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
reolink_connect: MagicMock,
reolink_host: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test when the firmware update fails 2 times."""
reolink_connect.check_new_firmware.side_effect = ReolinkError("Test error")
reolink_host.check_new_firmware.side_effect = ReolinkError("Test error")
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -158,13 +155,11 @@ async def test_firmware_error_twice(
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
reolink_connect.check_new_firmware.reset_mock(side_effect=True)
async def test_credential_error_three(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
reolink_connect: MagicMock,
reolink_host: MagicMock,
config_entry: MockConfigEntry,
issue_registry: ir.IssueRegistry,
) -> None:
@@ -174,7 +169,7 @@ async def test_credential_error_three(
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
reolink_connect.get_states.side_effect = CredentialsInvalidError("Test error")
reolink_host.get_states.side_effect = CredentialsInvalidError("Test error")
issue_id = f"config_entry_reauth_{DOMAIN}_{config_entry.entry_id}"
for _ in range(NUM_CRED_ERRORS):
@@ -185,31 +180,26 @@ async def test_credential_error_three(
assert (HOMEASSISTANT_DOMAIN, issue_id) in issue_registry.issues
reolink_connect.get_states.reset_mock(side_effect=True)
async def test_entry_reloading(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
) -> None:
"""Test the entry is reloaded correctly when settings change."""
reolink_connect.is_nvr = False
reolink_connect.logout.reset_mock()
reolink_host.is_nvr = False
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert reolink_connect.logout.call_count == 0
assert reolink_host.logout.call_count == 0
assert config_entry.title == "test_reolink_name"
hass.config_entries.async_update_entry(config_entry, title="New Name")
await hass.async_block_till_done()
assert reolink_connect.logout.call_count == 1
assert reolink_host.logout.call_count == 1
assert config_entry.title == "New Name"
reolink_connect.is_nvr = True
@pytest.mark.parametrize(
("attr", "value", "expected_models"),
@@ -241,7 +231,7 @@ async def test_removing_disconnected_cams(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
attr: str | None,
@@ -249,7 +239,7 @@ async def test_removing_disconnected_cams(
expected_models: list[str],
) -> None:
"""Test device and entity registry are cleaned up when camera is removed."""
reolink_connect.channels = [0]
reolink_host.channels = [0]
assert await async_setup_component(hass, "config", {})
client = await hass_ws_client(hass)
# setup CH 0 and NVR switch entities/device
@@ -265,8 +255,7 @@ async def test_removing_disconnected_cams(
# Try to remove the device after 'disconnecting' a camera.
if attr is not None:
original = getattr(reolink_connect, attr)
setattr(reolink_connect, attr, value)
setattr(reolink_host, attr, value)
expected_success = TEST_CAM_MODEL not in expected_models
for device in device_entries:
if device.model == TEST_CAM_MODEL:
@@ -279,9 +268,6 @@ async def test_removing_disconnected_cams(
device_models = [device.model for device in device_entries]
assert sorted(device_models) == sorted(expected_models)
if attr is not None:
setattr(reolink_connect, attr, original)
@pytest.mark.parametrize(
("attr", "value", "expected_models"),
@@ -307,8 +293,8 @@ async def test_removing_chime(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
test_chime: Chime,
reolink_host: MagicMock,
reolink_chime: Chime,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
attr: str | None,
@@ -316,7 +302,7 @@ async def test_removing_chime(
expected_models: list[str],
) -> None:
"""Test removing a chime."""
reolink_connect.channels = [0]
reolink_host.channels = [0]
assert await async_setup_component(hass, "config", {})
client = await hass_ws_client(hass)
# setup CH 0 and NVR switch entities/device
@@ -336,11 +322,11 @@ async def test_removing_chime(
async def test_remove_chime(*args, **key_args):
"""Remove chime."""
test_chime.connect_state = -1
reolink_chime.connect_state = -1
test_chime.remove = test_remove_chime
reolink_chime.remove = test_remove_chime
elif attr is not None:
setattr(test_chime, attr, value)
setattr(reolink_chime, attr, value)
# Try to remove the device after 'disconnecting' a chime.
expected_success = CHIME_MODEL not in expected_models
@@ -444,7 +430,7 @@ async def test_removing_chime(
async def test_migrate_entity_ids(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
original_id: str,
@@ -464,8 +450,8 @@ async def test_migrate_entity_ids(
return support_ch_uid
return True
reolink_connect.channels = [0]
reolink_connect.supported = mock_supported
reolink_host.channels = [0]
reolink_host.supported = mock_supported
dev_entry = device_registry.async_get_or_create(
identifiers={(DOMAIN, original_dev_id)},
@@ -513,7 +499,7 @@ async def test_migrate_entity_ids(
async def test_migrate_with_already_existing_device(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
@@ -529,8 +515,8 @@ async def test_migrate_with_already_existing_device(
return True
return True
reolink_connect.channels = [0]
reolink_connect.supported = mock_supported
reolink_host.channels = [0]
reolink_host.supported = mock_supported
device_registry.async_get_or_create(
identifiers={(DOMAIN, new_dev_id)},
@@ -562,7 +548,7 @@ async def test_migrate_with_already_existing_device(
async def test_migrate_with_already_existing_entity(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
@@ -579,8 +565,8 @@ async def test_migrate_with_already_existing_entity(
return True
return True
reolink_connect.channels = [0]
reolink_connect.supported = mock_supported
reolink_host.channels = [0]
reolink_host.supported = mock_supported
dev_entry = device_registry.async_get_or_create(
identifiers={(DOMAIN, dev_id)},
@@ -623,13 +609,13 @@ async def test_migrate_with_already_existing_entity(
async def test_cleanup_mac_connection(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test cleanup of the MAC of a IPC which was set to the MAC of the host."""
reolink_connect.channels = [0]
reolink_connect.baichuan.mac_address.return_value = None
reolink_host.channels = [0]
reolink_host.baichuan.mac_address.return_value = None
entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio"
dev_id = f"{TEST_UID}_{TEST_UID_CAM}"
domain = Platform.SWITCH
@@ -666,19 +652,17 @@ async def test_cleanup_mac_connection(
assert device
assert device.connections == set()
reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM
async def test_cleanup_combined_with_NVR(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test cleanup of the device registry if IPC camera device was combined with the NVR device."""
reolink_connect.channels = [0]
reolink_connect.baichuan.mac_address.return_value = None
reolink_host.channels = [0]
reolink_host.baichuan.mac_address.return_value = None
entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio"
dev_id = f"{TEST_UID}_{TEST_UID_CAM}"
domain = Platform.SWITCH
@@ -726,18 +710,16 @@ async def test_cleanup_combined_with_NVR(
("OTHER_INTEGRATION", "SOME_ID"),
}
reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM
async def test_cleanup_hub_and_direct_connection(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test cleanup of the device registry if IPC camera device was connected directly and through the hub/NVR."""
reolink_connect.channels = [0]
reolink_host.channels = [0]
entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio"
dev_id = f"{TEST_UID}_{TEST_UID_CAM}"
domain = Platform.SWITCH
@@ -801,11 +783,11 @@ async def test_no_repair_issue(
async def test_https_repair_issue(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test repairs issue is raised when https local url is used."""
reolink_connect.get_states = test_wait
reolink_host.get_states = test_wait
await async_process_ha_core_config(
hass, {"country": "GB", "internal_url": "https://test_homeassistant_address"}
)
@@ -828,11 +810,11 @@ async def test_https_repair_issue(
async def test_ssl_repair_issue(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test repairs issue is raised when global ssl certificate is used."""
reolink_connect.get_states = test_wait
reolink_host.get_states = test_wait
assert await async_setup_component(hass, "webhook", {})
hass.config.api.use_ssl = True
@@ -859,32 +841,30 @@ async def test_ssl_repair_issue(
async def test_port_repair_issue(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
protocol: str,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test repairs issue is raised when auto enable of ports fails."""
reolink_connect.set_net_port.side_effect = ReolinkError("Test error")
reolink_connect.onvif_enabled = False
reolink_connect.rtsp_enabled = False
reolink_connect.rtmp_enabled = False
reolink_connect.protocol = protocol
reolink_host.set_net_port.side_effect = ReolinkError("Test error")
reolink_host.onvif_enabled = False
reolink_host.rtsp_enabled = False
reolink_host.rtmp_enabled = False
reolink_host.protocol = protocol
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert (DOMAIN, "enable_port") in issue_registry.issues
reolink_connect.set_net_port.reset_mock(side_effect=True)
async def test_webhook_repair_issue(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test repairs issue is raised when the webhook url is unreachable."""
reolink_connect.get_states = test_wait
reolink_host.get_states = test_wait
with (
patch("homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0),
patch(
@@ -903,25 +883,24 @@ async def test_webhook_repair_issue(
async def test_firmware_repair_issue(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test firmware issue is raised when too old firmware is used."""
reolink_connect.camera_sw_version_update_required.return_value = True
reolink_host.camera_sw_version_update_required.return_value = True
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert (DOMAIN, "firmware_update_host") in issue_registry.issues
reolink_connect.camera_sw_version_update_required.return_value = False
async def test_password_too_long_repair_issue(
hass: HomeAssistant,
reolink_connect: MagicMock,
reolink_host: MagicMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test password too long issue is raised."""
reolink_connect.valid_password.return_value = False
reolink_host.valid_password.return_value = False
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=format_mac(TEST_MAC),
@@ -946,13 +925,12 @@ async def test_password_too_long_repair_issue(
DOMAIN,
f"password_too_long_{config_entry.entry_id}",
) in issue_registry.issues
reolink_connect.valid_password.return_value = True
async def test_new_device_discovered(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
reolink_connect: MagicMock,
reolink_host: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test the entry is reloaded when a new camera or chime is detected."""
@@ -960,26 +938,24 @@ async def test_new_device_discovered(
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
reolink_connect.logout.reset_mock()
assert reolink_connect.logout.call_count == 0
reolink_connect.new_devices = True
assert reolink_host.logout.call_count == 0
reolink_host.new_devices = True
freezer.tick(DEVICE_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert reolink_connect.logout.call_count == 1
assert reolink_host.logout.call_count == 1
async def test_port_changed(
hass: HomeAssistant,
reolink_connect: MagicMock,
reolink_host: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test config_entry port update when it has changed during initial login."""
assert config_entry.data[CONF_PORT] == TEST_PORT
reolink_connect.port = 4567
reolink_host.port = 4567
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -989,12 +965,12 @@ async def test_port_changed(
async def test_baichuan_port_changed(
hass: HomeAssistant,
reolink_connect: MagicMock,
reolink_host: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test config_entry baichuan port update when it has changed during initial login."""
assert config_entry.data[CONF_BC_PORT] == TEST_BC_PORT
reolink_connect.baichuan.port = 8901
reolink_host.baichuan.port = 8901
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -1005,14 +981,12 @@ async def test_baichuan_port_changed(
async def test_privacy_mode_on(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
reolink_connect: MagicMock,
reolink_host: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test successful setup even when privacy mode is turned on."""
reolink_connect.baichuan.privacy_mode.return_value = True
reolink_connect.get_states = AsyncMock(
side_effect=LoginPrivacyModeError("Test error")
)
reolink_host.baichuan.privacy_mode.return_value = True
reolink_host.get_states = AsyncMock(side_effect=LoginPrivacyModeError("Test error"))
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
@@ -1020,40 +994,36 @@ async def test_privacy_mode_on(
assert config_entry.state == ConfigEntryState.LOADED
reolink_connect.baichuan.privacy_mode.return_value = False
async def test_LoginPrivacyModeError(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
reolink_connect: MagicMock,
reolink_host: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test normal update when get_states returns a LoginPrivacyModeError."""
reolink_connect.baichuan.privacy_mode.return_value = False
reolink_connect.get_states = AsyncMock(
side_effect=LoginPrivacyModeError("Test error")
)
reolink_host.baichuan.privacy_mode.return_value = False
reolink_host.get_states = AsyncMock(side_effect=LoginPrivacyModeError("Test error"))
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
reolink_connect.baichuan.check_subscribe_events.reset_mock()
assert reolink_connect.baichuan.check_subscribe_events.call_count == 0
reolink_host.baichuan.check_subscribe_events.reset_mock()
assert reolink_host.baichuan.check_subscribe_events.call_count == 0
freezer.tick(DEVICE_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert reolink_connect.baichuan.check_subscribe_events.call_count >= 1
assert reolink_host.baichuan.check_subscribe_events.call_count >= 1
async def test_privacy_mode_change_callback(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
) -> None:
"""Test privacy mode changed callback."""
@@ -1068,13 +1038,12 @@ async def test_privacy_mode_change_callback(
callback_mock = callback_mock_class()
reolink_connect.model = TEST_HOST_MODEL
reolink_connect.baichuan.events_active = True
reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True)
reolink_connect.baichuan.register_callback = callback_mock.register_callback
reolink_connect.baichuan.privacy_mode.return_value = True
reolink_connect.audio_record.return_value = True
reolink_connect.get_states = AsyncMock()
reolink_host.model = TEST_HOST_MODEL
reolink_host.baichuan.events_active = True
reolink_host.baichuan.subscribe_events.reset_mock(side_effect=True)
reolink_host.baichuan.register_callback = callback_mock.register_callback
reolink_host.baichuan.privacy_mode.return_value = True
reolink_host.audio_record.return_value = True
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
@@ -1085,29 +1054,29 @@ async def test_privacy_mode_change_callback(
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# simulate a TCP push callback signaling a privacy mode change
reolink_connect.baichuan.privacy_mode.return_value = False
reolink_host.baichuan.privacy_mode.return_value = False
assert callback_mock.callback_func is not None
callback_mock.callback_func()
# check that a coordinator update was scheduled.
reolink_connect.get_states.reset_mock()
assert reolink_connect.get_states.call_count == 0
reolink_host.get_states.reset_mock()
assert reolink_host.get_states.call_count == 0
freezer.tick(5)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert reolink_connect.get_states.call_count >= 1
assert reolink_host.get_states.call_count >= 1
assert hass.states.get(entity_id).state == STATE_ON
# test cleanup during unloading, first reset to privacy mode ON
reolink_connect.baichuan.privacy_mode.return_value = True
reolink_host.baichuan.privacy_mode.return_value = True
callback_mock.callback_func()
freezer.tick(5)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# now fire the callback again, but unload before refresh took place
reolink_connect.baichuan.privacy_mode.return_value = False
reolink_host.baichuan.privacy_mode.return_value = False
callback_mock.callback_func()
await hass.async_block_till_done()
@@ -1120,7 +1089,7 @@ async def test_camera_wake_callback(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
reolink_host: MagicMock,
) -> None:
"""Test camera wake callback."""
@@ -1135,13 +1104,12 @@ async def test_camera_wake_callback(
callback_mock = callback_mock_class()
reolink_connect.model = TEST_HOST_MODEL
reolink_connect.baichuan.events_active = True
reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True)
reolink_connect.baichuan.register_callback = callback_mock.register_callback
reolink_connect.sleeping.return_value = True
reolink_connect.audio_record.return_value = True
reolink_connect.get_states = AsyncMock()
reolink_host.model = TEST_HOST_MODEL
reolink_host.baichuan.events_active = True
reolink_host.baichuan.subscribe_events.reset_mock(side_effect=True)
reolink_host.baichuan.register_callback = callback_mock.register_callback
reolink_host.sleeping.return_value = True
reolink_host.audio_record.return_value = True
with (
patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]),
@@ -1157,12 +1125,12 @@ async def test_camera_wake_callback(
entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio"
assert hass.states.get(entity_id).state == STATE_ON
reolink_connect.sleeping.return_value = False
reolink_connect.get_states.reset_mock()
assert reolink_connect.get_states.call_count == 0
reolink_host.sleeping.return_value = False
reolink_host.get_states.reset_mock()
assert reolink_host.get_states.call_count == 0
# simulate a TCP push callback signaling the battery camera woke up
reolink_connect.audio_record.return_value = False
reolink_host.audio_record.return_value = False
assert callback_mock.callback_func is not None
with (
patch(
@@ -1182,13 +1150,26 @@ async def test_camera_wake_callback(
await hass.async_block_till_done()
# check that a coordinator update was scheduled.
assert reolink_connect.get_states.call_count >= 1
assert reolink_host.get_states.call_count >= 1
assert hass.states.get(entity_id).state == STATE_OFF
async def test_baichaun_only(
hass: HomeAssistant,
reolink_connect: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test initializing a baichuan only device."""
reolink_connect.baichuan_only = True
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
async def test_remove(
hass: HomeAssistant,
reolink_connect: MagicMock,
reolink_host: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Test removing of the reolink integration."""
+27 -3
View File
@@ -1,9 +1,13 @@
"""The test for sensor helpers."""
"""Tests for sensor helpers."""
import pytest
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.components.sensor.helpers import async_parse_date_datetime
from homeassistant.components.sensor import DOMAIN, SensorDeviceClass, SensorStateClass
from homeassistant.components.sensor.helpers import (
async_parse_date_datetime,
create_sensor_device_class_select_selector,
create_sensor_state_class_select_selector,
)
def test_async_parse_datetime(caplog: pytest.LogCaptureFixture) -> None:
@@ -39,3 +43,23 @@ def test_async_parse_datetime(caplog: pytest.LogCaptureFixture) -> None:
# Invalid date
assert async_parse_date_datetime("December 12th", entity_id, device_class) is None
assert "sensor.timestamp rendered invalid date December 12th" in caplog.text
def test_create_sensor_device_class_select_selector() -> None:
"Test Create sensor state class select selector helper."
selector = create_sensor_device_class_select_selector()
assert selector.config["options"] == list(SensorDeviceClass)
assert selector.config["translation_domain"] == DOMAIN
assert selector.config["translation_key"] == "device_class"
assert selector.config["sort"]
assert not selector.config["custom_value"]
def test_create_sensor_state_class_select_selector() -> None:
"Test Create sensor state class select selector helper."
selector = create_sensor_state_class_select_selector()
assert selector.config["options"] == list(SensorStateClass)
assert selector.config["translation_domain"] == DOMAIN
assert selector.config["translation_key"] == "state_class"
assert selector.config["sort"]
assert not selector.config["custom_value"]
@@ -47,7 +47,7 @@
'state': 'on',
})
# ---
# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_cubes-entry]
# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_cubed_ice-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -60,7 +60,7 @@
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.refrigerator_ice_cubes',
'entity_id': 'switch.refrigerator_cubed_ice',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -72,7 +72,7 @@
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Ice cubes',
'original_name': 'Cubed ice',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -82,13 +82,13 @@
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_cubes-state]
# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_cubed_ice-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Refrigerator Ice cubes',
'friendly_name': 'Refrigerator Cubed ice',
}),
'context': <ANY>,
'entity_id': 'switch.refrigerator_ice_cubes',
'entity_id': 'switch.refrigerator_cubed_ice',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -239,7 +239,7 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_cubes-entry]
# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_cubed_ice-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -252,7 +252,7 @@
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.refrigerator_ice_cubes',
'entity_id': 'switch.refrigerator_cubed_ice',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -264,7 +264,7 @@
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Ice cubes',
'original_name': 'Cubed ice',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -274,13 +274,13 @@
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_cubes-state]
# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_cubed_ice-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Refrigerator Ice cubes',
'friendly_name': 'Refrigerator Cubed ice',
}),
'context': <ANY>,
'entity_id': 'switch.refrigerator_ice_cubes',
'entity_id': 'switch.refrigerator_cubed_ice',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -383,6 +383,54 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_ref_normal_01011][switch.frigo_cubed_ice-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.frigo_cubed_ice',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Cubed ice',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'ice_maker',
'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_icemaker_switch_switch_switch',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ref_normal_01011][switch.frigo_cubed_ice-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Frigo Cubed ice',
}),
'context': <ANY>,
'entity_id': 'switch.frigo_cubed_ice',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_bites-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -408,7 +456,7 @@
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Ice bites',
'original_name': 'Ice Bites',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -421,7 +469,7 @@
# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_bites-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Frigo Ice bites',
'friendly_name': 'Frigo Ice Bites',
}),
'context': <ANY>,
'entity_id': 'switch.frigo_ice_bites',
@@ -431,54 +479,6 @@
'state': 'on',
})
# ---
# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_cubes-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': None,
'entity_id': 'switch.frigo_ice_cubes',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Ice cubes',
'platform': 'smartthings',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'ice_maker',
'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_icemaker_switch_switch_switch',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_cubes-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Frigo Ice cubes',
}),
'context': <ANY>,
'entity_id': 'switch.frigo_ice_cubes',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[da_ref_normal_01011][switch.frigo_power_cool-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
+10 -1
View File
@@ -85,6 +85,15 @@ class SonosMockService:
self.subscribe = AsyncMock(return_value=SonosMockSubscribe(ip_address))
class SonosMockRenderingService(SonosMockService):
"""Mock rendering service."""
def __init__(self, return_value: dict[str, str], ip_address="192.168.42.2") -> None:
"""Initialize the instance."""
super().__init__("RenderingControl", ip_address)
self.GetVolume = Mock(return_value=30)
class SonosMockAlarmClock(SonosMockService):
"""Mock a Sonos AlarmClock Service used in callbacks."""
@@ -239,7 +248,7 @@ class SoCoMockFactory:
mock_soco.avTransport.GetPositionInfo = Mock(
return_value=self.current_track_info
)
mock_soco.renderingControl = SonosMockService("RenderingControl", ip_address)
mock_soco.renderingControl = SonosMockRenderingService(ip_address)
mock_soco.zoneGroupTopology = SonosMockService("ZoneGroupTopology", ip_address)
mock_soco.contentDirectory = SonosMockService("ContentDirectory", ip_address)
mock_soco.deviceProperties = SonosMockService("DeviceProperties", ip_address)
+54 -57
View File
@@ -3,15 +3,15 @@
import asyncio
from datetime import timedelta
import logging
from unittest.mock import Mock, patch
from unittest.mock import Mock, PropertyMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant import config_entries
from homeassistant.components import sonos
from homeassistant.components.sonos import SonosDiscoveryManager
from homeassistant.components.sonos.const import (
DATA_SONOS_DISCOVERY_MANAGER,
DISCOVERY_INTERVAL,
SONOS_SPEAKER_ACTIVITY,
)
from homeassistant.components.sonos.exception import SonosUpdateError
@@ -87,76 +87,73 @@ async def test_not_configuring_sonos_not_creates_entry(hass: HomeAssistant) -> N
async def test_async_poll_manual_hosts_warnings(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
soco_factory: SoCoMockFactory,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that host warnings are not logged repeatedly."""
await async_setup_component(
hass,
sonos.DOMAIN,
{"sonos": {"media_player": {"interface_addr": "127.0.0.1"}}},
)
await hass.async_block_till_done()
manager: SonosDiscoveryManager = hass.data[DATA_SONOS_DISCOVERY_MANAGER]
manager.hosts.add("10.10.10.10")
soco = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Bedroom")
with (
caplog.at_level(logging.DEBUG),
patch.object(manager, "_async_handle_discovery_message"),
patch(
"homeassistant.components.sonos.async_call_later"
) as mock_async_call_later,
patch("homeassistant.components.sonos.async_dispatcher_send"),
patch(
"homeassistant.components.sonos.sync_get_visible_zones",
side_effect=[
OSError(),
OSError(),
[],
[],
OSError(),
],
),
patch.object(
type(soco), "visible_zones", new_callable=PropertyMock
) as mock_visible_zones,
):
# First call fails, it should be logged as a WARNING message
mock_visible_zones.side_effect = OSError()
caplog.clear()
await manager.async_poll_manual_hosts()
assert len(caplog.messages) == 1
record = caplog.records[0]
assert record.levelname == "WARNING"
assert "Could not get visible Sonos devices from" in record.message
assert mock_async_call_later.call_count == 1
await _setup_hass(hass)
assert [
rec.levelname
for rec in caplog.records
if "Could not get visible Sonos devices from" in rec.message
] == ["WARNING"]
# Second call fails again, it should be logged as a DEBUG message
mock_visible_zones.side_effect = OSError()
caplog.clear()
await manager.async_poll_manual_hosts()
assert len(caplog.messages) == 1
record = caplog.records[0]
assert record.levelname == "DEBUG"
assert "Could not get visible Sonos devices from" in record.message
assert mock_async_call_later.call_count == 2
freezer.tick(DISCOVERY_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert [
rec.levelname
for rec in caplog.records
if "Could not get visible Sonos devices from" in rec.message
] == ["DEBUG"]
# Third call succeeds, it should log an info message
# Third call succeeds, logs message indicating reconnect
mock_visible_zones.return_value = {soco}
mock_visible_zones.side_effect = None
caplog.clear()
await manager.async_poll_manual_hosts()
assert len(caplog.messages) == 1
record = caplog.records[0]
assert record.levelname == "WARNING"
assert "Connection reestablished to Sonos device" in record.message
assert mock_async_call_later.call_count == 3
freezer.tick(DISCOVERY_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert [
rec.levelname
for rec in caplog.records
if "Connection reestablished to Sonos device" in rec.message
] == ["WARNING"]
# Fourth call succeeds again, no need to log
# Fourth call succeeds, it should log nothing
caplog.clear()
await manager.async_poll_manual_hosts()
assert len(caplog.messages) == 0
assert mock_async_call_later.call_count == 4
freezer.tick(DISCOVERY_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert "Connection reestablished to Sonos device" not in caplog.text
# Fifth call fail again again, should be logged as a WARNING message
# Fifth call fails again again, should be logged as a WARNING message
mock_visible_zones.side_effect = OSError()
caplog.clear()
await manager.async_poll_manual_hosts()
assert len(caplog.messages) == 1
record = caplog.records[0]
assert record.levelname == "WARNING"
assert "Could not get visible Sonos devices from" in record.message
assert mock_async_call_later.call_count == 5
freezer.tick(DISCOVERY_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert [
rec.levelname
for rec in caplog.records
if "Could not get visible Sonos devices from" in rec.message
] == ["WARNING"]
class _MockSoCoOsError(MockSoCo):
+16
View File
@@ -0,0 +1,16 @@
"""Tests for switch helpers."""
from homeassistant.components.switch import DOMAIN, SwitchDeviceClass
from homeassistant.components.switch.helpers import (
create_switch_device_class_select_selector,
)
def test_create_switch_device_class_select_selector() -> None:
"Test Create sensor state class select selector helper."
selector = create_switch_device_class_select_selector()
assert selector.config["options"] == list(SwitchDeviceClass)
assert selector.config["translation_domain"] == DOMAIN
assert selector.config["translation_key"] == "device_class"
assert selector.config["sort"]
assert not selector.config["custom_value"]
@@ -16,6 +16,22 @@ from homeassistant.components.blueprint import (
DomainBlueprints,
)
from homeassistant.components.template import DOMAIN, SERVICE_RELOAD
from homeassistant.components.template.config import (
DOMAIN_ALARM_CONTROL_PANEL,
DOMAIN_BINARY_SENSOR,
DOMAIN_COVER,
DOMAIN_FAN,
DOMAIN_IMAGE,
DOMAIN_LIGHT,
DOMAIN_LOCK,
DOMAIN_NUMBER,
DOMAIN_SELECT,
DOMAIN_SENSOR,
DOMAIN_SWITCH,
DOMAIN_VACUUM,
DOMAIN_WEATHER,
)
from homeassistant.const import STATE_ON
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
@@ -459,3 +475,51 @@ async def test_no_blueprint(hass: HomeAssistant) -> None:
template.helpers.blueprint_in_template(hass, "binary_sensor.test_entity")
is None
)
@pytest.mark.parametrize(
("domain", "set_state", "expected"),
[
(DOMAIN_ALARM_CONTROL_PANEL, STATE_ON, "armed_home"),
(DOMAIN_BINARY_SENSOR, STATE_ON, STATE_ON),
(DOMAIN_COVER, STATE_ON, "open"),
(DOMAIN_FAN, STATE_ON, STATE_ON),
(DOMAIN_IMAGE, "test.jpg", "2025-06-13T00:00:00+00:00"),
(DOMAIN_LIGHT, STATE_ON, STATE_ON),
(DOMAIN_LOCK, STATE_ON, "locked"),
(DOMAIN_NUMBER, "1", "1.0"),
(DOMAIN_SELECT, "option1", "option1"),
(DOMAIN_SENSOR, "foo", "foo"),
(DOMAIN_SWITCH, STATE_ON, STATE_ON),
(DOMAIN_VACUUM, "cleaning", "cleaning"),
(DOMAIN_WEATHER, "sunny", "sunny"),
],
)
@pytest.mark.freeze_time("2025-06-13 00:00:00+00:00")
async def test_variables_for_entity(
hass: HomeAssistant, domain: str, set_state: str, expected: str
) -> None:
"""Test regular template entities via blueprint with variables defined."""
hass.states.async_set("sensor.test_state", set_state)
await hass.async_block_till_done()
assert await async_setup_component(
hass,
"template",
{
"template": [
{
"use_blueprint": {
"path": f"test_{domain}_with_variables.yaml",
"input": {"sensor": "sensor.test_state"},
},
"name": "Test",
},
]
},
)
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.test")
assert state is not None
assert state.state == expected
+46
View File
@@ -11,7 +11,10 @@ from homeassistant import setup
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.components.template import DOMAIN
from homeassistant.components.template.button import DEFAULT_NAME
from homeassistant.components.template.const import CONF_PICTURE
from homeassistant.const import (
ATTR_ENTITY_PICTURE,
ATTR_ICON,
CONF_DEVICE_CLASS,
CONF_ENTITY_ID,
CONF_FRIENDLY_NAME,
@@ -247,6 +250,49 @@ async def test_name_template(hass: HomeAssistant) -> None:
)
@pytest.mark.parametrize(
("field", "attribute", "test_template", "expected"),
[
(CONF_ICON, ATTR_ICON, "mdi:test{{ 1 + 1 }}", "mdi:test2"),
(CONF_PICTURE, ATTR_ENTITY_PICTURE, "test{{ 1 + 1 }}.jpg", "test2.jpg"),
],
)
async def test_templated_optional_config(
hass: HomeAssistant,
field: str,
attribute: str,
test_template: str,
expected: str,
) -> None:
"""Test optional config templates."""
with assert_setup_component(1, "template"):
assert await setup.async_setup_component(
hass,
"template",
{
"template": {
"button": {
"press": {"service": "script.press"},
field: test_template,
},
}
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
_verify(
hass,
STATE_UNKNOWN,
{
attribute: expected,
},
"button.template_button",
)
async def test_unique_id(hass: HomeAssistant) -> None:
"""Test: unique id is ok."""
with assert_setup_component(1, "template"):
+73 -104
View File
@@ -21,10 +21,13 @@ from homeassistant.components.number import (
SERVICE_SET_VALUE as NUMBER_SERVICE_SET_VALUE,
)
from homeassistant.components.template import DOMAIN
from homeassistant.components.template.const import CONF_PICTURE
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ENTITY_PICTURE,
ATTR_ICON,
CONF_ENTITY_ID,
CONF_ICON,
CONF_UNIT_OF_MEASUREMENT,
STATE_UNKNOWN,
)
@@ -58,6 +61,20 @@ _VALUE_INPUT_NUMBER_CONFIG = {
}
}
TEST_STATE_ENTITY_ID = "number.test_state"
TEST_STATE_TRIGGER = {
"trigger": {
"trigger": "state",
"entity_id": [TEST_STATE_ENTITY_ID],
},
"variables": {"triggering_entity": "{{ trigger.entity_id }}"},
"action": [
{"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}}
],
}
TEST_REQUIRED = {"state": "0", "step": "1", "set_value": []}
async def async_setup_modern_format(
hass: HomeAssistant, count: int, number_config: dict[str, Any]
@@ -77,6 +94,24 @@ async def async_setup_modern_format(
await hass.async_block_till_done()
async def async_setup_trigger_format(
hass: HomeAssistant, count: int, number_config: dict[str, Any]
) -> None:
"""Do setup of number integration via trigger format."""
config = {"template": {**TEST_STATE_TRIGGER, "number": number_config}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.fixture
async def setup_number(
hass: HomeAssistant,
@@ -89,6 +124,10 @@ async def setup_number(
await async_setup_modern_format(
hass, count, {"name": _TEST_OBJECT_ID, **number_config}
)
if style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass, count, {"name": _TEST_OBJECT_ID, **number_config}
)
async def test_setup_config_entry(
@@ -446,119 +485,49 @@ def _verify(
assert attributes.get(CONF_UNIT_OF_MEASUREMENT) == expected_unit_of_measurement
async def test_icon_template(hass: HomeAssistant) -> None:
"""Test template numbers with icon templates."""
with assert_setup_component(1, "input_number"):
assert await setup.async_setup_component(
hass,
"input_number",
{"input_number": _VALUE_INPUT_NUMBER_CONFIG},
)
with assert_setup_component(1, "template"):
assert await setup.async_setup_component(
hass,
"template",
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
("style", "initial_expected_state"),
[(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)],
)
@pytest.mark.parametrize(
("number_config", "attribute", "expected"),
[
(
{
"template": {
"unique_id": "b",
"number": {
"state": f"{{{{ states('{_VALUE_INPUT_NUMBER}') }}}}",
"step": 1,
"min": 0,
"max": 100,
"set_value": {
"service": "input_number.set_value",
"data_template": {
"entity_id": _VALUE_INPUT_NUMBER,
"value": "{{ value }}",
},
},
"icon": "{% if ((states.input_number.value.state or 0) | int) > 50 %}mdi:greater{% else %}mdi:less{% endif %}",
},
}
CONF_ICON: "{% if states.number.test_state.state == '1' %}mdi:check{% endif %}",
**TEST_REQUIRED,
},
)
hass.states.async_set(_VALUE_INPUT_NUMBER, 49)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
state = hass.states.get(_TEST_NUMBER)
assert float(state.state) == 49
assert state.attributes[ATTR_ICON] == "mdi:less"
await hass.services.async_call(
INPUT_NUMBER_DOMAIN,
INPUT_NUMBER_SERVICE_SET_VALUE,
{CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 51},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(_TEST_NUMBER)
assert float(state.state) == 51
assert state.attributes[ATTR_ICON] == "mdi:greater"
async def test_icon_template_with_trigger(hass: HomeAssistant) -> None:
"""Test template numbers with icon templates."""
with assert_setup_component(1, "input_number"):
assert await setup.async_setup_component(
hass,
"input_number",
{"input_number": _VALUE_INPUT_NUMBER_CONFIG},
)
with assert_setup_component(1, "template"):
assert await setup.async_setup_component(
hass,
"template",
ATTR_ICON,
"mdi:check",
),
(
{
"template": {
"trigger": {"platform": "state", "entity_id": _VALUE_INPUT_NUMBER},
"unique_id": "b",
"number": {
"state": "{{ trigger.to_state.state }}",
"step": 1,
"min": 0,
"max": 100,
"set_value": {
"service": "input_number.set_value",
"data_template": {
"entity_id": _VALUE_INPUT_NUMBER,
"value": "{{ value }}",
},
},
"icon": "{% if ((trigger.to_state.state or 0) | int) > 50 %}mdi:greater{% else %}mdi:less{% endif %}",
},
}
CONF_PICTURE: "{% if states.number.test_state.state == '1' %}check.jpg{% endif %}",
**TEST_REQUIRED,
},
)
ATTR_ENTITY_PICTURE,
"check.jpg",
),
],
)
@pytest.mark.usefixtures("setup_number")
async def test_templated_optional_config(
hass: HomeAssistant,
attribute: str,
expected: str,
initial_expected_state: str | None,
) -> None:
"""Test optional config templates."""
state = hass.states.get(_TEST_NUMBER)
assert state.attributes.get(attribute) == initial_expected_state
hass.states.async_set(_VALUE_INPUT_NUMBER, 49)
await hass.async_block_till_done()
await hass.async_start()
state = hass.states.async_set(TEST_STATE_ENTITY_ID, "1")
await hass.async_block_till_done()
state = hass.states.get(_TEST_NUMBER)
assert float(state.state) == 49
assert state.attributes[ATTR_ICON] == "mdi:less"
await hass.services.async_call(
INPUT_NUMBER_DOMAIN,
INPUT_NUMBER_SERVICE_SET_VALUE,
{CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 51},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(_TEST_NUMBER)
assert float(state.state) == 51
assert state.attributes[ATTR_ICON] == "mdi:greater"
assert state.attributes[attribute] == expected
async def test_device_id(
+83 -124
View File
@@ -21,7 +21,15 @@ from homeassistant.components.select import (
SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION,
)
from homeassistant.components.template import DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN
from homeassistant.components.template.const import CONF_PICTURE
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ENTITY_PICTURE,
ATTR_ICON,
CONF_ENTITY_ID,
CONF_ICON,
STATE_UNKNOWN,
)
from homeassistant.core import Context, HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
@@ -34,6 +42,24 @@ _TEST_OBJECT_ID = "template_select"
_TEST_SELECT = f"select.{_TEST_OBJECT_ID}"
# Represent for select's current_option
_OPTION_INPUT_SELECT = "input_select.option"
TEST_STATE_ENTITY_ID = "select.test_state"
TEST_STATE_TRIGGER = {
"trigger": {
"trigger": "state",
"entity_id": [_OPTION_INPUT_SELECT, TEST_STATE_ENTITY_ID],
},
"variables": {"triggering_entity": "{{ trigger.entity_id }}"},
"action": [
{"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}}
],
}
TEST_OPTIONS = {
"state": "test",
"options": "{{ ['test', 'yes', 'no'] }}",
"select_option": [],
}
async def async_setup_modern_format(
@@ -54,6 +80,24 @@ async def async_setup_modern_format(
await hass.async_block_till_done()
async def async_setup_trigger_format(
hass: HomeAssistant, count: int, select_config: dict[str, Any]
) -> None:
"""Do setup of select integration via trigger format."""
config = {"template": {**TEST_STATE_TRIGGER, "select": select_config}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.fixture
async def setup_select(
hass: HomeAssistant,
@@ -66,6 +110,10 @@ async def setup_select(
await async_setup_modern_format(
hass, count, {"name": _TEST_OBJECT_ID, **select_config}
)
if style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass, count, {"name": _TEST_OBJECT_ID, **select_config}
)
async def test_setup_config_entry(
@@ -395,138 +443,49 @@ def _verify(
assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options
async def test_template_icon_with_entities(hass: HomeAssistant) -> None:
"""Test templates with values from other entities."""
with assert_setup_component(1, "input_select"):
assert await setup.async_setup_component(
hass,
"input_select",
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
("style", "initial_expected_state"),
[(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)],
)
@pytest.mark.parametrize(
("select_config", "attribute", "expected"),
[
(
{
"input_select": {
"option": {
"options": ["a", "b"],
"initial": "a",
"name": "Option",
},
}
**TEST_OPTIONS,
CONF_ICON: "{% if states.select.test_state.state == 'yes' %}mdi:check{% endif %}",
},
)
with assert_setup_component(1, "template"):
assert await setup.async_setup_component(
hass,
"template",
ATTR_ICON,
"mdi:check",
),
(
{
"template": {
"unique_id": "b",
"select": {
"state": f"{{{{ states('{_OPTION_INPUT_SELECT}') }}}}",
"options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}",
"select_option": {
"service": "input_select.select_option",
"data": {
"entity_id": _OPTION_INPUT_SELECT,
"option": "{{ option }}",
},
},
"optimistic": True,
"unique_id": "a",
"icon": f"{{% if (states('{_OPTION_INPUT_SELECT}') == 'a') %}}mdi:greater{{% else %}}mdi:less{{% endif %}}",
},
}
**TEST_OPTIONS,
CONF_PICTURE: "{% if states.select.test_state.state == 'yes' %}check.jpg{% endif %}",
},
)
ATTR_ENTITY_PICTURE,
"check.jpg",
),
],
)
@pytest.mark.usefixtures("setup_select")
async def test_templated_optional_config(
hass: HomeAssistant,
attribute: str,
expected: str,
initial_expected_state: str | None,
) -> None:
"""Test optional config templates."""
state = hass.states.get(_TEST_SELECT)
assert state.attributes.get(attribute) == initial_expected_state
await hass.async_block_till_done()
await hass.async_start()
state = hass.states.async_set(TEST_STATE_ENTITY_ID, "yes")
await hass.async_block_till_done()
state = hass.states.get(_TEST_SELECT)
assert state.state == "a"
assert state.attributes[ATTR_ICON] == "mdi:greater"
await hass.services.async_call(
INPUT_SELECT_DOMAIN,
INPUT_SELECT_SERVICE_SELECT_OPTION,
{CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(_TEST_SELECT)
assert state.state == "b"
assert state.attributes[ATTR_ICON] == "mdi:less"
async def test_template_icon_with_trigger(hass: HomeAssistant) -> None:
"""Test trigger based template select."""
with assert_setup_component(1, "input_select"):
assert await setup.async_setup_component(
hass,
"input_select",
{
"input_select": {
"option": {
"options": ["a", "b"],
"initial": "a",
"name": "Option",
},
}
},
)
assert await setup.async_setup_component(
hass,
"template",
{
"template": {
"trigger": {"platform": "state", "entity_id": _OPTION_INPUT_SELECT},
"select": {
"unique_id": "b",
"state": "{{ trigger.to_state.state }}",
"options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}",
"select_option": {
"service": "input_select.select_option",
"data": {
"entity_id": _OPTION_INPUT_SELECT,
"option": "{{ option }}",
},
},
"optimistic": True,
"icon": "{% if (trigger.to_state.state or '') == 'a' %}mdi:greater{% else %}mdi:less{% endif %}",
},
},
},
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
await hass.services.async_call(
INPUT_SELECT_DOMAIN,
INPUT_SELECT_SERVICE_SELECT_OPTION,
{CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(_TEST_SELECT)
assert state is not None
assert state.state == "b"
assert state.attributes[ATTR_ICON] == "mdi:less"
await hass.services.async_call(
INPUT_SELECT_DOMAIN,
INPUT_SELECT_SERVICE_SELECT_OPTION,
{CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "a"},
blocking=True,
)
await hass.async_block_till_done()
state = hass.states.get(_TEST_SELECT)
assert state.state == "a"
assert state.attributes[ATTR_ICON] == "mdi:greater"
assert state.attributes[attribute] == expected
async def test_device_id(
+131 -1
View File
@@ -5,6 +5,8 @@ from typing import Any
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import template
from homeassistant.components.template.const import CONF_PICTURE
from homeassistant.components.weather import (
ATTR_WEATHER_APPARENT_TEMPERATURE,
ATTR_WEATHER_CLOUD_COVERAGE,
@@ -21,12 +23,21 @@ from homeassistant.components.weather import (
SERVICE_GET_FORECASTS,
Forecast,
)
from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_ENTITY_PICTURE,
ATTR_ICON,
CONF_ICON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import Context, HomeAssistant, State
from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .conftest import ConfigurationStyle
from tests.common import (
assert_setup_component,
async_mock_restore_state_shutdown_restart,
@@ -35,6 +46,80 @@ from tests.common import (
ATTR_FORECAST = "forecast"
TEST_OBJECT_ID = "template_weather"
TEST_WEATHER = f"weather.{TEST_OBJECT_ID}"
TEST_STATE_ENTITY_ID = "weather.test_state"
TEST_STATE_TRIGGER = {
"trigger": {
"trigger": "state",
"entity_id": [TEST_STATE_ENTITY_ID],
},
"variables": {"triggering_entity": "{{ trigger.entity_id }}"},
"action": [
{"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}}
],
}
TEST_REQUIRED = {
"condition_template": "cloudy",
"temperature_template": "{{ 20 }}",
"humidity_template": "{{ 25 }}",
}
async def async_setup_modern_format(
hass: HomeAssistant, count: int, weather_config: dict[str, Any]
) -> None:
"""Do setup of weather integration via new format."""
config = {"template": {"weather": weather_config}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
async def async_setup_trigger_format(
hass: HomeAssistant, count: int, weather_config: dict[str, Any]
) -> None:
"""Do setup of weather integration via trigger format."""
config = {"template": {**TEST_STATE_TRIGGER, "weather": weather_config}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.fixture
async def setup_weather(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
weather_config: dict[str, Any],
) -> None:
"""Do setup of weather integration."""
if style == ConfigurationStyle.MODERN:
await async_setup_modern_format(
hass, count, {"name": TEST_OBJECT_ID, **weather_config}
)
if style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass, count, {"name": TEST_OBJECT_ID, **weather_config}
)
@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)])
@pytest.mark.parametrize(
@@ -990,3 +1075,48 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None:
assert state is not None
assert state.state == "sunny"
assert state.attributes.get(v_attr) == value
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
("style", "initial_expected_state"),
[(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)],
)
@pytest.mark.parametrize(
("weather_config", "attribute", "expected"),
[
(
{
CONF_ICON: "{% if states.weather.test_state.state == 'sunny' %}mdi:check{% endif %}",
**TEST_REQUIRED,
},
ATTR_ICON,
"mdi:check",
),
(
{
CONF_PICTURE: "{% if states.weather.test_state.state == 'sunny' %}check.jpg{% endif %}",
**TEST_REQUIRED,
},
ATTR_ENTITY_PICTURE,
"check.jpg",
),
],
)
@pytest.mark.usefixtures("setup_weather")
async def test_templated_optional_config(
hass: HomeAssistant,
attribute: str,
expected: str,
initial_expected_state: str | None,
) -> None:
"""Test optional config templates."""
state = hass.states.get(TEST_WEATHER)
assert state.attributes.get(attribute) == initial_expected_state
state = hass.states.async_set(TEST_STATE_ENTITY_ID, "sunny")
await hass.async_block_till_done()
state = hass.states.get(TEST_WEATHER)
assert state.attributes[attribute] == expected
+1
View File
@@ -638,6 +638,7 @@ def test_text_selector_schema(schema, valid_selections, invalid_selections) -> N
{
"options": ["red", "green", "blue"],
"translation_key": "color",
"translation_domain": "homeassistant",
},
("red", "green", "blue"),
("cat", 0, None, ["red"]),
@@ -0,0 +1,16 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
alarm_control_panel:
availability: "{{ sensor | has_value }}"
state: "{{ 'armed_home' if is_state(sensor,'on') else 'disarmed' }}"
@@ -0,0 +1,16 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
binary_sensor:
availability: "{{ sensor | has_value }}"
state: "{{ is_state(sensor, 'on') }}"
@@ -0,0 +1,18 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
cover:
availability: "{{ sensor | has_value }}"
state: "{{ is_state(sensor,'on') }}"
open_cover: []
close_cover: []
@@ -0,0 +1,18 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
fan:
availability: "{{ sensor | has_value }}"
state: "{{ is_state(sensor,'on') }}"
turn_on: []
turn_off: []
@@ -0,0 +1,16 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
image:
availability: "{{ sensor | has_value }}"
url: "{{ states(sensor) }}"
@@ -0,0 +1,18 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
light:
availability: "{{ sensor | has_value }}"
state: "{{ is_state(sensor,'on') }}"
turn_on: []
turn_off: []
@@ -0,0 +1,18 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
lock:
availability: "{{ sensor | has_value }}"
state: "{{ is_state(sensor,'on') }}"
lock: []
unlock: []
@@ -0,0 +1,18 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
number:
availability: "{{ sensor | has_value }}"
state: "{{ states(sensor) }}"
set_value: []
step: 1
@@ -0,0 +1,18 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
select:
availability: "{{ sensor | has_value }}"
state: "{{ states(sensor) }}"
options: "{{ ['option1', 'option2'] }}"
select_option: []
@@ -0,0 +1,16 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
sensor:
availability: "{{ sensor | has_value }}"
state: "{{ states(sensor) }}"
@@ -0,0 +1,18 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
switch:
availability: "{{ sensor | has_value }}"
state: "{{ is_state(sensor,'on') }}"
turn_on: []
turn_off: []
@@ -0,0 +1,17 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
vacuum:
availability: "{{ sensor | has_value }}"
state: "{{ states(sensor) }}"
start: []
@@ -0,0 +1,18 @@
blueprint:
name: Test With Variables
description: Creates a test with variables
domain: template
input:
sensor:
name: Sensor Entity
description: The sensor entity
selector:
entity:
domain: sensor
variables:
sensor: !input sensor
weather:
availability: "{{ sensor | has_value }}"
condition_template: "{{ states(sensor) }}"
temperature_template: "{{ 20 }}"
humidity_template: "{{ 25 }}"