diff --git a/CODEOWNERS b/CODEOWNERS index 494f3d42bee..dd538ace0ec 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1550,7 +1550,7 @@ build.json @home-assistant/supervisor /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core /tests/components/zone/ @home-assistant/core -/homeassistant/components/zoneminder/ @rohankapoorcom +/homeassistant/components/zoneminder/ @rohankapoorcom @nabbi /homeassistant/components/zwave_js/ @home-assistant/z-wave /tests/components/zwave_js/ @home-assistant/z-wave /homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 58095340146..527e51b5390 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -15,6 +15,9 @@ from homeassistant.helpers import aiohttp_client from homeassistant.helpers.storage import Store from homeassistant.util import dt as dt_util +from .const import STORAGE_ACCESS_TOKEN, STORAGE_REFRESH_TOKEN +from .diagnostics import async_redact_lwa_params + _LOGGER = logging.getLogger(__name__) LWA_TOKEN_URI = "https://api.amazon.com/auth/o2/token" @@ -24,8 +27,6 @@ PREEMPTIVE_REFRESH_TTL_IN_SECONDS = 300 STORAGE_KEY = "alexa_auth" STORAGE_VERSION = 1 STORAGE_EXPIRE_TIME = "expire_time" -STORAGE_ACCESS_TOKEN = "access_token" -STORAGE_REFRESH_TOKEN = "refresh_token" class Auth: @@ -56,7 +57,7 @@ class Auth: } _LOGGER.debug( "Calling LWA to get the access token (first time), with: %s", - json.dumps(lwa_params), + json.dumps(async_redact_lwa_params(lwa_params)), ) return await self._async_request_new_token(lwa_params) @@ -133,7 +134,7 @@ class Auth: return None response_json = await response.json() - _LOGGER.debug("LWA response body : %s", response_json) + _LOGGER.debug("LWA response body : %s", async_redact_lwa_params(response_json)) access_token: str = response_json["access_token"] refresh_token: str = response_json["refresh_token"] diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 502912ee8de..ab3bd8591fd 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1112,13 +1112,17 @@ class AlexaThermostatController(AlexaCapability): """Return what properties this entity supports.""" properties = [{"name": "thermostatMode"}] supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE: + if self.entity.domain == climate.DOMAIN: + if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: + properties.append({"name": "lowerSetpoint"}) + properties.append({"name": "upperSetpoint"}) + if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE: + properties.append({"name": "targetSetpoint"}) + elif ( + self.entity.domain == water_heater.DOMAIN + and supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE + ): properties.append({"name": "targetSetpoint"}) - if supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE: - properties.append({"name": "targetSetpoint"}) - if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: - properties.append({"name": "lowerSetpoint"}) - properties.append({"name": "upperSetpoint"}) return properties def properties_proactively_reported(self) -> bool: diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index f71bc091106..abdef0cb566 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -90,6 +90,9 @@ API_THERMOSTAT_PRESETS = {climate.PRESET_ECO: "ECO"} # we add PRESET_MODE_NA if a fan / humidifier has only one preset_mode PRESET_MODE_NA = "-" +STORAGE_ACCESS_TOKEN = "access_token" +STORAGE_REFRESH_TOKEN = "refresh_token" + class Cause: """Possible causes for property changes. diff --git a/homeassistant/components/alexa/diagnostics.py b/homeassistant/components/alexa/diagnostics.py new file mode 100644 index 00000000000..54233a0f432 --- /dev/null +++ b/homeassistant/components/alexa/diagnostics.py @@ -0,0 +1,34 @@ +"""Diagnostics helpers for Alexa.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import callback + +STORAGE_ACCESS_TOKEN = "access_token" +STORAGE_REFRESH_TOKEN = "refresh_token" + +TO_REDACT_LWA = { + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + STORAGE_ACCESS_TOKEN, + STORAGE_REFRESH_TOKEN, +} + +TO_REDACT_AUTH = {"correlationToken", "token"} + + +@callback +def async_redact_lwa_params(lwa_params: dict[str, str]) -> dict[str, str]: + """Redact lwa_params.""" + return async_redact_data(lwa_params, TO_REDACT_LWA) + + +@callback +def async_redact_auth_data(mapping: Mapping[Any, Any]) -> dict[str, str]: + """React auth data.""" + return async_redact_data(mapping, TO_REDACT_AUTH) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 5613da52db5..68702bc0533 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -144,7 +144,6 @@ async def async_api_accept_grant( Async friendly. """ auth_code: str = directive.payload["grant"]["code"] - _LOGGER.debug("AcceptGrant code: %s", auth_code) if config.supports_auth: await config.async_accept_grant(auth_code) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index a8101896116..88f66e93fc1 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -25,6 +25,7 @@ from .const import ( CONF_LOCALE, EVENT_ALEXA_SMART_HOME, ) +from .diagnostics import async_redact_auth_data from .errors import AlexaBridgeUnreachableError, AlexaError from .handlers import HANDLERS from .state_report import AlexaDirective @@ -149,12 +150,21 @@ class SmartHomeView(HomeAssistantView): user: User = request["hass_user"] message: dict[str, Any] = await request.json() - _LOGGER.debug("Received Alexa Smart Home request: %s", message) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Received Alexa Smart Home request: %s", + async_redact_auth_data(message), + ) response = await async_handle_message( hass, self.smart_home_config, message, context=core.Context(user_id=user.id) ) - _LOGGER.debug("Sending Alexa Smart Home response: %s", response) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Sending Alexa Smart Home response: %s", + async_redact_auth_data(response), + ) + return b"" if response is None else self.json(response) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index f1cf13a0a7e..20e66dfa084 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -34,6 +34,7 @@ from .const import ( DOMAIN, Cause, ) +from .diagnostics import async_redact_auth_data from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink @@ -43,6 +44,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 +TO_REDACT = {"correlationToken", "token"} + class AlexaDirective: """An incoming Alexa directive.""" @@ -379,7 +382,9 @@ async def async_send_changereport_message( response_text = await response.text() if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug( + "Sent: %s", json.dumps(async_redact_auth_data(message_serialized)) + ) _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status == HTTPStatus.ACCEPTED: @@ -533,7 +538,9 @@ async def async_send_doorbell_event_message( response_text = await response.text() if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug( + "Sent: %s", json.dumps(async_redact_auth_data(message_serialized)) + ) _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status == HTTPStatus.ACCEPTED: diff --git a/homeassistant/components/aosmith/diagnostics.py b/homeassistant/components/aosmith/diagnostics.py new file mode 100644 index 00000000000..a821c980faa --- /dev/null +++ b/homeassistant/components/aosmith/diagnostics.py @@ -0,0 +1,39 @@ +"""Diagnostics support for A. O. Smith.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import AOSmithData +from .const import DOMAIN + +TO_REDACT = { + "address", + "city", + "contactId", + "dsn", + "email", + "firstName", + "heaterSsid", + "id", + "lastName", + "phone", + "postalCode", + "registeredOwner", + "serial", + "ssid", + "state", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data: AOSmithData = hass.data[DOMAIN][config_entry.entry_id] + + all_device_info = await data.client.get_all_device_info() + return async_redact_data(all_device_info, TO_REDACT) diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index 895b03cf7fd..7651086e138 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", "iot_class": "cloud_polling", - "requirements": ["py-aosmith==1.0.1"] + "requirements": ["py-aosmith==1.0.4"] } diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py index d53d23c4344..aaf666208a6 100644 --- a/homeassistant/components/blink/coordinator.py +++ b/homeassistant/components/blink/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = 30 +SCAN_INTERVAL = 300 class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index a1268919052..6e9d912f332 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.22.4"] + "requirements": ["blinkpy==0.22.5"] } diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 7308f3a83ff..0e2a26381d2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -17,9 +17,9 @@ "bleak==0.21.1", "bleak-retry-connector==3.4.0", "bluetooth-adapters==0.16.2", - "bluetooth-auto-recovery==1.2.3", + "bluetooth-auto-recovery==1.3.0", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.0", - "habluetooth==2.0.2" + "habluetooth==2.1.0" ] } diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 6e5cddd0f28..17c50778b2e 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -291,7 +291,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } async def _on_start() -> None: - """Discover platforms.""" + """Handle cloud started after login.""" nonlocal loaded # Prevent multiple discovery @@ -299,14 +299,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return loaded = True - tts_info = {"platform_loaded": tts_platform_loaded} - - await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) - await tts_platform_loaded.wait() - - # The config entry should be loaded after the legacy tts platform is loaded - # to make sure that the tts integration is setup before we try to migrate - # old assist pipelines in the cloud stt entity. await hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) async def _on_connect() -> None: @@ -335,6 +327,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: account_link.async_setup(hass) + hass.async_create_task( + async_load_platform( + hass, + Platform.TTS, + DOMAIN, + {"platform_loaded": tts_platform_loaded}, + config, + ) + ) + async_call_later( hass=hass, delay=timedelta(hours=STARTUP_REPAIR_DELAY), diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index cbd79ac1e1a..bbb671a29a7 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -72,6 +72,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): _reauth_entry: ConfigEntry | None _reauth_host: str _reauth_port: int + _reauth_type: str async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -109,6 +110,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): ) self._reauth_host = entry_data[CONF_HOST] self._reauth_port = entry_data.get(CONF_PORT, DEFAULT_PORT) + self._reauth_type = entry_data.get(CONF_TYPE, BRIDGE) self.context["title_placeholders"] = {"host": self._reauth_host} return await self.async_step_reauth_confirm() @@ -127,6 +129,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): { CONF_HOST: self._reauth_host, CONF_PORT: self._reauth_port, + CONF_TYPE: self._reauth_type, } | user_input, ) @@ -144,6 +147,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: self._reauth_host, CONF_PORT: self._reauth_port, CONF_PIN: user_input[CONF_PIN], + CONF_TYPE: self._reauth_type, }, ) self.hass.async_create_task( diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 6559e2ffb87..4ff75ba5307 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -81,15 +81,11 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: await self.api.login() return await self._async_update_system_data() - except exceptions.CannotConnect as err: - _LOGGER.warning("Connection error for %s", self._host) - await self.api.close() - raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except (exceptions.CannotConnect, exceptions.CannotRetrieveData) as err: + raise UpdateFailed(repr(err)) from err except exceptions.CannotAuthenticate: raise ConfigEntryAuthFailed - return {} - @abstractmethod async def _async_update_system_data(self) -> dict[str, Any]: """Class method for updating data.""" diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 8b50ccdf767..8c47564b165 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.7.0"] + "requirements": ["aiocomelit==0.7.3"] } diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 945585de522..89f79ca9d7a 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -481,7 +481,7 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def _get_toggle_function( self, fns: dict[str, Callable[_P, _R]] ) -> Callable[_P, _R]: - if CoverEntityFeature.STOP | self.supported_features and ( + if self.supported_features & CoverEntityFeature.STOP and ( self.is_closing or self.is_opening ): return fns["stop"] diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 1dfd72dcaf3..2ae9dca63ba 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -479,10 +479,20 @@ class EnvoyInverterEntity(EnvoySensorBaseEntity): ) @property - def native_value(self) -> datetime.datetime | float: + def native_value(self) -> datetime.datetime | float | None: """Return the state of the sensor.""" inverters = self.data.inverters assert inverters is not None + # Some envoy fw versions return an empty inverter array every 4 hours when + # no production is taking place. Prevent collection failure due to this + # as other data seems fine. Inverters will show unknown during this cycle. + if self._serial_number not in inverters: + _LOGGER.debug( + "Inverter %s not in returned inverters array (size: %s)", + self._serial_number, + len(inverters), + ) + return None return self.entity_description.value_fn(inverters[self._serial_number]) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 06712a83b6a..390bdeb3f33 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -497,7 +497,6 @@ class EvoBroker: session_id = get_session_id(self.client_v1) - self.temps = {} # these are now stale, will fall back to v2 temps try: temps = await self.client_v1.get_temperatures() @@ -523,6 +522,11 @@ class EvoBroker: ), err, ) + self.temps = {} # high-precision temps now considered stale + + except Exception: + self.temps = {} # high-precision temps now considered stale + raise else: if str(self.client_v1.location_id) != self._location.locationId: @@ -654,6 +658,7 @@ class EvoChild(EvoDevice): assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check if self._evo_broker.temps.get(self._evo_id) is not None: + # use high-precision temps if available return self._evo_broker.temps[self._evo_id] return self._evo_device.temperature diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 20bebcf08c8..df6ddc38de7 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -118,7 +118,6 @@ class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorE super().__init__(coordinator) self.entity_description = description _id = coordinator.data.code - self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, _id)}, diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index c15cb59a6f3..79846bee019 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -27,6 +27,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DOMAIN, + MAX_TEMP, + MIN_TEMP, PRESET_TO_VENTILATION_MODE_MAP, VENTILATION_TO_PRESET_MODE_MAP, ) @@ -67,6 +69,8 @@ class FlexitClimateEntity(ClimateEntity): _attr_target_temperature_step = PRECISION_HALVES _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_max_temp = MAX_TEMP + _attr_min_temp = MIN_TEMP def __init__(self, device: FlexitBACnet) -> None: """Initialize the unit.""" diff --git a/homeassistant/components/flexit_bacnet/const.py b/homeassistant/components/flexit_bacnet/const.py index 269a88c4cec..ed52b45f05e 100644 --- a/homeassistant/components/flexit_bacnet/const.py +++ b/homeassistant/components/flexit_bacnet/const.py @@ -15,6 +15,9 @@ from homeassistant.components.climate import ( DOMAIN = "flexit_bacnet" +MAX_TEMP = 30 +MIN_TEMP = 10 + VENTILATION_TO_PRESET_MODE_MAP = { VENTILATION_MODE_STOP: PRESET_NONE, VENTILATION_MODE_AWAY: PRESET_AWAY, diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json index 9f77f9b112e..8f4b36657dd 100644 --- a/homeassistant/components/geo_json_events/manifest.json +++ b/homeassistant/components/geo_json_events/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_generic_client"], - "requirements": ["aio-geojson-generic-client==0.3"] + "requirements": ["aio-geojson-generic-client==0.4"] } diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py index bb9a332cb73..e48cc11d677 100644 --- a/homeassistant/components/holiday/calendar.py +++ b/homeassistant/components/holiday/calendar.py @@ -43,6 +43,18 @@ async def async_setup_entry( ) language = lang break + if ( + obj_holidays.supported_languages + and language not in obj_holidays.supported_languages + and (default_language := obj_holidays.default_language) + ): + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=default_language, + ) + language = default_language async_add_entities( [ diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py index 96f52ef7e03..b8407fd8bde 100644 --- a/homeassistant/components/kef/media_player.py +++ b/homeassistant/components/kef/media_player.py @@ -118,7 +118,7 @@ async def async_setup_platform( mode = get_ip_mode(host) mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) - if mac is None: + if mac is None or mac == "00:00:00:00:00:00": raise PlatformNotReady("Cannot get the ip address of kef speaker.") unique_id = f"kef-{mac}" diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 3d1e3c62a34..aa48bcdf557 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -82,6 +82,9 @@ DATA_HASS_CONFIG: Final = "knx_hass_config" ATTR_COUNTER: Final = "counter" ATTR_SOURCE: Final = "source" +# dispatcher signal for KNX interface device triggers +SIGNAL_KNX_TELEGRAM_DICT: Final = "knx_telegram_dict" + AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] MessageCallbackType = Callable[[Telegram], None] diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index 1abafb221db..867a7c075b0 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -9,11 +9,12 @@ from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEM from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import selector +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_KNX_TELEGRAM_DICT from .project import KNXProject from .schema import ga_list_validator from .telegrams import TelegramDict @@ -87,7 +88,6 @@ async def async_attach_trigger( trigger_data = trigger_info["trigger_data"] dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, []) job = HassJob(action, f"KNX device trigger {trigger_info}") - knx: KNXModule = hass.data[DOMAIN] @callback def async_call_trigger_action(telegram: TelegramDict) -> None: @@ -99,6 +99,8 @@ async def async_attach_trigger( {"trigger": {**trigger_data, **telegram}}, ) - return knx.telegrams.async_listen_telegram( - async_call_trigger_action, name="KNX device trigger call" + return async_dispatcher_connect( + hass, + signal=SIGNAL_KNX_TELEGRAM_DICT, + target=async_call_trigger_action, ) diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 87c1a8b6052..95250d99f85 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -11,10 +11,11 @@ from xknx.telegram import Telegram from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store import homeassistant.util.dt as dt_util -from .const import DOMAIN +from .const import DOMAIN, SIGNAL_KNX_TELEGRAM_DICT from .project import KNXProject STORAGE_VERSION: Final = 1 @@ -87,6 +88,7 @@ class Telegrams: """Handle incoming and outgoing telegrams from xknx.""" telegram_dict = self.telegram_to_dict(telegram) self.recent_telegrams.append(telegram_dict) + async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM_DICT, telegram_dict) for job in self._jobs: self.hass.async_run_hass_job(job, telegram_dict) diff --git a/homeassistant/components/ld2410_ble/__init__.py b/homeassistant/components/ld2410_ble/__init__.py index e127a4a9836..57e3dfa4617 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -2,7 +2,11 @@ import logging -from bleak_retry_connector import BleakError, close_stale_connections, get_device +from bleak_retry_connector import ( + BleakError, + close_stale_connections_by_address, + get_device, +) from ld2410_ble import LD2410BLE from homeassistant.components import bluetooth @@ -24,6 +28,9 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up LD2410 BLE from a config entry.""" address: str = entry.data[CONF_ADDRESS] + + await close_stale_connections_by_address(address) + ble_device = bluetooth.async_ble_device_from_address( hass, address.upper(), True ) or await get_device(address) @@ -32,8 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find LD2410B device with address {address}" ) - await close_stale_connections(ble_device) - ld2410_ble = LD2410BLE(ble_device) coordinator = LD2410BLECoordinator(hass, ld2410_ble) diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json index e990142923f..b7d0a90b511 100644 --- a/homeassistant/components/luftdaten/strings.json +++ b/homeassistant/components/luftdaten/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "station_id": "Sensor ID", + "sensor_id": "Sensor ID", "show_on_map": "Show on map" } } diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 6e47ad79f5b..af0567f99a1 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Coroutine import json import logging +from typing import Any import aiohttp from aiohttp.hdrs import CONTENT_TYPE @@ -267,11 +269,11 @@ class MicrosoftFace: """Store group/person data and IDs.""" return self._store - async def update_store(self): + async def update_store(self) -> None: """Load all group/person data into local store.""" groups = await self.call_api("get", "persongroups") - remove_tasks = [] + remove_tasks: list[Coroutine[Any, Any, None]] = [] new_entities = [] for group in groups: g_id = group["personGroupId"] @@ -293,7 +295,7 @@ class MicrosoftFace: self._store[g_id][person["name"]] = person["personId"] if remove_tasks: - await asyncio.gather(remove_tasks) + await asyncio.gather(*remove_tasks) await self._component.async_add_entities(new_entities) async def call_api(self, method, function, data=None, binary=False, params=None): diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index 73a7dc18d09..a00936852f0 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], "quality_scale": "gold", - "requirements": ["mcstatus==11.0.0"] + "requirements": ["mcstatus==11.1.1"] } diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index da93a6b619e..fb121c25a9c 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -70,8 +70,8 @@ MQTT_TEXT_ATTRIBUTES_BLOCKED = frozenset( def valid_text_size_configuration(config: ConfigType) -> ConfigType: """Validate that the text length configuration is valid, throws if it isn't.""" - if config[CONF_MIN] >= config[CONF_MAX]: - raise vol.Invalid("text length min must be >= max") + if config[CONF_MIN] > config[CONF_MAX]: + raise vol.Invalid("text length min must be <= max") if config[CONF_MAX] > MAX_LENGTH_STATE_STATE: raise vol.Invalid(f"max text length must be <= {MAX_LENGTH_STATE_STATE}") diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index d5116af0071..5670aea87ad 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.8.5"] + "requirements": ["reolink-aio==0.8.6"] } diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index 17c52932e09..c9c66183daf 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -61,10 +61,7 @@ def async_load_screenlogic_services(hass: HomeAssistant): color_num, ) try: - if not await coordinator.gateway.async_set_color_lights(color_num): - raise HomeAssistantError( - f"Failed to call service '{SERVICE_SET_COLOR_MODE}'" - ) + await coordinator.gateway.async_set_color_lights(color_num) # Debounced refresh to catch any secondary # changes in the device await coordinator.async_request_refresh() diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index b56ce07bc30..82833bf34af 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==7.0.0"], + "requirements": ["aioshelly==7.1.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 89dc10f0530..c7d89f2d284 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -36,6 +36,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType +from homeassistant.util.enum import try_parse_enum from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator @@ -969,7 +970,7 @@ def _build_block_description(entry: RegistryEntry) -> BlockSensorDescription: name="", icon=entry.original_icon, native_unit_of_measurement=entry.unit_of_measurement, - device_class=entry.original_device_class, + device_class=try_parse_enum(SensorDeviceClass, entry.original_device_class), ) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 6ef2697ba77..0204cc30fbb 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -61,6 +61,10 @@ REPEAT_MODE_MAPPING_TO_SPOTIFY = { value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() } +# This is a minimal representation of the DJ playlist that Spotify now offers +# The DJ is not fully integrated with the playlist API, so needs to have the playlist response mocked in order to maintain functionality +SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": "DJ"} + async def async_setup_entry( hass: HomeAssistant, @@ -423,7 +427,19 @@ class SpotifyMediaPlayer(MediaPlayerEntity): if context and (self._playlist is None or self._playlist["uri"] != uri): self._playlist = None if context["type"] == MediaType.PLAYLIST: - self._playlist = self.data.client.playlist(uri) + # The Spotify API does not currently support doing a lookup for the DJ playlist, so just use the minimal mock playlist object + if uri == SPOTIFY_DJ_PLAYLIST["uri"]: + self._playlist = SPOTIFY_DJ_PLAYLIST + else: + # Make sure any playlist lookups don't break the current playback state update + try: + self._playlist = self.data.client.playlist(uri) + except SpotifyException: + _LOGGER.debug( + "Unable to load spotify playlist '%s'. Continuing without playlist data", + uri, + ) + self._playlist = None device = self._currently_playing.get("device") if device is not None: diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index 9e01a07416f..a510b5b7414 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -10,6 +10,7 @@ from opendata_transport.exceptions import ( from homeassistant import config_entries, core from homeassistant.const import Platform from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_DESTINATION, CONF_START, DOMAIN @@ -65,3 +66,51 @@ async def async_unload_entry( hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Migrate config entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.minor_version > 3: + # This means the user has downgraded from a future version + return False + + if config_entry.minor_version == 1: + # Remove wrongly registered devices and entries + new_unique_id = ( + f"{config_entry.data[CONF_START]} {config_entry.data[CONF_DESTINATION]}" + ) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=config_entry.entry_id + ) + for dev in device_entries: + device_registry.async_remove_device(dev.id) + + entity_id = entity_registry.async_get_entity_id( + Platform.SENSOR, DOMAIN, "None_departure" + ) + if entity_id: + entity_registry.async_update_entity( + entity_id=entity_id, + new_unique_id=f"{new_unique_id}_departure", + ) + _LOGGER.debug( + "Faulty entity with unique_id 'None_departure' migrated to new unique_id '%s'", + f"{new_unique_id}_departure", + ) + + # Set a valid unique id for config entries + config_entry.unique_id = new_unique_id + config_entry.minor_version = 2 + hass.config_entries.async_update_entry(config_entry) + + _LOGGER.debug( + "Migration to minor version %s successful", config_entry.minor_version + ) + + return True diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py index 63eca1efe96..ceb6f46806d 100644 --- a/homeassistant/components/swiss_public_transport/config_flow.py +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -31,6 +31,7 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Swiss public transport config flow.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -59,6 +60,9 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unknown error") errors["base"] = "unknown" else: + await self.async_set_unique_id( + f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}" + ) return self.async_create_entry( title=f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}", data=user_input, @@ -98,6 +102,9 @@ class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="unknown") + await self.async_set_unique_id( + f"{import_input[CONF_START]} {import_input[CONF_DESTINATION]}" + ) return self.async_create_entry( title=import_input[CONF_NAME], data=import_input, diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 5d4a6813d2d..0e88cd2d3ad 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -122,15 +122,25 @@ class SwissPublicTransportSensor( entry_type=DeviceEntryType.SERVICE, ) + async def async_added_to_hass(self) -> None: + """Prepare the extra attributes at start.""" + self._async_update_attrs() + await super().async_added_to_hass() + @callback def _handle_coordinator_update(self) -> None: """Handle the state update and prepare the extra state attributes.""" + self._async_update_attrs() + return super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update the extra state attributes based on the coordinator data.""" self._attr_extra_state_attributes = { key: value for key, value in self.coordinator.data.items() if key not in {"departure"} } - return super()._handle_coordinator_update() @property def native_value(self) -> str: diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index fb6ded99346..051c5d2b72a 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -89,8 +89,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # New device - create device _LOGGER.info( - "Discovered Switcher device - id: %s, name: %s, type: %s (%s)", + "Discovered Switcher device - id: %s, key: %s, name: %s, type: %s (%s)", device.device_id, + device.device_key, device.name, device.device_type.value, device.device_type.hex_rep, diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 5a1b7c821d2..2085398232f 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -142,7 +142,9 @@ class SwitcherThermostatButtonEntity( try: async with SwitcherType2Api( - self.coordinator.data.ip_address, self.coordinator.data.device_id + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, ) as swapi: response = await self.entity_description.press_fn(swapi, self._remote) except (asyncio.TimeoutError, OSError, RuntimeError) as err: diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 809e3d6a3ad..272d3ccf6ef 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -162,7 +162,9 @@ class SwitcherClimateEntity( try: async with SwitcherType2Api( - self.coordinator.data.ip_address, self.coordinator.data.device_id + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, ) as swapi: response = await swapi.control_breeze_device(self._remote, **kwargs) except (asyncio.TimeoutError, OSError, RuntimeError) as err: diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index c627f361d7d..1e34ddd2325 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -98,7 +98,9 @@ class SwitcherCoverEntity( try: async with SwitcherType2Api( - self.coordinator.data.ip_address, self.coordinator.data.device_id + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, ) as swapi: response = await getattr(swapi, api)(*args) except (asyncio.TimeoutError, OSError, RuntimeError) as err: diff --git a/homeassistant/components/switcher_kis/diagnostics.py b/homeassistant/components/switcher_kis/diagnostics.py index 93b3c36bd21..765a3dde9e7 100644 --- a/homeassistant/components/switcher_kis/diagnostics.py +++ b/homeassistant/components/switcher_kis/diagnostics.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from .const import DATA_DEVICE, DOMAIN -TO_REDACT = {"device_id", "ip_address", "mac_address"} +TO_REDACT = {"device_id", "device_key", "ip_address", "mac_address"} async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 9accda95912..055c92cc2fa 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "platinum", - "requirements": ["aioswitcher==3.3.0"] + "requirements": ["aioswitcher==3.4.1"] } diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index ef8564b3770..88867393834 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -105,13 +105,17 @@ class SwitcherBaseSwitchEntity( async def _async_call_api(self, api: str, *args: Any) -> None: """Call Switcher API.""" - _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) + _LOGGER.debug( + "Calling api for %s, api: '%s', args: %s", self.coordinator.name, api, args + ) response: SwitcherBaseResponse = None error = None try: async with SwitcherType1Api( - self.coordinator.data.ip_address, self.coordinator.data.device_id + self.coordinator.data.ip_address, + self.coordinator.data.device_id, + self.coordinator.data.device_key, ) as swapi: response = await getattr(swapi, api)(*args) except (asyncio.TimeoutError, OSError, RuntimeError) as err: diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 28929d07a7c..da6e35238ec 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -405,7 +405,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{slugify(argument)}") entities.append( SystemMonitorSensor( sensor_registry, @@ -425,7 +425,7 @@ async def async_setup_entry( is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{slugify(argument)}") entities.append( SystemMonitorSensor( sensor_registry, @@ -449,7 +449,7 @@ async def async_setup_entry( sensor_registry[(_type, argument)] = SensorData( argument, None, None, None, None ) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(f"{_type}_{slugify(argument)}") entities.append( SystemMonitorSensor( sensor_registry, @@ -478,10 +478,13 @@ async def async_setup_entry( # of mount points automatically discovered for resource in legacy_resources: if resource.startswith("disk_"): + check_resource = slugify(resource) _LOGGER.debug( - "Check resource %s already loaded in %s", resource, loaded_resources + "Check resource %s already loaded in %s", + check_resource, + loaded_resources, ) - if resource not in loaded_resources: + if check_resource not in loaded_resources: split_index = resource.rfind("_") _type = resource[:split_index] argument = resource[split_index + 1 :] diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 25b8aa2eb1d..742e0d40f3d 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -25,6 +25,11 @@ def get_all_disk_mounts() -> set[str]: "No permission for running user to access %s", part.mountpoint ) continue + except OSError as err: + _LOGGER.debug( + "Mountpoint %s was excluded because of: %s", part.mountpoint, err + ) + continue if usage.total > 0 and part.device != "": disks.add(part.mountpoint) _LOGGER.debug("Adding disks: %s", ", ".join(disks)) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 7f166ccf01a..871d6c2e6b1 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -186,12 +186,13 @@ class TadoConnector: def get_mobile_devices(self): """Return the Tado mobile devices.""" - return self.tado.getMobileDevices() + return self.tado.get_mobile_devices() @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update the registered zones.""" self.update_devices() + self.update_mobile_devices() self.update_zones() self.update_home() @@ -203,17 +204,31 @@ class TadoConnector: _LOGGER.error("Unable to connect to Tado while updating mobile devices") return + if not mobile_devices: + _LOGGER.debug("No linked mobile devices found for home ID %s", self.home_id) + return + + # Errors are planned to be converted to exceptions + # in PyTado library, so this can be removed + if "errors" in mobile_devices and mobile_devices["errors"]: + _LOGGER.error( + "Error for home ID %s while updating mobile devices: %s", + self.home_id, + mobile_devices["errors"], + ) + return + for mobile_device in mobile_devices: self.data["mobile_device"][mobile_device["id"]] = mobile_device + _LOGGER.debug( + "Dispatching update to %s mobile device: %s", + self.home_id, + mobile_device, + ) - _LOGGER.debug( - "Dispatching update to %s mobile devices: %s", - self.home_id, - mobile_devices, - ) dispatcher_send( self.hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self.home_id), ) def update_devices(self): @@ -224,6 +239,20 @@ class TadoConnector: _LOGGER.error("Unable to connect to Tado while updating devices") return + if not devices: + _LOGGER.debug("No linked devices found for home ID %s", self.home_id) + return + + # Errors are planned to be converted to exceptions + # in PyTado library, so this can be removed + if "errors" in devices and devices["errors"]: + _LOGGER.error( + "Error for home ID %s while updating devices: %s", + self.home_id, + devices["errors"], + ) + return + for device in devices: device_short_serial_no = device["shortSerialNo"] _LOGGER.debug("Updating device %s", device_short_serial_no) diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index c14906c3a89..ee24af29b9d 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -179,7 +179,7 @@ TADO_TO_HA_SWING_MODE_MAP = { DOMAIN = "tado" SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}_{}" -SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED = "tado_mobile_device_update_received" +SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED = "tado_mobile_device_update_received_{}" UNIQUE_ID = "unique_id" DEFAULT_NAME = "Tado" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 9c50318639d..3ec75dee4bf 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -from typing import Any import voluptuous as vol @@ -22,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType +from . import TadoConnector from .const import CONF_HOME_ID, DATA, DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED _LOGGER = logging.getLogger(__name__) @@ -90,7 +90,7 @@ async def async_setup_entry( entry.async_on_unload( async_dispatcher_connect( hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(tado.home_id), update_devices, ) ) @@ -99,12 +99,12 @@ async def async_setup_entry( @callback def add_tracked_entities( hass: HomeAssistant, - tado: Any, + tado: TadoConnector, async_add_entities: AddEntitiesCallback, tracked: set[str], ) -> None: """Add new tracker entities from Tado.""" - _LOGGER.debug("Fetching Tado devices from API") + _LOGGER.debug("Fetching Tado devices from API for (newly) tracked entities") new_tracked = [] for device_key, device in tado.data["mobile_device"].items(): if device_key in tracked: @@ -128,7 +128,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): self, device_id: str, device_name: str, - tado: Any, + tado: TadoConnector, ) -> None: """Initialize a Tado Device Tracker entity.""" super().__init__() @@ -169,7 +169,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED.format(self._tado.home_id), self.on_demand_update, ) ) diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py index 935fa01eee0..335c3404cdd 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -121,5 +121,6 @@ class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): translation_domain=DOMAIN, translation_key="communication_error", ) from exc - self._attr_is_closing = False + finally: + self._attr_is_closing = False await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 8e77c68a880..071e0506c58 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -220,6 +220,26 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): hue, sat = tuple(int(val) for val in hs_color) await self.device.set_hsv(hue, sat, brightness, transition=transition) + async def _async_set_color_temp( + self, color_temp: float | int, brightness: int | None, transition: int | None + ) -> None: + device = self.device + valid_temperature_range = device.valid_temperature_range + requested_color_temp = round(color_temp) + # Clamp color temp to valid range + # since if the light in a group we will + # get requests for color temps for the range + # of the group and not the light + clamped_color_temp = min( + valid_temperature_range.max, + max(valid_temperature_range.min, requested_color_temp), + ) + await device.set_color_temp( + clamped_color_temp, + brightness=brightness, + transition=transition, + ) + async def _async_turn_on_with_brightness( self, brightness: int | None, transition: int | None ) -> None: @@ -234,10 +254,8 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): """Turn the light on.""" brightness, transition = self._async_extract_brightness_transition(**kwargs) if ATTR_COLOR_TEMP_KELVIN in kwargs: - await self.device.set_color_temp( - int(kwargs[ATTR_COLOR_TEMP_KELVIN]), - brightness=brightness, - transition=transition, + await self._async_set_color_temp( + kwargs[ATTR_COLOR_TEMP_KELVIN], brightness, transition ) if ATTR_HS_COLOR in kwargs: await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition) @@ -324,10 +342,8 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): # we have to set an HSV value to clear the effect # before we can set a color temp await self.device.set_hsv(0, 0, brightness) - await self.device.set_color_temp( - int(kwargs[ATTR_COLOR_TEMP_KELVIN]), - brightness=brightness, - transition=transition, + await self._async_set_color_temp( + kwargs[ATTR_COLOR_TEMP_KELVIN], brightness, transition ) elif ATTR_HS_COLOR in kwargs: await self._async_set_hsv(kwargs[ATTR_HS_COLOR], brightness, transition) diff --git a/homeassistant/components/tplink_lte/__init__.py b/homeassistant/components/tplink_lte/__init__.py index 378fd0a35d4..5ac3085520e 100644 --- a/homeassistant/components/tplink_lte/__init__.py +++ b/homeassistant/components/tplink_lte/__init__.py @@ -94,7 +94,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: tasks = [_setup_lte(hass, conf) for conf in domain_config] if tasks: - await asyncio.wait(tasks) + await asyncio.gather(*tasks) for conf in domain_config: for notify_conf in conf.get(CONF_NOTIFY, []): diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py index 457522dca82..3d29618281a 100644 --- a/homeassistant/components/trend/config_flow.py +++ b/homeassistant/components/trend/config_flow.py @@ -67,7 +67,6 @@ async def get_extended_options_schema(handler: SchemaCommonFlowHandler) -> vol.S CONF_MIN_GRADIENT, default=DEFAULT_MIN_GRADIENT ): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, step="any", mode=selector.NumberSelectorMode.BOX, ), diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 1104ecb98e1..b73e4669fbd 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -173,15 +173,6 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ufp_value="is_vehicle_detection_on", ufp_perm=PermRequired.NO_WRITE, ), - ProtectBinaryEntityDescription( - key="smart_face", - name="Detections: Face", - icon="mdi:mdi-face", - entity_category=EntityCategory.DIAGNOSTIC, - ufp_required_field="can_detect_face", - ufp_value="is_face_detection_on", - ufp_perm=PermRequired.NO_WRITE, - ), ProtectBinaryEntityDescription( key="smart_package", name="Detections: Package", @@ -202,13 +193,22 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ), ProtectBinaryEntityDescription( key="smart_smoke", - name="Detections: Smoke/CO", + name="Detections: Smoke", icon="mdi:fire", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="can_detect_smoke", ufp_value="is_smoke_detection_on", ufp_perm=PermRequired.NO_WRITE, ), + ProtectBinaryEntityDescription( + key="smart_cmonx", + name="Detections: CO", + icon="mdi:molecule-co", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_co", + ufp_value="is_co_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), ) LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -342,7 +342,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="motion", name="Motion", device_class=BinarySensorDeviceClass.MOTION, - ufp_value="is_motion_detected", + ufp_value="is_motion_currently_detected", ufp_enabled="is_motion_detection_on", ufp_event_obj="last_motion_event", ), @@ -350,7 +350,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_obj_any", name="Object Detected", icon="mdi:eye", - ufp_value="is_smart_detected", + ufp_value="is_smart_currently_detected", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_detect_event", ), @@ -358,7 +358,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_obj_person", name="Person Detected", icon="mdi:walk", - ufp_value="is_smart_detected", + ufp_value="is_person_currently_detected", ufp_required_field="can_detect_person", ufp_enabled="is_person_detection_on", ufp_event_obj="last_person_detect_event", @@ -367,25 +367,16 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_obj_vehicle", name="Vehicle Detected", icon="mdi:car", - ufp_value="is_smart_detected", + ufp_value="is_vehicle_currently_detected", ufp_required_field="can_detect_vehicle", ufp_enabled="is_vehicle_detection_on", ufp_event_obj="last_vehicle_detect_event", ), - ProtectBinaryEventEntityDescription( - key="smart_obj_face", - name="Face Detected", - icon="mdi:mdi-face", - ufp_value="is_smart_detected", - ufp_required_field="can_detect_face", - ufp_enabled="is_face_detection_on", - ufp_event_obj="last_face_detect_event", - ), ProtectBinaryEventEntityDescription( key="smart_obj_package", name="Package Detected", icon="mdi:package-variant-closed", - ufp_value="is_smart_detected", + ufp_value="is_package_currently_detected", ufp_required_field="can_detect_package", ufp_enabled="is_package_detection_on", ufp_event_obj="last_package_detect_event", @@ -394,7 +385,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_any", name="Audio Object Detected", icon="mdi:eye", - ufp_value="is_smart_detected", + ufp_value="is_audio_currently_detected", ufp_required_field="feature_flags.has_smart_detect", ufp_event_obj="last_smart_audio_detect_event", ), @@ -402,7 +393,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( key="smart_audio_smoke", name="Smoke Alarm Detected", icon="mdi:fire", - ufp_value="is_smart_detected", + ufp_value="is_smoke_currently_detected", ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", ufp_event_obj="last_smoke_detect_event", @@ -410,10 +401,10 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ProtectBinaryEventEntityDescription( key="smart_audio_cmonx", name="CO Alarm Detected", - icon="mdi:fire", - ufp_value="is_smart_detected", - ufp_required_field="can_detect_smoke", - ufp_enabled="is_smoke_detection_on", + icon="mdi:molecule-co", + ufp_value="is_cmonx_currently_detected", + ufp_required_field="can_detect_co", + ufp_enabled="is_co_detection_on", ufp_event_obj="last_cmonx_detect_event", ), ) @@ -619,7 +610,7 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - is_on = self.entity_description.get_is_on(self._event) + is_on = self.entity_description.get_is_on(self.device, self._event) self._attr_is_on: bool | None = is_on if not is_on: self._event = None diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 2fbf8f31071..edb2e28cc88 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.22.5", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.23.2", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 7f5612a72a8..08f5c2075e6 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel from homeassistant.helpers.entity import EntityDescription -from homeassistant.util import dt as dt_util from .utils import get_nested_attr @@ -114,17 +113,10 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): return cast(Event, getattr(obj, self.ufp_event_obj, None)) return None - def get_is_on(self, event: Event | None) -> bool: + def get_is_on(self, obj: T, event: Event | None) -> bool: """Return value if event is active.""" - if event is None: - return False - now = dt_util.utcnow() - value = now > event.start - if value and event.end is not None and now > event.end: - value = False - - return value + return event is not None and self.get_ufp_value(obj) @dataclass(frozen=True) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 3e2bd6ee858..abeb4859e6d 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -527,7 +527,7 @@ EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( name="License Plate Detected", icon="mdi:car", translation_key="license_plate", - ufp_value="is_smart_detected", + ufp_value="is_license_plate_currently_detected", ufp_required_field="can_detect_license_plate", ufp_event_obj="last_license_plate_detect_event", ), @@ -781,7 +781,7 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity): EventEntityMixin._async_update_device_from_protect(self, device) event = self._event entity_description = self.entity_description - is_on = entity_description.get_is_on(event) + is_on = entity_description.get_is_on(self.device, self._event) is_license_plate = ( entity_description.ufp_event_obj == "last_license_plate_detect_event" ) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index d8a3fc1c5bc..8466ffb6118 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -135,6 +135,16 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_set_method="set_osd_bitrate", ufp_perm=PermRequired.WRITE, ), + ProtectSwitchEntityDescription( + key="color_night_vision", + name="Color Night Vision", + icon="mdi:light-flood-down", + entity_category=EntityCategory.CONFIG, + ufp_required_field="has_color_night_vision", + ufp_value="isp_settings.is_color_night_vision_enabled", + ufp_set_method="set_color_night_vision", + ufp_perm=PermRequired.WRITE, + ), ProtectSwitchEntityDescription( key="motion", name="Detections: Motion", @@ -167,17 +177,6 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_set_method="set_vehicle_detection", ufp_perm=PermRequired.WRITE, ), - ProtectSwitchEntityDescription( - key="smart_face", - name="Detections: Face", - icon="mdi:human-greeting", - entity_category=EntityCategory.CONFIG, - ufp_required_field="can_detect_face", - ufp_value="is_face_detection_on", - ufp_enabled="is_recording_enabled", - ufp_set_method="set_face_detection", - ufp_perm=PermRequired.WRITE, - ), ProtectSwitchEntityDescription( key="smart_package", name="Detections: Package", @@ -202,7 +201,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ), ProtectSwitchEntityDescription( key="smart_smoke", - name="Detections: Smoke/CO", + name="Detections: Smoke", icon="mdi:fire", entity_category=EntityCategory.CONFIG, ufp_required_field="can_detect_smoke", @@ -212,13 +211,14 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( - key="color_night_vision", - name="Color Night Vision", - icon="mdi:light-flood-down", + key="smart_cmonx", + name="Detections: CO", + icon="mdi:molecule-co", entity_category=EntityCategory.CONFIG, - ufp_required_field="has_color_night_vision", - ufp_value="isp_settings.is_color_night_vision_enabled", - ufp_set_method="set_color_night_vision", + ufp_required_field="can_detect_co", + ufp_value="is_co_detection_on", + ufp_enabled="is_recording_enabled", + ufp_set_method="set_cmonx_detection", ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py index 9521d597303..c04e25355ff 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -186,9 +186,10 @@ class ValveEntity(Entity): @final @property - def state_attributes(self) -> dict[str, Any]: + def state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - + if not self.reports_position: + return None return {ATTR_CURRENT_POSITION: self.current_valve_position} @property diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index 5e9c881af85..1ff73048440 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -1,6 +1,7 @@ """Support for ZoneMinder.""" import logging +from requests.exceptions import ConnectionError as RequestsConnectionError import voluptuous as vol from zoneminder.zm import ZoneMinder @@ -75,7 +76,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: ) hass.data[DOMAIN][host_name] = zm_client - success = zm_client.login() and success + try: + success = zm_client.login() and success + except RequestsConnectionError as ex: + _LOGGER.error( + "ZoneMinder connection failure to %s: %s", + host_name, + ex, + ) def set_active_state(call: ServiceCall) -> None: """Set the ZoneMinder run state to the given state name.""" diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index e87e6f814cc..d8b2aa805e7 100644 --- a/homeassistant/components/zoneminder/camera.py +++ b/homeassistant/components/zoneminder/camera.py @@ -8,6 +8,7 @@ from zoneminder.zm import ZoneMinder from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -28,8 +29,9 @@ def setup_platform( zm_client: ZoneMinder for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): if not (monitors := zm_client.get_monitors()): - _LOGGER.warning("Could not fetch monitors from ZoneMinder host: %s") - return + raise PlatformNotReady( + "Camera could not fetch any monitors from ZoneMinder" + ) for monitor in monitors: _LOGGER.info("Initializing camera %s", monitor.id) diff --git a/homeassistant/components/zoneminder/manifest.json b/homeassistant/components/zoneminder/manifest.json index 80ecbe53315..f441a800555 100644 --- a/homeassistant/components/zoneminder/manifest.json +++ b/homeassistant/components/zoneminder/manifest.json @@ -1,9 +1,9 @@ { "domain": "zoneminder", "name": "ZoneMinder", - "codeowners": ["@rohankapoorcom"], + "codeowners": ["@rohankapoorcom", "@nabbi"], "documentation": "https://www.home-assistant.io/integrations/zoneminder", "iot_class": "local_polling", "loggers": ["zoneminder"], - "requirements": ["zm-py==0.5.2"] + "requirements": ["zm-py==0.5.4"] } diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py index c995e84343b..47863b5a5df 100644 --- a/homeassistant/components/zoneminder/sensor.py +++ b/homeassistant/components/zoneminder/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -77,7 +78,9 @@ def setup_platform( zm_client: ZoneMinder for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): if not (monitors := zm_client.get_monitors()): - _LOGGER.warning("Could not fetch any monitors from ZoneMinder") + raise PlatformNotReady( + "Sensor could not fetch any monitors from ZoneMinder" + ) for monitor in monitors: sensors.append(ZMSensorMonitors(monitor)) diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py index 985866272a6..b722ef53a77 100644 --- a/homeassistant/components/zoneminder/switch.py +++ b/homeassistant/components/zoneminder/switch.py @@ -11,6 +11,7 @@ from zoneminder.zm import ZoneMinder from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -42,8 +43,9 @@ def setup_platform( zm_client: ZoneMinder for zm_client in hass.data[ZONEMINDER_DOMAIN].values(): if not (monitors := zm_client.get_monitors()): - _LOGGER.warning("Could not fetch monitors from ZoneMinder") - return + raise PlatformNotReady( + "Switch could not fetch any monitors from ZoneMinder" + ) for monitor in monitors: switches.append(ZMSwitchMonitors(monitor, on_state, off_state)) diff --git a/homeassistant/const.py b/homeassistant/const.py index c91743e7ba9..9ddb002c261 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,7 +16,7 @@ from .helpers.deprecation import ( APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 1 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 743d3675a3b..1f3f96f300c 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -13,6 +13,7 @@ import logging import math import sys from timeit import default_timer as timer +from types import FunctionType from typing import ( TYPE_CHECKING, Any, @@ -374,6 +375,9 @@ class CachedProperties(type): # Check if an _attr_ class attribute exits and move it to __attr_. We check # __dict__ here because we don't care about _attr_ class attributes in parents. if attr_name in cls.__dict__: + attr = getattr(cls, attr_name) + if isinstance(attr, (FunctionType, property)): + raise TypeError(f"Can't override {attr_name} in subclass") setattr(cls, private_attr_name, getattr(cls, attr_name)) annotations = cls.__annotations__ if attr_name in annotations: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 655f46a8838..5eebfa4181b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -14,7 +14,7 @@ bcrypt==4.0.1 bleak-retry-connector==3.4.0 bleak==0.21.1 bluetooth-adapters==0.16.2 -bluetooth-auto-recovery==1.2.3 +bluetooth-auto-recovery==1.3.0 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 certifi>=2021.5.30 @@ -24,10 +24,10 @@ dbus-fast==2.21.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -habluetooth==2.0.2 +habluetooth==2.1.0 hass-nabucasa==0.75.1 hassil==1.5.1 -home-assistant-bluetooth==1.11.0 +home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240104.0 home-assistant-intents==2024.1.2 httpx==0.26.0 diff --git a/pyproject.toml b/pyproject.toml index bbf45725716..00d8b70f492 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.1.2" +version = "2024.1.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -37,7 +37,7 @@ dependencies = [ # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.26.0", - "home-assistant-bluetooth==1.11.0", + "home-assistant-bluetooth==1.12.0", "ifaddr==0.2.0", "Jinja2==3.1.2", "lru-dict==1.3.0", diff --git a/requirements.txt b/requirements.txt index 55cbdc31730..f86893bce46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 httpx==0.26.0 -home-assistant-bluetooth==1.11.0 +home-assistant-bluetooth==1.12.0 ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 012c601b785..872a05ee8fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -167,7 +167,7 @@ afsapi==0.2.7 agent-py==0.0.23 # homeassistant.components.geo_json_events -aio-geojson-generic-client==0.3 +aio-geojson-generic-client==0.4 # homeassistant.components.geonetnz_quakes aio-geojson-geonetnz-quakes==0.15 @@ -215,7 +215,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.7.0 +aiocomelit==0.7.3 # homeassistant.components.dhcp aiodiscover==1.6.0 @@ -356,7 +356,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==7.0.0 +aioshelly==7.1.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -368,7 +368,7 @@ aioslimproto==2.3.3 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.3.0 +aioswitcher==3.4.1 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -547,7 +547,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.4 +blinkpy==0.22.5 # homeassistant.components.bitcoin blockchain==1.4.4 @@ -566,7 +566,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.16.2 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.3 +bluetooth-auto-recovery==1.3.0 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -998,7 +998,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.0.2 +habluetooth==2.1.0 # homeassistant.components.cloud hass-nabucasa==0.75.1 @@ -1240,7 +1240,7 @@ maxcube-api==0.4.3 mbddns==0.1.2 # homeassistant.components.minecraft_server -mcstatus==11.0.0 +mcstatus==11.1.1 # homeassistant.components.meater meater-python==0.0.8 @@ -1548,7 +1548,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.1 +py-aosmith==1.0.4 # homeassistant.components.canary py-canary==0.5.3 @@ -2280,7 +2280,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.5 +pyunifiprotect==4.23.2 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2376,7 +2376,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.5 +reolink-aio==0.8.6 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2890,7 +2890,7 @@ zigpy-znp==0.12.1 zigpy==0.60.4 # homeassistant.components.zoneminder -zm-py==0.5.2 +zm-py==0.5.4 # homeassistant.components.zwave_js zwave-js-server-python==0.55.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 96775e05791..df239a77e63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ afsapi==0.2.7 agent-py==0.0.23 # homeassistant.components.geo_json_events -aio-geojson-generic-client==0.3 +aio-geojson-generic-client==0.4 # homeassistant.components.geonetnz_quakes aio-geojson-geonetnz-quakes==0.15 @@ -194,7 +194,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.7.0 +aiocomelit==0.7.3 # homeassistant.components.dhcp aiodiscover==1.6.0 @@ -329,7 +329,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==7.0.0 +aioshelly==7.1.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -341,7 +341,7 @@ aioslimproto==2.3.3 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.3.0 +aioswitcher==3.4.1 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -466,7 +466,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.22.4 +blinkpy==0.22.5 # homeassistant.components.blue_current bluecurrent-api==1.0.6 @@ -478,7 +478,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.16.2 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.3 +bluetooth-auto-recovery==1.3.0 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -803,7 +803,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.0.2 +habluetooth==2.1.0 # homeassistant.components.cloud hass-nabucasa==0.75.1 @@ -976,7 +976,7 @@ maxcube-api==0.4.3 mbddns==0.1.2 # homeassistant.components.minecraft_server -mcstatus==11.0.0 +mcstatus==11.1.1 # homeassistant.components.meater meater-python==0.0.8 @@ -1195,7 +1195,7 @@ pushover_complete==1.1.1 pvo==2.1.1 # homeassistant.components.aosmith -py-aosmith==1.0.1 +py-aosmith==1.0.4 # homeassistant.components.canary py-canary==0.5.3 @@ -1726,7 +1726,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.5 +pyunifiprotect==4.23.2 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1798,7 +1798,7 @@ renault-api==0.2.1 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.5 +reolink-aio==0.8.6 # homeassistant.components.rflink rflink==0.0.65 diff --git a/tests/components/alexa/test_common.py b/tests/components/alexa/test_common.py index d3ea1bcda3e..8c9cea526b6 100644 --- a/tests/components/alexa/test_common.py +++ b/tests/components/alexa/test_common.py @@ -224,9 +224,20 @@ class ReportedProperties: def assert_equal(self, namespace, name, value): """Assert a property is equal to a given value.""" + prop_set = None + prop_count = 0 for prop in self.properties: if prop["namespace"] == namespace and prop["name"] == name: assert prop["value"] == value - return prop + prop_set = prop + prop_count += 1 + + if prop_count > 1: + pytest.fail( + f"property {namespace}:{name} more than once in {self.properties!r}" + ) + + if prop_set: + return prop_set pytest.fail(f"property {namespace}:{name} not in {self.properties!r}") diff --git a/tests/components/alexa/test_smart_home_http.py b/tests/components/alexa/test_smart_home_http.py index b0f78e958d7..1426eac5c5d 100644 --- a/tests/components/alexa/test_smart_home_http.py +++ b/tests/components/alexa/test_smart_home_http.py @@ -1,6 +1,7 @@ """Test Smart Home HTTP endpoints.""" from http import HTTPStatus import json +import logging from typing import Any import pytest @@ -44,11 +45,16 @@ async def do_http_discovery(config, hass, hass_client): ], ) async def test_http_api( - hass: HomeAssistant, hass_client: ClientSessionGenerator, config: dict[str, Any] + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + hass_client: ClientSessionGenerator, + config: dict[str, Any], ) -> None: - """With `smart_home:` HTTP API is exposed.""" - response = await do_http_discovery(config, hass, hass_client) - response_data = await response.json() + """With `smart_home:` HTTP API is exposed and debug log is redacted.""" + with caplog.at_level(logging.DEBUG): + response = await do_http_discovery(config, hass, hass_client) + response_data = await response.json() + assert "'correlationToken': '**REDACTED**'" in caplog.text # Here we're testing just the HTTP view glue -- details of discovery are # covered in other tests. @@ -61,5 +67,4 @@ async def test_http_api_disabled( """Without `smart_home:`, the HTTP API is disabled.""" config = {"alexa": {}} response = await do_http_discovery(config, hass, hass_client) - assert response.status == HTTPStatus.NOT_FOUND diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py index 61c1fc9a562..f2c3ffc9c3c 100644 --- a/tests/components/aosmith/conftest.py +++ b/tests/components/aosmith/conftest.py @@ -54,10 +54,16 @@ async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, No get_energy_use_fixture = load_json_object_fixture( "get_energy_use_data.json", DOMAIN ) + get_all_device_info_fixture = load_json_object_fixture( + "get_all_device_info.json", DOMAIN + ) client_mock = MagicMock(AOSmithAPIClient) client_mock.get_devices = AsyncMock(return_value=get_devices_fixture) client_mock.get_energy_use_data = AsyncMock(return_value=get_energy_use_fixture) + client_mock.get_all_device_info = AsyncMock( + return_value=get_all_device_info_fixture + ) return client_mock diff --git a/tests/components/aosmith/fixtures/get_all_device_info.json b/tests/components/aosmith/fixtures/get_all_device_info.json new file mode 100644 index 00000000000..4d19a80a3ad --- /dev/null +++ b/tests/components/aosmith/fixtures/get_all_device_info.json @@ -0,0 +1,247 @@ +{ + "devices": [ + { + "alertSettings": { + "faultCode": { + "major": { + "email": true, + "sms": false + }, + "minor": { + "email": false, + "sms": false + } + }, + "operatingSetPoint": { + "email": false, + "sms": false + }, + "tankTemperature": { + "highTemperature": { + "email": false, + "sms": false, + "value": 160 + }, + "lowTemperature": { + "email": false, + "sms": false, + "value": 120 + } + } + }, + "brand": "aosmith", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "hardware": { + "hasBluetooth": true, + "interface": "CONTROL_PANEL" + }, + "id": "id", + "install": { + "address": "sample_address", + "city": "sample_city", + "country": "United States", + "date": "2023-09-29", + "email": "sample_email", + "group": "Residential", + "location": "Basement", + "phone": "sample_phone", + "postalCode": "sample_postal_code", + "professional": false, + "registeredOwner": "sample_owner", + "registrationDate": "2023-12-24", + "state": "sample_state" + }, + "isRegistered": true, + "junctionId": "junctionId", + "lastUpdate": 1703386473737, + "model": "HPTS-50 200 202172000", + "name": "Water Heater", + "permissions": "USER", + "productId": "100350404", + "serial": "sample_serial", + "users": [ + { + "contactId": "sample_contact_id", + "email": "sample_email", + "firstName": "sample_first_name", + "isSelf": true, + "lastName": "sample_last_name", + "permissions": "USER" + } + ], + "data": { + "activeAlerts": [], + "alertHistory": [], + "isOnline": true, + "isWifi": true, + "lastUpdate": 1703138389000, + "signalStrength": null, + "heaterSsid": "sample_heater_ssid", + "ssid": "sample_ssid", + "temperatureSetpoint": 145, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 145, + "temperatureSetpointMaximum": 145, + "error": "", + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "firmwareVersion": "2.14", + "hotWaterStatus": "HIGH", + "isAdvancedLoadUpMore": false, + "isCtaUcmPresent": false, + "isDemandResponsePaused": false, + "isEnrolled": false, + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 100, + "isLowes": false, + "canEditTimeOfUse": false, + "timeOfUseData": null, + "consumerScheduleData": null + } + } + ], + "energy_use_data": { + "junctionId": { + "average": 2.4744000000000006, + "graphData": [ + { + "date": "2023-11-26T04:00:00.000Z", + "kwh": 0.936 + }, + { + "date": "2023-11-27T04:00:00.000Z", + "kwh": 4.248 + }, + { + "date": "2023-11-28T04:00:00.000Z", + "kwh": 1.002 + }, + { + "date": "2023-11-29T04:00:00.000Z", + "kwh": 3.078 + }, + { + "date": "2023-11-30T04:00:00.000Z", + "kwh": 1.896 + }, + { + "date": "2023-12-01T04:00:00.000Z", + "kwh": 1.98 + }, + { + "date": "2023-12-02T04:00:00.000Z", + "kwh": 2.112 + }, + { + "date": "2023-12-03T04:00:00.000Z", + "kwh": 3.222 + }, + { + "date": "2023-12-04T04:00:00.000Z", + "kwh": 4.254 + }, + { + "date": "2023-12-05T04:00:00.000Z", + "kwh": 4.05 + }, + { + "date": "2023-12-06T04:00:00.000Z", + "kwh": 3.312 + }, + { + "date": "2023-12-07T04:00:00.000Z", + "kwh": 2.334 + }, + { + "date": "2023-12-08T04:00:00.000Z", + "kwh": 2.418 + }, + { + "date": "2023-12-09T04:00:00.000Z", + "kwh": 2.19 + }, + { + "date": "2023-12-10T04:00:00.000Z", + "kwh": 3.786 + }, + { + "date": "2023-12-11T04:00:00.000Z", + "kwh": 5.292 + }, + { + "date": "2023-12-12T04:00:00.000Z", + "kwh": 1.38 + }, + { + "date": "2023-12-13T04:00:00.000Z", + "kwh": 3.324 + }, + { + "date": "2023-12-14T04:00:00.000Z", + "kwh": 1.092 + }, + { + "date": "2023-12-15T04:00:00.000Z", + "kwh": 0.606 + }, + { + "date": "2023-12-16T04:00:00.000Z", + "kwh": 0 + }, + { + "date": "2023-12-17T04:00:00.000Z", + "kwh": 2.838 + }, + { + "date": "2023-12-18T04:00:00.000Z", + "kwh": 2.382 + }, + { + "date": "2023-12-19T04:00:00.000Z", + "kwh": 2.904 + }, + { + "date": "2023-12-20T04:00:00.000Z", + "kwh": 1.914 + }, + { + "date": "2023-12-21T04:00:00.000Z", + "kwh": 3.93 + }, + { + "date": "2023-12-22T04:00:00.000Z", + "kwh": 3.666 + }, + { + "date": "2023-12-23T04:00:00.000Z", + "kwh": 2.766 + }, + { + "date": "2023-12-24T04:00:00.000Z", + "kwh": 1.32 + } + ], + "lifetimeKwh": 203.259, + "startDate": "Nov 26" + } + } +} diff --git a/tests/components/aosmith/snapshots/test_diagnostics.ambr b/tests/components/aosmith/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..8704cdaa214 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_diagnostics.ambr @@ -0,0 +1,252 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'devices': list([ + dict({ + 'alertSettings': dict({ + 'faultCode': dict({ + 'major': dict({ + 'email': '**REDACTED**', + 'sms': False, + }), + 'minor': dict({ + 'email': '**REDACTED**', + 'sms': False, + }), + }), + 'operatingSetPoint': dict({ + 'email': '**REDACTED**', + 'sms': False, + }), + 'tankTemperature': dict({ + 'highTemperature': dict({ + 'email': '**REDACTED**', + 'sms': False, + 'value': 160, + }), + 'lowTemperature': dict({ + 'email': '**REDACTED**', + 'sms': False, + 'value': 120, + }), + }), + }), + 'brand': 'aosmith', + 'data': dict({ + 'activeAlerts': list([ + ]), + 'alertHistory': list([ + ]), + 'canEditTimeOfUse': False, + 'consumerScheduleData': None, + 'electricModeRemainingDays': 100, + 'error': '', + 'firmwareVersion': '2.14', + 'heaterSsid': '**REDACTED**', + 'hotWaterStatus': 'HIGH', + 'isAdvancedLoadUpMore': False, + 'isCtaUcmPresent': False, + 'isDemandResponsePaused': False, + 'isEnrolled': False, + 'isLowes': False, + 'isOnline': True, + 'isWifi': True, + 'lastUpdate': 1703138389000, + 'mode': 'HEAT_PUMP', + 'modePending': False, + 'modes': list([ + dict({ + 'controls': None, + 'mode': 'HYBRID', + }), + dict({ + 'controls': None, + 'mode': 'HEAT_PUMP', + }), + dict({ + 'controls': 'SELECT_DAYS', + 'mode': 'ELECTRIC', + }), + dict({ + 'controls': 'SELECT_DAYS', + 'mode': 'VACATION', + }), + ]), + 'signalStrength': None, + 'ssid': '**REDACTED**', + 'temperatureSetpoint': 145, + 'temperatureSetpointMaximum': 145, + 'temperatureSetpointPending': False, + 'temperatureSetpointPrevious': 145, + 'timeOfUseData': None, + 'vacationModeRemainingDays': 0, + }), + 'deviceType': 'NEXT_GEN_HEAT_PUMP', + 'dsn': '**REDACTED**', + 'hardware': dict({ + 'hasBluetooth': True, + 'interface': 'CONTROL_PANEL', + }), + 'id': '**REDACTED**', + 'install': dict({ + 'address': '**REDACTED**', + 'city': '**REDACTED**', + 'country': 'United States', + 'date': '2023-09-29', + 'email': '**REDACTED**', + 'group': 'Residential', + 'location': 'Basement', + 'phone': '**REDACTED**', + 'postalCode': '**REDACTED**', + 'professional': False, + 'registeredOwner': '**REDACTED**', + 'registrationDate': '2023-12-24', + 'state': '**REDACTED**', + }), + 'isRegistered': True, + 'junctionId': 'junctionId', + 'lastUpdate': 1703386473737, + 'model': 'HPTS-50 200 202172000', + 'name': 'Water Heater', + 'permissions': 'USER', + 'productId': '100350404', + 'serial': '**REDACTED**', + 'users': list([ + dict({ + 'contactId': '**REDACTED**', + 'email': '**REDACTED**', + 'firstName': '**REDACTED**', + 'isSelf': True, + 'lastName': '**REDACTED**', + 'permissions': 'USER', + }), + ]), + }), + ]), + 'energy_use_data': dict({ + 'junctionId': dict({ + 'average': 2.4744000000000006, + 'graphData': list([ + dict({ + 'date': '2023-11-26T04:00:00.000Z', + 'kwh': 0.936, + }), + dict({ + 'date': '2023-11-27T04:00:00.000Z', + 'kwh': 4.248, + }), + dict({ + 'date': '2023-11-28T04:00:00.000Z', + 'kwh': 1.002, + }), + dict({ + 'date': '2023-11-29T04:00:00.000Z', + 'kwh': 3.078, + }), + dict({ + 'date': '2023-11-30T04:00:00.000Z', + 'kwh': 1.896, + }), + dict({ + 'date': '2023-12-01T04:00:00.000Z', + 'kwh': 1.98, + }), + dict({ + 'date': '2023-12-02T04:00:00.000Z', + 'kwh': 2.112, + }), + dict({ + 'date': '2023-12-03T04:00:00.000Z', + 'kwh': 3.222, + }), + dict({ + 'date': '2023-12-04T04:00:00.000Z', + 'kwh': 4.254, + }), + dict({ + 'date': '2023-12-05T04:00:00.000Z', + 'kwh': 4.05, + }), + dict({ + 'date': '2023-12-06T04:00:00.000Z', + 'kwh': 3.312, + }), + dict({ + 'date': '2023-12-07T04:00:00.000Z', + 'kwh': 2.334, + }), + dict({ + 'date': '2023-12-08T04:00:00.000Z', + 'kwh': 2.418, + }), + dict({ + 'date': '2023-12-09T04:00:00.000Z', + 'kwh': 2.19, + }), + dict({ + 'date': '2023-12-10T04:00:00.000Z', + 'kwh': 3.786, + }), + dict({ + 'date': '2023-12-11T04:00:00.000Z', + 'kwh': 5.292, + }), + dict({ + 'date': '2023-12-12T04:00:00.000Z', + 'kwh': 1.38, + }), + dict({ + 'date': '2023-12-13T04:00:00.000Z', + 'kwh': 3.324, + }), + dict({ + 'date': '2023-12-14T04:00:00.000Z', + 'kwh': 1.092, + }), + dict({ + 'date': '2023-12-15T04:00:00.000Z', + 'kwh': 0.606, + }), + dict({ + 'date': '2023-12-16T04:00:00.000Z', + 'kwh': 0, + }), + dict({ + 'date': '2023-12-17T04:00:00.000Z', + 'kwh': 2.838, + }), + dict({ + 'date': '2023-12-18T04:00:00.000Z', + 'kwh': 2.382, + }), + dict({ + 'date': '2023-12-19T04:00:00.000Z', + 'kwh': 2.904, + }), + dict({ + 'date': '2023-12-20T04:00:00.000Z', + 'kwh': 1.914, + }), + dict({ + 'date': '2023-12-21T04:00:00.000Z', + 'kwh': 3.93, + }), + dict({ + 'date': '2023-12-22T04:00:00.000Z', + 'kwh': 3.666, + }), + dict({ + 'date': '2023-12-23T04:00:00.000Z', + 'kwh': 2.766, + }), + dict({ + 'date': '2023-12-24T04:00:00.000Z', + 'kwh': 1.32, + }), + ]), + 'lifetimeKwh': 203.259, + 'startDate': 'Nov 26', + }), + }), + }) +# --- diff --git a/tests/components/aosmith/test_diagnostics.py b/tests/components/aosmith/test_diagnostics.py new file mode 100644 index 00000000000..9090ef5e7b7 --- /dev/null +++ b/tests/components/aosmith/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Tests for the diagnostics data provided by the A. O. Smith integration.""" + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 4ec6c4e5388..a7e776f3a26 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -55,7 +55,7 @@ def macos_adapter(): ), patch( "bluetooth_adapters.systems.platform.system", return_value="Darwin", - ): + ), patch("habluetooth.scanner.SYSTEM", "Darwin"): yield @@ -65,7 +65,7 @@ def windows_adapter(): with patch( "bluetooth_adapters.systems.platform.system", return_value="Windows", - ): + ), patch("habluetooth.scanner.SYSTEM", "Windows"): yield @@ -81,7 +81,7 @@ def no_adapter_fixture(): ), patch( "bluetooth_adapters.systems.platform.system", return_value="Linux", - ), patch( + ), patch("habluetooth.scanner.SYSTEM", "Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", @@ -102,7 +102,7 @@ def one_adapter_fixture(): ), patch( "bluetooth_adapters.systems.platform.system", return_value="Linux", - ), patch( + ), patch("habluetooth.scanner.SYSTEM", "Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", ), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 7673acb80dc..837c058fa6b 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -571,6 +571,7 @@ async def test_restart_takes_longer_than_watchdog_time( assert "already restarting" in caplog.text +@pytest.mark.skipif("platform.system() != 'Darwin'") async def test_setup_and_stop_macos( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, macos_adapter: None ) -> None: diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 42852b15206..1e1877ae13c 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -76,16 +76,9 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: # Attributes that we mock with default values. - mock_cloud.id_token = jwt.encode( - { - "email": "hello@home-assistant.io", - "custom:sub-exp": "2018-01-03", - "cognito:username": "abcdefghjkl", - }, - "test", - ) - mock_cloud.access_token = "test_access_token" - mock_cloud.refresh_token = "test_refresh_token" + mock_cloud.id_token = None + mock_cloud.access_token = None + mock_cloud.refresh_token = None # Properties that we keep as properties. @@ -122,11 +115,31 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: When called, it should call the on_start callback. """ + mock_cloud.id_token = jwt.encode( + { + "email": "hello@home-assistant.io", + "custom:sub-exp": "2018-01-03", + "cognito:username": "abcdefghjkl", + }, + "test", + ) + mock_cloud.access_token = "test_access_token" + mock_cloud.refresh_token = "test_refresh_token" on_start_callback = mock_cloud.register_on_start.call_args[0][0] await on_start_callback() mock_cloud.login.side_effect = mock_login + async def mock_logout() -> None: + """Mock logout.""" + mock_cloud.id_token = None + mock_cloud.access_token = None + mock_cloud.refresh_token = None + await mock_cloud.stop() + await mock_cloud.client.logout_cleanups() + + mock_cloud.logout.side_effect = mock_logout + yield mock_cloud diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 29930632691..409d86d6e37 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -113,8 +113,8 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: }, ) await hass.async_block_till_done() - on_start_callback = cloud.register_on_start.call_args[0][0] - await on_start_callback() + await cloud.login("test-user", "test-pass") + cloud.login.reset_mock() async def test_google_actions_sync( diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 850f8e12e02..c537169bf01 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -19,7 +19,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import Unauthorized from homeassistant.setup import async_setup_component -from tests.common import MockUser +from tests.common import MockConfigEntry, MockUser async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: @@ -230,6 +230,7 @@ async def test_async_get_or_create_cloudhook( """Test async_get_or_create_cloudhook.""" assert await async_setup_component(hass, "cloud", {"cloud": {}}) await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") webhook_id = "mock-webhook-id" cloudhook_url = "https://cloudhook.nabu.casa/abcdefg" @@ -262,7 +263,7 @@ async def test_async_get_or_create_cloudhook( async_create_cloudhook_mock.assert_not_called() # Simulate logged out - cloud.id_token = None + await cloud.logout() # Not logged in with pytest.raises(CloudNotAvailable): @@ -274,3 +275,18 @@ async def test_async_get_or_create_cloudhook( # Not connected with pytest.raises(CloudNotConnected): await async_get_or_create_cloudhook(hass, webhook_id) + + +async def test_cloud_logout( + hass: HomeAssistant, + cloud: MagicMock, +) -> None: + """Test cloud setup with existing config entry when user is logged out.""" + assert cloud.is_logged_in is False + + mock_config_entry = MockConfigEntry(domain=DOMAIN) + mock_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + assert cloud.is_logged_in is False diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index 9f1af8aaeb4..5480cd557fd 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -42,6 +42,7 @@ async def test_cloud_system_health( }, ) await hass.async_block_till_done() + await cloud.login("test-user", "test-pass") cloud.remote.snitun_server = "us-west-1" cloud.remote.certificate_status = CertificateStatus.READY diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index dc32747182d..4069edcb744 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -4,7 +4,7 @@ from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock -from hass_nabucasa.voice import MAP_VOICE, VoiceError +from hass_nabucasa.voice import MAP_VOICE, VoiceError, VoiceTokenError import pytest import voluptuous as vol @@ -189,3 +189,55 @@ async def test_get_tts_audio( assert mock_process_tts.call_args.kwargs["language"] == "en-US" assert mock_process_tts.call_args.kwargs["gender"] == "female" assert mock_process_tts.call_args.kwargs["output"] == "mp3" + + +@pytest.mark.parametrize( + ("data", "expected_url_suffix"), + [ + ({"platform": DOMAIN}, DOMAIN), + ({"engine_id": DOMAIN}, DOMAIN), + ], +) +async def test_get_tts_audio_logged_out( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + cloud: MagicMock, + data: dict[str, Any], + expected_url_suffix: str, +) -> None: + """Test cloud get tts audio when user is logged out.""" + mock_process_tts = AsyncMock( + side_effect=VoiceTokenError("No token!"), + ) + cloud.voice.process_tts = mock_process_tts + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + client = await hass_client() + + url = "/api/tts_get_url" + data |= {"message": "There is someone at the door."} + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + ), + } + await hass.async_block_till_done() + + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + assert mock_process_tts.call_args.kwargs["language"] == "en-US" + assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 480d1ef83aa..0503017f634 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -34,7 +34,8 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - # ent3 = cover with simple tilt functions and no position # ent4 = cover with all tilt functions but no position # ent5 = cover with all functions - ent1, ent2, ent3, ent4, ent5 = platform.ENTITIES + # ent6 = cover with only open/close, but also reports opening/closing + ent1, ent2, ent3, ent4, ent5, ent6 = platform.ENTITIES # Test init all covers should be open assert is_open(hass, ent1) @@ -42,6 +43,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - assert is_open(hass, ent3) assert is_open(hass, ent4) assert is_open(hass, ent5) + assert is_open(hass, ent6) # call basic toggle services await call_service(hass, SERVICE_TOGGLE, ent1) @@ -49,13 +51,15 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - await call_service(hass, SERVICE_TOGGLE, ent3) await call_service(hass, SERVICE_TOGGLE, ent4) await call_service(hass, SERVICE_TOGGLE, ent5) + await call_service(hass, SERVICE_TOGGLE, ent6) - # entities without stop should be closed and with stop should be closing + # entities should be either closed or closing, depending on if they report transitional states assert is_closed(hass, ent1) assert is_closing(hass, ent2) assert is_closed(hass, ent3) assert is_closed(hass, ent4) assert is_closing(hass, ent5) + assert is_closing(hass, ent6) # call basic toggle services and set different cover position states await call_service(hass, SERVICE_TOGGLE, ent1) @@ -65,6 +69,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - await call_service(hass, SERVICE_TOGGLE, ent4) set_cover_position(ent5, 15) await call_service(hass, SERVICE_TOGGLE, ent5) + await call_service(hass, SERVICE_TOGGLE, ent6) # entities should be in correct state depending on the SUPPORT_STOP feature and cover position assert is_open(hass, ent1) @@ -72,6 +77,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - assert is_open(hass, ent3) assert is_open(hass, ent4) assert is_open(hass, ent5) + assert is_opening(hass, ent6) # call basic toggle services await call_service(hass, SERVICE_TOGGLE, ent1) @@ -79,6 +85,7 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - await call_service(hass, SERVICE_TOGGLE, ent3) await call_service(hass, SERVICE_TOGGLE, ent4) await call_service(hass, SERVICE_TOGGLE, ent5) + await call_service(hass, SERVICE_TOGGLE, ent6) # entities should be in correct state depending on the SUPPORT_STOP feature and cover position assert is_closed(hass, ent1) @@ -86,6 +93,12 @@ async def test_services(hass: HomeAssistant, enable_custom_integrations: None) - assert is_closed(hass, ent3) assert is_closed(hass, ent4) assert is_opening(hass, ent5) + assert is_closing(hass, ent6) + + # Without STOP but still reports opening/closing has a 4th possible toggle state + set_state(ent6, STATE_CLOSED) + await call_service(hass, SERVICE_TOGGLE, ent6) + assert is_opening(hass, ent6) def call_service(hass, service, ent): @@ -100,6 +113,11 @@ def set_cover_position(ent, position) -> None: ent._values["current_cover_position"] = position +def set_state(ent, state) -> None: + """Set the state of a cover.""" + ent._values["state"] = state + + def is_open(hass, ent): """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(ent.entity_id, STATE_OPEN) diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py index 06011fb8e6b..df0ce6d50d5 100644 --- a/tests/components/holiday/test_calendar.py +++ b/tests/components/holiday/test_calendar.py @@ -227,3 +227,88 @@ async def test_no_next_event( assert state is not None assert state.state == "off" assert state.attributes == {"friendly_name": "Germany"} + + +async def test_language_not_exist( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test when language doesn't exist it will fallback to country default language.""" + + hass.config.language = "nb" # Norweigan language "Norks bokmål" + hass.config.country = "NO" + + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "NO"}, + title="Norge", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("calendar.norge") + assert state is not None + assert state.state == "on" + assert state.attributes == { + "friendly_name": "Norge", + "all_day": True, + "description": "", + "end_time": "2023-01-02 00:00:00", + "location": "Norge", + "message": "Første nyttårsdag", + "start_time": "2023-01-01 00:00:00", + } + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.norge", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.norge": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "Første nyttårsdag", + "location": "Norge", + } + ] + } + } + + # Test with English as exist as optional language for Norway + hass.config.language = "en" + hass.config.country = "NO" + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.norge", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.norge": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "Norge", + } + ] + } + } diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py index e901fd7f29e..f3448947cf8 100644 --- a/tests/components/knx/test_device_trigger.py +++ b/tests/components/knx/test_device_trigger.py @@ -150,7 +150,6 @@ async def test_remove_device_trigger( }, ) - assert len(hass.data[DOMAIN].telegrams._jobs) == 1 await knx.receive_write("0/0/1", (0x03, 0x2F)) assert len(calls) == 1 assert calls.pop().data["catch_all"] == "telegram - 0/0/1" @@ -161,8 +160,6 @@ async def test_remove_device_trigger( {ATTR_ENTITY_ID: f"automation.{automation_name}"}, blocking=True, ) - - assert len(hass.data[DOMAIN].telegrams._jobs) == 0 await knx.receive_write("0/0/1", (0x03, 0x2F)) assert len(calls) == 0 diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index 56be9132f19..92d6c647d8f 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -41,6 +41,7 @@ TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( version=JavaStatusVersion.build(TEST_JAVA_STATUS_RESPONSE_RAW["version"]), motd=Motd.parse(TEST_JAVA_STATUS_RESPONSE_RAW["description"], bedrock=False), icon=None, + enforces_secure_chat=False, latency=5, ) diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index a602f1e3065..3aa2f96f478 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -115,6 +115,63 @@ async def test_controlling_state_via_topic( assert state.state == "" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + text.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "min": 5, + "max": 5, + } + } + } + ], +) +async def test_forced_text_length( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a text entity that only allows a fixed length.""" + await mqtt_mock_entry() + + state = hass.states.get("text.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", "12345") + state = hass.states.get("text.test") + assert state.state == "12345" + + caplog.clear() + # Text too long + async_fire_mqtt_message(hass, "state-topic", "123456") + state = hass.states.get("text.test") + assert state.state == "12345" + assert ( + "ValueError: Entity text.test provides state 123456 " + "which is too long (maximum length 5)" in caplog.text + ) + + caplog.clear() + # Text too short + async_fire_mqtt_message(hass, "state-topic", "1") + state = hass.states.get("text.test") + assert state.state == "12345" + assert ( + "ValueError: Entity text.test provides state 1 " + "which is too short (minimum length 5)" in caplog.text + ) + # Valid update + async_fire_mqtt_message(hass, "state-topic", "54321") + state = hass.states.get("text.test") + assert state.state == "54321" + + @pytest.mark.parametrize( "hass_config", [ @@ -211,7 +268,7 @@ async def test_attribute_validation_max_greater_then_min( ) -> None: """Test the validation of min and max configuration attributes.""" assert await mqtt_mock_entry() - assert "text length min must be >= max" in caplog.text + assert "text length min must be <= max" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/swiss_public_transport/test_init.py b/tests/components/swiss_public_transport/test_init.py new file mode 100644 index 00000000000..f2b4e41ed71 --- /dev/null +++ b/tests/components/swiss_public_transport/test_init.py @@ -0,0 +1,85 @@ +"""Test the swiss_public_transport config flow.""" +from unittest.mock import AsyncMock, patch + +from homeassistant.components.swiss_public_transport.const import ( + CONF_DESTINATION, + CONF_START, + DOMAIN, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +MOCK_DATA_STEP = { + CONF_START: "test_start", + CONF_DESTINATION: "test_destination", +} + +CONNECTIONS = [ + { + "departure": "2024-01-06T18:03:00+0100", + "number": 0, + "platform": 0, + "transfers": 0, + "duration": "10", + "delay": 0, + }, + { + "departure": "2024-01-06T18:04:00+0100", + "number": 1, + "platform": 1, + "transfers": 0, + "duration": "10", + "delay": 0, + }, + { + "departure": "2024-01-06T18:05:00+0100", + "number": 2, + "platform": 2, + "transfers": 0, + "duration": "10", + "delay": 0, + }, +] + + +async def test_migration_1_to_2( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test successful setup.""" + + with patch( + "homeassistant.components.swiss_public_transport.OpendataTransport", + return_value=AsyncMock(), + ) as mock: + mock().connections = CONNECTIONS + + config_entry_faulty = MockConfigEntry( + domain=DOMAIN, + data=MOCK_DATA_STEP, + title="MIGRATION_TEST", + minor_version=1, + ) + config_entry_faulty.add_to_hass(hass) + + # Setup the config entry + await hass.config_entries.async_setup(config_entry_faulty.entry_id) + await hass.async_block_till_done() + assert entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + (Platform.SENSOR, DOMAIN, "test_start test_destination_departure") + ) + ) + + # Check change in config entry + assert config_entry_faulty.minor_version == 2 + assert config_entry_faulty.unique_id == "test_start test_destination" + + # Check "None" is gone + assert not entity_registry.async_is_registered( + entity_registry.entities.get_entity_id( + (Platform.SENSOR, DOMAIN, "None_departure") + ) + ) diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index eaf6a69cb3d..aa0370bd347 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -26,6 +26,10 @@ DUMMY_DEVICE_ID1 = "a123bc" DUMMY_DEVICE_ID2 = "cafe12" DUMMY_DEVICE_ID3 = "bada77" DUMMY_DEVICE_ID4 = "bbd164" +DUMMY_DEVICE_KEY1 = "18" +DUMMY_DEVICE_KEY2 = "01" +DUMMY_DEVICE_KEY3 = "12" +DUMMY_DEVICE_KEY4 = "07" DUMMY_DEVICE_NAME1 = "Plug 23BC" DUMMY_DEVICE_NAME2 = "Heater FE12" DUMMY_DEVICE_NAME3 = "Breeze AB39" @@ -67,6 +71,7 @@ DUMMY_PLUG_DEVICE = SwitcherPowerPlug( DeviceType.POWER_PLUG, DeviceState.ON, DUMMY_DEVICE_ID1, + DUMMY_DEVICE_KEY1, DUMMY_IP_ADDRESS1, DUMMY_MAC_ADDRESS1, DUMMY_DEVICE_NAME1, @@ -78,6 +83,7 @@ DUMMY_WATER_HEATER_DEVICE = SwitcherWaterHeater( DeviceType.V4, DeviceState.ON, DUMMY_DEVICE_ID2, + DUMMY_DEVICE_KEY2, DUMMY_IP_ADDRESS2, DUMMY_MAC_ADDRESS2, DUMMY_DEVICE_NAME2, @@ -91,6 +97,7 @@ DUMMY_SHUTTER_DEVICE = SwitcherShutter( DeviceType.RUNNER, DeviceState.ON, DUMMY_DEVICE_ID4, + DUMMY_DEVICE_KEY4, DUMMY_IP_ADDRESS4, DUMMY_MAC_ADDRESS4, DUMMY_DEVICE_NAME4, @@ -102,6 +109,7 @@ DUMMY_THERMOSTAT_DEVICE = SwitcherThermostat( DeviceType.BREEZE, DeviceState.ON, DUMMY_DEVICE_ID3, + DUMMY_DEVICE_KEY3, DUMMY_IP_ADDRESS3, DUMMY_MAC_ADDRESS3, DUMMY_DEVICE_NAME3, diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index f238bceb39e..f49ab99ba6c 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -25,6 +25,7 @@ async def test_diagnostics( { "auto_shutdown": "02:00:00", "device_id": REDACTED, + "device_key": REDACTED, "device_state": { "__type": "", "repr": "", diff --git a/tests/components/tado/fixtures/mobile_devices.json b/tests/components/tado/fixtures/mobile_devices.json new file mode 100644 index 00000000000..80700a1e426 --- /dev/null +++ b/tests/components/tado/fixtures/mobile_devices.json @@ -0,0 +1,26 @@ +[ + { + "name": "Home", + "id": 123456, + "settings": { + "geoTrackingEnabled": false, + "specialOffersEnabled": false, + "onDemandLogRetrievalEnabled": false, + "pushNotifications": { + "lowBatteryReminder": true, + "awayModeReminder": true, + "homeModeReminder": true, + "openWindowReminder": true, + "energySavingsReportReminder": true, + "incidentDetection": true, + "energyIqReminder": false + } + }, + "deviceMetadata": { + "platform": "Android", + "osVersion": "14", + "model": "Samsung", + "locale": "nl" + } + } +] diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 21e0e255ed1..dd7c108c984 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -17,6 +17,7 @@ async def async_init_integration( token_fixture = "tado/token.json" devices_fixture = "tado/devices.json" + mobile_devices_fixture = "tado/mobile_devices.json" me_fixture = "tado/me.json" weather_fixture = "tado/weather.json" home_state_fixture = "tado/home_state.json" @@ -70,6 +71,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/devices", text=load_fixture(devices_fixture), ) + m.get( + "https://my.tado.com/api/v2/homes/1/mobileDevices", + text=load_fixture(mobile_devices_fixture), + ) m.get( "https://my.tado.com/api/v2/devices/WR1/", text=load_fixture(device_wr1_fixture), diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index 348fcc50ce0..ada454e0192 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -264,6 +264,26 @@ async def test_color_temp_light( bulb.set_color_temp.assert_called_with(6666, brightness=None, transition=None) bulb.set_color_temp.reset_mock() + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 20000}, + blocking=True, + ) + bulb.set_color_temp.assert_called_with(9000, brightness=None, transition=None) + bulb.set_color_temp.reset_mock() + + # Verify color temp is clamped to the valid range + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP_KELVIN: 1}, + blocking=True, + ) + bulb.set_color_temp.assert_called_with(4000, brightness=None, transition=None) + bulb.set_color_temp.reset_mock() + async def test_brightness_only_light(hass: HomeAssistant) -> None: """Test a light.""" diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 17db53d05ec..70a21a324d0 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -33,12 +33,14 @@ from .utils import ( CAMERA_SWITCHES_BASIC = [ d for d in CAMERA_SWITCHES - if d.name != "Detections: Face" - and d.name != "Detections: Package" - and d.name != "Detections: License Plate" - and d.name != "Detections: Smoke/CO" - and d.name != "SSH Enabled" - and d.name != "Color Night Vision" + if ( + not d.name.startswith("Detections:") + and d.name != "SSH Enabled" + and d.name != "Color Night Vision" + ) + or d.name == "Detections: Motion" + or d.name == "Detections: Person" + or d.name == "Detections: Vehicle" ] CAMERA_SWITCHES_NO_EXTRA = [ d for d in CAMERA_SWITCHES_BASIC if d.name not in ("High FPS", "Privacy Mode") diff --git a/tests/components/valve/snapshots/test_init.ambr b/tests/components/valve/snapshots/test_init.ambr new file mode 100644 index 00000000000..b46d76b6f0c --- /dev/null +++ b/tests/components/valve/snapshots/test_init.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_valve_setup + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.valve', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_valve_setup.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 50, + 'friendly_name': 'Valve', + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.valve_2', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_valve_setup.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve', + 'restored': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.valve', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_valve_setup.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Valve', + 'restored': True, + 'supported_features': , + }), + 'context': , + 'entity_id': 'valve.valve_2', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/valve/test_init.py b/tests/components/valve/test_init.py index 08b0771da8e..6f5c49830bb 100644 --- a/tests/components/valve/test_init.py +++ b/tests/components/valve/test_init.py @@ -2,6 +2,7 @@ from collections.abc import Generator import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.valve import ( DOMAIN, @@ -193,26 +194,34 @@ def mock_config_entry(hass) -> tuple[MockConfigEntry, list[ValveEntity]]: async def test_valve_setup( - hass: HomeAssistant, mock_config_entry: tuple[MockConfigEntry, list[ValveEntity]] + hass: HomeAssistant, + mock_config_entry: tuple[MockConfigEntry, list[ValveEntity]], + snapshot: SnapshotAssertion, ) -> None: """Test setup and tear down of valve platform and entity.""" config_entry = mock_config_entry[0] assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - entity_id = mock_config_entry[1][0].entity_id assert config_entry.state == ConfigEntryState.LOADED - assert hass.states.get(entity_id) + for entity in mock_config_entry[1]: + entity_id = entity.entity_id + state = hass.states.get(entity_id) + assert state + assert state == snapshot assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.NOT_LOADED - entity_state = hass.states.get(entity_id) - assert entity_state - assert entity_state.state == STATE_UNAVAILABLE + for entity in mock_config_entry[1]: + entity_id = entity.entity_id + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + assert state == snapshot async def test_services( diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index a18d8963947..2e2aac570ea 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -2039,6 +2039,47 @@ async def test_cached_entity_property_class_attribute(hass: HomeAssistant) -> No assert getattr(ent[1], property) == values[0] +async def test_cached_entity_property_override(hass: HomeAssistant) -> None: + """Test overriding cached _attr_ raises.""" + + class EntityWithClassAttribute1(entity.Entity): + """A derived class which overrides an _attr_ from a parent.""" + + _attr_attribution: str + + class EntityWithClassAttribute2(entity.Entity): + """A derived class which overrides an _attr_ from a parent.""" + + _attr_attribution = "blabla" + + class EntityWithClassAttribute3(entity.Entity): + """A derived class which overrides an _attr_ from a parent.""" + + _attr_attribution: str = "blabla" + + class EntityWithClassAttribute4(entity.Entity): + @property + def _attr_not_cached(self): + return "blabla" + + class EntityWithClassAttribute5(entity.Entity): + def _attr_not_cached(self): + return "blabla" + + with pytest.raises(TypeError): + + class EntityWithClassAttribute6(entity.Entity): + @property + def _attr_attribution(self): + return "🤡" + + with pytest.raises(TypeError): + + class EntityWithClassAttribute7(entity.Entity): + def _attr_attribution(self): + return "🤡" + + async def test_entity_report_deprecated_supported_features_values( caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index 2a57412ea9e..dc89b95981b 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -2,6 +2,8 @@ Call init before using it in your tests to ensure clean test data. """ +from typing import Any + from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING @@ -70,6 +72,13 @@ def init(empty=False): | CoverEntityFeature.STOP_TILT | CoverEntityFeature.SET_TILT_POSITION, ), + MockCover( + name="Simple with opening/closing cover", + is_on=True, + unique_id="unique_opening_closing_cover", + supported_features=CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + reports_opening_closing=True, + ), ] ) @@ -84,50 +93,59 @@ async def async_setup_platform( class MockCover(MockEntity, CoverEntity): """Mock Cover class.""" + def __init__( + self, reports_opening_closing: bool | None = None, **values: Any + ) -> None: + """Initialize a mock cover entity.""" + + super().__init__(**values) + self._reports_opening_closing = ( + reports_opening_closing + if reports_opening_closing is not None + else CoverEntityFeature.STOP in self.supported_features + ) + @property def is_closed(self): """Return if the cover is closed or not.""" - if self.supported_features & CoverEntityFeature.STOP: - return self.current_cover_position == 0 + if "state" in self._values and self._values["state"] == STATE_CLOSED: + return True - if "state" in self._values: - return self._values["state"] == STATE_CLOSED - return False + return self.current_cover_position == 0 @property def is_opening(self): """Return if the cover is opening or not.""" - if self.supported_features & CoverEntityFeature.STOP: - if "state" in self._values: - return self._values["state"] == STATE_OPENING + if "state" in self._values: + return self._values["state"] == STATE_OPENING return False @property def is_closing(self): """Return if the cover is closing or not.""" - if self.supported_features & CoverEntityFeature.STOP: - if "state" in self._values: - return self._values["state"] == STATE_CLOSING + if "state" in self._values: + return self._values["state"] == STATE_CLOSING return False def open_cover(self, **kwargs) -> None: """Open cover.""" - if self.supported_features & CoverEntityFeature.STOP: + if self._reports_opening_closing: self._values["state"] = STATE_OPENING else: self._values["state"] = STATE_OPEN def close_cover(self, **kwargs) -> None: """Close cover.""" - if self.supported_features & CoverEntityFeature.STOP: + if self._reports_opening_closing: self._values["state"] = STATE_CLOSING else: self._values["state"] = STATE_CLOSED def stop_cover(self, **kwargs) -> None: """Stop cover.""" + assert CoverEntityFeature.STOP in self.supported_features self._values["state"] = STATE_CLOSED if self.is_closed else STATE_OPEN @property