diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0292677ab9352f..4aa9724f515216 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -158,8 +158,9 @@ jobs: sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements_diff.txt + # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@bf4ddde339dde61ba98ccb4330517936bed6d2f8 # 2025.07.0 + uses: home-assistant/wheels@2025.07.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -218,8 +219,9 @@ jobs: sed -i "/uv/d" requirements.txt sed -i "/uv/d" requirements_diff.txt + # home-assistant/wheels doesn't support sha pinning - name: Build wheels - uses: home-assistant/wheels@bf4ddde339dde61ba98ccb4330517936bed6d2f8 # 2025.07.0 + uses: home-assistant/wheels@2025.07.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 14baed5bfce715..00922b75ed8005 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -395,6 +395,7 @@ "upsmode": SensorEntityDescription( key="upsmode", translation_key="ups_mode", + entity_category=EntityCategory.DIAGNOSTIC, ), "upsname": SensorEntityDescription( key="upsname", diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index ffffc3ec6f3e8f..bf5345e0ba476a 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,9 +18,9 @@ "bleak==1.0.1", "bleak-retry-connector==4.4.3", "bluetooth-adapters==2.1.0", - "bluetooth-auto-recovery==1.5.2", + "bluetooth-auto-recovery==1.5.3", "bluetooth-data-tools==1.28.2", "dbus-fast==2.44.3", - "habluetooth==5.6.2" + "habluetooth==5.6.4" ] } diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index ce8dc498d6d512..e20842d186f95f 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -57,7 +57,10 @@ RETRY_INTERVAL = 60 # seconds MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3 - +# HomeKit accessories have varying limits on how many characteristics +# they can handle per request. Since we don't know each device's specific limit, +# we batch requests to a conservative size to avoid overwhelming any device. +MAX_CHARACTERISTICS_PER_REQUEST = 49 BLE_AVAILABILITY_CHECK_INTERVAL = 1800 # seconds @@ -326,16 +329,20 @@ async def async_setup(self) -> None: ) entry.async_on_unload(self._async_cancel_subscription_timer) + if transport != Transport.BLE: + # Although async_populate_accessories_state fetched the accessory database, + # the /accessories endpoint may return cached values from the accessory's + # perspective. For example, Ecobee thermostats may report stale temperature + # values (like 100°C) in their /accessories response after restarting. + # We need to explicitly poll characteristics to get fresh sensor readings + # before processing the entity map and creating devices. + # Use poll_all=True since entities haven't registered their characteristics yet. + await self.async_update(poll_all=True) + await self.async_process_entity_map() if transport != Transport.BLE: - # When Home Assistant starts, we restore the accessory map from storage - # which contains characteristic values from when HA was last running. - # These values are stale and may be incorrect (e.g., Ecobee thermostats - # report 100°C when restarting). We need to poll for fresh values before - # creating entities. Use poll_all=True since entities haven't registered - # their characteristics yet. - await self.async_update(poll_all=True) + # Start regular polling after entity map is processed self._async_start_polling() # If everything is up to date, we can create the entities @@ -938,20 +945,26 @@ async def async_update( async with self._polling_lock: _LOGGER.debug("Starting HomeKit device update: %s", self.unique_id) - try: - new_values_dict = await self.get_characteristics(to_poll) - except AccessoryNotFoundError: - # Not only did the connection fail, but also the accessory is not - # visible on the network. - self.async_set_available_state(False) - return - except (AccessoryDisconnectedError, EncryptionError): - # Temporary connection failure. Device may still available but our - # connection was dropped or we are reconnecting - self._poll_failures += 1 - if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE: + new_values_dict: dict[tuple[int, int], dict[str, Any]] = {} + to_poll_list = list(to_poll) + + for i in range(0, len(to_poll_list), MAX_CHARACTERISTICS_PER_REQUEST): + batch = to_poll_list[i : i + MAX_CHARACTERISTICS_PER_REQUEST] + try: + batch_values = await self.get_characteristics(batch) + new_values_dict.update(batch_values) + except AccessoryNotFoundError: + # Not only did the connection fail, but also the accessory is not + # visible on the network. self.async_set_available_state(False) - return + return + except (AccessoryDisconnectedError, EncryptionError): + # Temporary connection failure. Device may still available but our + # connection was dropped or we are reconnecting + self._poll_failures += 1 + if self._poll_failures >= MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE: + self.async_set_available_state(False) + return self._poll_failures = 0 self.process_new_events(new_values_dict) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index d15479aa9d5b3c..ef4fdadb24c41f 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.15"], + "requirements": ["aiohomekit==3.2.16"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 5a279753507978..bbfa288d59533b 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -133,14 +133,13 @@ async def async_upload_media( def _do_move() -> None: """Move file to target.""" - if not target_dir.is_dir(): - raise PathNotSupportedError("Target is not an existing directory") - - target_path = target_dir / uploaded_file.filename - try: + target_path = target_dir / uploaded_file.filename + target_path.relative_to(target_dir) raise_if_invalid_path(str(target_path)) + + target_dir.mkdir(parents=True, exist_ok=True) except ValueError as err: raise PathNotSupportedError("Invalid path") from err diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index f1ffc7e0aada18..fcae7793185f63 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -18,7 +18,6 @@ from time import monotonic from typing import Any, Final, Generic, Protocol, TypeVar -import aiofiles from aiohttp import web import mutagen from mutagen.id3 import ID3, TextFrame as ID3Text @@ -591,13 +590,9 @@ async def _async_stream_override_result(self) -> AsyncGenerator[bytes]: if not needs_conversion: # Read file directly (no conversion) - async with aiofiles.open(self._override_media_path, "rb") as media_file: - while True: - chunk = await media_file.read(FFMPEG_CHUNK_SIZE) - if not chunk: - break - yield chunk - + yield await self.hass.async_add_executor_job( + self._override_media_path.read_bytes + ) return # Use ffmpeg to convert audio to preferred format diff --git a/homeassistant/components/twitch/coordinator.py b/homeassistant/components/twitch/coordinator.py index 010a9e90cccc9e..142c3509e0b93b 100644 --- a/homeassistant/components/twitch/coordinator.py +++ b/homeassistant/components/twitch/coordinator.py @@ -79,6 +79,7 @@ async def _async_setup(self) -> None: if not (user := await first(self.twitch.get_users())): raise UpdateFailed("Logged in user not found") self.current_user = user + self.users.append(self.current_user) # Add current_user to users list. async def _async_update_data(self) -> dict[str, TwitchUpdate]: await self.session.async_ensure_token_valid() @@ -95,6 +96,8 @@ async def _async_update_data(self) -> dict[str, TwitchUpdate]: user_id=self.current_user.id, first=100 ) } + async for s in self.twitch.get_streams(user_id=[self.current_user.id]): + streams.update({s.user_id: s}) follows: dict[str, FollowedChannel] = { f.broadcaster_id: f async for f in await self.twitch.get_followed_channels( diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index 7ead1f014c8181..db199b180f4958 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -67,8 +67,13 @@ async def _async_set_arm_state( ) LOGGER.debug("Verisure set arm state %s", state) result = None + attempts = 0 while result is None: - await asyncio.sleep(0.5) + if attempts == 30: + break + if attempts > 1: + await asyncio.sleep(0.5) + attempts += 1 transaction = await self.hass.async_add_executor_job( self.coordinator.verisure.request, self.coordinator.verisure.poll_arm_state( @@ -81,8 +86,10 @@ async def _async_set_arm_state( .get("armStateChangePollResult", {}) .get("result") ) - - await self.coordinator.async_refresh() + LOGGER.debug("Result is %s", result) + if result == "OK": + self._attr_alarm_state = ALARM_STATE_TO_HA.get(state) + self.async_write_ha_state() async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" @@ -108,16 +115,20 @@ async def async_alarm_arm_away(self, code: str | None = None) -> None: "ARMED_AWAY", self.coordinator.verisure.arm_away(code) ) - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + def _update_alarm_attributes(self) -> None: + """Update alarm state and changed by from coordinator data.""" self._attr_alarm_state = ALARM_STATE_TO_HA.get( self.coordinator.data["alarm"]["statusType"] ) self._attr_changed_by = self.coordinator.data["alarm"].get("name") + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_alarm_attributes() super()._handle_coordinator_update() async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() - self._handle_coordinator_update() + self._update_alarm_attributes() diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 76aeedd05fa99b..4d2229967a09eb 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity, LockState from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -70,7 +70,9 @@ def __init__( self._attr_unique_id = serial_number self.serial_number = serial_number - self._state: str | None = None + self._attr_is_locked = None + self._attr_changed_by = None + self._changed_method: str | None = None @property def device_info(self) -> DeviceInfo: @@ -92,20 +94,6 @@ def available(self) -> bool: super().available and self.serial_number in self.coordinator.data["locks"] ) - @property - def changed_by(self) -> str | None: - """Last change triggered by.""" - return ( - self.coordinator.data["locks"][self.serial_number] - .get("user", {}) - .get("name") - ) - - @property - def changed_method(self) -> str: - """Last change method.""" - return self.coordinator.data["locks"][self.serial_number]["lockMethod"] - @property def code_format(self) -> str: """Return the configured code format.""" @@ -115,16 +103,9 @@ def code_format(self) -> str: return f"^\\d{{{digits}}}$" @property - def is_locked(self) -> bool: - """Return true if lock is locked.""" - return ( - self.coordinator.data["locks"][self.serial_number]["lockStatus"] == "LOCKED" - ) - - @property - def extra_state_attributes(self) -> dict[str, str]: + def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" - return {"method": self.changed_method} + return {"method": self._changed_method} async def async_unlock(self, **kwargs: Any) -> None: """Send unlock command.""" @@ -154,7 +135,7 @@ async def async_set_lock_state(self, code: str, state: LockState) -> None: target_state = "LOCKED" if state == LockState.LOCKED else "UNLOCKED" lock_status = None attempts = 0 - while lock_status != "OK": + while lock_status is None: if attempts == 30: break if attempts > 1: @@ -172,8 +153,10 @@ async def async_set_lock_state(self, code: str, state: LockState) -> None: .get("doorLockStateChangePollResult", {}) .get("result") ) + LOGGER.debug("Lock status is %s", lock_status) if lock_status == "OK": - self._state = state + self._attr_is_locked = state == LockState.LOCKED + self.async_write_ha_state() def disable_autolock(self) -> None: """Disable autolock on a doorlock.""" @@ -196,3 +179,21 @@ def enable_autolock(self) -> None: LOGGER.debug("Enabling autolock on %s", self.serial_number) except VerisureError as ex: LOGGER.error("Could not enable autolock, %s", ex) + + def _update_lock_attributes(self) -> None: + """Update lock state, changed by, and method from coordinator data.""" + lock_data = self.coordinator.data["locks"][self.serial_number] + self._attr_is_locked = lock_data["lockStatus"] == "LOCKED" + self._attr_changed_by = lock_data.get("user", {}).get("name") + self._changed_method = lock_data["lockMethod"] + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_lock_attributes() + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._update_lock_attributes() diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index 0deb1da5e95c62..bdd933c753b068 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -99,4 +99,4 @@ async def async_set_plug_state(self, state: bool) -> None: ) self._state = state self._change_timestamp = monotonic() - await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json index 2bf72acb047b7b..98d21dd9425ec5 100644 --- a/homeassistant/components/waterfurnace/manifest.json +++ b/homeassistant/components/waterfurnace/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["waterfurnace"], "quality_scale": "legacy", - "requirements": ["waterfurnace==1.1.0"] + "requirements": ["waterfurnace==1.2.0"] } diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template/__init__.py similarity index 95% rename from homeassistant/helpers/template.py rename to homeassistant/helpers/template/__init__.py index 8e3106093aaf09..357a16c73404ea 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template/__init__.py @@ -4,7 +4,6 @@ from ast import literal_eval import asyncio -import base64 import collections.abc from collections.abc import Callable, Generator, Iterable, MutableSequence from contextlib import AbstractContextManager @@ -12,7 +11,6 @@ from copy import deepcopy from datetime import date, datetime, time, timedelta from functools import cache, lru_cache, partial, wraps -import hashlib import json import logging import math @@ -71,6 +69,19 @@ valid_entity_id, ) from homeassistant.exceptions import TemplateError +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + floor_registry as fr, + issue_registry as ir, + label_registry as lr, + location as loc_helper, +) +from homeassistant.helpers.deprecation import deprecated_function +from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.translation import async_translate_state +from homeassistant.helpers.typing import TemplateVarsType from homeassistant.loader import bind_hass from homeassistant.util import ( convert, @@ -84,20 +95,6 @@ from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException -from . import ( - area_registry, - device_registry, - entity_registry, - floor_registry as fr, - issue_registry, - label_registry, - location as loc_helper, -) -from .deprecation import deprecated_function -from .singleton import singleton -from .translation import async_translate_state -from .typing import TemplateVarsType - if TYPE_CHECKING: from _typeshed import OptExcInfo @@ -210,7 +207,7 @@ def _async_adjust_lru_sizes(_: Any) -> None: if new_size > current_size: lru.set_size(new_size) - from .event import async_track_time_interval # noqa: PLC0415 + from homeassistant.helpers.event import async_track_time_interval # noqa: PLC0415 cancel = async_track_time_interval( hass, _async_adjust_lru_sizes, timedelta(minutes=10) @@ -525,7 +522,10 @@ def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: Note: A valid hass instance should always be passed in. The hass parameter will be non optional in Home Assistant Core 2025.10. """ - from .frame import ReportBehavior, report_usage # noqa: PLC0415 + from homeassistant.helpers.frame import ( # noqa: PLC0415 + ReportBehavior, + report_usage, + ) if not isinstance(template, str): raise TypeError("Expected template to be a string") @@ -973,7 +973,7 @@ def __call__(self, entity_id: str) -> str | None: state_value = state.state domain = state.domain device_class = state.attributes.get("device_class") - entry = entity_registry.async_get(self._hass).async_get(entity_id) + entry = er.async_get(self._hass).async_get(entity_id) platform = None if entry is None else entry.platform translation_key = None if entry is None else entry.translation_key @@ -1274,7 +1274,7 @@ def forgiving_boolean[_T]( """Try to convert value to a boolean.""" try: # Import here, not at top-level to avoid circular import - from . import config_validation as cv # noqa: PLC0415 + from homeassistant.helpers import config_validation as cv # noqa: PLC0415 return cv.boolean(value) except vol.Invalid: @@ -1299,7 +1299,7 @@ def result_as_boolean(template_result: Any | None) -> bool: def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: """Expand out any groups and zones into entity states.""" # circular import. - from . import entity as entity_helper # noqa: PLC0415 + from homeassistant.helpers import entity as entity_helper # noqa: PLC0415 search = list(args) found = {} @@ -1341,8 +1341,8 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: """Get entity ids for entities tied to a device.""" - entity_reg = entity_registry.async_get(hass) - entries = entity_registry.async_entries_for_device(entity_reg, _device_id) + entity_reg = er.async_get(hass) + entries = er.async_entries_for_device(entity_reg, _device_id) return [entry.entity_id for entry in entries] @@ -1360,19 +1360,17 @@ def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]: # first try if there are any config entries with a matching title entities: list[str] = [] - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) for entry in hass.config_entries.async_entries(): if entry.title != entry_name: continue - entries = entity_registry.async_entries_for_config_entry( - ent_reg, entry.entry_id - ) + entries = er.async_entries_for_config_entry(ent_reg, entry.entry_id) entities.extend(entry.entity_id for entry in entries) if entities: return entities # fallback to just returning all entities for a domain - from .entity import entity_sources # noqa: PLC0415 + from homeassistant.helpers.entity import entity_sources # noqa: PLC0415 return [ entity_id @@ -1383,7 +1381,7 @@ def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]: def config_entry_id(hass: HomeAssistant, entity_id: str) -> str | None: """Get an config entry ID from an entity ID.""" - entity_reg = entity_registry.async_get(hass) + entity_reg = er.async_get(hass) if entity := entity_reg.async_get(entity_id): return entity.config_entry_id return None @@ -1391,12 +1389,12 @@ def config_entry_id(hass: HomeAssistant, entity_id: str) -> str | None: def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None: """Get a device ID from an entity ID or device name.""" - entity_reg = entity_registry.async_get(hass) + entity_reg = er.async_get(hass) entity = entity_reg.async_get(entity_id_or_device_name) if entity is not None: return entity.device_id - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) return next( ( device_id @@ -1410,13 +1408,13 @@ def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None: def device_name(hass: HomeAssistant, lookup_value: str) -> str | None: """Get the device name from an device id, or entity id.""" - device_reg = device_registry.async_get(hass) + device_reg = dr.async_get(hass) if device := device_reg.async_get(lookup_value): return device.name_by_user or device.name - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # noqa: PLC0415 + from homeassistant.helpers import config_validation as cv # noqa: PLC0415 try: cv.entity_id(lookup_value) @@ -1432,7 +1430,7 @@ def device_name(hass: HomeAssistant, lookup_value: str) -> str | None: def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any: """Get the device specific attribute.""" - device_reg = device_registry.async_get(hass) + device_reg = dr.async_get(hass) if not isinstance(device_or_entity_id, str): raise TemplateError("Must provide a device or entity ID") device = None @@ -1475,14 +1473,14 @@ def is_device_attr( def issues(hass: HomeAssistant) -> dict[tuple[str, str], dict[str, Any]]: """Return all open issues.""" - current_issues = issue_registry.async_get(hass).issues + current_issues = ir.async_get(hass).issues # Use JSON for safe representation return {k: v.to_json() for (k, v) in current_issues.items()} def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | None: """Get issue by domain and issue_id.""" - result = issue_registry.async_get(hass).async_get_issue(domain, issue_id) + result = ir.async_get(hass).async_get_issue(domain, issue_id) if result: return result.to_json() return None @@ -1505,7 +1503,7 @@ def floor_id(hass: HomeAssistant, lookup_value: Any) -> str | None: return floors_list[0].floor_id if aid := area_id(hass, lookup_value): - area_reg = area_registry.async_get(hass) + area_reg = ar.async_get(hass) if area := area_reg.async_get_area(aid): return area.floor_id @@ -1519,7 +1517,7 @@ def floor_name(hass: HomeAssistant, lookup_value: str) -> str | None: return floor.name if aid := area_id(hass, lookup_value): - area_reg = area_registry.async_get(hass) + area_reg = ar.async_get(hass) if ( (area := area_reg.async_get_area(aid)) and area.floor_id @@ -1542,8 +1540,8 @@ def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: if _floor_id is None: return [] - area_reg = area_registry.async_get(hass) - entries = area_registry.async_entries_for_floor(area_reg, _floor_id) + area_reg = ar.async_get(hass) + entries = ar.async_entries_for_floor(area_reg, _floor_id) return [entry.id for entry in entries if entry.id] @@ -1558,12 +1556,12 @@ def floor_entities(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: def areas(hass: HomeAssistant) -> Iterable[str | None]: """Return all areas.""" - return list(area_registry.async_get(hass).areas) + return list(ar.async_get(hass).areas) def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: """Get the area ID from an area name, alias, device id, or entity id.""" - area_reg = area_registry.async_get(hass) + area_reg = ar.async_get(hass) lookup_str = str(lookup_value) if area := area_reg.async_get_area_by_name(lookup_str): return area.id @@ -1571,10 +1569,10 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: if areas_list: return areas_list[0].id - ent_reg = entity_registry.async_get(hass) - dev_reg = device_registry.async_get(hass) + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # noqa: PLC0415 + from homeassistant.helpers import config_validation as cv # noqa: PLC0415 try: cv.entity_id(lookup_value) @@ -1596,7 +1594,7 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: return None -def _get_area_name(area_reg: area_registry.AreaRegistry, valid_area_id: str) -> str: +def _get_area_name(area_reg: ar.AreaRegistry, valid_area_id: str) -> str: """Get area name from valid area ID.""" area = area_reg.async_get_area(valid_area_id) assert area @@ -1605,14 +1603,14 @@ def _get_area_name(area_reg: area_registry.AreaRegistry, valid_area_id: str) -> def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: """Get the area name from an area id, device id, or entity id.""" - area_reg = area_registry.async_get(hass) + area_reg = ar.async_get(hass) if area := area_reg.async_get_area(lookup_value): return area.name - dev_reg = device_registry.async_get(hass) - ent_reg = entity_registry.async_get(hass) + dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # noqa: PLC0415 + from homeassistant.helpers import config_validation as cv # noqa: PLC0415 try: cv.entity_id(lookup_value) @@ -1649,19 +1647,18 @@ def area_entities(hass: HomeAssistant, area_id_or_name: str) -> Iterable[str]: _area_id = area_id_or_name if _area_id is None: return [] - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) entity_ids = [ - entry.entity_id - for entry in entity_registry.async_entries_for_area(ent_reg, _area_id) + entry.entity_id for entry in er.async_entries_for_area(ent_reg, _area_id) ] - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) # We also need to add entities tied to a device in the area that don't themselves # have an area specified since they inherit the area from the device. entity_ids.extend( [ entity.entity_id - for device in device_registry.async_entries_for_area(dev_reg, _area_id) - for entity in entity_registry.async_entries_for_device(ent_reg, device.id) + for device in dr.async_entries_for_area(dev_reg, _area_id) + for entity in er.async_entries_for_device(ent_reg, device.id) if entity.area_id is None ] ) @@ -1679,21 +1676,21 @@ def area_devices(hass: HomeAssistant, area_id_or_name: str) -> Iterable[str]: _area_id = area_id(hass, area_id_or_name) if _area_id is None: return [] - dev_reg = device_registry.async_get(hass) - entries = device_registry.async_entries_for_area(dev_reg, _area_id) + dev_reg = dr.async_get(hass) + entries = dr.async_entries_for_area(dev_reg, _area_id) return [entry.id for entry in entries] def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None]: """Return all labels, or those from a area ID, device ID, or entity ID.""" - label_reg = label_registry.async_get(hass) + label_reg = lr.async_get(hass) if lookup_value is None: return list(label_reg.labels) - ent_reg = entity_registry.async_get(hass) + ent_reg = er.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # noqa: PLC0415 + from homeassistant.helpers import config_validation as cv # noqa: PLC0415 lookup_value = str(lookup_value) @@ -1706,12 +1703,12 @@ def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None return list(entity.labels) # Check if this could be a device ID - dev_reg = device_registry.async_get(hass) + dev_reg = dr.async_get(hass) if device := dev_reg.async_get(lookup_value): return list(device.labels) # Check if this could be a area ID - area_reg = area_registry.async_get(hass) + area_reg = ar.async_get(hass) if area := area_reg.async_get_area(lookup_value): return list(area.labels) @@ -1720,7 +1717,7 @@ def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None def label_id(hass: HomeAssistant, lookup_value: Any) -> str | None: """Get the label ID from a label name.""" - label_reg = label_registry.async_get(hass) + label_reg = lr.async_get(hass) if label := label_reg.async_get_label_by_name(str(lookup_value)): return label.label_id return None @@ -1728,7 +1725,7 @@ def label_id(hass: HomeAssistant, lookup_value: Any) -> str | None: def label_name(hass: HomeAssistant, lookup_value: str) -> str | None: """Get the label name from a label ID.""" - label_reg = label_registry.async_get(hass) + label_reg = lr.async_get(hass) if label := label_reg.async_get_label(lookup_value): return label.name return None @@ -1736,7 +1733,7 @@ def label_name(hass: HomeAssistant, lookup_value: str) -> str | None: def label_description(hass: HomeAssistant, lookup_value: str) -> str | None: """Get the label description from a label ID.""" - label_reg = label_registry.async_get(hass) + label_reg = lr.async_get(hass) if label := label_reg.async_get_label(lookup_value): return label.description return None @@ -1755,8 +1752,8 @@ def label_areas(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: """Return areas for a given label ID or name.""" if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None: return [] - area_reg = area_registry.async_get(hass) - entries = area_registry.async_entries_for_label(area_reg, _label_id) + area_reg = ar.async_get(hass) + entries = ar.async_entries_for_label(area_reg, _label_id) return [entry.id for entry in entries] @@ -1764,8 +1761,8 @@ def label_devices(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: """Return device IDs for a given label ID or name.""" if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None: return [] - dev_reg = device_registry.async_get(hass) - entries = device_registry.async_entries_for_label(dev_reg, _label_id) + dev_reg = dr.async_get(hass) + entries = dr.async_entries_for_label(dev_reg, _label_id) return [entry.id for entry in entries] @@ -1773,8 +1770,8 @@ def label_entities(hass: HomeAssistant, label_id_or_name: str) -> Iterable[str]: """Return entities for a given label ID or name.""" if (_label_id := _label_id_or_name(hass, label_id_or_name)) is None: return [] - ent_reg = entity_registry.async_get(hass) - entries = entity_registry.async_entries_for_label(ent_reg, _label_id) + ent_reg = er.async_get(hass) + entries = er.async_entries_for_label(ent_reg, _label_id) return [entry.entity_id for entry in entries] @@ -1913,7 +1910,7 @@ def distance(hass: HomeAssistant, *args: Any) -> float | None: def is_hidden_entity(hass: HomeAssistant, entity_id: str) -> bool: """Test if an entity is hidden.""" - entity_reg = entity_registry.async_get(hass) + entity_reg = er.async_get(hass) entry = entity_reg.async_get(entity_id) return entry is not None and entry.hidden @@ -2608,22 +2605,6 @@ def from_hex(value: str) -> bytes: return bytes.fromhex(value) -def base64_encode(value: str | bytes) -> str: - """Perform base64 encode.""" - if isinstance(value, str): - value = value.encode("utf-8") - return base64.b64encode(value).decode("utf-8") - - -def base64_decode(value: str, encoding: str | None = "utf-8") -> str | bytes: - """Perform base64 decode.""" - decoded = base64.b64decode(value) - if encoding: - return decoded.decode(encoding) - - return decoded - - def ordinal(value): """Perform ordinal conversion.""" suffixes = ["th", "st", "nd", "rd"] + ["th"] * 6 # codespell:ignore nd @@ -2928,26 +2909,6 @@ def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]: return result -def md5(value: str) -> str: - """Generate md5 hash from a string.""" - return hashlib.md5(value.encode()).hexdigest() - - -def sha1(value: str) -> str: - """Generate sha1 hash from a string.""" - return hashlib.sha1(value.encode()).hexdigest() - - -def sha256(value: str) -> str: - """Generate sha256 hash from a string.""" - return hashlib.sha256(value.encode()).hexdigest() - - -def sha512(value: str) -> str: - """Generate sha512 hash from a string.""" - return hashlib.sha512(value.encode()).hexdigest() - - class TemplateContextManager(AbstractContextManager): """Context manager to store template being parsed or rendered in a ContextVar.""" @@ -3096,11 +3057,14 @@ def __init__( """Initialise template environment.""" super().__init__(undefined=make_logging_undefined(strict, log_fn)) self.hass = hass + self.limited = limited self.template_cache: weakref.WeakValueDictionary[ str | jinja2.nodes.Template, CodeType | None ] = weakref.WeakValueDictionary() self.add_extension("jinja2.ext.loopcontrols") self.add_extension("jinja2.ext.do") + self.add_extension("homeassistant.helpers.template.extensions.Base64Extension") + self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension") self.globals["acos"] = arc_cosine self.globals["as_datetime"] = as_datetime @@ -3125,16 +3089,12 @@ def __init__( self.globals["is_number"] = is_number self.globals["log"] = logarithm self.globals["max"] = min_max_from_filter(self.filters["max"], "max") - self.globals["md5"] = md5 self.globals["median"] = median self.globals["merge_response"] = merge_response self.globals["min"] = min_max_from_filter(self.filters["min"], "min") self.globals["pack"] = struct_pack self.globals["pi"] = math.pi self.globals["set"] = _to_set - self.globals["sha1"] = sha1 - self.globals["sha256"] = sha256 - self.globals["sha512"] = sha512 self.globals["shuffle"] = shuffle self.globals["sin"] = sine self.globals["slugify"] = slugify @@ -3165,8 +3125,6 @@ def __init__( self.filters["atan"] = arc_tangent self.filters["atan2"] = arc_tangent2 self.filters["average"] = average - self.filters["base64_decode"] = base64_decode - self.filters["base64_encode"] = base64_encode self.filters["bitwise_and"] = bitwise_and self.filters["bitwise_or"] = bitwise_or self.filters["bitwise_xor"] = bitwise_xor @@ -3185,7 +3143,6 @@ def __init__( self.filters["is_defined"] = fail_when_undefined self.filters["is_number"] = is_number self.filters["log"] = logarithm - self.filters["md5"] = md5 self.filters["median"] = median self.filters["multiply"] = multiply self.filters["ord"] = ord @@ -3198,9 +3155,6 @@ def __init__( self.filters["regex_replace"] = regex_replace self.filters["regex_search"] = regex_search self.filters["round"] = forgiving_round - self.filters["sha1"] = sha1 - self.filters["sha256"] = sha256 - self.filters["sha512"] = sha512 self.filters["shuffle"] = shuffle self.filters["sin"] = sine self.filters["slugify"] = slugify diff --git a/homeassistant/helpers/template/extensions/__init__.py b/homeassistant/helpers/template/extensions/__init__.py new file mode 100644 index 00000000000000..d1ed7e093faf99 --- /dev/null +++ b/homeassistant/helpers/template/extensions/__init__.py @@ -0,0 +1,6 @@ +"""Home Assistant template extensions.""" + +from .base64 import Base64Extension +from .crypto import CryptoExtension + +__all__ = ["Base64Extension", "CryptoExtension"] diff --git a/homeassistant/helpers/template/extensions/base.py b/homeassistant/helpers/template/extensions/base.py new file mode 100644 index 00000000000000..142e9e77d5ecf4 --- /dev/null +++ b/homeassistant/helpers/template/extensions/base.py @@ -0,0 +1,60 @@ +"""Base extension class for Home Assistant template extensions.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from jinja2.ext import Extension +from jinja2.nodes import Node +from jinja2.parser import Parser + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +@dataclass +class TemplateFunction: + """Definition for a template function, filter, or test.""" + + name: str + func: Callable[..., Any] + as_global: bool = False + as_filter: bool = False + as_test: bool = False + limited_ok: bool = ( + True # Whether this function is available in limited environments + ) + + +class BaseTemplateExtension(Extension): + """Base class for Home Assistant template extensions.""" + + environment: TemplateEnvironment + + def __init__( + self, + environment: TemplateEnvironment, + *, + functions: list[TemplateFunction] | None = None, + ) -> None: + """Initialize the extension with a list of template functions.""" + super().__init__(environment) + + if functions: + for template_func in functions: + # Skip functions not allowed in limited environments + if self.environment.limited and not template_func.limited_ok: + continue + + if template_func.as_global: + environment.globals[template_func.name] = template_func.func + if template_func.as_filter: + environment.filters[template_func.name] = template_func.func + if template_func.as_test: + environment.tests[template_func.name] = template_func.func + + def parse(self, parser: Parser) -> Node | list[Node]: + """Required by Jinja2 Extension base class.""" + return [] diff --git a/homeassistant/helpers/template/extensions/base64.py b/homeassistant/helpers/template/extensions/base64.py new file mode 100644 index 00000000000000..3ec88bf14f4794 --- /dev/null +++ b/homeassistant/helpers/template/extensions/base64.py @@ -0,0 +1,50 @@ +"""Base64 encoding and decoding functions for Home Assistant templates.""" + +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +class Base64Extension(BaseTemplateExtension): + """Jinja2 extension for base64 encoding and decoding functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the base64 extension.""" + super().__init__( + environment, + functions=[ + TemplateFunction( + "base64_encode", + self.base64_encode, + as_filter=True, + limited_ok=False, + ), + TemplateFunction( + "base64_decode", + self.base64_decode, + as_filter=True, + limited_ok=False, + ), + ], + ) + + @staticmethod + def base64_encode(value: str | bytes) -> str: + """Encode a string or bytes to base64.""" + if isinstance(value, str): + value = value.encode("utf-8") + return base64.b64encode(value).decode("utf-8") + + @staticmethod + def base64_decode(value: str, encoding: str | None = "utf-8") -> str | bytes: + """Decode a base64 string.""" + decoded = base64.b64decode(value) + if encoding is None: + return decoded + return decoded.decode(encoding) diff --git a/homeassistant/helpers/template/extensions/crypto.py b/homeassistant/helpers/template/extensions/crypto.py new file mode 100644 index 00000000000000..c3ff165d7272d3 --- /dev/null +++ b/homeassistant/helpers/template/extensions/crypto.py @@ -0,0 +1,64 @@ +"""Cryptographic hash functions for Home Assistant templates.""" + +from __future__ import annotations + +import hashlib +from typing import TYPE_CHECKING + +from .base import BaseTemplateExtension, TemplateFunction + +if TYPE_CHECKING: + from homeassistant.helpers.template import TemplateEnvironment + + +class CryptoExtension(BaseTemplateExtension): + """Jinja2 extension for cryptographic hash functions.""" + + def __init__(self, environment: TemplateEnvironment) -> None: + """Initialize the crypto extension.""" + super().__init__( + environment, + functions=[ + # Hash functions (as globals and filters) + TemplateFunction( + "md5", self.md5, as_global=True, as_filter=True, limited_ok=False + ), + TemplateFunction( + "sha1", self.sha1, as_global=True, as_filter=True, limited_ok=False + ), + TemplateFunction( + "sha256", + self.sha256, + as_global=True, + as_filter=True, + limited_ok=False, + ), + TemplateFunction( + "sha512", + self.sha512, + as_global=True, + as_filter=True, + limited_ok=False, + ), + ], + ) + + @staticmethod + def md5(value: str) -> str: + """Generate md5 hash from a string.""" + return hashlib.md5(value.encode()).hexdigest() + + @staticmethod + def sha1(value: str) -> str: + """Generate sha1 hash from a string.""" + return hashlib.sha1(value.encode()).hexdigest() + + @staticmethod + def sha256(value: str) -> str: + """Generate sha256 hash from a string.""" + return hashlib.sha256(value.encode()).hexdigest() + + @staticmethod + def sha512(value: str) -> str: + """Generate sha512 hash from a string.""" + return hashlib.sha512(value.encode()).hexdigest() diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 98622eab1d2086..b6c5e88984d5e2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bcrypt==4.3.0 bleak-retry-connector==4.4.3 bleak==1.0.1 bluetooth-adapters==2.1.0 -bluetooth-auto-recovery==1.5.2 +bluetooth-auto-recovery==1.5.3 bluetooth-data-tools==1.28.2 cached-ipaddress==0.10.0 certifi>=2021.5.30 @@ -34,7 +34,7 @@ dbus-fast==2.44.3 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==5.6.2 +habluetooth==5.6.4 hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 @@ -129,7 +129,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.7 +pydantic==2.11.9 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/pyproject.toml b/pyproject.toml index 007cda7fad47c1..c81dd7e00f3cbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -448,6 +448,7 @@ testpaths = ["tests"] norecursedirs = [".git", "testing_config"] log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" +asyncio_debug = true asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [ diff --git a/requirements_all.txt b/requirements_all.txt index a192b85e0c1d5b..dbcd081aed7f65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -271,7 +271,7 @@ aiohasupervisor==0.3.2 aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.15 +aiohomekit==3.2.16 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -658,7 +658,7 @@ bluemaestro-ble==0.4.1 bluetooth-adapters==2.1.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.5.2 +bluetooth-auto-recovery==1.5.3 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -1137,7 +1137,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.5 # homeassistant.components.bluetooth -habluetooth==5.6.2 +habluetooth==5.6.4 # homeassistant.components.cloud hass-nabucasa==1.1.1 @@ -3102,7 +3102,7 @@ wallbox==0.9.0 watchdog==6.0.0 # homeassistant.components.waterfurnace -waterfurnace==1.1.0 +waterfurnace==1.2.0 # homeassistant.components.watergate watergate-local-api==2024.4.1 diff --git a/requirements_test.txt b/requirements_test.txt index 2d1057590e9c88..658c3ab0a7a1b1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,13 +13,13 @@ freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.3 mock-open==1.4.0 -mypy-dev==1.18.0a4 +mypy-dev==1.19.0a2 pre-commit==4.2.0 -pydantic==2.11.7 +pydantic==2.11.9 pylint==3.3.8 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 -pytest-asyncio==1.1.0 +pytest-asyncio==1.2.0 pytest-aiohttp==1.1.0 pytest-cov==7.0.0 pytest-freezer==0.4.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b026a547cfc9b9..12386cc708fb08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -256,7 +256,7 @@ aiohasupervisor==0.3.2 aiohomeconnect==0.19.0 # homeassistant.components.homekit_controller -aiohomekit==3.2.15 +aiohomekit==3.2.16 # homeassistant.components.mcp_server aiohttp_sse==2.2.0 @@ -589,7 +589,7 @@ bluemaestro-ble==0.4.1 bluetooth-adapters==2.1.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.5.2 +bluetooth-auto-recovery==1.5.3 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -998,7 +998,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.5 # homeassistant.components.bluetooth -habluetooth==5.6.2 +habluetooth==5.6.4 # homeassistant.components.cloud hass-nabucasa==1.1.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e482c01b3dd602..4efbcea9ab9848 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -155,7 +155,7 @@ backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.7 +pydantic==2.11.9 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/tests/components/apcupsd/snapshots/test_sensor.ambr b/tests/components/apcupsd/snapshots/test_sensor.ambr index 2e991d7cfa6be2..a873607180f887 100644 --- a/tests/components/apcupsd/snapshots/test_sensor.ambr +++ b/tests/components/apcupsd/snapshots/test_sensor.ambr @@ -868,7 +868,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_mode', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/homekit_controller/test_connection.py b/tests/components/homekit_controller/test_connection.py index 99203d400fea5b..6c5ccdfd8b0a90 100644 --- a/tests/components/homekit_controller/test_connection.py +++ b/tests/components/homekit_controller/test_connection.py @@ -13,6 +13,9 @@ import pytest from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE +from homeassistant.components.homekit_controller.connection import ( + MAX_CHARACTERISTICS_PER_REQUEST, +) from homeassistant.components.homekit_controller.const import ( DEBOUNCE_COOLDOWN, DOMAIN, @@ -377,9 +380,15 @@ def _create_accessory(accessory: Accessory) -> Service: state = await helper.poll_and_get_state() assert state.state == STATE_OFF assert mock_get_characteristics.call_count == 2 - # Verify everything is polled - assert mock_get_characteristics.call_args_list[0][0][0] == {(1, 10), (1, 11)} - assert mock_get_characteristics.call_args_list[1][0][0] == {(1, 10), (1, 11)} + # Verify everything is polled (convert to set for comparison since batching changes the type) + assert set(mock_get_characteristics.call_args_list[0][0][0]) == { + (1, 10), + (1, 11), + } + assert set(mock_get_characteristics.call_args_list[1][0][0]) == { + (1, 10), + (1, 11), + } # Test device goes offline helper.pairing.available = False @@ -526,3 +535,84 @@ async def mock_get_characteristics( state = hass.states.get("climate.homew") assert state is not None assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.5 + + +async def test_characteristic_polling_batching( + hass: HomeAssistant, get_next_aid: Callable[[], int] +) -> None: + """Test that characteristic polling is batched to MAX_CHARACTERISTICS_PER_REQUEST.""" + + # Create a large accessory with many characteristics (more than 49) + def create_large_accessory_with_many_chars(accessory: Accessory) -> None: + """Create an accessory with many characteristics to test batching.""" + # Add multiple services with many characteristics each + for service_num in range(10): # 10 services + service = accessory.add_service( + ServicesTypes.LIGHTBULB, name=f"Light {service_num}" + ) + # Each lightbulb service gets several characteristics + service.add_char(CharacteristicsTypes.ON) + service.add_char(CharacteristicsTypes.BRIGHTNESS) + service.add_char(CharacteristicsTypes.HUE) + service.add_char(CharacteristicsTypes.SATURATION) + service.add_char(CharacteristicsTypes.COLOR_TEMPERATURE) + # Set initial values + for char in service.characteristics: + if char.type != CharacteristicsTypes.IDENTIFY: + char.value = 0 + + helper = await setup_test_component( + hass, get_next_aid(), create_large_accessory_with_many_chars + ) + + # Track the get_characteristics calls + get_chars_calls = [] + original_get_chars = helper.pairing.get_characteristics + + async def mock_get_characteristics(chars): + """Mock get_characteristics to track batch sizes.""" + get_chars_calls.append(list(chars)) + return await original_get_chars(chars) + + # Clear any calls from setup + get_chars_calls.clear() + + # Patch get_characteristics to track calls + with mock.patch.object( + helper.pairing, "get_characteristics", side_effect=mock_get_characteristics + ): + # Trigger an update through time_changed which simulates regular polling + # time_changed expects seconds, not a datetime + await time_changed(hass, 300) # 5 minutes in seconds + await hass.async_block_till_done() + + # We created 10 lightbulb services with 5 characteristics each = 50 total + # Plus any base accessory characteristics that are pollable + # This should result in exactly 2 batches + assert len(get_chars_calls) == 2, ( + f"Should have made exactly 2 batched calls, got {len(get_chars_calls)}" + ) + + # Check that no batch exceeded MAX_CHARACTERISTICS_PER_REQUEST + for i, batch in enumerate(get_chars_calls): + assert len(batch) <= MAX_CHARACTERISTICS_PER_REQUEST, ( + f"Batch {i} size {len(batch)} exceeded maximum {MAX_CHARACTERISTICS_PER_REQUEST}" + ) + + # Verify the total number of characteristics polled + total_chars = sum(len(batch) for batch in get_chars_calls) + # Each lightbulb has: ON, BRIGHTNESS, HUE, SATURATION, COLOR_TEMPERATURE = 5 + # 10 lightbulbs = 50 characteristics + assert total_chars == 50, ( + f"Should have polled exactly 50 characteristics, got {total_chars}" + ) + + # The first batch should be full (49 characteristics) + assert len(get_chars_calls[0]) == 49, ( + f"First batch should have exactly 49 characteristics, got {len(get_chars_calls[0])}" + ) + + # The second batch should have exactly 1 characteristic + assert len(get_chars_calls[1]) == 1, ( + f"Second batch should have exactly 1 characteristic, got {len(get_chars_calls[1])}" + ) diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index 73abc8c507537e..b830b0b44e4fc9 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -137,4 +137,5 @@ def devices(mock_thinq_api: AsyncMock, device_fixture: str) -> Generator[AsyncMo mock_thinq_api.async_get_device_status.return_value = load_json_object_fixture( f"{device_fixture}/status.json", DOMAIN ) + mock_thinq_api.async_get_device_energy_profile.return_value = MagicMock() return mock_thinq_api diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index d40dd7475a771d..d897c6216ae360 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -164,19 +164,21 @@ def get_file(name): client = await hass_client() # Test normal upload - res = await client.post( - "/api/media_source/local_source/upload", - data={ - "media_content_id": "media-source://media_source/test_dir", - "file": get_file("logo.png"), - }, - ) + with patch.object(Path, "mkdir", autospec=True, return_value=None) as mock_mkdir: + res = await client.post( + "/api/media_source/local_source/upload", + data={ + "media_content_id": "media-source://media_source/test_dir", + "file": get_file("logo.png"), + }, + ) assert res.status == 200 data = await res.json() assert data["media_content_id"] == "media-source://media_source/test_dir/logo.png" uploaded_path = Path(temp_dir) / "logo.png" assert uploaded_path.is_file() + mock_mkdir.assert_called_once() resolved = await media_source.async_resolve_media( hass, data["media_content_id"], target_media_player=None @@ -187,8 +189,6 @@ def get_file(name): # Test with bad media source ID for bad_id in ( - # Subdir doesn't exist - "media-source://media_source/test_dir/some-other-dir", # Main dir doesn't exist "media-source://media_source/test_dir2", # Location is invalid diff --git a/tests/helpers/template/__init__.py b/tests/helpers/template/__init__.py new file mode 100644 index 00000000000000..f1e980fd2fb607 --- /dev/null +++ b/tests/helpers/template/__init__.py @@ -0,0 +1 @@ +"""Tests for Home Assistant template engine.""" diff --git a/tests/helpers/template/extensions/__init__.py b/tests/helpers/template/extensions/__init__.py new file mode 100644 index 00000000000000..43b7c1caccf0a7 --- /dev/null +++ b/tests/helpers/template/extensions/__init__.py @@ -0,0 +1 @@ +"""Tests for Home Assistant template extensions.""" diff --git a/tests/helpers/template/extensions/test_base64.py b/tests/helpers/template/extensions/test_base64.py new file mode 100644 index 00000000000000..b0c1fb35134d05 --- /dev/null +++ b/tests/helpers/template/extensions/test_base64.py @@ -0,0 +1,43 @@ +"""Test base64 encoding and decoding functions for Home Assistant templates.""" + +from __future__ import annotations + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import template + + +@pytest.mark.parametrize( + ("value_template", "expected"), + [ + ('{{ "homeassistant" | base64_encode }}', "aG9tZWFzc2lzdGFudA=="), + ("{{ int('0F010003', base=16) | pack('>I') | base64_encode }}", "DwEAAw=="), + ("{{ 'AA01000200150020' | from_hex | base64_encode }}", "qgEAAgAVACA="), + ], +) +def test_base64_encode(hass: HomeAssistant, value_template: str, expected: str) -> None: + """Test the base64_encode filter.""" + assert template.Template(value_template, hass).async_render() == expected + + +def test_base64_decode(hass: HomeAssistant) -> None: + """Test the base64_decode filter.""" + assert ( + template.Template( + '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode }}', hass + ).async_render() + == "homeassistant" + ) + assert ( + template.Template( + '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode(None) }}', hass + ).async_render() + == b"homeassistant" + ) + assert ( + template.Template( + '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode("ascii") }}', hass + ).async_render() + == "homeassistant" + ) diff --git a/tests/helpers/template/extensions/test_crypto.py b/tests/helpers/template/extensions/test_crypto.py new file mode 100644 index 00000000000000..f1e4c3b39cc2b9 --- /dev/null +++ b/tests/helpers/template/extensions/test_crypto.py @@ -0,0 +1,58 @@ +"""Test cryptographic hash functions for Home Assistant templates.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import template + + +def test_md5(hass: HomeAssistant) -> None: + """Test the md5 function and filter.""" + assert ( + template.Template("{{ md5('Home Assistant') }}", hass).async_render() + == "3d15e5c102c3413d0337393c3287e006" + ) + + assert ( + template.Template("{{ 'Home Assistant' | md5 }}", hass).async_render() + == "3d15e5c102c3413d0337393c3287e006" + ) + + +def test_sha1(hass: HomeAssistant) -> None: + """Test the sha1 function and filter.""" + assert ( + template.Template("{{ sha1('Home Assistant') }}", hass).async_render() + == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha1 }}", hass).async_render() + == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" + ) + + +def test_sha256(hass: HomeAssistant) -> None: + """Test the sha256 function and filter.""" + assert ( + template.Template("{{ sha256('Home Assistant') }}", hass).async_render() + == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha256 }}", hass).async_render() + == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" + ) + + +def test_sha512(hass: HomeAssistant) -> None: + """Test the sha512 function and filter.""" + assert ( + template.Template("{{ sha512('Home Assistant') }}", hass).async_render() + == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" + ) + + assert ( + template.Template("{{ 'Home Assistant' | sha512 }}", hass).async_render() + == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" + ) diff --git a/tests/helpers/snapshots/test_template.ambr b/tests/helpers/template/snapshots/test_init.ambr similarity index 100% rename from tests/helpers/snapshots/test_template.ambr rename to tests/helpers/template/snapshots/test_init.ambr diff --git a/tests/helpers/test_template.py b/tests/helpers/template/test_init.py similarity index 98% rename from tests/helpers/test_template.py rename to tests/helpers/template/test_init.py index 85a2673f17d90f..6d4c27123fc266 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/template/test_init.py @@ -1739,41 +1739,6 @@ def test_from_hex(hass: HomeAssistant) -> None: ) -@pytest.mark.parametrize( - ("value_template", "expected"), - [ - ('{{ "homeassistant" | base64_encode }}', "aG9tZWFzc2lzdGFudA=="), - ("{{ int('0F010003', base=16) | pack('>I') | base64_encode }}", "DwEAAw=="), - ("{{ 'AA01000200150020' | from_hex | base64_encode }}", "qgEAAgAVACA="), - ], -) -def test_base64_encode(hass: HomeAssistant, value_template: str, expected: str) -> None: - """Test the base64_encode filter.""" - assert template.Template(value_template, hass).async_render() == expected - - -def test_base64_decode(hass: HomeAssistant) -> None: - """Test the base64_decode filter.""" - assert ( - template.Template( - '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode }}', hass - ).async_render() - == "homeassistant" - ) - assert ( - template.Template( - '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode(None) }}', hass - ).async_render() - == b"homeassistant" - ) - assert ( - template.Template( - '{{ "aG9tZWFzc2lzdGFudA==" | base64_decode("ascii") }}', hass - ).async_render() - == "homeassistant" - ) - - def test_slugify(hass: HomeAssistant) -> None: """Test the slugify filter.""" assert ( @@ -7174,58 +7139,6 @@ def test_symmetric_difference(hass: HomeAssistant) -> None: ).async_render() -def test_md5(hass: HomeAssistant) -> None: - """Test the md5 function and filter.""" - assert ( - template.Template("{{ md5('Home Assistant') }}", hass).async_render() - == "3d15e5c102c3413d0337393c3287e006" - ) - - assert ( - template.Template("{{ 'Home Assistant' | md5 }}", hass).async_render() - == "3d15e5c102c3413d0337393c3287e006" - ) - - -def test_sha1(hass: HomeAssistant) -> None: - """Test the sha1 function and filter.""" - assert ( - template.Template("{{ sha1('Home Assistant') }}", hass).async_render() - == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" - ) - - assert ( - template.Template("{{ 'Home Assistant' | sha1 }}", hass).async_render() - == "c8fd3bb19b94312664faa619af7729bdbf6e9f8a" - ) - - -def test_sha256(hass: HomeAssistant) -> None: - """Test the sha256 function and filter.""" - assert ( - template.Template("{{ sha256('Home Assistant') }}", hass).async_render() - == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" - ) - - assert ( - template.Template("{{ 'Home Assistant' | sha256 }}", hass).async_render() - == "2a366abb0cd47f51f3725bf0fb7ebcb4fefa6e20f4971e25fe2bb8da8145ce2b" - ) - - -def test_sha512(hass: HomeAssistant) -> None: - """Test the sha512 function and filter.""" - assert ( - template.Template("{{ sha512('Home Assistant') }}", hass).async_render() - == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" - ) - - assert ( - template.Template("{{ 'Home Assistant' | sha512 }}", hass).async_render() - == "9e3c2cdd1fbab0037378d37e1baf8a3a4bf92c54b56ad1d459deee30ccbb2acbebd7a3614552ea08992ad27dedeb7b4c5473525ba90cb73dbe8b9ec5f69295bb" - ) - - def test_combine(hass: HomeAssistant) -> None: """Test combine filter and function.""" assert template.Template(