diff --git a/.coveragerc b/.coveragerc index 166951352c4..4dde4c15201 100644 --- a/.coveragerc +++ b/.coveragerc @@ -863,7 +863,6 @@ omit = homeassistant/components/openhome/const.py homeassistant/components/openhome/media_player.py homeassistant/components/opensensemap/air_quality.py - homeassistant/components/opensky/sensor.py homeassistant/components/opentherm_gw/__init__.py homeassistant/components/opentherm_gw/binary_sensor.py homeassistant/components/opentherm_gw/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 5ba36fb30c1..012f256f372 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -297,8 +297,8 @@ build.json @home-assistant/supervisor /tests/components/dunehd/ @bieniu /homeassistant/components/duotecno/ @cereal2nd /tests/components/duotecno/ @cereal2nd -/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo -/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo +/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo +/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 1d1c26b5fcd..a22687c0fb5 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.13.3"], + "requirements": ["pyatv==0.13.4"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index bf4dbf81f01..2e0e62440ab 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -38,7 +38,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.loader import async_get_bluetooth -from . import models +from . import models, passive_update_processor from .api import ( _get_manager, async_address_present, @@ -125,6 +125,7 @@ async def _async_get_adapter_from_address( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the bluetooth integration.""" + await passive_update_processor.async_setup(hass) integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) integration_matcher.async_setup() bluetooth_adapters = get_adapters() diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 29634c9a18c..78965ae5cde 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -2,12 +2,31 @@ from __future__ import annotations import dataclasses +from datetime import timedelta +from functools import cache import logging -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast -from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant import config_entries +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_NAME, + CONF_ENTITY_CATEGORY, + EVENT_HOMEASSISTANT_STOP, + EntityCategory, +) +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.helpers.entity import ( + DeviceInfo, + Entity, + EntityDescription, +) +from homeassistant.helpers.entity_platform import ( + async_get_current_platform, +) +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.storage import Store +from homeassistant.util.enum import try_parse_enum from .const import DOMAIN from .update_coordinator import BasePassiveBluetoothCoordinator @@ -23,6 +42,12 @@ if TYPE_CHECKING: BluetoothServiceInfoBleak, ) +STORAGE_KEY = "bluetooth.passive_update_processor" +STORAGE_VERSION = 1 +STORAGE_SAVE_INTERVAL = timedelta(minutes=15) +PASSIVE_UPDATE_PROCESSOR = "passive_update_processor" +_T = TypeVar("_T") + @dataclasses.dataclass(slots=True, frozen=True) class PassiveBluetoothEntityKey: @@ -36,8 +61,67 @@ class PassiveBluetoothEntityKey: key: str device_id: str | None + def to_string(self) -> str: + """Convert the key to a string which can be used as JSON key.""" + return f"{self.key}___{self.device_id or ''}" -_T = TypeVar("_T") + @classmethod + def from_string(cls, key: str) -> PassiveBluetoothEntityKey: + """Convert a string (from JSON) to a key.""" + key, device_id = key.split("___") + return cls(key, device_id or None) + + +@dataclasses.dataclass(slots=True, frozen=False) +class PassiveBluetoothProcessorData: + """Data for the passive bluetooth processor.""" + + coordinators: set[PassiveBluetoothProcessorCoordinator] + all_restore_data: dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]] + + +class RestoredPassiveBluetoothDataUpdate(TypedDict): + """Restored PassiveBluetoothDataUpdate.""" + + devices: dict[str, DeviceInfo] + entity_descriptions: dict[str, dict[str, Any]] + entity_names: dict[str, str | None] + entity_data: dict[str, Any] + + +# Fields do not change so we can cache the result +# of calling fields() on the dataclass +cached_fields = cache(dataclasses.fields) + + +def deserialize_entity_description( + descriptions_class: type[EntityDescription], data: dict[str, Any] +) -> EntityDescription: + """Deserialize an entity description.""" + result: dict[str, Any] = {} + for field in cached_fields(descriptions_class): # type: ignore[arg-type] + field_name = field.name + # It would be nice if field.type returned the actual + # type instead of a str so we could avoid writing this + # out, but it doesn't. If we end up using this in more + # places we can add a `as_dict` and a `from_dict` + # method to these classes + if field_name == CONF_ENTITY_CATEGORY: + value = try_parse_enum(EntityCategory, data.get(field_name)) + else: + value = data.get(field_name) + result[field_name] = value + return descriptions_class(**result) + + +def serialize_entity_description(description: EntityDescription) -> dict[str, Any]: + """Serialize an entity description.""" + as_dict = dataclasses.asdict(description) + return { + field.name: as_dict[field.name] + for field in cached_fields(type(description)) # type: ignore[arg-type] + if field.default != as_dict.get(field.name) + } @dataclasses.dataclass(slots=True, frozen=True) @@ -62,6 +146,114 @@ class PassiveBluetoothDataUpdate(Generic[_T]): self.entity_data.update(new_data.entity_data) self.entity_names.update(new_data.entity_names) + def async_get_restore_data(self) -> RestoredPassiveBluetoothDataUpdate: + """Serialize restore data to storage.""" + return { + "devices": { + key or "": device_info for key, device_info in self.devices.items() + }, + "entity_descriptions": { + key.to_string(): serialize_entity_description(description) + for key, description in self.entity_descriptions.items() + }, + "entity_names": { + key.to_string(): name for key, name in self.entity_names.items() + }, + "entity_data": { + key.to_string(): data for key, data in self.entity_data.items() + }, + } + + @callback + def async_set_restore_data( + self, + restore_data: RestoredPassiveBluetoothDataUpdate, + entity_description_class: type[EntityDescription], + ) -> None: + """Set the restored data from storage.""" + self.devices.update( + { + key or None: device_info + for key, device_info in restore_data["devices"].items() + } + ) + self.entity_descriptions.update( + { + PassiveBluetoothEntityKey.from_string( + key + ): deserialize_entity_description(entity_description_class, description) + for key, description in restore_data["entity_descriptions"].items() + if description + } + ) + self.entity_names.update( + { + PassiveBluetoothEntityKey.from_string(key): name + for key, name in restore_data["entity_names"].items() + } + ) + self.entity_data.update( + { + PassiveBluetoothEntityKey.from_string(key): cast(_T, data) + for key, data in restore_data["entity_data"].items() + } + ) + + +def async_register_coordinator_for_restore( + hass: HomeAssistant, coordinator: PassiveBluetoothProcessorCoordinator +) -> CALLBACK_TYPE: + """Register a coordinator to have its processors data restored.""" + data: PassiveBluetoothProcessorData = hass.data[PASSIVE_UPDATE_PROCESSOR] + coordinators = data.coordinators + coordinators.add(coordinator) + if restore_key := coordinator.restore_key: + coordinator.restore_data = data.all_restore_data.setdefault(restore_key, {}) + + @callback + def _unregister_coordinator_for_restore() -> None: + """Unregister a coordinator.""" + coordinators.remove(coordinator) + + return _unregister_coordinator_for_restore + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the passive update processor coordinators.""" + storage: Store[dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]]] = Store( + hass, STORAGE_VERSION, STORAGE_KEY + ) + coordinators: set[PassiveBluetoothProcessorCoordinator] = set() + all_restore_data: dict[str, dict[str, RestoredPassiveBluetoothDataUpdate]] = ( + await storage.async_load() or {} + ) + hass.data[PASSIVE_UPDATE_PROCESSOR] = PassiveBluetoothProcessorData( + coordinators, all_restore_data + ) + + async def _async_save_processor_data(_: Any) -> None: + """Save the processor data.""" + await storage.async_save( + { + coordinator.restore_key: coordinator.async_get_restore_data() + for coordinator in coordinators + if coordinator.restore_key + } + ) + + cancel_interval = async_track_time_interval( + hass, _async_save_processor_data, STORAGE_SAVE_INTERVAL + ) + + async def _async_save_processor_data_at_stop(_event: Event) -> None: + """Save the processor data at shutdown.""" + cancel_interval() + await _async_save_processor_data(None) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _async_save_processor_data_at_stop + ) + class PassiveBluetoothProcessorCoordinator( Generic[_T], BasePassiveBluetoothCoordinator @@ -90,23 +282,49 @@ class PassiveBluetoothProcessorCoordinator( self._processors: list[PassiveBluetoothDataProcessor] = [] self._update_method = update_method self.last_update_success = True + self.restore_data: dict[str, RestoredPassiveBluetoothDataUpdate] = {} + self.restore_key = None + if config_entry := config_entries.current_entry.get(): + self.restore_key = config_entry.entry_id + self._on_stop.append(async_register_coordinator_for_restore(self.hass, self)) @property def available(self) -> bool: """Return if the device is available.""" return super().available and self.last_update_success + @callback + def async_get_restore_data( + self, + ) -> dict[str, RestoredPassiveBluetoothDataUpdate]: + """Generate the restore data.""" + return { + processor.restore_key: processor.data.async_get_restore_data() + for processor in self._processors + if processor.restore_key + } + @callback def async_register_processor( self, processor: PassiveBluetoothDataProcessor, + entity_description_class: type[EntityDescription] | None = None, ) -> Callable[[], None]: """Register a processor that subscribes to updates.""" - processor.async_register_coordinator(self) + + # entity_description_class will become mandatory + # in the future, but is optional for now to allow + # for a transition period. + processor.async_register_coordinator(self, entity_description_class) @callback def remove_processor() -> None: """Remove a processor.""" + # Save the data before removing the processor + # so if they reload its still there + if restore_key := processor.restore_key: + self.restore_data[restore_key] = processor.data.async_get_restore_data() + self._processors.remove(processor) self._processors.append(processor) @@ -182,12 +400,18 @@ class PassiveBluetoothDataProcessor(Generic[_T]): entity_data: dict[PassiveBluetoothEntityKey, _T] entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] devices: dict[str | None, DeviceInfo] + restore_key: str | None def __init__( self, update_method: Callable[[_T], PassiveBluetoothDataUpdate[_T]], + restore_key: str | None = None, ) -> None: """Initialize the coordinator.""" + try: + self.restore_key = restore_key or async_get_current_platform().domain + except RuntimeError: + self.restore_key = None self._listeners: list[ Callable[[PassiveBluetoothDataUpdate[_T] | None], None] ] = [] @@ -202,15 +426,29 @@ class PassiveBluetoothDataProcessor(Generic[_T]): def async_register_coordinator( self, coordinator: PassiveBluetoothProcessorCoordinator, + entity_description_class: type[EntityDescription] | None, ) -> None: """Register a coordinator.""" self.coordinator = coordinator self.data = PassiveBluetoothDataUpdate() data = self.data + # These attributes to access the data in + # self.data are for backwards compatibility. self.entity_names = data.entity_names self.entity_data = data.entity_data self.entity_descriptions = data.entity_descriptions self.devices = data.devices + if ( + entity_description_class + and (restore_key := self.restore_key) + and (restore_data := coordinator.restore_data) + and (restored_processor_data := restore_data.get(restore_key)) + ): + data.async_set_restore_data( + restored_processor_data, + entity_description_class, + ) + self.async_update_listeners(data) @property def available(self) -> bool: diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index a383e33eab2..dab3a39c10f 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -1,7 +1,7 @@ { "domain": "dwd_weather_warnings", "name": "Deutscher Wetterdienst (DWD) Weather Warnings", - "codeowners": ["@runningman84", "@stephan192", "@Hummel95", "@andarotajo"], + "codeowners": ["@runningman84", "@stephan192", "@andarotajo"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", "iot_class": "cloud_polling", diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 85a7dc4c2f8..f3ad1705080 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -1,6 +1,8 @@ """The enphase_envoy component.""" from __future__ import annotations +import contextlib +import datetime from datetime import timedelta import logging from typing import Any @@ -13,13 +15,19 @@ from pyenphase import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util from .const import CONF_TOKEN, INVALID_AUTH_ERRORS SCAN_INTERVAL = timedelta(seconds=60) + +TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) +STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() + _LOGGER = logging.getLogger(__name__) @@ -36,6 +44,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.username = entry_data[CONF_USERNAME] self.password = entry_data[CONF_PASSWORD] self._setup_complete = False + self._cancel_token_refresh: CALLBACK_TYPE | None = None super().__init__( hass, _LOGGER, @@ -44,39 +53,92 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): always_update=False, ) + @callback + def _async_refresh_token_if_needed(self, now: datetime.datetime) -> None: + """Proactively refresh token if its stale in case cloud services goes down.""" + assert isinstance(self.envoy.auth, EnvoyTokenAuth) + expire_time = self.envoy.auth.expire_timestamp + remain = expire_time - now.timestamp() + fresh = remain > STALE_TOKEN_THRESHOLD + name = self.name + _LOGGER.debug("%s: %s seconds remaining on token fresh=%s", name, remain, fresh) + if not fresh: + self.hass.async_create_background_task( + self._async_try_refresh_token(), "{name} token refresh" + ) + + async def _async_try_refresh_token(self) -> None: + """Try to refresh token.""" + assert isinstance(self.envoy.auth, EnvoyTokenAuth) + _LOGGER.debug("%s: Trying to refresh token", self.name) + try: + await self.envoy.auth.refresh() + except EnvoyError as err: + # If we can't refresh the token, we try again later + # If the token actually ends up expiring, we'll + # re-authenticate with username/password and get a new token + # or log an error if that fails + _LOGGER.debug("%s: Error refreshing token: %s", err, self.name) + return + self._async_update_saved_token() + + @callback + def _async_mark_setup_complete(self) -> None: + """Mark setup as complete and setup token refresh if needed.""" + self._setup_complete = True + if self._cancel_token_refresh: + self._cancel_token_refresh() + self._cancel_token_refresh = None + if not isinstance(self.envoy.auth, EnvoyTokenAuth): + return + self._cancel_token_refresh = async_track_time_interval( + self.hass, + self._async_refresh_token_if_needed, + TOKEN_REFRESH_CHECK_INTERVAL, + cancel_on_shutdown=True, + ) + async def _async_setup_and_authenticate(self) -> None: """Set up and authenticate with the envoy.""" envoy = self.envoy await envoy.setup() assert envoy.serial_number is not None self.envoy_serial_number = envoy.serial_number - if token := self.entry.data.get(CONF_TOKEN): - try: - await envoy.authenticate(token=token) - except INVALID_AUTH_ERRORS: - # token likely expired or firmware changed - # so we fall through to authenticate with username/password - pass - else: - self._setup_complete = True + with contextlib.suppress(*INVALID_AUTH_ERRORS): + # Always set the username and password + # so we can refresh the token if needed + await envoy.authenticate( + username=self.username, password=self.password, token=token + ) + # The token is valid, but we still want + # to refresh it if it's stale right away + self._async_refresh_token_if_needed(dt_util.utcnow()) return + # token likely expired or firmware changed + # so we fall through to authenticate with + # username/password + await self.envoy.authenticate(username=self.username, password=self.password) + # Password auth succeeded, so we can update the token + # if we are using EnvoyTokenAuth + self._async_update_saved_token() - await envoy.authenticate(username=self.username, password=self.password) - assert envoy.auth is not None - - if isinstance(envoy.auth, EnvoyTokenAuth): - # update token in config entry so we can - # startup without hitting the Cloud API - # as long as the token is valid - self.hass.config_entries.async_update_entry( - self.entry, - data={ - **self.entry.data, - CONF_TOKEN: envoy.auth.token, - }, - ) - self._setup_complete = True + def _async_update_saved_token(self) -> None: + """Update saved token in config entry.""" + envoy = self.envoy + if not isinstance(envoy.auth, EnvoyTokenAuth): + return + # update token in config entry so we can + # startup without hitting the Cloud API + # as long as the token is valid + _LOGGER.debug("%s: Updating token in config entry from auth", self.name) + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_TOKEN: envoy.auth.token, + }, + ) async def _async_update_data(self) -> dict[str, Any]: """Fetch all device and sensor data from api.""" @@ -85,6 +147,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: if not self._setup_complete: await self._async_setup_and_authenticate() + self._async_mark_setup_complete() return (await envoy.update()).raw except INVALID_AUTH_ERRORS as err: if self._setup_complete and tries == 0: diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 40e58348768..831553bd312 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==0.9.0"], + "requirements": ["pyenphase==0.14.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 7d89ba94499..71efba899d2 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -233,7 +233,7 @@ class EnvoyEntity(CoordinatorEntity[EnphaseUpdateCoordinator], SensorEntity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, envoy_serial_num)}, manufacturer="Enphase", - model="Envoy", + model=coordinator.envoy.part_number or "Envoy", name=envoy_name, sw_version=str(coordinator.envoy.firmware), ) diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 4f44cea34f4..9eab0fbb098 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -262,14 +262,13 @@ def async_fire_triggers(conn: HKDevice, events: dict[tuple[int, int], dict[str, if not trigger_sources: return for (aid, iid), ev in events.items(): - # If the value is None, we received the event via polling - # and we don't want to trigger on that - if ev["value"] is None: - continue if aid in conn.devices: device_id = conn.devices[aid] if source := trigger_sources.get(device_id): - source.fire(iid, ev) + # If the value is None, we received the event via polling + # and we don't want to trigger on that + if ev.get("value") is not None: + source.fire(iid, ev) async def async_get_triggers( diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 046dc9f17ec..6ebe777d5f8 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -5,6 +5,7 @@ from typing import Any from aiohomekit.model import Accessory from aiohomekit.model.characteristics import ( + EVENT_CHARACTERISTICS, Characteristic, CharacteristicPermissions, CharacteristicsTypes, @@ -111,7 +112,10 @@ class HomeKitEntity(Entity): def _setup_characteristic(self, char: Characteristic) -> None: """Configure an entity based on a HomeKit characteristics metadata.""" # Build up a list of (aid, iid) tuples to poll on update() - if CharacteristicPermissions.paired_read in char.perms: + if ( + CharacteristicPermissions.paired_read in char.perms + and char.type not in EVENT_CHARACTERISTICS + ): self.pollable_characteristics.append((self._aid, char.iid)) # Build up a list of (aid, iid) tuples to subscribe to diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 01b85ef6bbb..d26b15bdc7a 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==2.6.13"], + "requirements": ["aiohomekit==2.6.14"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 68602e34d3e..48ad0cb8752 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -53,6 +53,7 @@ from .const import ( # noqa: F401 ) from .cors import setup_cors from .forwarded import async_setup_forwarded +from .headers import setup_headers from .request_context import current_request, setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource @@ -69,6 +70,7 @@ CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate" CONF_SSL_KEY: Final = "ssl_key" CONF_CORS_ORIGINS: Final = "cors_allowed_origins" CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for" +CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options" CONF_TRUSTED_PROXIES: Final = "trusted_proxies" CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold" CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled" @@ -118,6 +120,7 @@ HTTP_SCHEMA: Final = vol.All( vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In( [SSL_INTERMEDIATE, SSL_MODERN] ), + vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, } ), ) @@ -136,6 +139,7 @@ class ConfData(TypedDict, total=False): ssl_key: str cors_allowed_origins: list[str] use_x_forwarded_for: bool + use_x_frame_options: bool trusted_proxies: list[IPv4Network | IPv6Network] login_attempts_threshold: int ip_ban_enabled: bool @@ -180,6 +184,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ssl_key = conf.get(CONF_SSL_KEY) cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) + use_x_frame_options = conf[CONF_USE_X_FRAME_OPTIONS] trusted_proxies = conf.get(CONF_TRUSTED_PROXIES) or [] is_ban_enabled = conf[CONF_IP_BAN_ENABLED] login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] @@ -200,6 +205,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: use_x_forwarded_for=use_x_forwarded_for, login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, + use_x_frame_options=use_x_frame_options, ) async def stop_server(event: Event) -> None: @@ -331,6 +337,7 @@ class HomeAssistantHTTP: use_x_forwarded_for: bool, login_threshold: int, is_ban_enabled: bool, + use_x_frame_options: bool, ) -> None: """Initialize the server.""" self.app[KEY_HASS] = self.hass @@ -348,6 +355,7 @@ class HomeAssistantHTTP: await async_setup_auth(self.hass, self.app) + setup_headers(self.app, use_x_frame_options) setup_cors(self.app, cors_origins) if self.ssl_certificate: diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py new file mode 100644 index 00000000000..b53f354b144 --- /dev/null +++ b/homeassistant/components/http/headers.py @@ -0,0 +1,32 @@ +"""Middleware that helps with the control of headers in our responses.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from aiohttp.web import Application, Request, StreamResponse, middleware + +from homeassistant.core import callback + + +@callback +def setup_headers(app: Application, use_x_frame_options: bool) -> None: + """Create headers middleware for the app.""" + + @middleware + async def headers_middleware( + request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] + ) -> StreamResponse: + """Process request and add headers to the responses.""" + response = await handler(request) + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Content-Type-Options"] = "nosniff" + + # Set an empty server header, to prevent aiohttp of setting one. + response.headers["Server"] = "" + + if use_x_frame_options: + response.headers["X-Frame-Options"] = "SAMEORIGIN" + + return response + + app.middlewares.append(headers_middleware) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index d9e81b74ce9..0108c37a10b 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -82,6 +82,7 @@ from .const import ( # noqa: F401 CONF_MIN_TEMP, CONF_MIN_VALUE, CONF_MSG_WAIT, + CONF_NAN_VALUE, CONF_PARITY, CONF_PRECISION, CONF_RETRIES, @@ -123,6 +124,7 @@ from .modbus import ModbusHub, async_modbus_setup from .validators import ( duplicate_entity_validator, duplicate_modbus_validator, + nan_validator, number_validator, scan_interval_validator, struct_validator, @@ -298,6 +300,7 @@ SENSOR_SCHEMA = vol.All( vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int, vol.Optional(CONF_MIN_VALUE): number_validator, vol.Optional(CONF_MAX_VALUE): number_validator, + vol.Optional(CONF_NAN_VALUE): nan_validator, vol.Optional(CONF_ZERO_SUPPRESS): number_validator, } ), diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index c936773bea7..e4c657a6c54 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -22,6 +22,7 @@ from homeassistant.const import ( CONF_STRUCTURE, CONF_UNIQUE_ID, STATE_ON, + STATE_UNAVAILABLE, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -46,6 +47,7 @@ from .const import ( CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, + CONF_NAN_VALUE, CONF_PRECISION, CONF_SCALE, CONF_STATE_OFF, @@ -101,6 +103,7 @@ class BasePlatform(Entity): self._min_value = get_optional_numeric_config(CONF_MIN_VALUE) self._max_value = get_optional_numeric_config(CONF_MAX_VALUE) + self._nan_value = entry.get(CONF_NAN_VALUE, None) self._zero_suppress = get_optional_numeric_config(CONF_ZERO_SUPPRESS) @abstractmethod @@ -173,8 +176,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): registers.reverse() return registers - def __process_raw_value(self, entry: float | int) -> float | int: - """Process value from sensor with scaling, offset, min/max etc.""" + def __process_raw_value(self, entry: float | int | str) -> float | int | str: + """Process value from sensor with NaN handling, scaling, offset, min/max etc.""" + if self._nan_value and entry in (self._nan_value, -self._nan_value): + return STATE_UNAVAILABLE val: float | int = self._scale * entry + self._offset if self._min_value is not None and val < self._min_value: return self._min_value @@ -213,6 +218,9 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): # the conversion only when it's absolutely necessary. if isinstance(v_temp, int) and self._precision == 0: v_result.append(str(v_temp)) + elif v_temp != v_temp: # noqa: PLR0124 + # NaN float detection replace with None + v_result.append("nan") # pragma: no cover else: v_result.append(f"{float(v_temp):.{self._precision}f}") return ",".join(map(str, v_result)) @@ -223,8 +231,16 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): # We could convert int to float, and the code would still work; however # we lose some precision, and unit tests will fail. Therefore, we do # the conversion only when it's absolutely necessary. + + # NaN float detection replace with None + if val_result != val_result: # noqa: PLR0124 + return None # pragma: no cover if isinstance(val_result, int) and self._precision == 0: return str(val_result) + if isinstance(val_result, str): + if val_result == "nan": + val_result = STATE_UNAVAILABLE # pragma: no cover + return val_result return f"{float(val_result):.{self._precision}f}" diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 264268f323e..3b565e91f92 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -30,6 +30,7 @@ CONF_MAX_VALUE = "max_value" CONF_MIN_TEMP = "min_temp" CONF_MIN_VALUE = "min_value" CONF_MSG_WAIT = "message_wait_milliseconds" +CONF_NAN_VALUE = "nan_value" CONF_PARITY = "parity" CONF_REGISTER = "register" CONF_REGISTER_TYPE = "register_type" diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index a583b93ea80..ee9d40dd874 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -139,6 +139,20 @@ def number_validator(value: Any) -> int | float: raise vol.Invalid(f"invalid number {value}") from err +def nan_validator(value: Any) -> int: + """Convert nan string to number (can be hex string or int).""" + if isinstance(value, int): + return value + try: + return int(value) + except (TypeError, ValueError): + pass + try: + return int(value, 16) + except (TypeError, ValueError) as err: + raise vol.Invalid(f"invalid number {value}") from err + + def scan_interval_validator(config: dict) -> dict: """Control scan_interval.""" for hub in config: diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index eda6a5609a2..05fff332c67 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.31.1"] + "requirements": ["python-roborock==0.32.2"] } diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 9a84a3446c7..25316004c58 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.8.0"] + "requirements": ["pyschlage==2023.8.1"] } diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py index aa2ff87e5bf..2cf1694e111 100644 --- a/homeassistant/components/schlage/sensor.py +++ b/homeassistant/components/schlage/sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory @@ -22,6 +23,7 @@ _SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, ), ] diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 5822566505b..47f1e62be00 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.3"] + "requirements": ["pytrafikverket==0.3.5"] } diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index fba6eb93dd9..3125fea8e39 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -6,6 +6,12 @@ from datetime import date, datetime, time, timedelta import logging from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleTrainAnnouncementFound, + NoTrainAnnouncementFound, + UnknownError, +) from pytrafikverket.trafikverket_train import StationInfo, TrainStop from homeassistant.config_entries import ConfigEntry @@ -119,9 +125,13 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): state = await self._train_api.async_get_next_train_stop( self.from_station, self.to_station, when ) - except ValueError as error: - if "Invalid authentication" in error.args[0]: - raise ConfigEntryAuthFailed from error + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except ( + NoTrainAnnouncementFound, + MultipleTrainAnnouncementFound, + UnknownError, + ) as error: raise UpdateFailed( f"Train departure {when} encountered a problem: {error}" ) from error diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 7b8369cec17..47b4c21c867 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.3"] + "requirements": ["pytrafikverket==0.3.5"] } diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 47f31e35c63..97d7a6b34fa 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -3,9 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, time, timedelta - -from pytrafikverket.trafikverket_train import StationInfo +from datetime import datetime from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,37 +11,23 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_WEEKDAY +from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt as dt_util -from .const import CONF_TIME, DOMAIN +from .const import ATTRIBUTION, DOMAIN from .coordinator import TrainData, TVDataUpdateCoordinator -ATTR_DEPARTURE_STATE = "departure_state" -ATTR_CANCELED = "canceled" -ATTR_DELAY_TIME = "number_of_minutes_delayed" -ATTR_PLANNED_TIME = "planned_time" -ATTR_ESTIMATED_TIME = "estimated_time" -ATTR_ACTUAL_TIME = "actual_time" -ATTR_OTHER_INFORMATION = "other_information" -ATTR_DEVIATIONS = "deviations" - -ICON = "mdi:train" -SCAN_INTERVAL = timedelta(minutes=5) - @dataclass class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[TrainData], StateType | datetime] - extra_fn: Callable[[TrainData], dict[str, StateType | datetime]] @dataclass @@ -60,16 +44,64 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: data.departure_time, - extra_fn=lambda data: { - ATTR_DEPARTURE_STATE: data.departure_state, - ATTR_CANCELED: data.cancelled, - ATTR_DELAY_TIME: data.delayed_time, - ATTR_PLANNED_TIME: data.planned_time, - ATTR_ESTIMATED_TIME: data.estimated_time, - ATTR_ACTUAL_TIME: data.actual_time, - ATTR_OTHER_INFORMATION: data.other_info, - ATTR_DEVIATIONS: data.deviation, - }, + ), + TrafikverketSensorEntityDescription( + key="departure_state", + translation_key="departure_state", + icon="mdi:clock", + value_fn=lambda data: data.departure_state, + device_class=SensorDeviceClass.ENUM, + options=["on_time", "delayed", "canceled"], + ), + TrafikverketSensorEntityDescription( + key="cancelled", + translation_key="cancelled", + icon="mdi:alert", + value_fn=lambda data: data.cancelled, + ), + TrafikverketSensorEntityDescription( + key="delayed_time", + translation_key="delayed_time", + icon="mdi:clock", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + value_fn=lambda data: data.delayed_time, + ), + TrafikverketSensorEntityDescription( + key="planned_time", + translation_key="planned_time", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.planned_time, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="estimated_time", + translation_key="estimated_time", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.estimated_time, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="actual_time", + translation_key="actual_time", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.actual_time, + entity_registry_enabled_default=False, + ), + TrafikverketSensorEntityDescription( + key="other_info", + translation_key="other_info", + icon="mdi:information-variant", + value_fn=lambda data: data.other_info, + ), + TrafikverketSensorEntityDescription( + key="deviation", + translation_key="deviation", + icon="mdi:alert", + value_fn=lambda data: data.deviation, ), ) @@ -81,71 +113,48 @@ async def async_setup_entry( coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - to_station = coordinator.to_station - from_station = coordinator.from_station - get_time: str | None = entry.data.get(CONF_TIME) - train_time = dt_util.parse_time(get_time) if get_time else None - async_add_entities( [ - TrainSensor( - coordinator, - entry.data[CONF_NAME], - from_station, - to_station, - entry.data[CONF_WEEKDAY], - train_time, - entry.entry_id, - description, - ) + TrainSensor(coordinator, entry.data[CONF_NAME], entry.entry_id, description) for description in SENSOR_TYPES - ], - True, + ] ) class TrainSensor(CoordinatorEntity[TVDataUpdateCoordinator], SensorEntity): """Contains data about a train depature.""" - _attr_has_entity_name = True entity_description: TrafikverketSensorEntityDescription + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, coordinator: TVDataUpdateCoordinator, name: str, - from_station: StationInfo, - to_station: StationInfo, - weekday: list, - departuretime: time | None, entry_id: str, entity_description: TrafikverketSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) + self._attr_unique_id = f"{entry_id}-{entity_description.key}" self.entity_description = entity_description self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, - manufacturer="Trafikverket", - model="v2.0", name=name, configuration_url="https://api.trafikinfo.trafikverket.se/", ) - self._attr_unique_id = f"{entry_id}-{entity_description.key}" self._update_attr() + @callback + def _update_attr(self) -> None: + """Update _attr.""" + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) + @callback def _handle_coordinator_update(self) -> None: self._update_attr() return super()._handle_coordinator_update() - - @callback - def _update_attr(self) -> None: - """Retrieve latest states.""" - self._attr_native_value = self.entity_description.value_fn( - self.coordinator.data - ) - self._attr_extra_state_attributes = self.entity_description.extra_fn( - self.coordinator.data - ) diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 05032027b97..59431107ae2 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -45,38 +45,36 @@ "entity": { "sensor": { "departure_time": { - "name": "Departure time", - "state_attributes": { - "departure_state": { - "name": "Departure state", - "state": { - "on_time": "On time", - "delayed": "Delayed", - "canceled": "Cancelled" - } - }, - "canceled": { - "name": "Cancelled" - }, - "number_of_minutes_delayed": { - "name": "Minutes delayed" - }, - "planned_time": { - "name": "Planned time" - }, - "estimated_time": { - "name": "Estimated time" - }, - "actual_time": { - "name": "Actual time" - }, - "other_information": { - "name": "Other information" - }, - "deviations": { - "name": "Deviations" - } + "name": "Departure time" + }, + "departure_state": { + "name": "Departure state", + "state": { + "on_time": "On time", + "delayed": "Delayed", + "canceled": "Cancelled" } + }, + "cancelled": { + "name": "Cancelled" + }, + "delayed_time": { + "name": "Delayed time" + }, + "planned_time": { + "name": "Planned time" + }, + "estimated_time": { + "name": "Estimated time" + }, + "actual_time": { + "name": "Actual time" + }, + "other_info": { + "name": "Other information" + }, + "deviation": { + "name": "Deviation" } } } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 014637b99f6..8c46afa5972 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.3"] + "requirements": ["pytrafikverket==0.3.5"] } diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py index ef0cef938b1..25591cc1cb0 100644 --- a/homeassistant/components/velbus/binary_sensor.py +++ b/homeassistant/components/velbus/binary_sensor.py @@ -18,10 +18,9 @@ async def async_setup_entry( """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - entities = [] - for channel in cntrl.get_all("binary_sensor"): - entities.append(VelbusBinarySensor(channel)) - async_add_entities(entities) + async_add_entities( + VelbusBinarySensor(channel) for channel in cntrl.get_all("binary_sensor") + ) class VelbusBinarySensor(VelbusEntity, BinarySensorEntity): diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py index d75486bab7a..2a0392c48cb 100644 --- a/homeassistant/components/velbus/button.py +++ b/homeassistant/components/velbus/button.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -24,10 +24,7 @@ async def async_setup_entry( """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - entities = [] - for channel in cntrl.get_all("button"): - entities.append(VelbusButton(channel)) - async_add_entities(entities) + async_add_entities(VelbusButton(channel) for channel in cntrl.get_all("button")) class VelbusButton(VelbusEntity, ButtonEntity): @@ -37,6 +34,7 @@ class VelbusButton(VelbusEntity, ButtonEntity): _attr_entity_registry_enabled_default = False _attr_entity_category = EntityCategory.CONFIG + @api_call async def async_press(self) -> None: """Handle the button press.""" await self._channel.press() diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index ccdfb3b073b..ecdddd19289 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, PRESET_MODES -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -27,10 +27,7 @@ async def async_setup_entry( """Set up Velbus switch based on config_entry.""" await hass.data[DOMAIN][entry.entry_id]["tsk"] cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] - entities = [] - for channel in cntrl.get_all("climate"): - entities.append(VelbusClimate(channel)) - async_add_entities(entities) + async_add_entities(VelbusClimate(channel) for channel in cntrl.get_all("climate")) class VelbusClimate(VelbusEntity, ClimateEntity): @@ -67,6 +64,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity): """Return the current temperature.""" return self._channel.get_state() + @api_call async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is None: @@ -74,6 +72,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity): await self._channel.set_temp(temp) self.async_write_ha_state() + @api_call async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the new preset mode.""" await self._channel.set_preset(PRESET_MODES[preset_mode]) diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py index 009c4fadfb9..46881fcdcaf 100644 --- a/homeassistant/components/velbus/cover.py +++ b/homeassistant/components/velbus/cover.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -81,18 +81,22 @@ class VelbusCover(VelbusEntity, CoverEntity): return 100 - pos return None + @api_call async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._channel.open() + @api_call async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._channel.close() + @api_call async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._channel.stop() + @api_call async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self._channel.set_position(100 - kwargs[ATTR_POSITION]) diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 13ecb7febab..46d9f03b4fb 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -1,8 +1,13 @@ """Support for Velbus devices.""" from __future__ import annotations +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate, ParamSpec, TypeVar + from velbusaio.channels import Channel as VelbusChannel +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN @@ -35,3 +40,25 @@ class VelbusEntity(Entity): async def _on_update(self) -> None: self.async_write_ha_state() + + +_T = TypeVar("_T", bound="VelbusEntity") +_P = ParamSpec("_P") + + +def api_call( + func: Callable[Concatenate[_T, _P], Awaitable[None]] +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch command exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except OSError as exc: + raise HomeAssistantError( + f"Could not execute {func.__name__} service for {self.name}" + ) from exc + + return cmd_wrapper diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py index ca00a3134ce..1806c2905e9 100644 --- a/homeassistant/components/velbus/light.py +++ b/homeassistant/components/velbus/light.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -63,6 +63,7 @@ class VelbusLight(VelbusEntity, LightEntity): """Return the brightness of the light.""" return int((self._channel.get_dimmer_state() * 255) / 100) + @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the Velbus light to turn on.""" if ATTR_BRIGHTNESS in kwargs: @@ -83,6 +84,7 @@ class VelbusLight(VelbusEntity, LightEntity): ) await getattr(self._channel, attr)(*args) + @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the velbus light to turn off.""" attr, *args = ( @@ -113,6 +115,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity): """Return true if the light is on.""" return self._channel.is_on() + @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the Velbus light to turn on.""" if ATTR_FLASH in kwargs: @@ -126,6 +129,7 @@ class VelbusButtonLight(VelbusEntity, LightEntity): attr, *args = "set_led_state", "on" await getattr(self._channel, attr)(*args) + @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the velbus light to turn off.""" attr, *args = "set_led_state", "off" diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py index af79b5d1276..6e2b4d1a746 100644 --- a/homeassistant/components/velbus/select.py +++ b/homeassistant/components/velbus/select.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -37,6 +37,7 @@ class VelbusSelect(VelbusEntity, SelectEntity): self._attr_options = self._channel.get_options() self._attr_unique_id = f"{self._attr_unique_id}-program_select" + @api_call async def async_select_option(self, option: str) -> None: """Update the program on the module.""" await self._channel.set_selected_program(option) diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py index 6de8373d3fc..db7c165840e 100644 --- a/homeassistant/components/velbus/switch.py +++ b/homeassistant/components/velbus/switch.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .entity import VelbusEntity +from .entity import VelbusEntity, api_call async def async_setup_entry( @@ -36,10 +36,12 @@ class VelbusSwitch(VelbusEntity, SwitchEntity): """Return true if the switch is on.""" return self._channel.is_on() + @api_call async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the switch to turn on.""" await self._channel.turn_on() + @api_call async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the switch to turn off.""" await self._channel.turn_off() diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index f0c32f2d8cc..bfad18cb84a 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1048,6 +1048,12 @@ class WeatherEntity(Entity): self, forecast_types: Iterable[Literal["daily", "hourly", "twice_daily"]] | None ) -> None: """Push updated forecast to all listeners.""" + if not hasattr(self, "_forecast_listeners"): + # Required for entities initiated with `update_before_add` + # as `self._forecast_listeners` has not yet been set. + # `async_internal_added_to_hass()` will execute once entity has been added. + return + if forecast_types is None: forecast_types = {"daily", "hourly", "twice_daily"} for forecast_type in forecast_types: diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 9711c30b19e..5f82ca54283 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -145,16 +145,26 @@ async def async_handle_webhook( return Response(status=HTTPStatus.METHOD_NOT_ALLOWED) if webhook["local_only"] in (True, None) and not isinstance(request, MockRequest): - if TYPE_CHECKING: - assert isinstance(request, Request) - assert request.remote is not None - try: - remote = ip_address(request.remote) - except ValueError: - _LOGGER.debug("Unable to parse remote ip %s", request.remote) - return Response(status=HTTPStatus.OK) + if has_cloud := "cloud" in hass.config.components: + from hass_nabucasa import remote # pylint: disable=import-outside-toplevel - if not network.is_local(remote): + is_local = True + if has_cloud and remote.is_cloud_request.get(): + is_local = False + else: + if TYPE_CHECKING: + assert isinstance(request, Request) + assert request.remote is not None + + try: + request_remote = ip_address(request.remote) + except ValueError: + _LOGGER.debug("Unable to parse remote ip %s", request.remote) + return Response(status=HTTPStatus.OK) + + is_local = network.is_local(request_remote) + + if not is_local: _LOGGER.warning("Received remote request for local webhook %s", webhook_id) if webhook["local_only"]: return Response(status=HTTPStatus.OK) diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 7f5a67f4220..766ac0700e5 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.12", "async-upnp-client==0.34.1"], + "requirements": ["yeelight==0.7.13", "async-upnp-client==0.34.1"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 15fcb9a50de..1e3af81395a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -964,10 +964,9 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): Handler key is the domain of the component that we want to set up. """ - await _load_integration(self.hass, handler_key, self._hass_config) - if (handler := HANDLERS.get(handler_key)) is None: - raise data_entry_flow.UnknownHandler - + handler = await _async_get_flow_handler( + self.hass, handler_key, self._hass_config + ) if not context or "source" not in context: raise KeyError("Context not set or doesn't have a source set") @@ -1830,12 +1829,8 @@ class OptionsFlowManager(data_entry_flow.FlowManager): if entry is None: raise UnknownEntry(handler_key) - await _load_integration(self.hass, entry.domain, {}) - - if entry.domain not in HANDLERS: - raise data_entry_flow.UnknownHandler - - return HANDLERS[entry.domain].async_get_options_flow(entry) + handler = await _async_get_flow_handler(self.hass, entry.domain, {}) + return handler.async_get_options_flow(entry) async def async_finish_flow( self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult @@ -2021,9 +2016,15 @@ async def support_remove_from_device(hass: HomeAssistant, domain: str) -> bool: return hasattr(component, "async_remove_config_entry_device") -async def _load_integration( +async def _async_get_flow_handler( hass: HomeAssistant, domain: str, hass_config: ConfigType -) -> None: +) -> type[ConfigFlow]: + """Get a flow handler for specified domain.""" + + # First check if there is a handler registered for the domain + if domain in hass.config.components and (handler := HANDLERS.get(domain)): + return handler + try: integration = await loader.async_get_integration(hass, domain) except loader.IntegrationNotFound as err: @@ -2042,3 +2043,8 @@ async def _load_integration( err, ) raise data_entry_flow.UnknownHandler + + if handler := HANDLERS.get(domain): + return handler + + raise data_entry_flow.UnknownHandler diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e8557a3b922..61ed6ab3dcd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ janus==1.0.0 Jinja2==3.1.2 lru-dict==1.2.0 mutagen==1.46.0 -orjson==3.9.2 +orjson==3.9.3 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.0.0 diff --git a/pyproject.toml b/pyproject.toml index 9a526187999..3ee1bf33477 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==41.0.3", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", - "orjson==3.9.2", + "orjson==3.9.3", "packaging>=23.1", "pip>=21.3.1", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index debdc7dbcb3..9cca2393a0d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.2.0 PyJWT==2.8.0 cryptography==41.0.3 pyOpenSSL==23.2.0 -orjson==3.9.2 +orjson==3.9.3 packaging>=23.1 pip>=21.3.1 python-slugify==4.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 8f8ed2852ff..696a931634f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -249,7 +249,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.13 +aiohomekit==2.6.14 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -1569,7 +1569,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.13.3 +pyatv==0.13.4 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 @@ -1662,7 +1662,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==0.9.0 +pyenphase==0.14.1 # homeassistant.components.envisalink pyenvisalink==4.6 @@ -1982,7 +1982,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.8.0 +pyschlage==2023.8.1 # homeassistant.components.sensibo pysensibo==1.0.33 @@ -2153,7 +2153,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.31.1 +python-roborock==0.32.2 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -2191,7 +2191,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.3 +pytrafikverket==0.3.5 # homeassistant.components.usb pyudev==0.23.2 @@ -2728,7 +2728,7 @@ yalexs-ble==2.2.3 yalexs==1.5.1 # homeassistant.components.yeelight -yeelight==0.7.12 +yeelight==0.7.13 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2fdafad096c..8fb487aea9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -227,7 +227,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.13 +aiohomekit==2.6.14 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -1175,7 +1175,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.13.3 +pyatv==0.13.4 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 @@ -1229,7 +1229,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==0.9.0 +pyenphase==0.14.1 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1474,7 +1474,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.8.0 +pyschlage==2023.8.1 # homeassistant.components.sensibo pysensibo==1.0.33 @@ -1582,7 +1582,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.31.1 +python-roborock==0.32.2 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -1611,7 +1611,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.3 +pytrafikverket==0.3.5 # homeassistant.components.usb pyudev==0.23.2 @@ -2010,7 +2010,7 @@ yalexs-ble==2.2.3 yalexs==1.5.1 # homeassistant.components.yeelight -yeelight==0.7.12 +yeelight==0.7.13 # homeassistant.components.yolink yolink-api==0.3.0 diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index a270b20855a..5906ab0bf25 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1,15 +1,18 @@ """Tests for the Bluetooth integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging import time +from typing import Any from unittest.mock import MagicMock, patch from home_assistant_bluetooth import BluetoothServiceInfo import pytest from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntityDescription, ) @@ -22,13 +25,19 @@ from homeassistant.components.bluetooth import ( ) from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.components.bluetooth.passive_update_processor import ( + STORAGE_KEY, PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, PassiveBluetoothEntityKey, PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) -from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntityDescription, +) +from homeassistant.config_entries import current_entry from homeassistant.const import UnitOfTemperature from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo @@ -41,7 +50,12 @@ from . import ( patch_all_discovered_devices, ) -from tests.common import MockEntityPlatform, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + MockEntityPlatform, + async_fire_time_changed, + async_test_home_assistant, +) _LOGGER = logging.getLogger(__name__) @@ -1092,6 +1106,18 @@ BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( ) +DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( + devices={ + None: DeviceInfo( + name="Test Device", model="Test Model", manufacturer="Test Manufacturer" + ), + }, + entity_data={}, + entity_names={}, + entity_descriptions={}, +) + + async def test_integration_multiple_entity_platforms( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, @@ -1118,21 +1144,21 @@ async def test_integration_multiple_entity_platforms( binary_sensor_processor = PassiveBluetoothDataProcessor( lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE ) - sesnor_processor = PassiveBluetoothDataProcessor( + sensor_processor = PassiveBluetoothDataProcessor( lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE ) coordinator.async_register_processor(binary_sensor_processor) - coordinator.async_register_processor(sesnor_processor) + coordinator.async_register_processor(sensor_processor) cancel_coordinator = coordinator.async_start() binary_sensor_processor.async_add_listener(MagicMock()) - sesnor_processor.async_add_listener(MagicMock()) + sensor_processor.async_add_listener(MagicMock()) mock_add_sensor_entities = MagicMock() mock_add_binary_sensor_entities = MagicMock() - sesnor_processor.async_add_entities_listener( + sensor_processor.async_add_entities_listener( PassiveBluetoothProcessorEntity, mock_add_sensor_entities, ) @@ -1146,14 +1172,14 @@ async def test_integration_multiple_entity_platforms( assert len(mock_add_binary_sensor_entities.mock_calls) == 1 assert len(mock_add_sensor_entities.mock_calls) == 1 - binary_sesnor_entities = [ + binary_sensor_entities = [ *mock_add_binary_sensor_entities.mock_calls[0][1][0], ] - sesnor_entities = [ + sensor_entities = [ *mock_add_sensor_entities.mock_calls[0][1][0], ] - sensor_entity_one: PassiveBluetoothProcessorEntity = sesnor_entities[0] + sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0] sensor_entity_one.hass = hass assert sensor_entity_one.available is True assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" @@ -1167,7 +1193,7 @@ async def test_integration_multiple_entity_platforms( key="pressure", device_id=None ) - binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sesnor_entities[ + binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[ 0 ] binary_sensor_entity_one.hass = hass @@ -1242,3 +1268,281 @@ async def test_exception_from_coordinator_update_method( assert processor.available is True unregister_processor() cancel_coordinator() + + +async def test_integration_multiple_entity_platforms_with_reload_and_restart( + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + mock_bluetooth_adapters: None, + hass_storage: dict[str, Any], +) -> None: + """Test integration of PassiveBluetoothProcessorCoordinator with multiple platforms with reload.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + entry = MockConfigEntry(domain=DOMAIN, data={}) + + @callback + def _mock_update_method( + service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + current_entry.set(entry) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + assert coordinator.available is False # no data yet + + binary_sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: BINARY_SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE, + BINARY_SENSOR_DOMAIN, + ) + sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: SENSOR_PASSIVE_BLUETOOTH_DATA_UPDATE, SENSOR_DOMAIN + ) + + unregister_binary_sensor_processor = coordinator.async_register_processor( + binary_sensor_processor, BinarySensorEntityDescription + ) + unregister_sensor_processor = coordinator.async_register_processor( + sensor_processor, SensorEntityDescription + ) + cancel_coordinator = coordinator.async_start() + + binary_sensor_processor.async_add_listener(MagicMock()) + sensor_processor.async_add_listener(MagicMock()) + + mock_add_sensor_entities = MagicMock() + mock_add_binary_sensor_entities = MagicMock() + + sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_sensor_entities, + ) + binary_sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_binary_sensor_entities, + ) + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + # First call with just the remote sensor entities results in them being added + assert len(mock_add_binary_sensor_entities.mock_calls) == 1 + assert len(mock_add_sensor_entities.mock_calls) == 1 + + binary_sensor_entities = [ + *mock_add_binary_sensor_entities.mock_calls[0][1][0], + ] + sensor_entities = [ + *mock_add_sensor_entities.mock_calls[0][1][0], + ] + + sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0] + sensor_entity_one.hass = hass + assert sensor_entity_one.available is True + assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" + assert sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="pressure", device_id=None + ) + + binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[ + 0 + ] + binary_sensor_entity_one.hass = hass + assert binary_sensor_entity_one.available is True + assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" + assert binary_sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="motion", device_id=None + ) + cancel_coordinator() + unregister_binary_sensor_processor() + unregister_sensor_processor() + + mock_add_sensor_entities = MagicMock() + mock_add_binary_sensor_entities = MagicMock() + + current_entry.set(entry) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + binary_sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE, + BINARY_SENSOR_DOMAIN, + ) + sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE, + SENSOR_DOMAIN, + ) + + sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_sensor_entities, + ) + binary_sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_binary_sensor_entities, + ) + + unregister_binary_sensor_processor = coordinator.async_register_processor( + binary_sensor_processor, BinarySensorEntityDescription + ) + unregister_sensor_processor = coordinator.async_register_processor( + sensor_processor, SensorEntityDescription + ) + cancel_coordinator = coordinator.async_start() + + assert len(mock_add_binary_sensor_entities.mock_calls) == 1 + assert len(mock_add_sensor_entities.mock_calls) == 1 + + binary_sensor_entities = [ + *mock_add_binary_sensor_entities.mock_calls[0][1][0], + ] + sensor_entities = [ + *mock_add_sensor_entities.mock_calls[0][1][0], + ] + + sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0] + sensor_entity_one.hass = hass + assert sensor_entity_one.available is True + assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" + assert sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="pressure", device_id=None + ) + + binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[ + 0 + ] + binary_sensor_entity_one.hass = hass + assert binary_sensor_entity_one.available is True + assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" + assert binary_sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="motion", device_id=None + ) + + await hass.async_stop() + await hass.async_block_till_done() + + assert SENSOR_DOMAIN in hass_storage[STORAGE_KEY]["data"][entry.entry_id] + assert BINARY_SENSOR_DOMAIN in hass_storage[STORAGE_KEY]["data"][entry.entry_id] + + # We don't normally cancel or unregister these at stop, + # but since we are mocking a restart we need to cleanup + cancel_coordinator() + unregister_binary_sensor_processor() + unregister_sensor_processor() + + hass = await async_test_home_assistant(asyncio.get_running_loop()) + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + current_entry.set(entry) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + assert coordinator.available is False # no data yet + + mock_add_sensor_entities = MagicMock() + mock_add_binary_sensor_entities = MagicMock() + + binary_sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE, + BINARY_SENSOR_DOMAIN, + ) + sensor_processor = PassiveBluetoothDataProcessor( + lambda service_info: DEVICE_ONLY_PASSIVE_BLUETOOTH_DATA_UPDATE, + SENSOR_DOMAIN, + ) + + sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_sensor_entities, + ) + binary_sensor_processor.async_add_entities_listener( + PassiveBluetoothProcessorEntity, + mock_add_binary_sensor_entities, + ) + + unregister_binary_sensor_processor = coordinator.async_register_processor( + binary_sensor_processor, BinarySensorEntityDescription + ) + unregister_sensor_processor = coordinator.async_register_processor( + sensor_processor, SensorEntityDescription + ) + cancel_coordinator = coordinator.async_start() + + assert len(mock_add_binary_sensor_entities.mock_calls) == 1 + assert len(mock_add_sensor_entities.mock_calls) == 1 + + binary_sensor_entities = [ + *mock_add_binary_sensor_entities.mock_calls[0][1][0], + ] + sensor_entities = [ + *mock_add_sensor_entities.mock_calls[0][1][0], + ] + + sensor_entity_one: PassiveBluetoothProcessorEntity = sensor_entities[0] + sensor_entity_one.hass = hass + assert sensor_entity_one.available is False # service data not injected + assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" + assert sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="pressure", device_id=None + ) + + binary_sensor_entity_one: PassiveBluetoothProcessorEntity = binary_sensor_entities[ + 0 + ] + binary_sensor_entity_one.hass = hass + assert binary_sensor_entity_one.available is False # service data not injected + assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" + assert binary_sensor_entity_one.device_info == { + "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "manufacturer": "Test Manufacturer", + "model": "Test Model", + "name": "Test Device", + } + assert binary_sensor_entity_one.entity_key == PassiveBluetoothEntityKey( + key="motion", device_id=None + ) + cancel_coordinator() + unregister_binary_sensor_processor() + unregister_sensor_processor() + await hass.async_stop() diff --git a/tests/components/http/test_headers.py b/tests/components/http/test_headers.py new file mode 100644 index 00000000000..6d7dbad68f6 --- /dev/null +++ b/tests/components/http/test_headers.py @@ -0,0 +1,44 @@ +"""Test headers middleware.""" +from http import HTTPStatus + +from aiohttp import web + +from homeassistant.components.http.headers import setup_headers + +from tests.typing import ClientSessionGenerator + + +async def mock_handler(request): + """Return OK.""" + return web.Response(text="OK") + + +async def test_headers_added(aiohttp_client: ClientSessionGenerator) -> None: + """Test that headers are being added on each request.""" + app = web.Application() + app.router.add_get("/", mock_handler) + + setup_headers(app, use_x_frame_options=True) + + mock_api_client = await aiohttp_client(app) + resp = await mock_api_client.get("/") + + assert resp.status == HTTPStatus.OK + assert resp.headers["Referrer-Policy"] == "no-referrer" + assert resp.headers["Server"] == "" + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["X-Frame-Options"] == "SAMEORIGIN" + + +async def test_allow_framing(aiohttp_client: ClientSessionGenerator) -> None: + """Test that we allow framing when disabled.""" + app = web.Application() + app.router.add_get("/", mock_handler) + + setup_headers(app, use_x_frame_options=False) + + mock_api_client = await aiohttp_client(app) + resp = await mock_api_client.get("/") + + assert resp.status == HTTPStatus.OK + assert "X-Frame-Options" not in resp.headers diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 2daf722bb05..d9d3b035c94 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -64,6 +64,7 @@ from homeassistant.components.modbus.const import ( from homeassistant.components.modbus.validators import ( duplicate_entity_validator, duplicate_modbus_validator, + nan_validator, number_validator, struct_validator, ) @@ -141,6 +142,23 @@ async def test_number_validator() -> None: pytest.fail("Number_validator not throwing exception") +async def test_nan_validator() -> None: + """Test number validator.""" + + for value, value_type in ( + (15, int), + ("15", int), + ("abcdef", int), + ("0xabcdef", int), + ): + assert isinstance(nan_validator(value), value_type) + + with pytest.raises(vol.Invalid): + nan_validator("x15") + with pytest.raises(vol.Invalid): + nan_validator("not a hex string") + + @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index be9ea95d86a..48a081ef637 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.modbus.const import ( CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, + CONF_NAN_VALUE, CONF_PRECISION, CONF_SCALE, CONF_SLAVE_COUNT, @@ -558,6 +559,15 @@ async def test_config_wrong_struct_sensor( False, str(int(0x02010404)), ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_NAN_VALUE: "0x80000000", + }, + [0x8000, 0x0000], + False, + STATE_UNAVAILABLE, + ), ( { CONF_DATA_TYPE: DataType.INT32, diff --git a/tests/components/opensky/conftest.py b/tests/components/opensky/conftest.py index 63e514d0d8f..7cf3074a2a3 100644 --- a/tests/components/opensky/conftest.py +++ b/tests/components/opensky/conftest.py @@ -1,5 +1,6 @@ """Configure tests for the OpenSky integration.""" from collections.abc import Awaitable, Callable +import json from unittest.mock import patch import pytest @@ -10,7 +11,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture ComponentSetup = Callable[[MockConfigEntry], Awaitable[None]] @@ -32,6 +33,23 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture(name="config_entry_altitude") +def mock_config_entry_altitude() -> MockConfigEntry: + """Create Opensky entry with altitude in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title="OpenSky", + data={ + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + }, + options={ + CONF_RADIUS: 10.0, + CONF_ALTITUDE: 12500.0, + }, + ) + + @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, @@ -40,9 +58,10 @@ async def mock_setup_integration( async def func(mock_config_entry: MockConfigEntry) -> None: mock_config_entry.add_to_hass(hass) + json_fixture = load_fixture("opensky/states.json") with patch( "python_opensky.OpenSky.get_states", - return_value=StatesResponse(states=[], time=0), + return_value=StatesResponse.parse_obj(json.loads(json_fixture)), ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/opensky/fixtures/states.json b/tests/components/opensky/fixtures/states.json new file mode 100644 index 00000000000..7fee53157c8 --- /dev/null +++ b/tests/components/opensky/fixtures/states.json @@ -0,0 +1,105 @@ +{ + "time": 1691244533, + "states": [ + { + "icao24": "3c6708", + "callsign": "DLH459 ", + "origin_country": "Germany", + "time_position": 1691244522, + "last_contact": 1691244522, + "longitude": 5.4445, + "latitude": 52.2991, + "baro_altitude": 12496.8, + "on_ground": false, + "velocity": 259.73, + "true_track": 134.84, + "vertical_rate": 0, + "sensors": null, + "geo_altitude": 12710.16, + "squawk": "1151", + "spi": false, + "position_source": 0, + "category": 6 + }, + { + "icao24": "3c6708", + "callsign": " ", + "origin_country": "Germany", + "time_position": 1691244522, + "last_contact": 1691244522, + "longitude": 5.4445, + "latitude": 52.2991, + "baro_altitude": 12496.8, + "on_ground": false, + "velocity": 259.73, + "true_track": 134.84, + "vertical_rate": 0, + "sensors": null, + "geo_altitude": 12710.16, + "squawk": "1151", + "spi": false, + "position_source": 0, + "category": 6 + }, + { + "icao24": "4846df", + "callsign": "", + "origin_country": "Kingdom of the Netherlands", + "time_position": 1691244404, + "last_contact": 1691244404, + "longitude": 4.7441, + "latitude": 52.3076, + "baro_altitude": null, + "on_ground": true, + "velocity": 8.75, + "true_track": 272.81, + "vertical_rate": null, + "sensors": null, + "geo_altitude": null, + "squawk": null, + "spi": false, + "position_source": 0, + "category": 17 + }, + { + "icao24": "4846df", + "callsign": "DLH420 ", + "origin_country": "Kingdom of the Netherlands", + "time_position": 1691244404, + "last_contact": 1691244404, + "longitude": 4.7441, + "latitude": 52.3076, + "baro_altitude": null, + "on_ground": true, + "velocity": 8.75, + "true_track": 272.81, + "vertical_rate": null, + "sensors": null, + "geo_altitude": null, + "squawk": null, + "spi": false, + "position_source": 0, + "category": 17 + }, + { + "icao24": "3e3d01", + "callsign": "ECA2HL ", + "origin_country": "Germany", + "time_position": 1691244533, + "last_contact": 1691244533, + "longitude": 5.5217, + "latitude": 52.4561, + "baro_altitude": 12500.8, + "on_ground": false, + "velocity": 201.9, + "true_track": 82.39, + "vertical_rate": 0, + "sensors": null, + "geo_altitude": 12733.02, + "squawk": "1071", + "spi": false, + "position_source": 0, + "category": 1 + } + ] +} diff --git a/tests/components/opensky/fixtures/states_1.json b/tests/components/opensky/fixtures/states_1.json new file mode 100644 index 00000000000..bd76428627e --- /dev/null +++ b/tests/components/opensky/fixtures/states_1.json @@ -0,0 +1,45 @@ +{ + "time": 1691244533, + "states": [ + { + "icao24": "4846df", + "callsign": "", + "origin_country": "Kingdom of the Netherlands", + "time_position": 1691244404, + "last_contact": 1691244404, + "longitude": 4.7441, + "latitude": 52.3076, + "baro_altitude": null, + "on_ground": true, + "velocity": 8.75, + "true_track": 272.81, + "vertical_rate": null, + "sensors": null, + "geo_altitude": null, + "squawk": null, + "spi": false, + "position_source": 0, + "category": 17 + }, + { + "icao24": "3e3d01", + "callsign": "ECA2HL ", + "origin_country": "Germany", + "time_position": 1691244533, + "last_contact": 1691244533, + "longitude": 5.5217, + "latitude": 52.4561, + "baro_altitude": 12500.8, + "on_ground": false, + "velocity": 201.9, + "true_track": 82.39, + "vertical_rate": 0, + "sensors": null, + "geo_altitude": 12733.02, + "squawk": "1071", + "spi": false, + "position_source": 0, + "category": 1 + } + ] +} diff --git a/tests/components/opensky/snapshots/test_sensor.ambr b/tests/components/opensky/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..1bd85d23400 --- /dev/null +++ b/tests/components/opensky/snapshots/test_sensor.ambr @@ -0,0 +1,42 @@ +# serializer version: 1 +# name: test_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Information provided by the OpenSky Network (https://opensky-network.org)', + 'friendly_name': 'OpenSky', + 'icon': 'mdi:airplane', + 'unit_of_measurement': 'flights', + }), + 'context': , + 'entity_id': 'sensor.opensky', + 'last_changed': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor_altitude + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Information provided by the OpenSky Network (https://opensky-network.org)', + 'friendly_name': 'OpenSky', + 'icon': 'mdi:airplane', + 'unit_of_measurement': 'flights', + }), + 'context': , + 'entity_id': 'sensor.opensky', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor_updating + list([ + , + ]) +# --- +# name: test_sensor_updating.1 + list([ + , + , + ]) +# --- diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index 1768efebc78..eb17721929c 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -1,20 +1,115 @@ """OpenSky sensor tests.""" -from homeassistant.components.opensky.const import DOMAIN +from datetime import timedelta +import json +from unittest.mock import patch + +from python_opensky import StatesResponse +from syrupy import SnapshotAssertion + +from homeassistant.components.opensky.const import ( + DOMAIN, + EVENT_OPENSKY_ENTRY, + EVENT_OPENSKY_EXIT, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PLATFORM, CONF_RADIUS, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from .conftest import ComponentSetup + +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture LEGACY_CONFIG = {Platform.SENSOR: [{CONF_PLATFORM: DOMAIN, CONF_RADIUS: 10.0}]} async def test_legacy_migration(hass: HomeAssistant) -> None: """Test migration from yaml to config flow.""" - assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) - await hass.async_block_till_done() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - issue_registry = ir.async_get(hass) - assert len(issue_registry.issues) == 1 + json_fixture = load_fixture("opensky/states.json") + with patch( + "python_opensky.OpenSky.get_states", + return_value=StatesResponse.parse_obj(json.loads(json_fixture)), + ): + assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 + + +async def test_sensor( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_integration: ComponentSetup, + snapshot: SnapshotAssertion, +): + """Test setup sensor.""" + await setup_integration(config_entry) + + state = hass.states.get("sensor.opensky") + assert state == snapshot + events = [] + + async def event_listener(event: Event) -> None: + events.append(event) + + hass.bus.async_listen(EVENT_OPENSKY_ENTRY, event_listener) + hass.bus.async_listen(EVENT_OPENSKY_EXIT, event_listener) + assert events == [] + + +async def test_sensor_altitude( + hass: HomeAssistant, + config_entry_altitude: MockConfigEntry, + setup_integration: ComponentSetup, + snapshot: SnapshotAssertion, +): + """Test setup sensor with a set altitude.""" + await setup_integration(config_entry_altitude) + + state = hass.states.get("sensor.opensky") + assert state == snapshot + + +async def test_sensor_updating( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_integration: ComponentSetup, + snapshot: SnapshotAssertion, +): + """Test updating sensor.""" + await setup_integration(config_entry) + + def get_states_response_fixture(fixture: str) -> StatesResponse: + json_fixture = load_fixture(fixture) + return StatesResponse.parse_obj(json.loads(json_fixture)) + + events = [] + + async def event_listener(event: Event) -> None: + events.append(event) + + hass.bus.async_listen(EVENT_OPENSKY_ENTRY, event_listener) + hass.bus.async_listen(EVENT_OPENSKY_EXIT, event_listener) + + async def skip_time_and_check_events() -> None: + future = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert events == snapshot + + with patch( + "python_opensky.OpenSky.get_states", + return_value=get_states_response_fixture("opensky/states_1.json"), + ): + await skip_time_and_check_events() + with patch( + "python_opensky.OpenSky.get_states", + return_value=get_states_response_fixture("opensky/states.json"), + ): + await skip_time_and_check_events() diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index eb281076825..ef841769f8d 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -67,6 +67,10 @@ async def setup_entry( return_value=PROP, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ), patch( + "homeassistant.components.roborock.RoborockMqttClient._wait_response" + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient._wait_response" ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index ff0346a3d8b..fbe0da15853 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -1,7 +1,7 @@ """Test the webhook component.""" from http import HTTPStatus from ipaddress import ip_address -from unittest.mock import patch +from unittest.mock import Mock, patch from aiohttp import web import pytest @@ -206,6 +206,8 @@ async def test_webhook_not_allowed_method(hass: HomeAssistant) -> None: async def test_webhook_local_only(hass: HomeAssistant, mock_client) -> None: """Test posting a webhook with local only.""" + hass.config.components.add("cloud") + hooks = [] webhook_id = webhook.async_generate_id() @@ -234,6 +236,16 @@ async def test_webhook_local_only(hass: HomeAssistant, mock_client) -> None: # No hook received assert len(hooks) == 1 + # Request from Home Assistant Cloud remote UI + with patch( + "hass_nabucasa.remote.is_cloud_request", Mock(get=Mock(return_value=True)) + ): + resp = await mock_client.post(f"/api/webhook/{webhook_id}", json={"data": True}) + + # No hook received + assert resp.status == HTTPStatus.OK + assert len(hooks) == 1 + async def test_listing_webhook( hass: HomeAssistant, diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index 392ab58a30f..990482c500e 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -1,6 +1,6 @@ """The tests for the webhook automation trigger.""" from ipaddress import ip_address -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -68,6 +68,9 @@ async def test_webhook_post( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: """Test triggering with a POST webhook.""" + # Set up fake cloud + hass.config.components.add("cloud") + events = [] @callback @@ -114,6 +117,16 @@ async def test_webhook_post( await hass.async_block_till_done() assert len(events) == 1 + # Request from Home Assistant Cloud remote UI + with patch( + "hass_nabucasa.remote.is_cloud_request", Mock(get=Mock(return_value=True)) + ): + await client.post("/api/webhook/post_webhook", data={"hello": "world"}) + + # No hook received + await hass.async_block_till_done() + assert len(events) == 1 + async def test_webhook_allowed_methods_internet( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator @@ -141,7 +154,6 @@ async def test_webhook_allowed_methods_internet( }, "action": { "event": "test_success", - "event_data_template": {"hello": "yo {{ trigger.data.hello }}"}, }, } }, @@ -150,7 +162,7 @@ async def test_webhook_allowed_methods_internet( client = await hass_client_no_auth() - await client.post("/api/webhook/post_webhook", data={"hello": "world"}) + await client.post("/api/webhook/post_webhook") await hass.async_block_till_done() assert len(events) == 0 @@ -160,7 +172,7 @@ async def test_webhook_allowed_methods_internet( "homeassistant.components.webhook.ip_address", return_value=ip_address("123.123.123.123"), ): - await client.put("/api/webhook/post_webhook", data={"hello": "world"}) + await client.put("/api/webhook/post_webhook") await hass.async_block_till_done() assert len(events) == 1 diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 4e451f46e9b..8e8c5513097 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone from unittest.mock import MagicMock +import pytest from whirlpool.washerdryer import MachineState from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL @@ -325,6 +326,7 @@ async def test_no_restore_state( assert state.state != "unknown" +@pytest.mark.freeze_time("2022-11-30 00:00:00") async def test_callback( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, @@ -377,8 +379,8 @@ async def test_callback( assert state.state == time # Test timestamp change for > 60 seconds. - mock_sensor1_api.get_attribute.return_value = "120" + mock_sensor1_api.get_attribute.return_value = "125" callback() state = hass.states.get("sensor.washer_end_time") - newtime = utc_from_timestamp(as_timestamp(time) + 60) + newtime = utc_from_timestamp(as_timestamp(time) + 65) assert state.state == newtime.isoformat() diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 44a4d55d545..e410dd672ce 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -117,6 +117,7 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: "login_attempts_threshold": -1, "server_port": 8123, "ssl_profile": "modern", + "use_x_frame_options": True, } assert res["secret_cache"] == { get_test_config_dir("secrets.yaml"): {"http_pw": "http://google.com"} diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 68e6fc59987..3485162cbb3 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1544,6 +1544,28 @@ async def test_init_custom_integration(hass: HomeAssistant) -> None: await hass.config_entries.flow.async_init("bla", context={"source": "user"}) +async def test_init_custom_integration_with_missing_handler( + hass: HomeAssistant, +) -> None: + """Test initializing flow for custom integration with a missing handler.""" + integration = loader.Integration( + hass, + "custom_components.hue", + None, + {"name": "Hue", "dependencies": [], "requirements": [], "domain": "hue"}, + ) + mock_integration( + hass, + MockModule("hue"), + ) + mock_entity_platform(hass, "config_flow.hue", None) + with pytest.raises(data_entry_flow.UnknownHandler), patch( + "homeassistant.loader.async_get_integration", + return_value=integration, + ): + await hass.config_entries.flow.async_init("bla", context={"source": "user"}) + + async def test_support_entry_unload(hass: HomeAssistant) -> None: """Test unloading entry.""" assert await config_entries.support_entry_unload(hass, "light")