diff --git a/CODEOWNERS b/CODEOWNERS index 543ef798b1c52a..a0f5171dd49550 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1729,8 +1729,8 @@ build.json @home-assistant/supervisor /tests/components/volumio/ @OnFreund /homeassistant/components/volvo/ @thomasddn /tests/components/volvo/ @thomasddn -/homeassistant/components/volvooncall/ @molobrakos -/tests/components/volvooncall/ @molobrakos +/homeassistant/components/volvooncall/ @molobrakos @svrooij +/tests/components/volvooncall/ @molobrakos @svrooij /homeassistant/components/wake_on_lan/ @ntilley905 /tests/components/wake_on_lan/ @ntilley905 /homeassistant/components/wake_word/ @home-assistant/core @synesthesiam diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 631910bde86527..6990bf56099861 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -253,6 +253,7 @@ ), EcoWittSensorTypes.PM4: SensorEntityDescription( key="PM4", + device_class=SensorDeviceClass.PM4, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index c6a07a93331078..82561d9f75e949 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -29,6 +29,7 @@ config_validation as cv, device_registry as dr, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.typing import ConfigType @@ -70,6 +71,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def generate_content(call: ServiceCall) -> ServiceResponse: """Generate content from text and optionally images.""" + LOGGER.warning( + "Action '%s.%s' is deprecated and will be removed in the 2026.4.0 release. " + "Please use the 'ai_task.generate_data' action instead", + DOMAIN, + SERVICE_GENERATE_CONTENT, + ) + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_generate_content", + breaks_in_ha_version="2026.4.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_generate_content", + ) prompt_parts = [call.data[CONF_PROMPT]] diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 43008332e6879d..cc94d7de8fe834 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -150,10 +150,16 @@ } } }, + "issues": { + "deprecated_generate_content": { + "title": "Deprecated 'generate_content' action", + "description": "Action 'google_generative_ai_conversation.generate_content' is deprecated and will be removed in the 2026.4.0 release. Please use the 'ai_task.generate_data' action instead" + } + }, "services": { "generate_content": { - "name": "Generate content", - "description": "Generate content from a prompt consisting of text and optionally images", + "name": "Generate content (deprecated)", + "description": "Generate content from a prompt consisting of text and optionally images (deprecated)", "fields": { "prompt": { "name": "Prompt", diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index dc208610b8cb84..366f989b2925b4 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1209,7 +1209,6 @@ class PlatformField: default=lambda config: bool(config.get(CONF_DIRECTION_COMMAND_TOPIC)), ), }, - Platform.NOTIFY.value: {}, Platform.LIGHT.value: { CONF_SCHEMA: PlatformField( selector=LIGHT_SCHEMA_SELECTOR, @@ -1225,6 +1224,7 @@ class PlatformField: ), }, Platform.LOCK.value: {}, + Platform.NOTIFY.value: {}, Platform.SENSOR.value: { CONF_DEVICE_CLASS: PlatformField( selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 2075345e038e1b..3eadb2f5917a10 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -321,11 +321,11 @@ "code_arm_required": "Code arm required", "code_disarm_required": "Code disarm required", "code_trigger_required": "Code trigger required", + "color_temp_template": "Color temperature template", "command_template": "Command template", "command_topic": "Command topic", "command_off_template": "Command \"off\" template", "command_on_template": "Command \"on\" template", - "color_temp_template": "Color temperature template", "force_update": "Force update", "green_template": "Green template", "last_reset_value_template": "Last reset value template", @@ -358,11 +358,11 @@ "code_arm_required": "If set, the code is required to arm the alarm. If not set, the code is not validated.", "code_disarm_required": "If set, the code is required to disarm the alarm. If not set, the code is not validated.", "code_trigger_required": "If set, the code is required to manually trigger the alarm. If not set, the code is not validated.", + "color_temp_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract color temperature in Kelvin from the state payload value. Expected result of the template is an integer.", "command_off_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"off\" state changes. Available variables are: `state` and `transition`.", "command_on_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"on\" state changes. Available variables: `state`, `brightness`, `color_temp`, `red`, `green`, `blue`, `hue`, `sat`, `flash`, `transition` and `effect`. Values `red`, `green`, `blue` and `brightness` are provided as integers from range 0-255. Value of `hue` is provided as float from range 0-360. Value of `sat` is provided as float from range 0-100. Value of `color_temp` is provided as integer representing Kelvin units.", "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic. [Learn more.]({url}#command_template)", "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", - "color_temp_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract color temperature in Kelvin from the state payload value. Expected result of the template is an integer.", "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", "green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.", "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", @@ -1261,6 +1261,12 @@ "diagnostic": "Diagnostic" } }, + "image_processing_mode": { + "options": { + "image_data": "Image data is received", + "image_url": "Image URL is received" + } + }, "light_schema": { "options": { "basic": "Default schema", diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 1a53f9a5dc4692..6542f34b487da0 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -1,71 +1,46 @@ -"""Support for Volvo On Call.""" +"""The Volvo On Call integration.""" -from volvooncall import Connection +from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_REGION, - CONF_UNIT_SYSTEM, - CONF_USERNAME, -) from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import issue_registry as ir -from .const import ( - CONF_SCANDINAVIAN_MILES, - DOMAIN, - PLATFORMS, - UNIT_SYSTEM_METRIC, - UNIT_SYSTEM_SCANDINAVIAN_MILES, -) -from .coordinator import VolvoUpdateCoordinator -from .models import VolvoData +from .const import DOMAIN async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up the Volvo On Call component from a ConfigEntry.""" - - # added CONF_UNIT_SYSTEM / deprecated CONF_SCANDINAVIAN_MILES in 2022.10 to support imperial units - if CONF_UNIT_SYSTEM not in entry.data: - new_conf = {**entry.data} - - scandinavian_miles: bool = entry.data[CONF_SCANDINAVIAN_MILES] - - new_conf[CONF_UNIT_SYSTEM] = ( - UNIT_SYSTEM_SCANDINAVIAN_MILES if scandinavian_miles else UNIT_SYSTEM_METRIC - ) - - hass.config_entries.async_update_entry(entry, data=new_conf) - - session = async_get_clientsession(hass) - - connection = Connection( - session=session, - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - service_url=None, - region=entry.data[CONF_REGION], + """Set up Volvo On Call integration.""" + + # Create repair issue pointing to the new volvo integration + ir.async_create_issue( + hass, + DOMAIN, + "volvooncall_deprecated", + breaks_in_ha_version="2026.3", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="volvooncall_deprecated", ) - hass.data.setdefault(DOMAIN, {}) - - volvo_data = VolvoData(hass, connection, entry) - - coordinator = VolvoUpdateCoordinator(hass, entry, volvo_data) - - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][entry.entry_id] = coordinator - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + # Only delete the repair issue if this is the last config entry for this domain + remaining_entries = [ + config_entry + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ] + + if not remaining_entries: + ir.async_delete_issue( + hass, + DOMAIN, + "volvooncall_deprecated", + ) + + return True diff --git a/homeassistant/components/volvooncall/binary_sensor.py b/homeassistant/components/volvooncall/binary_sensor.py deleted file mode 100644 index 2ba8d19e3dbb74..00000000000000 --- a/homeassistant/components/volvooncall/binary_sensor.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Support for VOC.""" - -from __future__ import annotations - -from contextlib import suppress - -import voluptuous as vol -from volvooncall.dashboard import Instrument - -from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, - BinarySensorEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, VOLVO_DISCOVERY_NEW -from .coordinator import VolvoUpdateCoordinator -from .entity import VolvoEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Configure binary_sensors from a config entry created in the integrations UI.""" - coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - volvo_data = coordinator.volvo_data - - @callback - def async_discover_device(instruments: list[Instrument]) -> None: - """Discover and add a discovered Volvo On Call binary sensor.""" - async_add_entities( - VolvoSensor( - coordinator, - instrument.vehicle.vin, - instrument.component, - instrument.attr, - instrument.slug_attr, - ) - for instrument in instruments - if instrument.component == "binary_sensor" - ) - - async_discover_device([*volvo_data.instruments]) - - config_entry.async_on_unload( - async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device) - ) - - -class VolvoSensor(VolvoEntity, BinarySensorEntity): - """Representation of a Volvo sensor.""" - - def __init__( - self, - coordinator: VolvoUpdateCoordinator, - vin: str, - component: str, - attribute: str, - slug_attr: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(vin, component, attribute, slug_attr, coordinator) - - with suppress(vol.Invalid): - self._attr_device_class = DEVICE_CLASSES_SCHEMA( - self.instrument.device_class - ) - - @property - def is_on(self) -> bool | None: - """Fetch from update coordinator.""" - if self.instrument.attr == "is_locked": - return not self.instrument.is_on - return self.instrument.is_on diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py index ccb0a7f62e1128..e1aa95cb7306b5 100644 --- a/homeassistant/components/volvooncall/config_flow.py +++ b/homeassistant/components/volvooncall/config_flow.py @@ -2,127 +2,21 @@ from __future__ import annotations -from collections.abc import Mapping -import logging from typing import Any -import voluptuous as vol -from volvooncall import Connection +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult -from homeassistant.const import ( - CONF_PASSWORD, - CONF_REGION, - CONF_UNIT_SYSTEM, - CONF_USERNAME, -) -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import ( - CONF_MUTABLE, - DOMAIN, - UNIT_SYSTEM_IMPERIAL, - UNIT_SYSTEM_METRIC, - UNIT_SYSTEM_SCANDINAVIAN_MILES, -) -from .errors import InvalidAuth -from .models import VolvoData - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN class VolvoOnCallConfigFlow(ConfigFlow, domain=DOMAIN): - """VolvoOnCall config flow.""" + """Handle a config flow for Volvo On Call.""" VERSION = 1 async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle user step.""" - errors = {} - defaults = { - CONF_USERNAME: "", - CONF_PASSWORD: "", - CONF_REGION: None, - CONF_MUTABLE: True, - CONF_UNIT_SYSTEM: UNIT_SYSTEM_METRIC, - } - - if user_input is not None: - await self.async_set_unique_id(user_input[CONF_USERNAME]) - - if self.source != SOURCE_REAUTH: - self._abort_if_unique_id_configured() - - try: - await self.is_valid(user_input) - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unhandled exception in user step") - errors["base"] = "unknown" - if not errors: - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._get_reauth_entry(), data_updates=user_input - ) - - return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input - ) - elif self.source == SOURCE_REAUTH: - reauth_entry = self._get_reauth_entry() - for key in defaults: - defaults[key] = reauth_entry.data.get(key) - - user_schema = vol.Schema( - { - vol.Required(CONF_USERNAME, default=defaults[CONF_USERNAME]): str, - vol.Required(CONF_PASSWORD, default=defaults[CONF_PASSWORD]): str, - vol.Required(CONF_REGION, default=defaults[CONF_REGION]): vol.In( - {"na": "North America", "cn": "China", None: "Rest of world"} - ), - vol.Optional( - CONF_UNIT_SYSTEM, default=defaults[CONF_UNIT_SYSTEM] - ): vol.In( - { - UNIT_SYSTEM_METRIC: "Metric", - UNIT_SYSTEM_SCANDINAVIAN_MILES: ( - "Metric with Scandinavian Miles" - ), - UNIT_SYSTEM_IMPERIAL: "Imperial", - } - ), - vol.Optional(CONF_MUTABLE, default=defaults[CONF_MUTABLE]): bool, - }, - ) - - return self.async_show_form( - step_id="user", data_schema=user_schema, errors=errors - ) - - async def async_step_reauth( - self, entry_data: Mapping[str, Any] - ) -> ConfigFlowResult: - """Perform reauth upon an API authentication error.""" - return await self.async_step_user() - - async def is_valid(self, user_input): - """Check for user input errors.""" - - session = async_get_clientsession(self.hass) - - region: str | None = user_input.get(CONF_REGION) - - connection = Connection( - session=session, - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - service_url=None, - region=region, - ) - - test_volvo_data = VolvoData(self.hass, connection, user_input) + """Handle the initial step.""" - await test_volvo_data.auth_is_valid() + return self.async_abort(reason="deprecated") diff --git a/homeassistant/components/volvooncall/const.py b/homeassistant/components/volvooncall/const.py index 4c969669af6d2f..e04de08008b270 100644 --- a/homeassistant/components/volvooncall/const.py +++ b/homeassistant/components/volvooncall/const.py @@ -1,66 +1,3 @@ -"""Constants for volvooncall.""" - -from datetime import timedelta +"""Constants for the Volvo On Call integration.""" DOMAIN = "volvooncall" - -DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) - -CONF_SERVICE_URL = "service_url" -CONF_SCANDINAVIAN_MILES = "scandinavian_miles" -CONF_MUTABLE = "mutable" - -UNIT_SYSTEM_SCANDINAVIAN_MILES = "scandinavian_miles" -UNIT_SYSTEM_METRIC = "metric" -UNIT_SYSTEM_IMPERIAL = "imperial" - -PLATFORMS = { - "sensor": "sensor", - "binary_sensor": "binary_sensor", - "lock": "lock", - "device_tracker": "device_tracker", - "switch": "switch", -} - -RESOURCES = [ - "position", - "lock", - "heater", - "odometer", - "trip_meter1", - "trip_meter2", - "average_speed", - "fuel_amount", - "fuel_amount_level", - "average_fuel_consumption", - "distance_to_empty", - "washer_fluid_level", - "brake_fluid", - "service_warning_status", - "bulb_failures", - "battery_range", - "battery_level", - "time_to_fully_charged", - "battery_charge_status", - "engine_start", - "last_trip", - "is_engine_running", - "doors_hood_open", - "doors_tailgate_open", - "doors_front_left_door_open", - "doors_front_right_door_open", - "doors_rear_left_door_open", - "doors_rear_right_door_open", - "windows_front_left_window_open", - "windows_front_right_window_open", - "windows_rear_left_window_open", - "windows_rear_right_window_open", - "tyre_pressure_front_left_tyre_pressure", - "tyre_pressure_front_right_tyre_pressure", - "tyre_pressure_rear_left_tyre_pressure", - "tyre_pressure_rear_right_tyre_pressure", - "any_door_open", - "any_window_open", -] - -VOLVO_DISCOVERY_NEW = "volvo_discovery_new" diff --git a/homeassistant/components/volvooncall/coordinator.py b/homeassistant/components/volvooncall/coordinator.py deleted file mode 100644 index 2c3e2ba365f69c..00000000000000 --- a/homeassistant/components/volvooncall/coordinator.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Support for Volvo On Call.""" - -import asyncio -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import DEFAULT_UPDATE_INTERVAL -from .models import VolvoData - -_LOGGER = logging.getLogger(__name__) - - -class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): - """Volvo coordinator.""" - - config_entry: ConfigEntry - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, volvo_data: VolvoData - ) -> None: - """Initialize the data update coordinator.""" - - super().__init__( - hass, - _LOGGER, - config_entry=config_entry, - name="volvooncall", - update_interval=DEFAULT_UPDATE_INTERVAL, - ) - - self.volvo_data = volvo_data - - async def _async_update_data(self) -> None: - """Fetch data from API endpoint.""" - - async with asyncio.timeout(10): - await self.volvo_data.update() diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py deleted file mode 100644 index 018acb02d49ccd..00000000000000 --- a/homeassistant/components/volvooncall/device_tracker.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Support for tracking a Volvo.""" - -from __future__ import annotations - -from volvooncall.dashboard import Instrument - -from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, VOLVO_DISCOVERY_NEW -from .coordinator import VolvoUpdateCoordinator -from .entity import VolvoEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Configure device_trackers from a config entry created in the integrations UI.""" - coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - volvo_data = coordinator.volvo_data - - @callback - def async_discover_device(instruments: list[Instrument]) -> None: - """Discover and add a discovered Volvo On Call device tracker.""" - async_add_entities( - VolvoTrackerEntity( - instrument.vehicle.vin, - instrument.component, - instrument.attr, - instrument.slug_attr, - coordinator, - ) - for instrument in instruments - if instrument.component == "device_tracker" - ) - - async_discover_device([*volvo_data.instruments]) - - config_entry.async_on_unload( - async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device) - ) - - -class VolvoTrackerEntity(VolvoEntity, TrackerEntity): - """A tracked Volvo vehicle.""" - - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - latitude, _ = self._get_pos() - return latitude - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - _, longitude = self._get_pos() - return longitude - - def _get_pos(self) -> tuple[float, float]: - volvo_data = self.coordinator.volvo_data - instrument = volvo_data.instrument( - self.vin, self.component, self.attribute, self.slug_attr - ) - - latitude, longitude, _, _, _ = instrument.state - - return (float(latitude), float(longitude)) diff --git a/homeassistant/components/volvooncall/entity.py b/homeassistant/components/volvooncall/entity.py deleted file mode 100644 index 5a1194e8b1ab4a..00000000000000 --- a/homeassistant/components/volvooncall/entity.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Support for Volvo On Call.""" - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import VolvoUpdateCoordinator - - -class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]): - """Base class for all VOC entities.""" - - def __init__( - self, - vin: str, - component: str, - attribute: str, - slug_attr: str, - coordinator: VolvoUpdateCoordinator, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator) - - self.vin = vin - self.component = component - self.attribute = attribute - self.slug_attr = slug_attr - - @property - def instrument(self): - """Return corresponding instrument.""" - return self.coordinator.volvo_data.instrument( - self.vin, self.component, self.attribute, self.slug_attr - ) - - @property - def icon(self): - """Return the icon.""" - return self.instrument.icon - - @property - def vehicle(self): - """Return vehicle.""" - return self.instrument.vehicle - - @property - def _entity_name(self): - return self.instrument.name - - @property - def _vehicle_name(self): - return self.coordinator.volvo_data.vehicle_name(self.vehicle) - - @property - def name(self): - """Return full name of the entity.""" - return f"{self._vehicle_name} {self._entity_name}" - - @property - def assumed_state(self) -> bool: - """Return true if unable to access real state of entity.""" - return True - - @property - def device_info(self) -> DeviceInfo: - """Return a inique set of attributes for each vehicle.""" - return DeviceInfo( - identifiers={(DOMAIN, self.vehicle.vin)}, - name=self._vehicle_name, - model=self.vehicle.vehicle_type, - manufacturer="Volvo", - ) - - @property - def extra_state_attributes(self): - """Return device specific state attributes.""" - return dict( - self.instrument.attributes, - model=f"{self.vehicle.vehicle_type}/{self.vehicle.model_year}", - ) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - slug_override = "" - if self.instrument.slug_override is not None: - slug_override = f"-{self.instrument.slug_override}" - return f"{self.vin}-{self.component}-{self.attribute}{slug_override}" diff --git a/homeassistant/components/volvooncall/errors.py b/homeassistant/components/volvooncall/errors.py deleted file mode 100644 index 3736c5b92906d3..00000000000000 --- a/homeassistant/components/volvooncall/errors.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Exceptions specific to volvooncall.""" - -from homeassistant.exceptions import HomeAssistantError - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py deleted file mode 100644 index 75b54e9dbbc944..00000000000000 --- a/homeassistant/components/volvooncall/lock.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Support for Volvo On Call locks.""" - -from __future__ import annotations - -from typing import Any - -from volvooncall.dashboard import Instrument, Lock - -from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, VOLVO_DISCOVERY_NEW -from .coordinator import VolvoUpdateCoordinator -from .entity import VolvoEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Configure locks from a config entry created in the integrations UI.""" - coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - volvo_data = coordinator.volvo_data - - @callback - def async_discover_device(instruments: list[Instrument]) -> None: - """Discover and add a discovered Volvo On Call lock.""" - async_add_entities( - VolvoLock( - coordinator, - instrument.vehicle.vin, - instrument.component, - instrument.attr, - instrument.slug_attr, - ) - for instrument in instruments - if instrument.component == "lock" - ) - - async_discover_device([*volvo_data.instruments]) - - config_entry.async_on_unload( - async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device) - ) - - -class VolvoLock(VolvoEntity, LockEntity): - """Represents a car lock.""" - - instrument: Lock - - def __init__( - self, - coordinator: VolvoUpdateCoordinator, - vin: str, - component: str, - attribute: str, - slug_attr: str, - ) -> None: - """Initialize the lock.""" - super().__init__(vin, component, attribute, slug_attr, coordinator) - - @property - def is_locked(self) -> bool | None: - """Determine if car is locked.""" - return self.instrument.is_locked - - async def async_lock(self, **kwargs: Any) -> None: - """Lock the car.""" - await self.instrument.lock() - await self.coordinator.async_request_refresh() - - async def async_unlock(self, **kwargs: Any) -> None: - """Unlock the car.""" - await self.instrument.unlock() - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/volvooncall/manifest.json b/homeassistant/components/volvooncall/manifest.json index 89a35ecde1d723..b158cf7ed8010b 100644 --- a/homeassistant/components/volvooncall/manifest.json +++ b/homeassistant/components/volvooncall/manifest.json @@ -1,10 +1,9 @@ { "domain": "volvooncall", "name": "Volvo On Call", - "codeowners": ["@molobrakos"], + "codeowners": ["@molobrakos", "@svrooij"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/volvooncall", "iot_class": "cloud_polling", - "loggers": ["geopy", "hbmqtt", "volvooncall"], - "requirements": ["volvooncall==0.10.3"] + "quality_scale": "legacy" } diff --git a/homeassistant/components/volvooncall/models.py b/homeassistant/components/volvooncall/models.py deleted file mode 100644 index 159379a908b43f..00000000000000 --- a/homeassistant/components/volvooncall/models.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Support for Volvo On Call.""" - -from aiohttp.client_exceptions import ClientResponseError -from volvooncall import Connection -from volvooncall.dashboard import Instrument - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIT_SYSTEM -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.update_coordinator import UpdateFailed - -from .const import ( - CONF_MUTABLE, - PLATFORMS, - UNIT_SYSTEM_IMPERIAL, - UNIT_SYSTEM_SCANDINAVIAN_MILES, - VOLVO_DISCOVERY_NEW, -) -from .errors import InvalidAuth - - -class VolvoData: - """Hold component state.""" - - def __init__( - self, - hass: HomeAssistant, - connection: Connection, - entry: ConfigEntry, - ) -> None: - """Initialize the component state.""" - self.hass = hass - self.vehicles: set[str] = set() - self.instruments: set[Instrument] = set() - self.config_entry = entry - self.connection = connection - - def instrument(self, vin, component, attr, slug_attr): - """Return corresponding instrument.""" - return next( - instrument - for instrument in self.instruments - if instrument.vehicle.vin == vin - and instrument.component == component - and instrument.attr == attr - and instrument.slug_attr == slug_attr - ) - - def vehicle_name(self, vehicle): - """Provide a friendly name for a vehicle.""" - if vehicle.registration_number and vehicle.registration_number != "UNKNOWN": - return vehicle.registration_number - if vehicle.vin: - return vehicle.vin - return "Volvo" - - def discover_vehicle(self, vehicle): - """Load relevant platforms.""" - self.vehicles.add(vehicle.vin) - - dashboard = vehicle.dashboard( - mutable=self.config_entry.data[CONF_MUTABLE], - scandinavian_miles=( - self.config_entry.data[CONF_UNIT_SYSTEM] - == UNIT_SYSTEM_SCANDINAVIAN_MILES - ), - usa_units=( - self.config_entry.data[CONF_UNIT_SYSTEM] == UNIT_SYSTEM_IMPERIAL - ), - ) - - for instrument in ( - instrument - for instrument in dashboard.instruments - if instrument.component in PLATFORMS - ): - self.instruments.add(instrument) - async_dispatcher_send(self.hass, VOLVO_DISCOVERY_NEW, [instrument]) - - async def update(self): - """Update status from the online service.""" - try: - await self.connection.update(journal=True) - except ClientResponseError as ex: - if ex.status == 401: - raise ConfigEntryAuthFailed(ex) from ex - raise UpdateFailed(ex) from ex - - for vehicle in self.connection.vehicles: - if vehicle.vin not in self.vehicles: - self.discover_vehicle(vehicle) - - async def auth_is_valid(self): - """Check if provided username/password/region authenticate.""" - try: - await self.connection.get("customeraccounts") - except ClientResponseError as exc: - raise InvalidAuth from exc diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py deleted file mode 100644 index feb7248ccaf750..00000000000000 --- a/homeassistant/components/volvooncall/sensor.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Support for Volvo On Call sensors.""" - -from __future__ import annotations - -from volvooncall.dashboard import Instrument - -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, VOLVO_DISCOVERY_NEW -from .coordinator import VolvoUpdateCoordinator -from .entity import VolvoEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Configure sensors from a config entry created in the integrations UI.""" - coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - volvo_data = coordinator.volvo_data - - @callback - def async_discover_device(instruments: list[Instrument]) -> None: - """Discover and add a discovered Volvo On Call sensor.""" - async_add_entities( - VolvoSensor( - coordinator, - instrument.vehicle.vin, - instrument.component, - instrument.attr, - instrument.slug_attr, - ) - for instrument in instruments - if instrument.component == "sensor" - ) - - async_discover_device([*volvo_data.instruments]) - - config_entry.async_on_unload( - async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device) - ) - - -class VolvoSensor(VolvoEntity, SensorEntity): - """Representation of a Volvo sensor.""" - - def __init__( - self, - coordinator: VolvoUpdateCoordinator, - vin: str, - component: str, - attribute: str, - slug_attr: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(vin, component, attribute, slug_attr, coordinator) - self._update_value_and_unit() - - def _update_value_and_unit(self) -> None: - self._attr_native_value = self.instrument.state - self._attr_native_unit_of_measurement = self.instrument.unit - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._update_value_and_unit() - self.async_write_ha_state() diff --git a/homeassistant/components/volvooncall/strings.json b/homeassistant/components/volvooncall/strings.json index 8524293d6063f1..72a406273bd3b9 100644 --- a/homeassistant/components/volvooncall/strings.json +++ b/homeassistant/components/volvooncall/strings.json @@ -2,22 +2,17 @@ "config": { "step": { "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]", - "region": "Region", - "unit_system": "Unit system", - "mutable": "Allow remote start/lock etc." - } + "description": "Volvo on Call is deprecated, use the Volvo integration" } }, - "error": { - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "deprecated": "Volvo On Call has been replaced by the Volvo integration. Please use the Volvo integration instead." + } + }, + "issues": { + "volvooncall_deprecated": { + "title": "Volvo On Call has been replaced", + "description": "The Volvo On Call integration is deprecated and will be removed in 2026.3. Please use the Volvo integration instead.\n\nSteps:\n1. Remove this Volvo On Call integration.\n2. Add the Volvo integration through Settings > Devices & services > Add integration > Volvo.\n3. Follow the setup instructions to authenticate with your Volvo account." } } } diff --git a/homeassistant/components/volvooncall/switch.py b/homeassistant/components/volvooncall/switch.py deleted file mode 100644 index ff321577348927..00000000000000 --- a/homeassistant/components/volvooncall/switch.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Support for Volvo heater.""" - -from __future__ import annotations - -from typing import Any - -from volvooncall.dashboard import Instrument - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, VOLVO_DISCOVERY_NEW -from .coordinator import VolvoUpdateCoordinator -from .entity import VolvoEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Configure binary_sensors from a config entry created in the integrations UI.""" - coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - volvo_data = coordinator.volvo_data - - @callback - def async_discover_device(instruments: list[Instrument]) -> None: - """Discover and add a discovered Volvo On Call switch.""" - async_add_entities( - VolvoSwitch( - coordinator, - instrument.vehicle.vin, - instrument.component, - instrument.attr, - instrument.slug_attr, - ) - for instrument in instruments - if instrument.component == "switch" - ) - - async_discover_device([*volvo_data.instruments]) - - config_entry.async_on_unload( - async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device) - ) - - -class VolvoSwitch(VolvoEntity, SwitchEntity): - """Representation of a Volvo switch.""" - - def __init__( - self, - coordinator: VolvoUpdateCoordinator, - vin: str, - component: str, - attribute: str, - slug_attr: str, - ) -> None: - """Initialize the switch.""" - super().__init__(vin, component, attribute, slug_attr, coordinator) - - @property - def is_on(self): - """Determine if switch is on.""" - return self.instrument.state - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the switch on.""" - await self.instrument.turn_on() - await self.coordinator.async_request_refresh() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the switch off.""" - await self.instrument.turn_off() - await self.coordinator.async_request_refresh() diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 39cff22396a6f7..8cadf4b7d4cc21 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -54,7 +54,8 @@ ) from .ratelimit import KeyedRateLimit from .sun import get_astral_event_next -from .template import RenderInfo, Template, result_as_boolean +from .template import Template, result_as_boolean +from .template.render_info import RenderInfo from .typing import TemplateVarsType _TRACK_STATE_CHANGE_DATA: HassKey[_KeyedEventData[EventStateChangedData]] = HassKey( diff --git a/homeassistant/helpers/template/__init__.py b/homeassistant/helpers/template/__init__.py index 4d9581444dda66..efe27dd015676a 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -6,8 +6,6 @@ import asyncio import collections.abc from collections.abc import Callable, Generator, Iterable -from contextlib import AbstractContextManager -from contextvars import ContextVar from copy import deepcopy from datetime import date, datetime, time, timedelta from functools import cache, lru_cache, partial, wraps @@ -20,17 +18,8 @@ import re from struct import error as StructError, pack, unpack_from import sys -from types import CodeType, TracebackType -from typing import ( - TYPE_CHECKING, - Any, - Concatenate, - Literal, - NoReturn, - Self, - cast, - overload, -) +from types import CodeType +from typing import TYPE_CHECKING, Any, Concatenate, Literal, NoReturn, Self, overload import weakref from awesomeversion import AwesomeVersion @@ -62,7 +51,6 @@ ServiceResponse, State, callback, - split_entity_id, valid_domain, valid_entity_id, ) @@ -88,6 +76,14 @@ from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException +from .context import ( + TemplateContextManager as TemplateContextManager, + render_with_context, + template_context_manager, + template_cv, +) +from .render_info import RenderInfo, render_info_cv + if TYPE_CHECKING: from _typeshed import OptExcInfo @@ -127,15 +123,6 @@ "name", } -ALL_STATES_RATE_LIMIT = 60 # seconds -DOMAIN_STATES_RATE_LIMIT = 1 # seconds - -_render_info: ContextVar[RenderInfo | None] = ContextVar("_render_info", default=None) - - -template_cv: ContextVar[tuple[str, str] | None] = ContextVar( - "template_cv", default=None -) # # CACHED_TEMPLATE_STATES is a rough estimate of the number of entities @@ -334,14 +321,6 @@ def __str__(self) -> str: RESULT_WRAPPERS[tuple] = TupleWrapper -def _true(arg: str) -> bool: - return True - - -def _false(arg: str) -> bool: - return False - - @lru_cache(maxsize=EVAL_CACHE_SIZE) def _cached_parse_result(render_result: str) -> Any: """Parse a result and cache the result.""" @@ -371,126 +350,6 @@ def _cached_parse_result(render_result: str) -> Any: return render_result -class RenderInfo: - """Holds information about a template render.""" - - __slots__ = ( - "_result", - "all_states", - "all_states_lifecycle", - "domains", - "domains_lifecycle", - "entities", - "exception", - "filter", - "filter_lifecycle", - "has_time", - "is_static", - "rate_limit", - "template", - ) - - def __init__(self, template: Template) -> None: - """Initialise.""" - self.template = template - # Will be set sensibly once frozen. - self.filter_lifecycle: Callable[[str], bool] = _true - self.filter: Callable[[str], bool] = _true - self._result: str | None = None - self.is_static = False - self.exception: TemplateError | None = None - self.all_states = False - self.all_states_lifecycle = False - self.domains: collections.abc.Set[str] = set() - self.domains_lifecycle: collections.abc.Set[str] = set() - self.entities: collections.abc.Set[str] = set() - self.rate_limit: float | None = None - self.has_time = False - - def __repr__(self) -> str: - """Representation of RenderInfo.""" - return ( - f"" - ) - - def _filter_domains_and_entities(self, entity_id: str) -> bool: - """Template should re-render if the entity state changes. - - Only when we match specific domains or entities. - """ - return ( - split_entity_id(entity_id)[0] in self.domains or entity_id in self.entities - ) - - def _filter_entities(self, entity_id: str) -> bool: - """Template should re-render if the entity state changes. - - Only when we match specific entities. - """ - return entity_id in self.entities - - def _filter_lifecycle_domains(self, entity_id: str) -> bool: - """Template should re-render if the entity is added or removed. - - Only with domains watched. - """ - return split_entity_id(entity_id)[0] in self.domains_lifecycle - - def result(self) -> str: - """Results of the template computation.""" - if self.exception is not None: - raise self.exception - return cast(str, self._result) - - def _freeze_static(self) -> None: - self.is_static = True - self._freeze_sets() - self.all_states = False - - def _freeze_sets(self) -> None: - self.entities = frozenset(self.entities) - self.domains = frozenset(self.domains) - self.domains_lifecycle = frozenset(self.domains_lifecycle) - - def _freeze(self) -> None: - self._freeze_sets() - - if self.rate_limit is None: - if self.all_states or self.exception: - self.rate_limit = ALL_STATES_RATE_LIMIT - elif self.domains or self.domains_lifecycle: - self.rate_limit = DOMAIN_STATES_RATE_LIMIT - - if self.exception: - return - - if not self.all_states_lifecycle: - if self.domains_lifecycle: - self.filter_lifecycle = self._filter_lifecycle_domains - else: - self.filter_lifecycle = _false - - if self.all_states: - return - - if self.domains: - self.filter = self._filter_domains_and_entities - elif self.entities: - self.filter = self._filter_entities - else: - self.filter = _false - - class Template: """Class to hold a template and manage caching and rendering.""" @@ -572,7 +431,7 @@ def ensure_valid(self) -> None: self._compiled_code = compiled return - with _template_context_manager as cm: + with template_context_manager as cm: cm.set_template(self.template, "compiling") try: self._compiled_code = self._env.compile(self.template) @@ -631,7 +490,7 @@ def async_render( kwargs.update(variables) try: - render_result = _render_with_context(self.template, compiled, **kwargs) + render_result = render_with_context(self.template, compiled, **kwargs) except Exception as err: raise TemplateError(err) from err @@ -693,7 +552,7 @@ async def async_render_will_timeout( def _render_template() -> None: assert self.hass is not None, "hass variable not set on template" try: - _render_with_context(self.template, compiled, **kwargs) + render_with_context(self.template, compiled, **kwargs) except TimeoutError: pass except Exception: # noqa: BLE001 @@ -734,7 +593,7 @@ def async_render_to_info( if not self.hass: raise RuntimeError(f"hass not set while rendering {self}") - if _render_info.get() is not None: + if render_info_cv.get() is not None: raise RuntimeError( f"RenderInfo already set while rendering {self}, " "this usually indicates the template is being rendered " @@ -746,7 +605,7 @@ def async_render_to_info( render_info._freeze_static() # noqa: SLF001 return render_info - token = _render_info.set(render_info) + token = render_info_cv.set(render_info) try: render_info._result = self.async_render( # noqa: SLF001 variables, strict=strict, log_fn=log_fn, **kwargs @@ -754,7 +613,7 @@ def async_render_to_info( except TemplateError as ex: render_info.exception = ex finally: - _render_info.reset(token) + render_info_cv.reset(token) render_info._freeze() # noqa: SLF001 return render_info @@ -804,7 +663,7 @@ def async_render_with_possible_json_value( pass try: - render_result = _render_with_context( + render_result = render_with_context( self.template, compiled, **variables ).strip() except jinja2.TemplateError as ex: @@ -911,11 +770,11 @@ def __getattr__(self, name): __getitem__ = __getattr__ def _collect_all(self) -> None: - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.all_states = True def _collect_all_lifecycle(self) -> None: - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.all_states_lifecycle = True def __iter__(self) -> Generator[TemplateState]: @@ -1001,11 +860,11 @@ def __getattr__(self, name: str) -> TemplateState | None: __getitem__ = __getattr__ def _collect_domain(self) -> None: - if (entity_collect := _render_info.get()) is not None: + if (entity_collect := render_info_cv.get()) is not None: entity_collect.domains.add(self._domain) # type: ignore[attr-defined] def _collect_domain_lifecycle(self) -> None: - if (entity_collect := _render_info.get()) is not None: + if (entity_collect := render_info_cv.get()) is not None: entity_collect.domains_lifecycle.add(self._domain) # type: ignore[attr-defined] def __iter__(self) -> Generator[TemplateState]: @@ -1043,7 +902,7 @@ def __init__(self, hass: HomeAssistant, collect: bool, entity_id: str) -> None: self._cache: dict[str, Any] = {} def _collect_state(self) -> None: - if self._collect and (render_info := _render_info.get()): + if self._collect and (render_info := render_info_cv.get()): render_info.entities.add(self._entity_id) # type: ignore[attr-defined] # Jinja will try __getitem__ first and it avoids the need @@ -1052,7 +911,7 @@ def __getitem__(self, item: str) -> Any: """Return a property as an attribute for jinja.""" if item in _COLLECTABLE_STATE_ATTRIBUTES: # _collect_state inlined here for performance - if self._collect and (render_info := _render_info.get()): + if self._collect and (render_info := render_info_cv.get()): render_info.entities.add(self._entity_id) # type: ignore[attr-defined] return getattr(self._state, item) if item == "entity_id": @@ -1194,7 +1053,7 @@ def __repr__(self) -> str: def _collect_state(hass: HomeAssistant, entity_id: str) -> None: - if (entity_collect := _render_info.get()) is not None: + if (entity_collect := render_info_cv.get()) is not None: entity_collect.entities.add(entity_id) # type: ignore[attr-defined] @@ -1945,7 +1804,7 @@ def has_value(hass: HomeAssistant, entity_id: str) -> bool: def now(hass: HomeAssistant) -> datetime: """Record fetching now.""" - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True return dt_util.now() @@ -1953,7 +1812,7 @@ def now(hass: HomeAssistant) -> datetime: def utcnow(hass: HomeAssistant) -> datetime: """Record fetching utcnow.""" - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True return dt_util.utcnow() @@ -2356,7 +2215,7 @@ def random_every_time(context, values): def today_at(hass: HomeAssistant, time_str: str = "") -> datetime: """Record fetching now where the time has been replaced with value.""" - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True today = dt_util.start_of_local_day() @@ -2386,7 +2245,7 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any: supported so as not to break old templates. """ - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True if not isinstance(value, datetime): @@ -2407,7 +2266,7 @@ def time_since(hass: HomeAssistant, value: Any | datetime, precision: int = 1) - If the value not a datetime object the input will be returned unmodified. """ - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True if not isinstance(value, datetime): @@ -2429,7 +2288,7 @@ def time_until(hass: HomeAssistant, value: Any | datetime, precision: int = 1) - If the value not a datetime object the input will be returned unmodified. """ - if (render_info := _render_info.get()) is not None: + if (render_info := render_info_cv.get()) is not None: render_info.has_time = True if not isinstance(value, datetime): @@ -2493,35 +2352,6 @@ def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: return result -class TemplateContextManager(AbstractContextManager): - """Context manager to store template being parsed or rendered in a ContextVar.""" - - def set_template(self, template_str: str, action: str) -> None: - """Store template being parsed or rendered in a Contextvar to aid error handling.""" - template_cv.set((template_str, action)) - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - """Raise any exception triggered within the runtime context.""" - template_cv.set(None) - - -_template_context_manager = TemplateContextManager() - - -def _render_with_context( - template_str: str, template: jinja2.Template, **kwargs: Any -) -> str: - """Store template being rendered in a ContextVar to aid error handling.""" - with _template_context_manager as cm: - cm.set_template(template_str, "rendering") - return template.render(**kwargs) - - def make_logging_undefined( strict: bool | None, log_fn: Callable[[int, str], None] | None ) -> type[jinja2.Undefined]: diff --git a/homeassistant/helpers/template/context.py b/homeassistant/helpers/template/context.py new file mode 100644 index 00000000000000..3f2a56fba48ebe --- /dev/null +++ b/homeassistant/helpers/template/context.py @@ -0,0 +1,45 @@ +"""Template context management for Home Assistant.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager +from contextvars import ContextVar +from types import TracebackType +from typing import Any + +import jinja2 + +# Context variable for template string tracking +template_cv: ContextVar[tuple[str, str] | None] = ContextVar( + "template_cv", default=None +) + + +class TemplateContextManager(AbstractContextManager): + """Context manager to store template being parsed or rendered in a ContextVar.""" + + def set_template(self, template_str: str, action: str) -> None: + """Store template being parsed or rendered in a Contextvar to aid error handling.""" + template_cv.set((template_str, action)) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Raise any exception triggered within the runtime context.""" + template_cv.set(None) + + +# Global context manager instance +template_context_manager = TemplateContextManager() + + +def render_with_context( + template_str: str, template: jinja2.Template, **kwargs: Any +) -> str: + """Store template being rendered in a ContextVar to aid error handling.""" + with template_context_manager as cm: + cm.set_template(template_str, "rendering") + return template.render(**kwargs) diff --git a/homeassistant/helpers/template/extensions/math.py b/homeassistant/helpers/template/extensions/math.py index ac64de50a476ea..60b0452c3f1958 100644 --- a/homeassistant/helpers/template/extensions/math.py +++ b/homeassistant/helpers/template/extensions/math.py @@ -11,7 +11,7 @@ import jinja2 from jinja2 import pass_environment -from homeassistant.helpers.template import template_cv +from homeassistant.helpers.template.context import template_cv from .base import BaseTemplateExtension, TemplateFunction diff --git a/homeassistant/helpers/template/render_info.py b/homeassistant/helpers/template/render_info.py new file mode 100644 index 00000000000000..3899ab0add1545 --- /dev/null +++ b/homeassistant/helpers/template/render_info.py @@ -0,0 +1,155 @@ +"""Template render information tracking for Home Assistant.""" + +from __future__ import annotations + +import collections.abc +from collections.abc import Callable +from contextvars import ContextVar +from typing import TYPE_CHECKING, cast + +from homeassistant.core import split_entity_id + +if TYPE_CHECKING: + from homeassistant.exceptions import TemplateError + + from . import Template + +# Rate limiting constants +ALL_STATES_RATE_LIMIT = 60 # seconds +DOMAIN_STATES_RATE_LIMIT = 1 # seconds + +# Context variable for render information tracking +render_info_cv: ContextVar[RenderInfo | None] = ContextVar( + "render_info_cv", default=None +) + + +# Filter functions for efficiency +def _true(entity_id: str) -> bool: + """Return True for all entity IDs.""" + return True + + +def _false(entity_id: str) -> bool: + """Return False for all entity IDs.""" + return False + + +class RenderInfo: + """Holds information about a template render.""" + + __slots__ = ( + "_result", + "all_states", + "all_states_lifecycle", + "domains", + "domains_lifecycle", + "entities", + "exception", + "filter", + "filter_lifecycle", + "has_time", + "is_static", + "rate_limit", + "template", + ) + + def __init__(self, template: Template) -> None: + """Initialise.""" + self.template = template + # Will be set sensibly once frozen. + self.filter_lifecycle: Callable[[str], bool] = _true + self.filter: Callable[[str], bool] = _true + self._result: str | None = None + self.is_static = False + self.exception: TemplateError | None = None + self.all_states = False + self.all_states_lifecycle = False + self.domains: collections.abc.Set[str] = set() + self.domains_lifecycle: collections.abc.Set[str] = set() + self.entities: collections.abc.Set[str] = set() + self.rate_limit: float | None = None + self.has_time = False + + def __repr__(self) -> str: + """Representation of RenderInfo.""" + return ( + f"" + ) + + def _filter_domains_and_entities(self, entity_id: str) -> bool: + """Template should re-render if the entity state changes. + + Only when we match specific domains or entities. + """ + return ( + split_entity_id(entity_id)[0] in self.domains or entity_id in self.entities + ) + + def _filter_entities(self, entity_id: str) -> bool: + """Template should re-render if the entity state changes. + + Only when we match specific entities. + """ + return entity_id in self.entities + + def _filter_lifecycle_domains(self, entity_id: str) -> bool: + """Template should re-render if the entity is added or removed. + + Only with domains watched. + """ + return split_entity_id(entity_id)[0] in self.domains_lifecycle + + def result(self) -> str: + """Results of the template computation.""" + if self.exception is not None: + raise self.exception + return cast(str, self._result) + + def _freeze_static(self) -> None: + self.is_static = True + self._freeze_sets() + self.all_states = False + + def _freeze_sets(self) -> None: + self.entities = frozenset(self.entities) + self.domains = frozenset(self.domains) + self.domains_lifecycle = frozenset(self.domains_lifecycle) + + def _freeze(self) -> None: + self._freeze_sets() + + if self.rate_limit is None: + if self.all_states or self.exception: + self.rate_limit = ALL_STATES_RATE_LIMIT + elif self.domains or self.domains_lifecycle: + self.rate_limit = DOMAIN_STATES_RATE_LIMIT + + if self.exception: + return + + if not self.all_states_lifecycle: + if self.domains_lifecycle: + self.filter_lifecycle = self._filter_lifecycle_domains + else: + self.filter_lifecycle = _false + + if self.all_states: + return + + if self.domains: + self.filter = self._filter_domains_and_entities + elif self.entities: + self.filter = self._filter_entities + else: + self.filter = _false diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index d8ebab8b83eaea..46a50b184b5041 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -37,10 +37,10 @@ _SENTINEL, Template, TemplateStateFromEntityId, - _render_with_context, render_complex, result_as_boolean, ) +from .template.context import render_with_context from .typing import ConfigType CONF_AVAILABILITY = "availability" @@ -131,7 +131,7 @@ def async_render_as_value_template( compiled = self._compiled or self._ensure_compiled() try: - render_result = _render_with_context( + render_result = render_with_context( self.template, compiled, **variables ).strip() except jinja2.TemplateError as ex: diff --git a/requirements_all.txt b/requirements_all.txt index 9bc0fc1ab41043..56e3db6a2e5300 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3087,9 +3087,6 @@ volkszaehler==0.4.0 # homeassistant.components.volvo volvocarsapi==0.4.2 -# homeassistant.components.volvooncall -volvooncall==0.10.3 - # homeassistant.components.verisure vsure==2.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f91022f936669f..48e19389e2a915 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2558,9 +2558,6 @@ voip-utils==0.3.4 # homeassistant.components.volvo volvocarsapi==0.4.2 -# homeassistant.components.volvooncall -volvooncall==0.10.3 - # homeassistant.components.verisure vsure==2.6.7 diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index 417b1465aa3176..9c05fee8fd9d97 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -356,6 +356,51 @@ "speed_range_min": 1, }, } +MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { + "8131babc5e8d4f44b82e0761d39091a2": { + "platform": "light", + "name": "Basic light", + "on_command_type": "last", + "optimistic": True, + "payload_off": "OFF", + "payload_on": "ON", + "command_topic": "test-topic", + "entity_category": None, + "schema": "basic", + "state_topic": "test-topic", + "color_temp_kelvin": True, + "state_value_template": "{{ value_json.value }}", + "brightness_scale": 255, + "max_kelvin": 6535, + "min_kelvin": 2000, + "white_scale": 255, + "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", + }, +} +MOCK_SUBENTRY_LOCK_COMPONENT = { + "3faf1318016c46c5aea26707eeb6f100": { + "platform": "lock", + "name": "Lock", + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{ value }}", + "value_template": "{{ value_json.value }}", + "code_format": "^\\d{4}$", + "payload_open": "OPEN", + "payload_lock": "LOCK", + "payload_unlock": "UNLOCK", + "payload_reset": "None", + "state_jammed": "JAMMED", + "state_locked": "LOCKED", + "state_locking": "LOCKING", + "state_unlocked": "UNLOCKED", + "state_unlocking": "UNLOCKING", + "retain": False, + "entity_category": None, + "entity_picture": "https://example.com/3faf1318016c46c5aea26707eeb6f100", + "optimistic": True, + }, +} MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { "363a7ecad6be4a19b939a016ea93e994": { "platform": "notify", @@ -387,32 +432,13 @@ "retain": False, }, } - -MOCK_SUBENTRY_LOCK_COMPONENT = { - "3faf1318016c46c5aea26707eeb6f100": { - "platform": "lock", - "name": "Lock", - "command_topic": "test-topic", - "state_topic": "test-topic", - "command_template": "{{ value }}", - "value_template": "{{ value_json.value }}", - "code_format": "^\\d{4}$", - "payload_open": "OPEN", - "payload_lock": "LOCK", - "payload_unlock": "UNLOCK", - "payload_reset": "None", - "state_jammed": "JAMMED", - "state_locked": "LOCKED", - "state_locking": "LOCKING", - "state_unlocked": "UNLOCKED", - "state_unlocking": "UNLOCKING", - "retain": False, - "entity_category": None, - "entity_picture": "https://example.com/3faf1318016c46c5aea26707eeb6f100", - "optimistic": True, +MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { + "b10b531e15244425a74bb0abb1e9d2c6": { + "platform": "notify", + "name": "Test", + "command_topic": "bad#topic", }, } - MOCK_SUBENTRY_SENSOR_COMPONENT = { "e9261f6feed443e7b7d5f3fbe2a47412": { "platform": "sensor", @@ -464,35 +490,6 @@ }, } -MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { - "8131babc5e8d4f44b82e0761d39091a2": { - "platform": "light", - "name": "Basic light", - "on_command_type": "last", - "optimistic": True, - "payload_off": "OFF", - "payload_on": "ON", - "command_topic": "test-topic", - "entity_category": None, - "schema": "basic", - "state_topic": "test-topic", - "color_temp_kelvin": True, - "state_value_template": "{{ value_json.value }}", - "brightness_scale": 255, - "max_kelvin": 6535, - "min_kelvin": 2000, - "white_scale": 255, - "entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2", - }, -} -MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { - "b10b531e15244425a74bb0abb1e9d2c6": { - "platform": "notify", - "name": "Test", - "command_topic": "bad#topic", - }, -} - MOCK_SUBENTRY_AVAILABILITY_DATA = { "availability": { "availability_topic": "test/availability", @@ -556,14 +553,6 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_FAN_COMPONENT, } -MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { - "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, - "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, -} -MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { - "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, - "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, -} MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT, @@ -572,6 +561,14 @@ "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_LOCK_COMPONENT, } +MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, +} +MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, +} MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT, diff --git a/tests/components/volvooncall/test_config_flow.py b/tests/components/volvooncall/test_config_flow.py index 5268432c17e500..206e35dd33020f 100644 --- a/tests/components/volvooncall/test_config_flow.py +++ b/tests/components/volvooncall/test_config_flow.py @@ -1,9 +1,5 @@ """Test the Volvo On Call config flow.""" -from unittest.mock import Mock, patch - -from aiohttp import ClientResponseError - from homeassistant import config_entries from homeassistant.components.volvooncall.const import DOMAIN from homeassistant.core import HomeAssistant @@ -13,172 +9,27 @@ async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" + """Test we get an abort with deprecation message.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert len(result["errors"]) == 0 - - with ( - patch("volvooncall.Connection.get"), - patch( - "homeassistant.components.volvooncall.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "deprecated" - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - } - assert len(mock_setup_entry.mock_calls) == 1 - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +async def test_flow_aborts_with_existing_config_entry(hass: HomeAssistant) -> None: + """Test the config flow aborts even with existing config entries.""" + # Create an existing config entry + entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call", + data={}, ) + entry.add_to_hass(hass) - exc = ClientResponseError(Mock(), (), status=401) - - with patch( - "volvooncall.Connection.get", - side_effect=exc, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_flow_already_configured(hass: HomeAssistant) -> None: - """Test we handle a flow that has already been configured.""" - first_entry = MockConfigEntry(domain=DOMAIN, unique_id="test-username") - first_entry.add_to_hass(hass) - + # New flow should still abort result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM - assert len(result["errors"]) == 0 - - with ( - patch("volvooncall.Connection.get"), - patch( - "homeassistant.components.volvooncall.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" - - -async def test_form_other_exception(hass: HomeAssistant) -> None: - """Test we handle other exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "volvooncall.Connection.get", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_reauth(hass: HomeAssistant) -> None: - """Test that we handle the reauth flow.""" - - first_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="test-username", - data={ - "username": "test-username", - "password": "test-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - first_entry.add_to_hass(hass) - - result = await first_entry.start_reauth_flow(hass) - - # the first form is just the confirmation prompt - assert result["type"] is FlowResultType.FORM - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() - - # the second form is the user flow where reauth happens - assert result2["type"] is FlowResultType.FORM - - with patch("volvooncall.Connection.get"): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - { - "username": "test-username", - "password": "test-new-password", - "region": "na", - "unit_system": "metric", - "mutable": True, - }, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.ABORT - assert result3["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "deprecated" diff --git a/tests/components/volvooncall/test_init.py b/tests/components/volvooncall/test_init.py new file mode 100644 index 00000000000000..a0b65fad659432 --- /dev/null +++ b/tests/components/volvooncall/test_init.py @@ -0,0 +1,76 @@ +"""Test the Volvo On Call integration setup.""" + +from homeassistant.components.volvooncall.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_setup_entry_creates_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that setup creates a repair issue.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call", + data={}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + + assert issue is not None + assert issue.severity is ir.IssueSeverity.WARNING + assert issue.translation_key == "volvooncall_deprecated" + + +async def test_unload_entry_removes_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test that unloading the last config entry removes the repair issue.""" + first_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call", + data={}, + ) + first_config_entry.add_to_hass(hass) + second_config_entry = MockConfigEntry( + domain=DOMAIN, + title="Volvo On Call second", + data={}, + ) + second_config_entry.add_to_hass(hass) + + # Setup entry + assert await hass.config_entries.async_setup(first_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + # Check that the repair issue was created + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + assert issue is not None + + # Unload entry (this is the only entry, so issue should be removed) + assert await hass.config_entries.async_remove(first_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + # Check that the repair issue still exists because there's another entry + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + assert issue is not None + + # Unload entry (this is the only entry, so issue should be removed) + assert await hass.config_entries.async_remove(second_config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + # Check that the repair issue was removed + issue = issue_registry.async_get_issue(DOMAIN, "volvooncall_deprecated") + assert issue is None diff --git a/tests/helpers/template/test_context.py b/tests/helpers/template/test_context.py new file mode 100644 index 00000000000000..7773be5be2024d --- /dev/null +++ b/tests/helpers/template/test_context.py @@ -0,0 +1,91 @@ +"""Test template context management for Home Assistant.""" + +from __future__ import annotations + +import jinja2 + +from homeassistant.helpers.template.context import ( + TemplateContextManager, + render_with_context, + template_context_manager, + template_cv, +) + + +def test_template_context_manager() -> None: + """Test TemplateContextManager functionality.""" + cm = TemplateContextManager() + + # Test setting template + cm.set_template("{{ test }}", "rendering") + assert template_cv.get() == ("{{ test }}", "rendering") + + # Test context manager exit + cm.__exit__(None, None, None) + assert template_cv.get() is None + + +def test_template_context_manager_context() -> None: + """Test TemplateContextManager as context manager.""" + cm = TemplateContextManager() + + with cm: + cm.set_template("{{ test }}", "parsing") + assert template_cv.get() == ("{{ test }}", "parsing") + + # Should be cleared after exit + assert template_cv.get() is None + + +def test_global_template_context_manager() -> None: + """Test global template context manager instance.""" + # Should be an instance of TemplateContextManager + assert isinstance(template_context_manager, TemplateContextManager) + + # Test it works like any other context manager + template_context_manager.set_template("{{ global_test }}", "testing") + assert template_cv.get() == ("{{ global_test }}", "testing") + + template_context_manager.__exit__(None, None, None) + assert template_cv.get() is None + + +def test_render_with_context() -> None: + """Test render_with_context function.""" + # Create a simple template + env = jinja2.Environment() + template_obj = env.from_string("Hello {{ name }}!") + + # Test rendering with context tracking + result = render_with_context("Hello {{ name }}!", template_obj, name="World") + assert result == "Hello World!" + + # Context should be cleared after rendering + assert template_cv.get() is None + + +def test_render_with_context_sets_context() -> None: + """Test that render_with_context properly sets template context.""" + # Create a template that we can use to check context + jinja2.Environment() + + # We'll use a custom template class to capture context during rendering + context_during_render = [] + + class MockTemplate: + def render(self, **kwargs): + # Capture the context during rendering + context_during_render.append(template_cv.get()) + return "rendered" + + mock_template = MockTemplate() + + # Render with context + result = render_with_context("{{ test_template }}", mock_template, test=True) + + assert result == "rendered" + # Should have captured the context during rendering + assert len(context_during_render) == 1 + assert context_during_render[0] == ("{{ test_template }}", "rendering") + # Context should be cleared after rendering + assert template_cv.get() is None diff --git a/tests/helpers/template/test_init.py b/tests/helpers/template/test_init.py index d6df489e84215b..44399869ef826e 100644 --- a/tests/helpers/template/test_init.py +++ b/tests/helpers/template/test_init.py @@ -48,6 +48,10 @@ ) from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.json import json_dumps +from homeassistant.helpers.template.render_info import ( + ALL_STATES_RATE_LIMIT, + DOMAIN_STATES_RATE_LIMIT, +) from homeassistant.helpers.typing import TemplateVarsType from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -127,7 +131,7 @@ async def test_template_render_missing_hass(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test", "23") template_str = "{{ states('sensor.test') }}" template_obj = template.Template(template_str, None) - template._render_info.set(template.RenderInfo(template_obj)) + template.render_info_cv.set(template.RenderInfo(template_obj)) with pytest.raises(RuntimeError, match="hass not set while rendering"): template_obj.async_render_to_info() @@ -143,7 +147,7 @@ async def test_template_render_info_collision(hass: HomeAssistant) -> None: template_str = "{{ states('sensor.test') }}" template_obj = template.Template(template_str, None) template_obj.hass = hass - template._render_info.set(template.RenderInfo(template_obj)) + template.render_info_cv.set(template.RenderInfo(template_obj)) with pytest.raises(RuntimeError, match="RenderInfo already set while rendering"): template_obj.async_render_to_info() @@ -229,7 +233,7 @@ def test_iterating_all_states(hass: HomeAssistant) -> None: info = render_to_info(hass, tmpl_str) assert_result_info(info, "", all_states=True) - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT hass.states.async_set("test.object", "happy") hass.states.async_set("sensor.temperature", 10) @@ -254,7 +258,7 @@ def test_iterating_all_states_unavailable(hass: HomeAssistant) -> None: info = render_to_info(hass, tmpl_str) assert info.all_states is True - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT hass.states.async_set("test.object", "unknown") hass.states.async_set("sensor.temperature", 10) @@ -269,7 +273,7 @@ def test_iterating_domain_states(hass: HomeAssistant) -> None: info = render_to_info(hass, tmpl_str) assert_result_info(info, "", domains=["sensor"]) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT hass.states.async_set("test.object", "happy") hass.states.async_set("sensor.back_door", "open") @@ -2663,7 +2667,7 @@ async def test_expand(hass: HomeAssistant) -> None: "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "", [], ["group"]) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() @@ -2690,7 +2694,7 @@ async def test_expand(hass: HomeAssistant) -> None: "{{ expand(states.group) | sort(attribute='entity_id') | map(attribute='entity_id') | join(', ') }}", ) assert_result_info(info, "test.object", {"test.object"}, ["group"]) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT info = render_to_info( hass, @@ -3747,7 +3751,7 @@ def test_async_render_to_info_with_complex_branching(hass: HomeAssistant) -> Non ) assert_result_info(info, ["sensor.a"], {"light.a", "light.b"}, {"sensor"}) - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT async def test_async_render_to_info_with_wildcard_matching_entity_id( @@ -3771,7 +3775,7 @@ async def test_async_render_to_info_with_wildcard_matching_entity_id( assert info.domains == {"cover"} assert info.entities == set() assert info.all_states is False - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT async def test_async_render_to_info_with_wildcard_matching_state( @@ -3799,7 +3803,7 @@ async def test_async_render_to_info_with_wildcard_matching_state( assert not info.domains assert info.entities == set() assert info.all_states is True - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT hass.states.async_set("binary_sensor.door", "off") info = render_to_info(hass, template_complex_str) @@ -3807,7 +3811,7 @@ async def test_async_render_to_info_with_wildcard_matching_state( assert not info.domains assert info.entities == set() assert info.all_states is True - assert info.rate_limit == template.ALL_STATES_RATE_LIMIT + assert info.rate_limit == ALL_STATES_RATE_LIMIT template_cover_str = """ @@ -3824,7 +3828,7 @@ async def test_async_render_to_info_with_wildcard_matching_state( assert info.domains == {"cover"} assert info.entities == set() assert info.all_states is False - assert info.rate_limit == template.DOMAIN_STATES_RATE_LIMIT + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT def test_nested_async_render_to_info_case(hass: HomeAssistant) -> None: diff --git a/tests/helpers/template/test_render_info.py b/tests/helpers/template/test_render_info.py new file mode 100644 index 00000000000000..9b746a8461018f --- /dev/null +++ b/tests/helpers/template/test_render_info.py @@ -0,0 +1,196 @@ +"""Test template render information tracking for Home Assistant.""" + +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template +from homeassistant.helpers.template.render_info import ( + ALL_STATES_RATE_LIMIT, + DOMAIN_STATES_RATE_LIMIT, + RenderInfo, + _false, + _true, + render_info_cv, +) + + +def test_render_info_initialization(hass: HomeAssistant) -> None: + """Test RenderInfo initialization.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + assert info.template is template_obj + assert info._result is None + assert info.is_static is False + assert info.exception is None + assert info.all_states is False + assert info.all_states_lifecycle is False + assert info.domains == set() + assert info.domains_lifecycle == set() + assert info.entities == set() + assert info.rate_limit is None + assert info.has_time is False + assert info.filter_lifecycle is _true + assert info.filter is _true + + +def test_render_info_repr(hass: HomeAssistant) -> None: + """Test RenderInfo representation.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + info.domains.add("sensor") + info.entities.add("sensor.test") + + repr_str = repr(info) + assert "RenderInfo" in repr_str + assert "domains={'sensor'}" in repr_str + assert "entities={'sensor.test'}" in repr_str + + +def test_render_info_result(hass: HomeAssistant) -> None: + """Test RenderInfo result property.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + # Test with no result set - should return None cast as str + assert info.result() is None + + # Test with result set + info._result = "test_result" + assert info.result() == "test_result" + + # Test with exception + info.exception = TemplateError("Test error") + with pytest.raises(TemplateError, match="Test error"): + info.result() + + +def test_render_info_filter_domains_and_entities(hass: HomeAssistant) -> None: + """Test RenderInfo entity and domain filtering.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + # Add domain and entity + info.domains.add("sensor") + info.entities.add("light.test") + + # Should match domain + assert info._filter_domains_and_entities("sensor.temperature") is True + # Should match entity + assert info._filter_domains_and_entities("light.test") is True + # Should not match + assert info._filter_domains_and_entities("switch.kitchen") is False + + +def test_render_info_filter_entities(hass: HomeAssistant) -> None: + """Test RenderInfo entity-only filtering.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + info.entities.add("sensor.test") + + assert info._filter_entities("sensor.test") is True + assert info._filter_entities("sensor.other") is False + + +def test_render_info_filter_lifecycle_domains(hass: HomeAssistant) -> None: + """Test RenderInfo domain lifecycle filtering.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + info.domains_lifecycle.add("sensor") + + assert info._filter_lifecycle_domains("sensor.test") is True + assert info._filter_lifecycle_domains("light.test") is False + + +def test_render_info_freeze_static(hass: HomeAssistant) -> None: + """Test RenderInfo static freezing.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + info.domains.add("sensor") + info.entities.add("sensor.test") + info.all_states = True + + info._freeze_static() + + assert info.is_static is True + assert info.all_states is False + assert isinstance(info.domains, frozenset) + assert isinstance(info.entities, frozenset) + + +def test_render_info_freeze(hass: HomeAssistant) -> None: + """Test RenderInfo freezing with rate limits.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + + # Test all_states rate limit + info.all_states = True + info._freeze() + assert info.rate_limit == ALL_STATES_RATE_LIMIT + + # Test domain rate limit + info = RenderInfo(template_obj) + info.domains.add("sensor") + info._freeze() + assert info.rate_limit == DOMAIN_STATES_RATE_LIMIT + + # Test exception rate limit + info = RenderInfo(template_obj) + info.exception = TemplateError("Test") + info._freeze() + assert info.rate_limit == ALL_STATES_RATE_LIMIT + + +def test_render_info_freeze_filters(hass: HomeAssistant) -> None: + """Test RenderInfo filter assignment during freeze.""" + template_obj = template.Template("{{ 1 + 1 }}", hass) + + # Test lifecycle filter assignment + info = RenderInfo(template_obj) + info.domains_lifecycle.add("sensor") + info._freeze() + assert info.filter_lifecycle == info._filter_lifecycle_domains + + # Test no lifecycle domains + info = RenderInfo(template_obj) + info._freeze() + assert info.filter_lifecycle is _false + + # Test domain and entity filter + info = RenderInfo(template_obj) + info.domains.add("sensor") + info._freeze() + assert info.filter == info._filter_domains_and_entities + + # Test entity-only filter + info = RenderInfo(template_obj) + info.entities.add("sensor.test") + info._freeze() + assert info.filter == info._filter_entities + + # Test no domains or entities + info = RenderInfo(template_obj) + info._freeze() + assert info.filter is _false + + +def test_render_info_context_var(hass: HomeAssistant) -> None: + """Test render_info_cv context variable.""" + # Should start as None + assert render_info_cv.get() is None + + # Test setting and getting + template_obj = template.Template("{{ 1 + 1 }}", hass) + info = RenderInfo(template_obj) + render_info_cv.set(info) + assert render_info_cv.get() is info + + # Reset for other tests + render_info_cv.set(None) + assert render_info_cv.get() is None