diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bd072752d16..9a926c18d76 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.28.13 + uses: github/codeql-action/init@v3.28.15 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.13 + uses: github/codeql-action/analyze@v3.28.15 with: category: "/language:python" diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index 44b2d2a5f20..bf146a11e13 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity): self._api.send_key_command(key_code, direction) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc def _send_launch_app_command(self, app_link: str) -> None: @@ -85,5 +85,5 @@ class AndroidTVRemoteBaseEntity(Entity): self._api.send_launch_app_command(app_link) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index 3d3a97092bc..5bc205b32df 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AndroidTVRemoteConfigEntry -from .const import CONF_APP_ICON, CONF_APP_NAME +from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN from .entity import AndroidTVRemoteBaseEntity PARALLEL_UPDATES = 0 @@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt await asyncio.sleep(delay_secs) except ConnectionClosed as exc: raise HomeAssistantError( - "Connection to Android TV device is closed" + translation_domain=DOMAIN, translation_key="connection_closed" ) from exc diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index e41cbcf9a76..106cac3a63d 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -54,5 +54,10 @@ } } } + }, + "exceptions": { + "connection_closed": { + "message": "Connection to the Android TV device is closed" + } } } diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index ddd736b47c0..602c801701d 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import device_registry as dr from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN -PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL] +PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR] type BoschAlarmConfigEntry = ConfigEntry[Panel] diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py index a1d8a7b90f4..2854298f815 100644 --- a/homeassistant/components/bosch_alarm/alarm_control_panel.py +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -10,11 +10,10 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BoschAlarmConfigEntry -from .const import DOMAIN +from .entity import BoschAlarmAreaEntity async def async_setup_entry( @@ -35,7 +34,7 @@ async def async_setup_entry( ) -class AreaAlarmControlPanel(AlarmControlPanelEntity): +class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity): """An alarm control panel entity for a bosch alarm panel.""" _attr_has_entity_name = True @@ -48,19 +47,8 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity): def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: """Initialise a Bosch Alarm control panel entity.""" - self.panel = panel - self._area = panel.areas[area_id] - self._area_id = area_id - self._attr_unique_id = f"{unique_id}_area_{area_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - name=self._area.name, - manufacturer="Bosch Security Systems", - via_device=( - DOMAIN, - unique_id, - ), - ) + super().__init__(panel, area_id, unique_id, False, False, True) + self._attr_unique_id = self._area_unique_id @property def alarm_state(self) -> AlarmControlPanelState | None: @@ -90,20 +78,3 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity): async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.panel.area_arm_all(self._area_id) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.panel.connection_status() - - async def async_added_to_hass(self) -> None: - """Run when entity attached to hass.""" - await super().async_added_to_hass() - self._area.status_observer.attach(self.schedule_update_ha_state) - self.panel.connection_status_observer.attach(self.schedule_update_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity removed from hass.""" - await super().async_will_remove_from_hass() - self._area.status_observer.detach(self.schedule_update_ha_state) - self.panel.connection_status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py index 4b1e3e511fc..9e664e49ca9 100644 --- a/homeassistant/components/bosch_alarm/config_flow.py +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -11,7 +11,12 @@ from typing import Any from bosch_alarm_mode2 import Panel import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import ( CONF_CODE, CONF_HOST, @@ -108,6 +113,13 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): else: self._data = user_input self._data[CONF_MODEL] = model + + if self.source == SOURCE_RECONFIGURE: + if ( + self._get_reconfigure_entry().data[CONF_MODEL] + != self._data[CONF_MODEL] + ): + return self.async_abort(reason="device_mismatch") return await self.async_step_auth() return self.async_show_form( step_id="user", @@ -117,6 +129,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reconfigure step.""" + return await self.async_step_user() + async def async_step_auth( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -154,10 +172,22 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN): else: if serial_number: await self.async_set_unique_id(str(serial_number)) - self._abort_if_unique_id_configured() - else: - self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]}) - return self.async_create_entry(title=f"Bosch {model}", data=self._data) + if self.source == SOURCE_USER: + if serial_number: + self._abort_if_unique_id_configured() + else: + self._async_abort_entries_match( + {CONF_HOST: self._data[CONF_HOST]} + ) + return self.async_create_entry( + title=f"Bosch {model}", data=self._data + ) + if serial_number: + self._abort_if_unique_id_mismatch(reason="device_mismatch") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data=self._data, + ) return self.async_show_form( step_id="auth", diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py new file mode 100644 index 00000000000..f74634125c4 --- /dev/null +++ b/homeassistant/components/bosch_alarm/entity.py @@ -0,0 +1,88 @@ +"""Support for Bosch Alarm Panel History as a sensor.""" + +from __future__ import annotations + +from bosch_alarm_mode2 import Panel + +from homeassistant.components.sensor import Entity +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import DOMAIN + +PARALLEL_UPDATES = 0 + + +class BoschAlarmEntity(Entity): + """A base entity for a bosch alarm panel.""" + + _attr_has_entity_name = True + + def __init__(self, panel: Panel, unique_id: str) -> None: + """Set up a entity for a bosch alarm panel.""" + self.panel = panel + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=f"Bosch {panel.model}", + manufacturer="Bosch Security Systems", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.panel.connection_status() + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + self.panel.connection_status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + self.panel.connection_status_observer.detach(self.schedule_update_ha_state) + + +class BoschAlarmAreaEntity(BoschAlarmEntity): + """A base entity for area related entities within a bosch alarm panel.""" + + def __init__( + self, + panel: Panel, + area_id: int, + unique_id: str, + observe_alarms: bool, + observe_ready: bool, + observe_status: bool, + ) -> None: + """Set up a area related entity for a bosch alarm panel.""" + super().__init__(panel, unique_id) + self._area_id = area_id + self._area_unique_id = f"{unique_id}_area_{area_id}" + self._observe_alarms = observe_alarms + self._observe_ready = observe_ready + self._observe_status = observe_status + self._area = panel.areas[area_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._area_unique_id)}, + name=self._area.name, + manufacturer="Bosch Security Systems", + via_device=(DOMAIN, unique_id), + ) + + async def async_added_to_hass(self) -> None: + """Observe state changes.""" + await super().async_added_to_hass() + if self._observe_alarms: + self._area.alarm_observer.attach(self.schedule_update_ha_state) + if self._observe_ready: + self._area.ready_observer.attach(self.schedule_update_ha_state) + if self._observe_status: + self._area.status_observer.attach(self.schedule_update_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop observing state changes.""" + await super().async_added_to_hass() + if self._observe_alarms: + self._area.alarm_observer.detach(self.schedule_update_ha_state) + if self._observe_ready: + self._area.ready_observer.detach(self.schedule_update_ha_state) + if self._observe_status: + self._area.status_observer.detach(self.schedule_update_ha_state) diff --git a/homeassistant/components/bosch_alarm/icons.json b/homeassistant/components/bosch_alarm/icons.json new file mode 100644 index 00000000000..1e207310713 --- /dev/null +++ b/homeassistant/components/bosch_alarm/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "faulting_points": { + "default": "mdi:alert-circle-outline" + } + } + } +} diff --git a/homeassistant/components/bosch_alarm/manifest.json b/homeassistant/components/bosch_alarm/manifest.json index a54ace71782..eefcc400ee7 100644 --- a/homeassistant/components/bosch_alarm/manifest.json +++ b/homeassistant/components/bosch_alarm/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["bosch-alarm-mode2==0.4.3"] + "requirements": ["bosch-alarm-mode2==0.4.6"] } diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 75c331ede40..3a64667a407 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -62,9 +62,9 @@ rules: entity-category: todo entity-device-class: todo entity-disabled-by-default: todo - entity-translations: todo + entity-translations: done exception-translations: todo - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/bosch_alarm/sensor.py b/homeassistant/components/bosch_alarm/sensor.py new file mode 100644 index 00000000000..3d61c72a883 --- /dev/null +++ b/homeassistant/components/bosch_alarm/sensor.py @@ -0,0 +1,86 @@ +"""Support for Bosch Alarm Panel History as a sensor.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from bosch_alarm_mode2 import Panel +from bosch_alarm_mode2.panel import Area + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BoschAlarmConfigEntry +from .entity import BoschAlarmAreaEntity + + +@dataclass(kw_only=True, frozen=True) +class BoschAlarmSensorEntityDescription(SensorEntityDescription): + """Describes Bosch Alarm sensor entity.""" + + value_fn: Callable[[Area], int] + observe_alarms: bool = False + observe_ready: bool = False + observe_status: bool = False + + +SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [ + BoschAlarmSensorEntityDescription( + key="faulting_points", + translation_key="faulting_points", + value_fn=lambda area: area.faults, + observe_ready=True, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BoschAlarmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up bosch alarm sensors.""" + + panel = config_entry.runtime_data + unique_id = config_entry.unique_id or config_entry.entry_id + + async_add_entities( + BoschAreaSensor(panel, area_id, unique_id, template) + for area_id in panel.areas + for template in SENSOR_TYPES + ) + + +PARALLEL_UPDATES = 0 + + +class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity): + """An area sensor entity for a bosch alarm panel.""" + + entity_description: BoschAlarmSensorEntityDescription + + def __init__( + self, + panel: Panel, + area_id: int, + unique_id: str, + entity_description: BoschAlarmSensorEntityDescription, + ) -> None: + """Set up an area sensor entity for a bosch alarm panel.""" + super().__init__( + panel, + area_id, + unique_id, + entity_description.observe_alarms, + entity_description.observe_ready, + entity_description.observe_status, + ) + self.entity_description = entity_description + self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}" + + @property + def native_value(self) -> int: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._area) diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index 3123c1697f3..6b916dad4fa 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -43,7 +43,9 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "device_mismatch": "Please ensure you reconfigure against the same device." } }, "exceptions": { @@ -53,5 +55,13 @@ "authentication_failed": { "message": "Incorrect credentials for panel." } + }, + "entity": { + "sensor": { + "faulting_points": { + "name": "Faulting points", + "unit_of_measurement": "points" + } + } } } diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 1dbe0adbf6c..2c30af5adce 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -13,7 +13,7 @@ }, "data_description": { "email": "The email address associated with your Bring! account.", - "password": "The password to login to your Bring! account." + "password": "The password to log in to your Bring! account." } }, "reauth_confirm": { diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index cc25a88ae39..f5febafc4dc 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from homeassistant.util.ssl import client_context_no_verify from .const import DOMAIN, KEY_MAC, TIMEOUT @@ -90,6 +91,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): key=key, uuid=uuid, password=password, + ssl_context=client_context_no_verify(), ) except (TimeoutError, ClientError): self.host = None diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 078643ee789..bc61cb444c1 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -55,7 +55,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to create the vacation." + "description": "ecobee thermostat on which to create the vacation." }, "vacation_name": { "name": "Vacation name", @@ -101,7 +101,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to delete the vacation." + "description": "ecobee thermostat on which to delete the vacation." }, "vacation_name": { "name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]", @@ -149,7 +149,7 @@ }, "set_mic_mode": { "name": "Set mic mode", - "description": "Enables/disables Alexa microphone (only for Ecobee 4).", + "description": "Enables/disables Alexa microphone (only for ecobee 4).", "fields": { "mic_enabled": { "name": "Mic enabled", @@ -177,7 +177,7 @@ "fields": { "entity_id": { "name": "Entity", - "description": "Ecobee thermostat on which to set active sensors." + "description": "ecobee thermostat on which to set active sensors." }, "preset_mode": { "name": "Climate Name", @@ -203,12 +203,12 @@ }, "issues": { "migrate_aux_heat": { - "title": "Migration of Ecobee set_aux_heat action", + "title": "Migration of ecobee set_aux_heat action", "fix_flow": { "step": { "confirm": { - "description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", - "title": "Disable legacy Ecobee set_aux_heat action" + "description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.", + "title": "Disable legacy ecobee set_aux_heat action" } } } diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 654e2262730..5ee81dd8315 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -16,7 +16,13 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -40,6 +46,13 @@ CONF_SERIAL = "serial" INSTALLER_AUTH_USERNAME = "installer" +AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN} + + +def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]: + """Return a dictionary without AVOID_REFLECT_KEYS.""" + return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS} + async def validate_input( hass: HomeAssistant, @@ -205,7 +218,10 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders["serial"] = serial return self.async_show_form( step_id="reauth_confirm", - data_schema=self._async_generate_schema(), + data_schema=self.add_suggested_values_to_schema( + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or reauth_entry.data), + ), description_placeholders=description_placeholders, errors=errors, ) @@ -259,10 +275,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): CONF_SERIAL: self.unique_id, CONF_HOST: host, } - return self.async_show_form( step_id="user", - data_schema=self._async_generate_schema(), + data_schema=self.add_suggested_values_to_schema( + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or {}), + ), description_placeholders=description_placeholders, errors=errors, ) @@ -306,11 +324,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): } description_placeholders["serial"] = serial - suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( - self._async_generate_schema(), suggested_values + self._async_generate_schema(), + without_avoid_reflect_keys(user_input or reconfigure_entry.data), ), description_placeholders=description_placeholders, errors=errors, diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index a4f59d8ab76..88288a86b59 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -14,9 +14,10 @@ from pyfibaro.fibaro_client import ( ) from pyfibaro.fibaro_data_helper import read_rooms from pyfibaro.fibaro_device import DeviceModel +from pyfibaro.fibaro_device_manager import FibaroDeviceManager from pyfibaro.fibaro_info import InfoModel from pyfibaro.fibaro_scene import SceneModel -from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver +from pyfibaro.fibaro_state_resolver import FibaroEvent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform @@ -81,8 +82,8 @@ class FibaroController: self._client = fibaro_client self._fibaro_info = info - # Whether to import devices from plugins - self._import_plugins = import_plugins + # The fibaro device manager exposes higher level API to access fibaro devices + self._fibaro_device_manager = FibaroDeviceManager(fibaro_client, import_plugins) # Mapping roomId to room object self._room_map = read_rooms(fibaro_client) self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object @@ -91,79 +92,30 @@ class FibaroController: ) # List of devices by entity platform # All scenes self._scenes = self._client.read_scenes() - self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId - # Event callbacks by device id - self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} # Unique serial number of the hub self.hub_serial = info.serial_number # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} self._read_devices() - def enable_state_handler(self) -> None: - """Start StateHandler thread for monitoring updates.""" - self._client.register_update_handler(self._on_state_change) + def disconnect(self) -> None: + """Close push channel.""" + self._fibaro_device_manager.close() - def disable_state_handler(self) -> None: - """Stop StateHandler thread used for monitoring updates.""" - self._client.unregister_update_handler() - - def _on_state_change(self, state: Any) -> None: - """Handle change report received from the HomeCenter.""" - callback_set = set() - for change in state.get("changes", []): - try: - dev_id = change.pop("id") - if dev_id not in self._device_map: - continue - device = self._device_map[dev_id] - for property_name, value in change.items(): - if property_name == "log": - if value and value != "transfer OK": - _LOGGER.debug("LOG %s: %s", device.friendly_name, value) - continue - if property_name == "logTemp": - continue - if property_name in device.properties: - device.properties[property_name] = value - _LOGGER.debug( - "<- %s.%s = %s", device.ha_id, property_name, str(value) - ) - else: - _LOGGER.warning("%s.%s not found", device.ha_id, property_name) - if dev_id in self._callbacks: - callback_set.add(dev_id) - except (ValueError, KeyError): - pass - for item in callback_set: - for callback in self._callbacks[item]: - callback() - - resolver = FibaroStateResolver(state) - for event in resolver.get_events(): - # event does not always have a fibaro id, therefore it is - # essential that we first check for relevant event type - if ( - event.event_type.lower() == "centralsceneevent" - and event.fibaro_id in self._event_callbacks - ): - for callback in self._event_callbacks[event.fibaro_id]: - callback(event) - - def register(self, device_id: int, callback: Any) -> None: + def register( + self, device_id: int, callback: Callable[[DeviceModel], None] + ) -> Callable[[], None]: """Register device with a callback for updates.""" - device_callbacks = self._callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_change_listener(device_id, callback) def register_event( self, device_id: int, callback: Callable[[FibaroEvent], None] - ) -> None: + ) -> Callable[[], None]: """Register device with a callback for central scene events. The callback receives one parameter with the event. """ - device_callbacks = self._event_callbacks.setdefault(device_id, []) - device_callbacks.append(callback) + return self._fibaro_device_manager.add_event_listener(device_id, callback) def get_children(self, device_id: int) -> list[DeviceModel]: """Get a list of child devices.""" @@ -286,7 +238,7 @@ class FibaroController: def _read_devices(self) -> None: """Read and process the device list.""" - devices = self._client.read_devices() + devices = self._fibaro_device_manager.get_devices() self._device_map = {} last_climate_parent = None last_endpoint = None @@ -301,9 +253,8 @@ class FibaroController: device.ha_id = ( f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" ) - platform = None - if device.enabled and (not device.is_plugin or self._import_plugins): - platform = self._map_device_to_platform(device) + + platform = self._map_device_to_platform(device) if platform is None: continue device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}" @@ -393,8 +344,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - controller.enable_state_handler() - return True @@ -403,8 +352,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - entry.runtime_data.disable_state_handler() - + entry.runtime_data.disconnect() return unload_ok diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py index 5375b058315..e8ed5afc500 100644 --- a/homeassistant/components/fibaro/entity.py +++ b/homeassistant/components/fibaro/entity.py @@ -36,9 +36,13 @@ class FibaroEntity(Entity): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - self.controller.register(self.fibaro_device.fibaro_id, self._update_callback) + self.async_on_remove( + self.controller.register( + self.fibaro_device.fibaro_id, self._update_callback + ) + ) - def _update_callback(self) -> None: + def _update_callback(self, fibaro_device: DeviceModel) -> None: """Update the state.""" self.schedule_update_ha_state(True) diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index 0beea2e336e..ad44719c8be 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -60,11 +60,16 @@ class FibaroEventEntity(FibaroEntity, EventEntity): await super().async_added_to_hass() # Register event callback - self.controller.register_event( - self.fibaro_device.fibaro_id, self._event_callback + self.async_on_remove( + self.controller.register_event( + self.fibaro_device.fibaro_id, self._event_callback + ) ) def _event_callback(self, event: FibaroEvent) -> None: - if event.key_id == self._button: + if ( + event.event_type.lower() == "centralsceneevent" + and event.key_id == self._button + ): self._trigger_event(event.key_event_type) self.schedule_update_ha_state() diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 29e46b3a0c9..c2d18a0be84 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -4,12 +4,8 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: one coverage miss in line 110 - config-flow: - status: todo - comment: data_description are missing + config-flow-test-coverage: done + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index 06a07cba79e..6191fc524dd 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -1,4 +1,11 @@ { + "common": { + "data_description_host": "The hostname or IP address of your FRITZ!Box router.", + "data_description_port": "Leave empty to use the default port.", + "data_description_username": "Username for the FRITZ!Box.", + "data_description_password": "Password for the FRITZ!Box.", + "data_description_ssl": "Use SSL to connect to the FRITZ!Box." + }, "config": { "flow_title": "{name}", "step": { @@ -9,6 +16,11 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "ssl": "[%key:common::config_flow::data::ssl%]" + }, + "data_description": { + "username": "[%key:component::fritz::common::data_description_username%]", + "password": "[%key:component::fritz::common::data_description_password%]", + "ssl": "[%key:component::fritz::common::data_description_ssl%]" } }, "reauth_confirm": { @@ -17,6 +29,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::fritz::common::data_description_username%]", + "password": "[%key:component::fritz::common::data_description_password%]" } }, "reconfigure": { @@ -28,8 +44,9 @@ "ssl": "[%key:common::config_flow::data::ssl%]" }, "data_description": { - "host": "The hostname or IP address of your FRITZ!Box router.", - "port": "Leave it empty to use the default port." + "host": "[%key:component::fritz::common::data_description_host%]", + "port": "[%key:component::fritz::common::data_description_port%]", + "ssl": "[%key:component::fritz::common::data_description_ssl%]" } }, "user": { @@ -43,8 +60,11 @@ "ssl": "[%key:common::config_flow::data::ssl%]" }, "data_description": { - "host": "The hostname or IP address of your FRITZ!Box router.", - "port": "Leave it empty to use the default port." + "host": "[%key:component::fritz::common::data_description_host%]", + "port": "[%key:component::fritz::common::data_description_port%]", + "username": "[%key:component::fritz::common::data_description_username%]", + "password": "[%key:component::fritz::common::data_description_password%]", + "ssl": "[%key:component::fritz::common::data_description_ssl%]" } } }, @@ -70,6 +90,10 @@ "data": { "consider_home": "Seconds to consider a device at 'home'", "old_discovery": "Enable old discovery method" + }, + "data_description": { + "consider_home": "Time in seconds to consider a device at home. Default is 180 seconds.", + "old_discovery": "Enable old discovery method. This is needed for some scenarios." } } } @@ -169,8 +193,12 @@ "config_entry_not_found": { "message": "Failed to perform action \"{service}\". Config entry for target not found" }, - "service_parameter_unknown": { "message": "Action or parameter unknown" }, - "service_not_supported": { "message": "Action not supported" }, + "service_parameter_unknown": { + "message": "Action or parameter unknown" + }, + "service_not_supported": { + "message": "Action not supported" + }, "error_refresh_hosts_info": { "message": "Error refreshing hosts info" }, diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index f595b66ee37..a10fa5bfc47 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -9,8 +9,8 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "username": "The email address to login to your FYTA account.", - "password": "The password to login to your FYTA account." + "username": "The email address to log in to your FYTA account.", + "password": "The password to log in to your FYTA account." } }, "reauth_confirm": { diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ac6cb696a7d..ee980c9bf48 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -303,7 +303,7 @@ async def google_generative_ai_config_option_schema( CONF_TEMPERATURE, description={"suggested_value": options.get(CONF_TEMPERATURE)}, default=RECOMMENDED_TEMPERATURE, - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), vol.Optional( CONF_TOP_P, description={"suggested_value": options.get(CONF_TOP_P)}, diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 7c19c5445a7..73a82b98664 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -55,6 +55,10 @@ from .const import ( # Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 +ERROR_GETTING_RESPONSE = ( + "Sorry, I had a problem getting a response from Google Generative AI." +) + async def async_setup_entry( hass: HomeAssistant, @@ -429,6 +433,12 @@ class GoogleGenerativeAIConversationEntity( raise HomeAssistantError( f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}" ) + if not chat_response.candidates: + LOGGER.error( + "No candidates found in the response: %s", + chat_response, + ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) except ( APIError, @@ -452,9 +462,7 @@ class GoogleGenerativeAIConversationEntity( response_parts = chat_response.candidates[0].content.parts if not response_parts: - raise HomeAssistantError( - "Sorry, I had a problem getting a response from Google Generative AI." - ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) content = " ".join( [part.text.strip() for part in response_parts if part.text] ) diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index c046e20feab..6714d5782e1 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -182,6 +182,6 @@ async def websocket_update_addon( async def websocket_update_core( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: - """Websocket handler to update an addon.""" + """Websocket handler to update Home Assistant Core.""" await update_core(hass, None, msg["backup"]) connection.send_result(msg[WS_ID]) diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index cbac9f20574..8a88913456d 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pyheos"], "quality_scale": "platinum", - "requirements": ["pyheos==1.0.4"], + "requirements": ["pyheos==1.0.5"], "ssdp": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 294da492e31..810244a815a 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -71,6 +71,7 @@ BASE_SUPPORTED_FEATURES = ( PLAY_STATE_TO_STATE = { None: MediaPlayerState.IDLE, + PlayState.UNKNOWN: MediaPlayerState.IDLE, PlayState.PLAY: MediaPlayerState.PLAYING, PlayState.STOP: MediaPlayerState.IDLE, PlayState.PAUSE: MediaPlayerState.PAUSED, diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index a28b4ff2b49..7e4523201f9 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -5,37 +5,18 @@ from typing import cast from aiohomeconnect.model import EventKey, StatusKey -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from .common import setup_home_connect_entry -from .const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, - DOMAIN, - REFRIGERATION_STATUS_DOOR_CLOSED, - REFRIGERATION_STATUS_DOOR_OPEN, -) -from .coordinator import ( - HomeConnectApplianceData, - HomeConnectConfigEntry, - HomeConnectCoordinator, -) +from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN +from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .entity import HomeConnectEntity PARALLEL_UPDATES = 0 @@ -173,8 +154,6 @@ def _get_entities_for_appliance( for description in BINARY_SENSORS if description.key in appliance.status ) - if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status: - entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance)) return entities @@ -220,83 +199,3 @@ class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity) def available(self) -> bool: """Return the availability.""" return self.coordinator.last_update_success - - -class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): - """Binary sensor for Home Connect Generic Door.""" - - _attr_has_entity_name = False - - def __init__( - self, - coordinator: HomeConnectCoordinator, - appliance: HomeConnectApplianceData, - ) -> None: - """Initialize the entity.""" - super().__init__( - coordinator, - appliance, - HomeConnectBinarySensorEntityDescription( - key=StatusKey.BSH_COMMON_DOOR_STATE, - device_class=BinarySensorDeviceClass.DOOR, - boolean_map={ - BSH_DOOR_STATE_CLOSED: False, - BSH_DOOR_STATE_LOCKED: False, - BSH_DOOR_STATE_OPEN: True, - }, - entity_registry_enabled_default=False, - ), - ) - self._attr_unique_id = f"{appliance.info.ha_id}-Door" - self._attr_name = f"{appliance.info.name} Door" - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - automations = automations_with_entity(self.hass, self.entity_id) - scripts = scripts_with_entity(self.hass, self.entity_id) - items = automations + scripts - if not items: - return - - entity_reg: er.EntityRegistry = er.async_get(self.hass) - entity_automations = [ - automation_entity - for automation_id in automations - if (automation_entity := entity_reg.async_get(automation_id)) - ] - entity_scripts = [ - script_entity - for script_id in scripts - if (script_entity := entity_reg.async_get(script_id)) - ] - - items_list = [ - f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" - for item in entity_automations - ] + [ - f"- [{item.original_name}](/config/script/edit/{item.unique_id})" - for item in entity_scripts - ] - - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_common_door_sensor_{self.entity_id}", - breaks_in_ha_version="2025.5.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_binary_common_door_sensor", - translation_placeholders={ - "entity": self.entity_id, - "items": "\n".join(items_list), - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Call when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" - ) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index dfbe1ca26fe..5b52183fccf 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -132,17 +132,6 @@ } } }, - "deprecated_binary_common_door_sensor": { - "title": "Deprecated binary door sensor detected in some automations or scripts", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::deprecated_binary_common_door_sensor::title%]", - "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue." - } - } - } - }, "deprecated_command_actions": { "title": "The command related actions are deprecated in favor of the new buttons", "fix_flow": { diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 623a4e93895..806a21556cb 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "Homee {name} ({host})", + "flow_title": "homee {name} ({host})", "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, @@ -18,9 +18,9 @@ "username": "[%key:common::config_flow::data::username%]" }, "data_description": { - "host": "The IP address of your Homee.", - "username": "The username for your Homee.", - "password": "The password for your Homee." + "host": "The IP address of your homee.", + "username": "The username for your homee.", + "password": "The password for your homee." } } } @@ -45,7 +45,7 @@ "load_alarm": { "name": "Load", "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "on": "Overload" } }, @@ -352,7 +352,7 @@ }, "exceptions": { "connection_closed": { - "message": "Could not connect to Homee while setting attribute." + "message": "Could not connect to homee while setting attribute." } } } diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 9bd5711832c..8b526b62302 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -31,6 +31,7 @@ from homeassistant.components.device_automation.trigger import ( async_validate_trigger_config, ) from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN @@ -49,6 +50,7 @@ from homeassistant.const import ( CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, + CONF_TYPE, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) @@ -83,6 +85,7 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util.async_ import create_eager_task from . import ( # noqa: F401 + type_air_purifiers, type_cameras, type_covers, type_fans, @@ -113,6 +116,8 @@ from .const import ( CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, CONFIG_OPTIONS, DEFAULT_EXCLUDE_ACCESSORY_MODE, DEFAULT_HOMEKIT_MODE, @@ -126,6 +131,7 @@ from .const import ( SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, SIGNAL_RELOAD_ENTITIES, + TYPE_AIR_PURIFIER, ) from .iidmanager import AccessoryIIDStorage from .models import HomeKitConfigEntry, HomeKitEntryData @@ -169,6 +175,8 @@ MOTION_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.MOTION) MOTION_SENSOR = (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION) DOORBELL_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.DOORBELL) HUMIDITY_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY) +TEMPERATURE_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.TEMPERATURE) +PM25_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.PM25) def _has_all_unique_names_and_ports( @@ -1136,6 +1144,21 @@ class HomeKit: CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id ) + if domain == FAN_DOMAIN: + if current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR): + config[entity_id].setdefault( + CONF_LINKED_HUMIDITY_SENSOR, current_humidity_sensor_entity_id + ) + if current_pm25_sensor_entity_id := lookup.get(PM25_SENSOR): + config[entity_id].setdefault(CONF_TYPE, TYPE_AIR_PURIFIER) + config[entity_id].setdefault( + CONF_LINKED_PM25_SENSOR, current_pm25_sensor_entity_id + ) + if current_temperature_sensor_entity_id := lookup.get(TEMPERATURE_SENSOR): + config[entity_id].setdefault( + CONF_LINKED_TEMPERATURE_SENSOR, current_temperature_sensor_entity_id + ) + if domain == HUMIDIFIER_DOMAIN and ( current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR) ): diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 0d810d6986d..d680181f5e4 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -85,6 +85,8 @@ from .const import ( SERV_ACCESSORY_INFO, SERV_BATTERY_SERVICE, SIGNAL_RELOAD_ENTITIES, + TYPE_AIR_PURIFIER, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -112,6 +114,10 @@ SWITCH_TYPES = { TYPE_SWITCH: "Switch", TYPE_VALVE: "ValveSwitch", } +FAN_TYPES = { + TYPE_AIR_PURIFIER: "AirPurifier", + TYPE_FAN: "Fan", +} TYPES: Registry[str, type[HomeAccessory]] = Registry() RELOAD_ON_CHANGE_ATTRS = ( @@ -178,7 +184,10 @@ def get_accessory( # noqa: C901 a_type = "WindowCovering" elif state.domain == "fan": - a_type = "Fan" + if fan_type := config.get(CONF_TYPE): + a_type = FAN_TYPES[fan_type] + else: + a_type = "Fan" elif state.domain == "humidifier": a_type = "HumidifierDehumidifier" diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 00b3de49169..ae682a0ea2d 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -49,9 +49,13 @@ CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode" CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor" +CONF_LINKED_FILTER_CHANGE_INDICATION = "linked_filter_change_indication_binary_sensor" +CONF_LINKED_FILTER_LIFE_LEVEL = "linked_filter_life_level_sensor" CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor" CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor" +CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor" +CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" CONF_MAX_FPS = "max_fps" CONF_MAX_HEIGHT = "max_height" @@ -120,12 +124,15 @@ TYPE_SHOWER = "shower" TYPE_SPRINKLER = "sprinkler" TYPE_SWITCH = "switch" TYPE_VALVE = "valve" +TYPE_FAN = "fan" +TYPE_AIR_PURIFIER = "air_purifier" # #### Categories #### CATEGORY_RECEIVER = 34 # #### Services #### SERV_ACCESSORY_INFO = "AccessoryInformation" +SERV_AIR_PURIFIER = "AirPurifier" SERV_AIR_QUALITY_SENSOR = "AirQualitySensor" SERV_BATTERY_SERVICE = "BatteryService" SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" @@ -135,6 +142,7 @@ SERV_CONTACT_SENSOR = "ContactSensor" SERV_DOOR = "Door" SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" +SERV_FILTER_MAINTENANCE = "FilterMaintenance" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" SERV_HUMIDITY_SENSOR = "HumiditySensor" @@ -181,6 +189,7 @@ CHAR_CONFIGURED_NAME = "ConfiguredName" CHAR_CONTACT_SENSOR_STATE = "ContactSensorState" CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature" CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel" +CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState" CHAR_CURRENT_DOOR_STATE = "CurrentDoorState" CHAR_CURRENT_FAN_STATE = "CurrentFanState" CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState" @@ -192,6 +201,8 @@ CHAR_CURRENT_TEMPERATURE = "CurrentTemperature" CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle" CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState" CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold" +CHAR_FILTER_CHANGE_INDICATION = "FilterChangeIndication" +CHAR_FILTER_LIFE_LEVEL = "FilterLifeLevel" CHAR_FIRMWARE_REVISION = "FirmwareRevision" CHAR_HARDWARE_REVISION = "HardwareRevision" CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature" @@ -229,6 +240,7 @@ CHAR_SMOKE_DETECTED = "SmokeDetected" CHAR_STATUS_LOW_BATTERY = "StatusLowBattery" CHAR_STREAMING_STRATUS = "StreamingStatus" CHAR_SWING_MODE = "SwingMode" +CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState" CHAR_TARGET_DOOR_STATE = "TargetDoorState" CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" @@ -256,6 +268,7 @@ PROP_VALID_VALUES = "ValidValues" # #### Thresholds #### THRESHOLD_CO = 25 THRESHOLD_CO2 = 1000 +THRESHOLD_FILTER_CHANGE_NEEDED = 10 # #### Default values #### DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C diff --git a/homeassistant/components/homekit/type_air_purifiers.py b/homeassistant/components/homekit/type_air_purifiers.py new file mode 100644 index 00000000000..25d305a0aa9 --- /dev/null +++ b/homeassistant/components/homekit/type_air_purifiers.py @@ -0,0 +1,469 @@ +"""Class to hold all air purifier accessories.""" + +import logging +from typing import Any + +from pyhap.characteristic import Characteristic +from pyhap.const import CATEGORY_AIR_PURIFIER +from pyhap.service import Service +from pyhap.util import callback as pyhap_callback + +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import ( + Event, + EventStateChangedData, + HassJobType, + State, + callback, +) +from homeassistant.helpers.event import async_track_state_change_event + +from .accessories import TYPES +from .const import ( + CHAR_ACTIVE, + CHAR_AIR_QUALITY, + CHAR_CURRENT_AIR_PURIFIER_STATE, + CHAR_CURRENT_HUMIDITY, + CHAR_CURRENT_TEMPERATURE, + CHAR_FILTER_CHANGE_INDICATION, + CHAR_FILTER_LIFE_LEVEL, + CHAR_NAME, + CHAR_PM25_DENSITY, + CHAR_TARGET_AIR_PURIFIER_STATE, + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, + CONF_LINKED_HUMIDITY_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, + SERV_AIR_PURIFIER, + SERV_AIR_QUALITY_SENSOR, + SERV_FILTER_MAINTENANCE, + SERV_HUMIDITY_SENSOR, + SERV_TEMPERATURE_SENSOR, + THRESHOLD_FILTER_CHANGE_NEEDED, +) +from .type_fans import ATTR_PRESET_MODE, CHAR_ROTATION_SPEED, Fan +from .util import cleanup_name_for_homekit, convert_to_float, density_to_air_quality + +_LOGGER = logging.getLogger(__name__) + +CURRENT_STATE_INACTIVE = 0 +CURRENT_STATE_IDLE = 1 +CURRENT_STATE_PURIFYING_AIR = 2 +TARGET_STATE_MANUAL = 0 +TARGET_STATE_AUTO = 1 +FILTER_CHANGE_FILTER = 1 +FILTER_OK = 0 + +IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN} + + +@TYPES.register("AirPurifier") +class AirPurifier(Fan): + """Generate an AirPurifier accessory for an air purifier entity. + + Currently supports, in addition to Fan properties: + temperature; humidity; PM2.5; auto mode. + """ + + def __init__(self, *args: Any) -> None: + """Initialize a new AirPurifier accessory object.""" + super().__init__(*args, category=CATEGORY_AIR_PURIFIER) + + self.auto_preset: str | None = None + if self.preset_modes is not None: + for preset in self.preset_modes: + if str(preset).lower() == "auto": + self.auto_preset = preset + break + + def create_services(self) -> Service: + """Create and configure the primary service for this accessory.""" + self.chars.append(CHAR_ACTIVE) + self.chars.append(CHAR_CURRENT_AIR_PURIFIER_STATE) + self.chars.append(CHAR_TARGET_AIR_PURIFIER_STATE) + serv_air_purifier = self.add_preload_service(SERV_AIR_PURIFIER, self.chars) + self.set_primary_service(serv_air_purifier) + + self.char_active: Characteristic = serv_air_purifier.configure_char( + CHAR_ACTIVE, value=0 + ) + + self.preset_mode_chars: dict[str, Characteristic] + self.char_current_humidity: Characteristic | None = None + self.char_pm25_density: Characteristic | None = None + self.char_current_temperature: Characteristic | None = None + self.char_filter_change_indication: Characteristic | None = None + self.char_filter_life_level: Characteristic | None = None + + self.char_target_air_purifier_state: Characteristic = ( + serv_air_purifier.configure_char( + CHAR_TARGET_AIR_PURIFIER_STATE, + value=0, + ) + ) + + self.char_current_air_purifier_state: Characteristic = ( + serv_air_purifier.configure_char( + CHAR_CURRENT_AIR_PURIFIER_STATE, + value=0, + ) + ) + + self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR) + if self.linked_humidity_sensor: + humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME) + serv_air_purifier.add_linked_service(humidity_serv) + self.char_current_humidity = humidity_serv.configure_char( + CHAR_CURRENT_HUMIDITY, value=0 + ) + + humidity_state = self.hass.states.get(self.linked_humidity_sensor) + if humidity_state: + self._async_update_current_humidity(humidity_state) + + self.linked_pm25_sensor = self.config.get(CONF_LINKED_PM25_SENSOR) + if self.linked_pm25_sensor: + pm25_serv = self.add_preload_service( + SERV_AIR_QUALITY_SENSOR, + [CHAR_AIR_QUALITY, CHAR_NAME, CHAR_PM25_DENSITY], + ) + serv_air_purifier.add_linked_service(pm25_serv) + self.char_pm25_density = pm25_serv.configure_char( + CHAR_PM25_DENSITY, value=0 + ) + + self.char_air_quality = pm25_serv.configure_char(CHAR_AIR_QUALITY) + + pm25_state = self.hass.states.get(self.linked_pm25_sensor) + if pm25_state: + self._async_update_current_pm25(pm25_state) + + self.linked_temperature_sensor = self.config.get(CONF_LINKED_TEMPERATURE_SENSOR) + if self.linked_temperature_sensor: + temperature_serv = self.add_preload_service( + SERV_TEMPERATURE_SENSOR, [CHAR_NAME, CHAR_CURRENT_TEMPERATURE] + ) + serv_air_purifier.add_linked_service(temperature_serv) + self.char_current_temperature = temperature_serv.configure_char( + CHAR_CURRENT_TEMPERATURE, value=0 + ) + + temperature_state = self.hass.states.get(self.linked_temperature_sensor) + if temperature_state: + self._async_update_current_temperature(temperature_state) + + self.linked_filter_change_indicator_binary_sensor = self.config.get( + CONF_LINKED_FILTER_CHANGE_INDICATION + ) + self.linked_filter_life_level_sensor = self.config.get( + CONF_LINKED_FILTER_LIFE_LEVEL + ) + if ( + self.linked_filter_change_indicator_binary_sensor + or self.linked_filter_life_level_sensor + ): + chars = [CHAR_NAME, CHAR_FILTER_CHANGE_INDICATION] + if self.linked_filter_life_level_sensor: + chars.append(CHAR_FILTER_LIFE_LEVEL) + serv_filter_maintenance = self.add_preload_service( + SERV_FILTER_MAINTENANCE, chars + ) + serv_air_purifier.add_linked_service(serv_filter_maintenance) + serv_filter_maintenance.configure_char( + CHAR_NAME, + value=cleanup_name_for_homekit(f"{self.display_name} Filter"), + ) + + self.char_filter_change_indication = serv_filter_maintenance.configure_char( + CHAR_FILTER_CHANGE_INDICATION, + value=0, + ) + + if self.linked_filter_change_indicator_binary_sensor: + filter_change_indicator_state = self.hass.states.get( + self.linked_filter_change_indicator_binary_sensor + ) + if filter_change_indicator_state: + self._async_update_filter_change_indicator( + filter_change_indicator_state + ) + + if self.linked_filter_life_level_sensor: + self.char_filter_life_level = serv_filter_maintenance.configure_char( + CHAR_FILTER_LIFE_LEVEL, + value=0, + ) + + filter_life_level_state = self.hass.states.get( + self.linked_filter_life_level_sensor + ) + if filter_life_level_state: + self._async_update_filter_life_level(filter_life_level_state) + + return serv_air_purifier + + def should_add_preset_mode_switch(self, preset_mode: str) -> bool: + """Check if a preset mode switch should be added.""" + return preset_mode.lower() != "auto" + + @callback + @pyhap_callback # type: ignore[misc] + def run(self) -> None: + """Handle accessory driver started event. + + Run inside the Home Assistant event loop. + """ + if self.linked_humidity_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_humidity_sensor], + self._async_update_current_humidity_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_pm25_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_pm25_sensor], + self._async_update_current_pm25_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_temperature_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_temperature_sensor], + self._async_update_current_temperature_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_filter_change_indicator_binary_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_filter_change_indicator_binary_sensor], + self._async_update_filter_change_indicator_event, + job_type=HassJobType.Callback, + ) + ) + + if self.linked_filter_life_level_sensor: + self._subscriptions.append( + async_track_state_change_event( + self.hass, + [self.linked_filter_life_level_sensor], + self._async_update_filter_life_level_event, + job_type=HassJobType.Callback, + ) + ) + + super().run() + + @callback + def _async_update_current_humidity_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_humidity(event.data["new_state"]) + + @callback + def _async_update_current_humidity(self, new_state: State | None) -> None: + """Handle linked humidity sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_humidity := convert_to_float(new_state.state)) is None + or not self.char_current_humidity + or self.char_current_humidity.value == current_humidity + ): + return + + _LOGGER.debug( + "%s: Linked humidity sensor %s changed to %d", + self.entity_id, + self.linked_humidity_sensor, + current_humidity, + ) + self.char_current_humidity.set_value(current_humidity) + + @callback + def _async_update_current_pm25_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_pm25(event.data["new_state"]) + + @callback + def _async_update_current_pm25(self, new_state: State | None) -> None: + """Handle linked pm25 sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_pm25 := convert_to_float(new_state.state)) is None + or not self.char_pm25_density + or self.char_pm25_density.value == current_pm25 + ): + return + + _LOGGER.debug( + "%s: Linked pm25 sensor %s changed to %d", + self.entity_id, + self.linked_pm25_sensor, + current_pm25, + ) + self.char_pm25_density.set_value(current_pm25) + air_quality = density_to_air_quality(current_pm25) + self.char_air_quality.set_value(air_quality) + _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) + + @callback + def _async_update_current_temperature_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_current_temperature(event.data["new_state"]) + + @callback + def _async_update_current_temperature(self, new_state: State | None) -> None: + """Handle linked temperature sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_temperature := convert_to_float(new_state.state)) is None + or not self.char_current_temperature + or self.char_current_temperature.value == current_temperature + ): + return + + _LOGGER.debug( + "%s: Linked temperature sensor %s changed to %d", + self.entity_id, + self.linked_temperature_sensor, + current_temperature, + ) + self.char_current_temperature.set_value(current_temperature) + + @callback + def _async_update_filter_change_indicator_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_filter_change_indicator(event.data.get("new_state")) + + @callback + def _async_update_filter_change_indicator(self, new_state: State | None) -> None: + """Handle linked filter change indicator binary sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + current_change_indicator = ( + FILTER_CHANGE_FILTER if new_state.state == "on" else FILTER_OK + ) + if ( + not self.char_filter_change_indication + or self.char_filter_change_indication.value == current_change_indicator + ): + return + + _LOGGER.debug( + "%s: Linked filter change indicator binary sensor %s changed to %d", + self.entity_id, + self.linked_filter_change_indicator_binary_sensor, + current_change_indicator, + ) + self.char_filter_change_indication.set_value(current_change_indicator) + + @callback + def _async_update_filter_life_level_event( + self, event: Event[EventStateChangedData] + ) -> None: + """Handle state change event listener callback.""" + self._async_update_filter_life_level(event.data.get("new_state")) + + @callback + def _async_update_filter_life_level(self, new_state: State | None) -> None: + """Handle linked filter life level sensor state change to update HomeKit value.""" + if new_state is None or new_state.state in IGNORED_STATES: + return + + if ( + (current_life_level := convert_to_float(new_state.state)) is not None + and self.char_filter_life_level + and self.char_filter_life_level.value != current_life_level + ): + _LOGGER.debug( + "%s: Linked filter life level sensor %s changed to %d", + self.entity_id, + self.linked_filter_life_level_sensor, + current_life_level, + ) + self.char_filter_life_level.set_value(current_life_level) + + if self.linked_filter_change_indicator_binary_sensor or not current_life_level: + # Handled by its own event listener + return + + current_change_indicator = ( + FILTER_CHANGE_FILTER + if (current_life_level < THRESHOLD_FILTER_CHANGE_NEEDED) + else FILTER_OK + ) + if ( + not self.char_filter_change_indication + or self.char_filter_change_indication.value == current_change_indicator + ): + return + + _LOGGER.debug( + "%s: Linked filter life level sensor %s changed to %d", + self.entity_id, + self.linked_filter_life_level_sensor, + current_change_indicator, + ) + self.char_filter_change_indication.set_value(current_change_indicator) + + @callback + def async_update_state(self, new_state: State) -> None: + """Update fan after state change.""" + super().async_update_state(new_state) + # Handle State + state = new_state.state + + if self.char_current_air_purifier_state is not None: + self.char_current_air_purifier_state.set_value( + CURRENT_STATE_PURIFYING_AIR + if state == STATE_ON + else CURRENT_STATE_INACTIVE + ) + + # Automatic mode is represented in HASS by a preset called Auto or auto + attributes = new_state.attributes + if ATTR_PRESET_MODE in attributes: + current_preset_mode = attributes.get(ATTR_PRESET_MODE) + self.char_target_air_purifier_state.set_value( + TARGET_STATE_AUTO + if current_preset_mode and current_preset_mode.lower() == "auto" + else TARGET_STATE_MANUAL + ) + + def set_chars(self, char_values: dict[str, Any]) -> None: + """Handle automatic mode after state change.""" + super().set_chars(char_values) + if ( + CHAR_TARGET_AIR_PURIFIER_STATE in char_values + and self.auto_preset is not None + ): + if char_values[CHAR_TARGET_AIR_PURIFIER_STATE] == TARGET_STATE_AUTO: + super().set_preset_mode(True, self.auto_preset) + elif self.char_speed is not None: + super().set_chars({CHAR_ROTATION_SPEED: self.char_speed.get_value()}) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 542d4500cbc..595dbc7ded3 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -4,6 +4,7 @@ import logging from typing import Any from pyhap.const import CATEGORY_FAN +from pyhap.service import Service from homeassistant.components.fan import ( ATTR_DIRECTION, @@ -56,9 +57,9 @@ class Fan(HomeAccessory): Currently supports: state, speed, oscillate, direction. """ - def __init__(self, *args: Any) -> None: + def __init__(self, *args: Any, category: int = CATEGORY_FAN) -> None: """Initialize a new Fan accessory object.""" - super().__init__(*args, category=CATEGORY_FAN) + super().__init__(*args, category=category) self.chars: list[str] = [] state = self.hass.states.get(self.entity_id) assert state @@ -79,12 +80,8 @@ class Fan(HomeAccessory): self.chars.append(CHAR_SWING_MODE) if features & FanEntityFeature.SET_SPEED: self.chars.append(CHAR_ROTATION_SPEED) - if self.preset_modes and len(self.preset_modes) == 1: - self.chars.append(CHAR_TARGET_FAN_STATE) - serv_fan = self.add_preload_service(SERV_FANV2, self.chars) - self.set_primary_service(serv_fan) - self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0) + serv_fan = self.create_services() self.char_direction = None self.char_speed = None @@ -107,13 +104,21 @@ class Fan(HomeAccessory): properties={PROP_MIN_STEP: percentage_step}, ) - if self.preset_modes and len(self.preset_modes) == 1: + if ( + self.preset_modes + and len(self.preset_modes) == 1 + # NOTE: This would be missing for air purifiers + and CHAR_TARGET_FAN_STATE in self.chars + ): self.char_target_fan_state = serv_fan.configure_char( CHAR_TARGET_FAN_STATE, value=0, ) elif self.preset_modes: for preset_mode in self.preset_modes: + if not self.should_add_preset_mode_switch(preset_mode): + continue + preset_serv = self.add_preload_service( SERV_SWITCH, CHAR_NAME, unique_id=preset_mode ) @@ -126,7 +131,7 @@ class Fan(HomeAccessory): ) def setter_callback(value: int, preset_mode: str = preset_mode) -> None: - return self.set_preset_mode(value, preset_mode) + self.set_preset_mode(value, preset_mode) self.preset_mode_chars[preset_mode] = preset_serv.configure_char( CHAR_ON, @@ -137,10 +142,27 @@ class Fan(HomeAccessory): if CHAR_SWING_MODE in self.chars: self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0) self.async_update_state(state) - serv_fan.setter_callback = self._set_chars + serv_fan.setter_callback = self.set_chars - def _set_chars(self, char_values: dict[str, Any]) -> None: - _LOGGER.debug("Fan _set_chars: %s", char_values) + def create_services(self) -> Service: + """Create and configure the primary service for this accessory.""" + if self.preset_modes and len(self.preset_modes) == 1: + self.chars.append(CHAR_TARGET_FAN_STATE) + serv_fan = self.add_preload_service(SERV_FANV2, self.chars) + self.set_primary_service(serv_fan) + self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0) + return serv_fan + + def should_add_preset_mode_switch(self, preset_mode: str) -> bool: + """Check if a preset mode switch should be added. + + Always true for fans, but can be overridden by subclasses. + """ + return True + + def set_chars(self, char_values: dict[str, Any]) -> None: + """Set characteristic values.""" + _LOGGER.debug("Fan set_chars: %s", char_values) if CHAR_ACTIVE in char_values: if char_values[CHAR_ACTIVE]: # If the device supports set speed we diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 1181ceaa953..bc98f00c15a 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -62,9 +62,13 @@ from .const import ( CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, CONF_LINKED_DOORBELL_SENSOR, + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_LINKED_OBSTRUCTION_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, CONF_LOW_BATTERY_THRESHOLD, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -98,6 +102,8 @@ from .const import ( FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, MAX_NAME_LENGTH, + TYPE_AIR_PURIFIER, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -187,6 +193,27 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend( {vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)} ) +FAN_SCHEMA = BASIC_INFO_SCHEMA.extend( + { + vol.Optional(CONF_TYPE, default=TYPE_FAN): vol.All( + cv.string, + vol.In( + ( + TYPE_FAN, + TYPE_AIR_PURIFIER, + ) + ), + ), + vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_PM25_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_TEMPERATURE_SENSOR): cv.entity_domain(sensor.DOMAIN), + vol.Optional(CONF_LINKED_FILTER_CHANGE_INDICATION): cv.entity_domain( + binary_sensor.DOMAIN + ), + vol.Optional(CONF_LINKED_FILTER_LIFE_LEVEL): cv.entity_domain(sensor.DOMAIN), + } +) + COVER_SCHEMA = BASIC_INFO_SCHEMA.extend( { vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain( @@ -325,6 +352,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]: elif domain == "cover": config = COVER_SCHEMA(config) + elif domain == "fan": + config = FAN_SCHEMA(config) + elif domain == "sensor": config = SENSOR_SCHEMA(config) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index a5a60d8406d..be9d02e45fd 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -88,7 +88,7 @@ from .utils import get_device_macs, non_verifying_requests_session _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=30) NOTIFY_SCHEMA = vol.Any( None, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index abd9ca5757b..361636eadc6 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -62,7 +62,7 @@ "mode": { "name": "Mode", "state": { - "normal": "Normal", + "normal": "[%key:common::state::normal%]", "home": "[%key:common::state::home%]", "away": "[%key:common::state::not_home%]", "auto": "Auto", diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 9456074596a..c23ca508916 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -136,6 +136,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): # Process new device new_devices = current_devices - self._devices_last_update if new_devices: + self.data = data _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) self._add_new_devices(new_devices) diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py index 467fa2445e8..738d412d849 100644 --- a/homeassistant/components/inkbird/__init__.py +++ b/homeassistant/components/inkbird/__init__.py @@ -2,106 +2,17 @@ from __future__ import annotations -from datetime import datetime, timedelta -import logging +from inkbird_ble import INKBIRDBluetoothDeviceData -from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate - -from homeassistant.components.bluetooth import ( - BluetoothScanningMode, - BluetoothServiceInfo, - BluetoothServiceInfoBleak, - async_ble_device_from_address, -) -from homeassistant.components.bluetooth.active_update_processor import ( - ActiveBluetoothProcessorCoordinator, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.core import HomeAssistant from .const import CONF_DEVICE_TYPE, DOMAIN +from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - -FALLBACK_POLL_INTERVAL = timedelta(seconds=180) - - -class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): - """Coordinator for INKBIRD Bluetooth devices.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - data: INKBIRDBluetoothDeviceData, - ) -> None: - """Initialize the INKBIRD Bluetooth processor coordinator.""" - self._data = data - self._entry = entry - address = entry.unique_id - assert address is not None - entry.async_on_unload( - async_track_time_interval( - hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL - ) - ) - super().__init__( - hass=hass, - logger=_LOGGER, - address=address, - mode=BluetoothScanningMode.ACTIVE, - update_method=self._async_on_update, - needs_poll_method=self._async_needs_poll, - poll_method=self._async_poll_data, - ) - - async def _async_poll_data( - self, last_service_info: BluetoothServiceInfoBleak - ) -> SensorUpdate: - """Poll the device.""" - return await self._data.async_poll(last_service_info.device) - - @callback - def _async_needs_poll( - self, service_info: BluetoothServiceInfoBleak, last_poll: float | None - ) -> bool: - return ( - not self.hass.is_stopping - and self._data.poll_needed(service_info, last_poll) - and bool( - async_ble_device_from_address( - self.hass, service_info.device.address, connectable=True - ) - ) - ) - - @callback - def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate: - """Handle update callback from the passive BLE processor.""" - update = self._data.update(service_info) - if ( - self._entry.data.get(CONF_DEVICE_TYPE) is None - and self._data.device_type is not None - ): - device_type_str = str(self._data.device_type) - self.hass.config_entries.async_update_entry( - self._entry, - data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str}, - ) - return update - - @callback - def _async_schedule_poll(self, _: datetime) -> None: - """Schedule a poll of the device.""" - if self._last_service_info and self._async_needs_poll( - self._last_service_info, self._last_poll - ): - self._debounced_poll.async_schedule_call() - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up INKBIRD BLE device from a config entry.""" diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py new file mode 100644 index 00000000000..bcd519b32aa --- /dev/null +++ b/homeassistant/components/inkbird/coordinator.py @@ -0,0 +1,100 @@ +"""The INKBIRD Bluetooth integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfo, + BluetoothServiceInfoBleak, + async_ble_device_from_address, +) +from homeassistant.components.bluetooth.active_update_processor import ( + ActiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_time_interval + +from .const import CONF_DEVICE_TYPE + +_LOGGER = logging.getLogger(__name__) + +FALLBACK_POLL_INTERVAL = timedelta(seconds=180) + + +class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): + """Coordinator for INKBIRD Bluetooth devices.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + data: INKBIRDBluetoothDeviceData, + ) -> None: + """Initialize the INKBIRD Bluetooth processor coordinator.""" + self._data = data + self._entry = entry + address = entry.unique_id + assert address is not None + entry.async_on_unload( + async_track_time_interval( + hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL + ) + ) + super().__init__( + hass=hass, + logger=_LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=self._async_on_update, + needs_poll_method=self._async_needs_poll, + poll_method=self._async_poll_data, + ) + + async def _async_poll_data( + self, last_service_info: BluetoothServiceInfoBleak + ) -> SensorUpdate: + """Poll the device.""" + return await self._data.async_poll(last_service_info.device) + + @callback + def _async_needs_poll( + self, service_info: BluetoothServiceInfoBleak, last_poll: float | None + ) -> bool: + return ( + not self.hass.is_stopping + and self._data.poll_needed(service_info, last_poll) + and bool( + async_ble_device_from_address( + self.hass, service_info.device.address, connectable=True + ) + ) + ) + + @callback + def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate: + """Handle update callback from the passive BLE processor.""" + update = self._data.update(service_info) + if ( + self._entry.data.get(CONF_DEVICE_TYPE) is None + and self._data.device_type is not None + ): + device_type_str = str(self._data.device_type) + self.hass.config_entries.async_update_entry( + self._entry, + data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str}, + ) + return update + + @callback + def _async_schedule_poll(self, _: datetime) -> None: + """Schedule a poll of the device.""" + if self._last_service_info and self._async_needs_poll( + self._last_service_info, self._last_poll + ): + self._debounced_poll.async_schedule_call() diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 62ad21eb99a..6fa2c00da9f 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.6"] + "requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.7"] } diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index c59d9e22483..2cd5921d794 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -199,7 +199,7 @@ turn_on: example: "[255, 100, 100]" selector: color_rgb: - kelvin: &kelvin + color_temp_kelvin: &color_temp_kelvin filter: *color_temp_support selector: color_temp: @@ -316,7 +316,7 @@ toggle: fields: transition: *transition rgb_color: *rgb_color - kelvin: *kelvin + color_temp_kelvin: *color_temp_kelvin brightness_pct: *brightness_pct effect: *effect advanced_fields: diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index d4b709f65aa..7a53f2569e7 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -19,8 +19,8 @@ "field_flash_name": "Flash", "field_hs_color_description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100.", "field_hs_color_name": "Hue/Sat color", - "field_kelvin_description": "Color temperature in Kelvin.", - "field_kelvin_name": "Color temperature", + "field_color_temp_kelvin_description": "Color temperature in Kelvin.", + "field_color_temp_kelvin_name": "Color temperature", "field_profile_description": "Name of a light profile to use.", "field_profile_name": "Profile", "field_rgb_color_description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue.", @@ -328,9 +328,9 @@ "name": "[%key:component::light::common::field_color_temp_name%]", "description": "[%key:component::light::common::field_color_temp_description%]" }, - "kelvin": { - "name": "[%key:component::light::common::field_kelvin_name%]", - "description": "[%key:component::light::common::field_kelvin_description%]" + "color_temp_kelvin": { + "name": "[%key:component::light::common::field_color_temp_kelvin_name%]", + "description": "[%key:component::light::common::field_color_temp_kelvin_description%]" }, "brightness": { "name": "[%key:component::light::common::field_brightness_name%]", @@ -426,9 +426,9 @@ "name": "[%key:component::light::common::field_color_temp_name%]", "description": "[%key:component::light::common::field_color_temp_description%]" }, - "kelvin": { - "name": "[%key:component::light::common::field_kelvin_name%]", - "description": "[%key:component::light::common::field_kelvin_description%]" + "color_temp_kelvin": { + "name": "[%key:component::light::common::field_color_temp_kelvin_name%]", + "description": "[%key:component::light::common::field_color_temp_kelvin_description%]" }, "brightness": { "name": "[%key:component::light::common::field_brightness_name%]", diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index a8fcc84f2ec..861faa319cd 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -46,6 +46,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): super().__init__(mm, config_entry) self._attr_unique_id = f"{self._base_unique_id}-preset" self._presets: list[motionmount.Preset] = [] + self._attr_current_option = None def _update_options(self, presets: list[motionmount.Preset]) -> None: """Convert presets to select options.""" diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 5ee93cfba07..c3cc31bf04f 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -70,8 +70,8 @@ MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset( def validate_config(config: ConfigType) -> ConfigType: """Validate that the configuration is valid, throws if it isn't.""" - if config[CONF_MIN] >= config[CONF_MAX]: - raise vol.Invalid(f"'{CONF_MAX}' must be > '{CONF_MIN}'") + if config[CONF_MIN] > config[CONF_MAX]: + raise vol.Invalid(f"{CONF_MAX} must be >= {CONF_MIN}") return config diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index cedf120def1..542b16bab80 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -43,8 +43,8 @@ "data_description": { "broker": "The hostname or IP address of your MQTT broker.", "port": "The port your MQTT broker listens to. For example 1883.", - "username": "The username to login to your MQTT broker.", - "password": "The password to login to your MQTT broker.", + "username": "The username to log in to your MQTT broker.", + "password": "The password to log in to your MQTT broker.", "advanced_options": "Enable and select **Next** to set advanced options.", "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 08176307829..11cc48f28a3 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -151,6 +151,9 @@ async def async_setup_entry( assert event.object_id is not None if event.object_id in added_ids: return + player = mass.players.get(event.object_id) + if TYPE_CHECKING: + assert player is not None if not player.expose_to_ha: return added_ids.add(event.object_id) diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 23b800e460d..afa8a670704 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -241,10 +241,10 @@ "name": "Reachability" }, "rf_strength": { - "name": "Radio" + "name": "RF strength" }, "wifi_strength": { - "name": "Wi-Fi" + "name": "Wi-Fi strength" }, "health_idx": { "name": "Health index", diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index e7ab63d4712..e8a1b53cc08 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.4.0"] + "requirements": ["nexia==2.7.0"] } diff --git a/homeassistant/components/niko_home_control/quality_scale.yaml b/homeassistant/components/niko_home_control/quality_scale.yaml new file mode 100644 index 00000000000..390efb8fc90 --- /dev/null +++ b/homeassistant/components/niko_home_control/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not require polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: + status: todo + comment: | + Be more specific in the config flow with catching exceptions. + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: todo + docs-installation-instructions: done + docs-removal-instructions: todo + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide additional actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: + status: exempt + comment: No options to configure + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: done + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + This integration does not require a websession. + strict-typing: todo diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index f44a510b1c0..280edb819d4 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -323,7 +323,7 @@ class NumberDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var` + Unit of measurement: `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -497,7 +497,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), NumberDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), NumberDeviceClass.PRESSURE: set(UnitOfPressure), - NumberDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE}, + NumberDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower), NumberDeviceClass.SIGNAL_STRENGTH: { SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json index fa19adbede8..bcd9cfd17fe 100644 --- a/homeassistant/components/ohme/strings.json +++ b/homeassistant/components/ohme/strings.json @@ -140,7 +140,7 @@ }, "exceptions": { "auth_failed": { - "message": "Unable to login to Ohme" + "message": "Unable to log in to Ohme" }, "device_info_failed": { "message": "Unable to get Ohme device information" diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index e691d01257a..2cc942363cf 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.10.0"] + "requirements": ["opower==0.11.1"] } diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py index 40ede9de103..2aac359a5c6 100644 --- a/homeassistant/components/pterodactyl/api.py +++ b/homeassistant/components/pterodactyl/api.py @@ -63,15 +63,24 @@ class PterodactylAPI: self.pterodactyl = None self.identifiers = [] + def get_game_servers(self) -> list[str]: + """Get all game servers.""" + paginated_response = self.pterodactyl.client.servers.list_servers() # type: ignore[union-attr] + + return paginated_response.collect() + async def async_init(self): """Initialize the Pterodactyl API.""" self.pterodactyl = PterodactylClient(self.host, self.api_key) try: - paginated_response = await self.hass.async_add_executor_job( - self.pterodactyl.client.servers.list_servers - ) - except (BadRequestError, PterodactylApiError, ConnectionError) as error: + game_servers = await self.hass.async_add_executor_job(self.get_game_servers) + except ( + BadRequestError, + PterodactylApiError, + ConnectionError, + StopIteration, + ) as error: raise PterodactylConnectionError(error) from error except HTTPError as error: if error.response.status_code == 401: @@ -79,7 +88,6 @@ class PterodactylAPI: raise PterodactylConnectionError(error) from error else: - game_servers = paginated_response.collect() for game_server in game_servers: self.identifiers.append(game_server["attributes"]["identifier"]) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 8bfea1c6910..e478f06b556 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -17,8 +17,8 @@ "port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.", "use_https": "Use an HTTPS (SSL) connection to the Reolink device.", "baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.", - "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", - "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." + "username": "Username to log in to the Reolink device itself. Not the Reolink cloud account.", + "password": "Password to log in to the Reolink device itself. Not the Reolink cloud account." } }, "privacy": { @@ -33,7 +33,7 @@ "not_admin": "User needs to be admin, user \"{username}\" has authorisation level \"{userlevel}\"", "password_incompatible": "Password contains incompatible special character or is too long, maximum 31 characters and only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}", "unknown": "[%key:common::config_flow::error::unknown%]", - "update_needed": "Failed to login because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed", + "update_needed": "Failed to log in because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed", "webhook_exception": "Home Assistant URL is not available, go to Settings > System > Network > Home Assistant URL and correct the URLs, see {more_info}" }, "abort": { diff --git a/homeassistant/components/romy/strings.json b/homeassistant/components/romy/strings.json index 78721da17ba..b8725624ac7 100644 --- a/homeassistant/components/romy/strings.json +++ b/homeassistant/components/romy/strings.json @@ -36,11 +36,11 @@ "fan_speed": { "state": { "default": "Default", - "normal": "Normal", - "silent": "Silent", + "normal": "[%key:common::state::normal%]", + "high": "[%key:common::state::high%]", "intensive": "Intensive", + "silent": "Silent", "super_silent": "Super silent", - "high": "High", "auto": "Auto" } } diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 63af8e5bf52..c845980e9df 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -352,7 +352,7 @@ class SensorDeviceClass(StrEnum): REACTIVE_POWER = "reactive_power" """Reactive power. - Unit of measurement: `var` + Unit of measurement: `var`, `kvar` """ SIGNAL_STRENGTH = "signal_strength" @@ -596,7 +596,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth), SensorDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux), SensorDeviceClass.PRESSURE: set(UnitOfPressure), - SensorDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE}, + SensorDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower), SensorDeviceClass.SIGNAL_STRENGTH: { SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 200a88ea24c..6e41df282ef 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -12,6 +12,7 @@ from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, InvalidAuthError, + InvalidHostError, MacAddressMismatchError, ) from aioshelly.rpc_device import RpcDevice @@ -157,6 +158,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): self.info = await self._async_get_info(host, port) except DeviceConnectionError: errors["base"] = "cannot_connect" + except InvalidHostError: + errors["base"] = "invalid_host" except Exception: # noqa: BLE001 LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 43fb6df18d0..0c64df52409 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -277,3 +277,7 @@ ROLE_TO_DEVICE_CLASS_MAP = { "current_humidity": SensorDeviceClass.HUMIDITY, "current_temperature": SensorDeviceClass.TEMPERATURE, } + +# We want to check only the first 5 KB of the script if it contains emitEvent() +# so that the integration startup remains fast. +MAX_SCRIPT_SIZE = 5120 diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index e863720e476..19ccd1354a7 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], - "requirements": ["aioshelly==13.4.0"], + "requirements": ["aioshelly==13.4.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 3465891dc68..43c709f4641 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -51,6 +51,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]", "firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support", "custom_port_not_supported": "Gen1 device does not support custom port.", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 474e2bb9410..a5e08faf0e0 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -58,6 +58,7 @@ from .const import ( GEN2_BETA_RELEASE_URL, GEN2_RELEASE_URL, LOGGER, + MAX_SCRIPT_SIZE, RPC_INPUTS_EVENTS_TYPES, SHAIR_MAX_WORK_HOURS, SHBTN_INPUTS_EVENTS_TYPES, @@ -642,7 +643,7 @@ def get_rpc_ws_url(hass: HomeAssistant) -> str | None: async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]: """Return a list of event types for a specific script.""" - code_response = await device.script_getcode(id) + code_response = await device.script_getcode(id, bytes_to_read=MAX_SCRIPT_SIZE) matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"]) return sorted([*{str(event_type.group(1)) for event_type in matches}]) diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 6aae74922e4..27fa54e46dd 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -10,7 +10,9 @@ import pysma from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONNECTIONS, CONF_HOST, + CONF_MAC, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_SSL, @@ -19,6 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -75,6 +78,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: serial_number=sma_device_info["serial"], ) + # Add the MAC address to connections, if it comes via DHCP + if CONF_MAC in entry.data: + device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, entry.data[CONF_MAC]) + } + # Define the coordinator async def async_update_data(): """Update the used SMA sensors.""" diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index 3f5eb635989..3210d904b6b 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -7,26 +7,43 @@ from typing import Any import pysma import voluptuous as vol +from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CONF_GROUP, DOMAIN, GROUPS _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: +async def validate_input( + hass: HomeAssistant, + user_input: dict[str, Any], + data: dict[str, Any] | None = None, +) -> dict[str, Any]: """Validate the user input allows us to connect.""" - session = async_get_clientsession(hass, verify_ssl=data[CONF_VERIFY_SSL]) + session = async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL]) - protocol = "https" if data[CONF_SSL] else "http" - url = f"{protocol}://{data[CONF_HOST]}" + protocol = "https" if user_input[CONF_SSL] else "http" + host = data[CONF_HOST] if data is not None else user_input[CONF_HOST] + url = URL.build(scheme=protocol, host=host) - sma = pysma.SMA(session, url, data[CONF_PASSWORD], group=data[CONF_GROUP]) + sma = pysma.SMA( + session, str(url), user_input[CONF_PASSWORD], group=user_input[CONF_GROUP] + ) # new_session raises SmaAuthenticationException on failure await sma.new_session() @@ -51,34 +68,53 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): CONF_GROUP: GROUPS[0], CONF_PASSWORD: vol.UNDEFINED, } + self._discovery_data: dict[str, Any] = {} + + async def _handle_user_input( + self, user_input: dict[str, Any], discovery: bool = False + ) -> tuple[dict[str, str], dict[str, str]]: + """Handle the user input.""" + errors: dict[str, str] = {} + device_info: dict[str, str] = {} + + if not discovery: + self._data[CONF_HOST] = user_input[CONF_HOST] + + self._data[CONF_SSL] = user_input[CONF_SSL] + self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL] + self._data[CONF_GROUP] = user_input[CONF_GROUP] + self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD] + + try: + device_info = await validate_input( + self.hass, user_input=user_input, data=self._data + ) + except pysma.exceptions.SmaConnectionException: + errors["base"] = "cannot_connect" + except pysma.exceptions.SmaAuthenticationException: + errors["base"] = "invalid_auth" + except pysma.exceptions.SmaReadException: + errors["base"] = "cannot_retrieve_device_info" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return errors, device_info async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """First step in config flow.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - self._data[CONF_HOST] = user_input[CONF_HOST] - self._data[CONF_SSL] = user_input[CONF_SSL] - self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL] - self._data[CONF_GROUP] = user_input[CONF_GROUP] - self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD] - - try: - device_info = await validate_input(self.hass, user_input) - except pysma.exceptions.SmaConnectionException: - errors["base"] = "cannot_connect" - except pysma.exceptions.SmaAuthenticationException: - errors["base"] = "invalid_auth" - except pysma.exceptions.SmaReadException: - errors["base"] = "cannot_retrieve_device_info" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + errors, device_info = await self._handle_user_input(user_input=user_input) if not errors: - await self.async_set_unique_id(str(device_info["serial"])) + await self.async_set_unique_id( + str(device_info["serial"]), raise_on_progress=False + ) self._abort_if_unique_id_configured(updates=self._data) + return self.async_create_entry( title=self._data[CONF_HOST], data=self._data ) @@ -100,3 +136,50 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + self._discovery_data[CONF_HOST] = discovery_info.ip + self._discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress) + self._discovery_data[CONF_NAME] = discovery_info.hostname + self._data[CONF_HOST] = discovery_info.ip + self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC]) + + await self.async_set_unique_id(discovery_info.hostname.replace("SMA", "")) + self._abort_if_unique_id_configured() + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + errors: dict[str, str] = {} + if user_input is not None: + errors, device_info = await self._handle_user_input( + user_input=user_input, discovery=True + ) + + if not errors: + return self.async_create_entry( + title=self._data[CONF_HOST], data=self._data + ) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean, + vol.Optional( + CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL] + ): cv.boolean, + vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In( + GROUPS + ), + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 8024aad82d6..bb3f5318280 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,6 +3,13 @@ "name": "SMA Solar", "codeowners": ["@kellerza", "@rklomp", "@erwindouna"], "config_flow": true, + "dhcp": [ + { + "hostname": "sma*", + "macaddress": "0015BB*" + }, + { "registered_devices": true } + ], "documentation": "https://www.home-assistant.io/integrations/sma", "iot_class": "local_polling", "loggers": ["pysma"], diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json index 79fa7a4820f..8391aaa4d47 100644 --- a/homeassistant/components/smarttub/strings.json +++ b/homeassistant/components/smarttub/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Login", - "description": "Enter your SmartTub email address and password to login", + "description": "Enter your SmartTub email address and password to log in", "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 5a118e7de15..8a8dcd74b8f 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -111,7 +111,11 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): raise ConfigEntryAuthFailed from err except SmlightConnectionError as err: - raise UpdateFailed(err) from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect_device", + translation_placeholders={"error": str(err)}, + ) from err @abstractmethod async def _internal_update_data(self) -> _DataT: diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index e9025203b8c..b2a03a737fc 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,6 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", + "quality_scale": "silver", "requirements": ["pysmlight==0.2.4"], "zeroconf": [ { diff --git a/homeassistant/components/smlight/quality_scale.yaml b/homeassistant/components/smlight/quality_scale.yaml new file mode 100644 index 00000000000..5c6d7364704 --- /dev/null +++ b/homeassistant/components/smlight/quality_scale.yaml @@ -0,0 +1,85 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: done + comment: | + Entities subscribe to SSE events from pysmlight library. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: done + comment: Handled implicitly within coordinator + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide an option flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: done + comment: Handled by coordinator + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: | + Device type integration. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index ca52f6fea38..4abc6349d1e 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -15,6 +15,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "Username for the device's web login.", + "password": "Password for the device's web login." } }, "reauth_confirm": { @@ -23,6 +27,10 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::smlight::config::step::auth::data_description::username%]", + "password": "[%key:component::smlight::config::step::auth::data_description::password%]" } }, "confirm_discovery": { @@ -137,6 +145,14 @@ } } }, + "exceptions": { + "firmware_update_failed": { + "message": "Firmware update failed for {device_name}." + }, + "cannot_connect_device": { + "message": "An error occurred while connecting to the SMLIGHT device: {error}." + } + }, "issues": { "unsupported_firmware": { "title": "SLZB core firmware update required", diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index 48f9149645c..d7aed0ecb4d 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import LOGGER +from .const import DOMAIN, LOGGER from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData from .entity import SmEntity @@ -210,7 +210,13 @@ class SmUpdateEntity(SmEntity, UpdateEntity): def _update_failed(self, event: MessageEvent) -> None: self._update_done() self.coordinator.in_progress = False - raise HomeAssistantError(f"Update failed for {self.name}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="firmware_update_failed", + translation_placeholders={ + "device_name": str(self.name), + }, + ) async def async_install( self, version: str | None, backup: bool, **kwargs: Any diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 09bc157d4d2..73b7307aa2d 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -66,6 +66,12 @@ PLATFORMS_BY_TYPE = { SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH], SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.REMOTE.value: [Platform.SENSOR], + SupportedModels.ROLLER_SHADE.value: [ + Platform.COVER, + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], + SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -80,6 +86,7 @@ CLASS_BY_DEVICE = { SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt, SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch, SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch, + SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 16b41d75541..787c1fa720b 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -35,6 +35,8 @@ class SupportedModels(StrEnum): RELAY_SWITCH_1 = "relay_switch_1" LEAK = "leak" REMOTE = "remote" + ROLLER_SHADE = "roller_shade" + HUBMINI_MATTER = "hubmini_matter" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -51,6 +53,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.HUB2: SupportedModels.HUB2, SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM, SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1, + SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -62,6 +65,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.LEAK: SupportedModels.LEAK, SwitchbotModel.REMOTE: SupportedModels.REMOTE, + SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER, } SUPPORTED_MODEL_TYPES = ( diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 5a9613ab2a2..bb73339aa05 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -37,6 +37,8 @@ async def async_setup_entry( coordinator = entry.runtime_data if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt): async_add_entities([SwitchBotBlindTiltEntity(coordinator)]) + elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade): + async_add_entities([SwitchBotRollerShadeEntity(coordinator)]) else: async_add_entities([SwitchBotCurtainEntity(coordinator)]) @@ -199,3 +201,85 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity): self._attr_is_opening = self.parsed_data["motionDirection"]["opening"] self._attr_is_closing = self.parsed_data["motionDirection"]["closing"] self.async_write_ha_state() + + +class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity): + """Representation of a Switchbot.""" + + _device: switchbot.SwitchbotRollerShade + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + _attr_translation_key = "cover" + _attr_name = None + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the switchbot.""" + super().__init__(coordinator) + self._attr_is_closed = None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes: + return + + self._attr_current_cover_position = last_state.attributes.get( + ATTR_CURRENT_POSITION + ) + self._last_run_success = last_state.attributes.get("last_run_success") + if self._attr_current_cover_position is not None: + self._attr_is_closed = self._attr_current_cover_position <= 20 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the roller shade.""" + + _LOGGER.debug("Switchbot to open roller shade %s", self._address) + self._last_run_success = bool(await self._device.open()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the roller shade.""" + + _LOGGER.debug("Switchbot to close roller shade %s", self._address) + self._last_run_success = bool(await self._device.close()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the moving of roller shade.""" + + _LOGGER.debug("Switchbot to stop roller shade %s", self._address) + self._last_run_success = bool(await self._device.stop()) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + + position = kwargs.get(ATTR_POSITION) + _LOGGER.debug("Switchbot to move at %d %s", position, self._address) + self._last_run_success = bool(await self._device.set_position(position)) + self._attr_is_opening = self._device.is_opening() + self._attr_is_closing = self._device.is_closing() + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_closing = self._device.is_closing() + self._attr_is_opening = self._device.is_opening() + self._attr_current_cover_position = self.parsed_data["position"] + self._attr_is_closed = self.parsed_data["position"] <= 20 + + self.async_write_ha_state() diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index e4da161c63d..fcd2e07306f 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -141,7 +141,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "keep": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 4ff78781c7f..69b1551a561 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -221,7 +221,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "keep": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 5de18f13140..1c0ec7ecc80 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -48,7 +48,7 @@ "state_attributes": { "preset_mode": { "state": { - "off": "Normal", + "off": "[%key:common::state::normal%]", "on": "Keep mode", "dog": "Dog mode", "camp": "Camp mode" diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index 3487f41efaa..a4b57562388 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -12,7 +12,7 @@ }, "data_description": { "host": "The hostname or IP address of your Traccar Server", - "username": "The username (email) you use to login to your Traccar Server" + "username": "The username (email) you use to log in to your Traccar Server" } } }, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index c86e60c22ef..55fd9b18b1e 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -321,9 +321,9 @@ "vacuum_cistern": { "name": "Water tank adjustment", "state": { - "low": "Low", + "low": "[%key:common::state::low%]", "middle": "Middle", - "high": "High", + "high": "[%key:common::state::high%]", "closed": "[%key:common::state::closed%]" } }, diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index d84b2038dde..88d42503a03 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -127,6 +127,7 @@ class ViCareFan(ViCareEntity, FanEntity): _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) _attr_translation_key = "ventilation" + _attributes: dict[str, Any] = {} def __init__( self, @@ -155,7 +156,7 @@ class ViCareFan(ViCareEntity, FanEntity): self._attr_supported_features |= FanEntityFeature.SET_SPEED # evaluate quickmodes - quickmodes: list[str] = ( + self._attributes["vicare_quickmodes"] = quickmodes = list[str]( device.getVentilationQuickmodes() if is_supported( "getVentilationQuickmodes", @@ -196,26 +197,23 @@ class ViCareFan(ViCareEntity, FanEntity): @property def is_on(self) -> bool | None: """Return true if the entity is on.""" - if ( - self._attr_supported_features & FanEntityFeature.TURN_OFF - and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) - ): + if VentilationQuickmode.STANDBY in self._attributes[ + "vicare_quickmodes" + ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): return False return self.percentage is not None and self.percentage > 0 def turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self._api.activateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) @property def icon(self) -> str | None: """Return the icon to use in the frontend.""" - if ( - self._attr_supported_features & FanEntityFeature.TURN_OFF - and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY) - ): + if VentilationQuickmode.STANDBY in self._attributes[ + "vicare_quickmodes" + ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): return "mdi:fan-off" if hasattr(self, "_attr_preset_mode"): if self._attr_preset_mode == VentilationMode.VENTILATION: @@ -242,7 +240,9 @@ class ViCareFan(ViCareEntity, FanEntity): """Set the speed of the fan, as a percentage.""" if self._attr_preset_mode != str(VentilationMode.PERMANENT): self.set_preset_mode(VentilationMode.PERMANENT) - elif self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): + elif VentilationQuickmode.STANDBY in self._attributes[ + "vicare_quickmodes" + ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY): self._api.deactivateVentilationQuickmode(str(VentilationQuickmode.STANDBY)) level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) @@ -254,3 +254,8 @@ class ViCareFan(ViCareEntity, FanEntity): target_mode = VentilationMode.to_vicare_mode(preset_mode) _LOGGER.debug("changing ventilation mode to %s", target_mode) self._api.activateVentilationMode(target_mode) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Show Device Attributes.""" + return self._attributes diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 7297c601213..3a4cff6f295 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2025.2.26"] + "requirements": ["weheat==2025.3.7"] } diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 84a2c0d52ca..6829dca3004 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -2,13 +2,11 @@ from __future__ import annotations -import logging from typing import Any from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode from homeassistant.components.climate import ( - ENTITY_ID_FORMAT, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -22,15 +20,10 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WhirlpoolConfigEntry -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - +from .entity import WhirlpoolEntity AIRCON_MODE_MAP = { AirconMode.Cool: HVACMode.COOL, @@ -71,14 +64,13 @@ async def async_setup_entry( """Set up entry.""" appliances_manager = config_entry.runtime_data aircons = [AirConEntity(hass, aircon) for aircon in appliances_manager.aircons] - async_add_entities(aircons, True) + async_add_entities(aircons) -class AirConEntity(ClimateEntity): +class AirConEntity(WhirlpoolEntity, ClimateEntity): """Representation of an air conditioner.""" _attr_fan_modes = SUPPORTED_FAN_MODES - _attr_has_entity_name = True _attr_name = None _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_max_temp = SUPPORTED_MAX_TEMP @@ -97,29 +89,8 @@ class AirConEntity(ClimateEntity): def __init__(self, hass: HomeAssistant, aircon: Aircon) -> None: """Initialize the entity.""" + super().__init__(aircon) self._aircon = aircon - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, aircon.said, hass=hass) - self._attr_unique_id = aircon.said - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, aircon.said)}, - name=aircon.name if aircon.name is not None else aircon.said, - manufacturer="Whirlpool", - model="Sixth Sense", - ) - - async def async_added_to_hass(self) -> None: - """Register updates callback.""" - self._aircon.register_attr_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self) -> None: - """Unregister updates callback.""" - self._aircon.unregister_attr_callback(self.async_write_ha_state) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._aircon.get_online() @property def current_temperature(self) -> float: diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py index e74ed596e1e..3f2fc81d358 100644 --- a/homeassistant/components/whirlpool/entity.py +++ b/homeassistant/components/whirlpool/entity.py @@ -19,8 +19,9 @@ class WhirlpoolEntity(Entity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, appliance.said)}, - name=appliance.name.capitalize(), + name=appliance.name.capitalize() if appliance.name else appliance.said, manufacturer="Whirlpool", + model_id=appliance.appliance_info.model_number, ) self._attr_unique_id = f"{appliance.said}{unique_id_suffix}" diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 895c7cd50e2..b0b1e9fcc02 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, NumberSelectorMode, + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -79,10 +80,19 @@ def add_province_and_language_to_schema( } if provinces := all_countries.get(country): + if _country.subdivisions_aliases and ( + subdiv_aliases := _country.get_subdivision_aliases() + ): + province_options: list[Any] = [ + SelectOptionDict(value=k, label=", ".join(v)) + for k, v in subdiv_aliases.items() + ] + else: + province_options = provinces province_schema = { vol.Optional(CONF_PROVINCE): SelectSelector( SelectSelectorConfig( - options=provinces, + options=province_options, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_PROVINCE, ) diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index b4cfe80f287..8867457342f 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -61,8 +61,8 @@ "power_failure_alarm": { "name": "Power failure alarm", "state": { - "normal": "Normal", "alert": "Alert", + "normal": "[%key:common::state::normal%]", "off": "[%key:common::state::off%]" } }, diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 700e2833705..c819f94ceba 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -514,6 +514,7 @@ class ZHAGatewayProxy(EventBase): self._log_queue_handler.listener = logging.handlers.QueueListener( log_simple_queue, log_relay_handler ) + self._log_queue_handler_count: int = 0 self._unsubs: list[Callable[[], None]] = [] self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol)) @@ -747,7 +748,10 @@ class ZHAGatewayProxy(EventBase): if filterer: self._log_queue_handler.addFilter(filterer) - if self._log_queue_handler.listener: + # Only start a new log queue handler if the old one is no longer running + self._log_queue_handler_count += 1 + + if self._log_queue_handler.listener and self._log_queue_handler_count == 1: self._log_queue_handler.listener.start() for logger_name in DEBUG_RELAY_LOGGERS: @@ -763,7 +767,10 @@ class ZHAGatewayProxy(EventBase): for logger_name in DEBUG_RELAY_LOGGERS: logging.getLogger(logger_name).removeHandler(self._log_queue_handler) - if self._log_queue_handler.listener: + # Only stop the log queue handler if nothing else is using it + self._log_queue_handler_count -= 1 + + if self._log_queue_handler.listener and self._log_queue_handler_count == 0: self._log_queue_handler.listener.stop() if filterer: diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index d95f3208e17..20ebe94c00e 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -420,17 +420,20 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() dev_path = discovery_info.device self.usb_path = dev_path - self._title = usb.human_readable_device_name( - dev_path, - serial_number, - manufacturer, - description, - vid, - pid, - ) - self.context["title_placeholders"] = { - CONF_NAME: self._title.split(" - ")[0].strip() - } + if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2": + title = "Home Assistant Connect ZWA-2" + else: + human_name = usb.human_readable_device_name( + dev_path, + serial_number, + manufacturer, + description, + vid, + pid, + ) + title = human_name.split(" - ")[0].strip() + self.context["title_placeholders"] = {CONF_NAME: title} + self._title = title return await self.async_step_usb_confirm() async def async_step_usb_confirm( diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 7e8b473922f..6f415ce257d 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -21,6 +21,13 @@ "pid": "8A2A", "description": "*z-wave*", "known_devices": ["Nortek HUSBZB-1"] + }, + { + "vid": "303A", + "pid": "4001", + "description": "*nabu casa zwa-2*", + "manufacturer": "nabu casa", + "known_devices": ["Nabu Casa Connect ZWA-2"] } ], "zeroconf": ["_zwave-js-server._tcp.local."] diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 6718f6acc81..ebedbd807c5 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -72,12 +72,12 @@ from .helpers.json import json_bytes, json_bytes_sorted, json_fragment from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType from .loader import async_suggest_report_issue from .setup import ( - DATA_SETUP_DONE, SetupPhases, async_pause_setup, async_process_deps_reqs, async_setup_component, async_start_setup, + async_wait_component, ) from .util import ulid as ulid_util from .util.async_ import create_eager_task @@ -1511,6 +1511,22 @@ class ConfigEntriesFlowManager( future.set_result(None) self._discovery_event_debouncer.async_shutdown() + @callback + def async_flow_removed( + self, + flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], + ) -> None: + """Handle a removed config flow.""" + flow = cast(ConfigFlow, flow) + + # Clean up issue if this is a reauth flow + if flow.context["source"] == SOURCE_REAUTH: + if (entry_id := flow.context.get("entry_id")) is not None and ( + entry := self.config_entries.async_get_entry(entry_id) + ) is not None: + issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" + ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) + async def async_finish_flow( self, flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult], @@ -1523,20 +1539,6 @@ class ConfigEntriesFlowManager( """ flow = cast(ConfigFlow, flow) - # Mark the step as done. - # We do this to avoid a circular dependency where async_finish_flow sets up a - # new entry, which needs the integration to be set up, which is waiting for - # init to be done. - self._set_pending_import_done(flow) - - # Clean up issue if this is a reauth flow - if flow.context["source"] == SOURCE_REAUTH: - if (entry_id := flow.context.get("entry_id")) is not None and ( - entry := self.config_entries.async_get_entry(entry_id) - ) is not None: - issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" - ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) - if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: # If there's a config entry with a matching unique ID, # update the discovery key. @@ -1575,6 +1577,12 @@ class ConfigEntriesFlowManager( ) return result + # Mark the step as done. + # We do this to avoid a circular dependency where async_finish_flow sets up a + # new entry, which needs the integration to be set up, which is waiting for + # init to be done. + self._set_pending_import_done(flow) + # Avoid adding a config entry for a integration # that only supports a single config entry, but already has an entry if ( @@ -2732,11 +2740,7 @@ class ConfigEntries: Config entries which are created after Home Assistant is started can't be waited for, the function will just return if the config entry is loaded or not. """ - setup_done = self.hass.data.get(DATA_SETUP_DONE, {}) - if setup_future := setup_done.get(entry.domain): - await setup_future - # The component was not loaded. - if entry.domain not in self.hass.config.components: + if not await async_wait_component(self.hass, entry.domain): return False return entry.state is ConfigEntryState.LOADED diff --git a/homeassistant/const.py b/homeassistant/const.py index a6f39db8532..db0af10fba3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -603,6 +603,7 @@ class UnitOfReactivePower(StrEnum): """Reactive power units.""" VOLT_AMPERE_REACTIVE = "var" + KILO_VOLT_AMPERE_REACTIVE = "kvar" _DEPRECATED_POWER_VOLT_AMPERE_REACTIVE: Final = DeprecatedConstantEnum( diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 511bab25a7f..6a288380cd0 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -207,6 +207,13 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): Handler key is the domain of the component that we want to set up. """ + @callback + def async_flow_removed( + self, + flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT], + ) -> None: + """Handle a removed data entry flow.""" + @abc.abstractmethod async def async_finish_flow( self, @@ -457,6 +464,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): """Remove a flow from in progress.""" if (flow := self._progress.pop(flow_id, None)) is None: raise UnknownFlow + self.async_flow_removed(flow) self._async_remove_flow_from_index(flow) flow.async_cancel_progress_task() try: @@ -485,6 +493,10 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): description_placeholders=err.description_placeholders, ) + if flow.flow_id not in self._progress: + # The flow was removed during the step + raise UnknownFlow + # Setup the flow handler's preview if needed if result.get("preview") is not None: await self._async_setup_preview(flow) diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 9a8fd349a8b..39854ff0af6 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -613,6 +613,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "sleepiq", "macaddress": "64DBA0*", }, + { + "domain": "sma", + "hostname": "sma*", + "macaddress": "0015BB*", + }, + { + "domain": "sma", + "registered_devices": True, + }, { "domain": "smartthings", "hostname": "st*", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index e66a5861d18..8aea15df283 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -148,4 +148,11 @@ USB = [ "pid": "8A2A", "vid": "10C4", }, + { + "description": "*nabu casa zwa-2*", + "domain": "zwave_js", + "manufacturer": "nabu casa", + "pid": "4001", + "vid": "303A", + }, ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index af75218bf7e..3bcad4b8f30 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -130,7 +130,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.2 +pydantic==2.11.3 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -212,3 +212,8 @@ async-timeout==4.0.3 # https://github.com/home-assistant/core/issues/122508 # https://github.com/home-assistant/core/issues/118004 aiofiles>=24.1.0 + +# multidict < 6.4.0 has memory leaks +# https://github.com/aio-libs/multidict/issues/1134 +# https://github.com/aio-libs/multidict/issues/1131 +multidict>=6.4.2 diff --git a/homeassistant/setup.py b/homeassistant/setup.py index aeaea1146a1..39f0a7656f3 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -45,36 +45,36 @@ _LOGGER = logging.getLogger(__name__) ATTR_COMPONENT: Final = "component" -# DATA_SETUP is a dict, indicating domains which are currently +# _DATA_SETUP is a dict, indicating domains which are currently # being setup or which failed to setup: -# - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain +# - Tasks are added to _DATA_SETUP by `async_setup_component`, the key is the domain # being setup and the Task is the `_async_setup_component` helper. -# - Tasks are removed from DATA_SETUP if setup was successful, that is, +# - Tasks are removed from _DATA_SETUP if setup was successful, that is, # the task returned True. -DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks") +_DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks") -# DATA_SETUP_DONE is a dict, indicating components which will be setup: -# - Events are added to DATA_SETUP_DONE during bootstrap by +# _DATA_SETUP_DONE is a dict, indicating components which will be setup: +# - Events are added to _DATA_SETUP_DONE during bootstrap by # async_set_domains_to_be_loaded, the key is the domain which will be loaded. -# - Events are set and removed from DATA_SETUP_DONE when async_setup_component +# - Events are set and removed from _DATA_SETUP_DONE when async_setup_component # is finished, regardless of if the setup was successful or not. -DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done") +_DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done") -# DATA_SETUP_STARTED is a dict, indicating when an attempt +# _DATA_SETUP_STARTED is a dict, indicating when an attempt # to setup a component started. -DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey( +_DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey( "setup_started" ) -# DATA_SETUP_TIME is a defaultdict, indicating how time was spent +# _DATA_SETUP_TIME is a defaultdict, indicating how time was spent # setting up a component. -DATA_SETUP_TIME: HassKey[ +_DATA_SETUP_TIME: HassKey[ defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]] ] = HassKey("setup_time") -DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed") +_DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed") -DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey( +_DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey( "bootstrap_persistent_errors" ) @@ -104,8 +104,8 @@ def async_notify_setup_error( # pylint: disable-next=import-outside-toplevel from .components import persistent_notification - if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None: - errors = hass.data[DATA_PERSISTENT_ERRORS] = {} + if (errors := hass.data.get(_DATA_PERSISTENT_ERRORS)) is None: + errors = hass.data[_DATA_PERSISTENT_ERRORS] = {} errors[component] = errors.get(component) or display_link @@ -131,8 +131,8 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str]) - Properly handle after_dependencies. - Keep track of domains which will load but have not yet finished loading """ - setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) - setup_futures = hass.data.setdefault(DATA_SETUP, {}) + setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components if overlap := old_domains & domains: _LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap) @@ -158,8 +158,8 @@ async def async_setup_component( if domain in hass.config.components: return True - setup_futures = hass.data.setdefault(DATA_SETUP, {}) - setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) + setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {}) if existing_setup_future := setup_futures.get(domain): return await existing_setup_future @@ -200,35 +200,40 @@ async def _async_process_dependencies( Returns a list of dependencies which failed to set up. """ - setup_futures = hass.data.setdefault(DATA_SETUP, {}) + setup_futures = hass.data.setdefault(_DATA_SETUP, {}) - dependencies_tasks = { - dep: setup_futures.get(dep) - or create_eager_task( - async_setup_component(hass, dep, config), - name=f"setup {dep} as dependency of {integration.domain}", - loop=hass.loop, - ) - for dep in integration.dependencies - if dep not in hass.config.components - } + dependencies_tasks: dict[str, asyncio.Future[bool]] = {} - to_be_loaded = hass.data.get(DATA_SETUP_DONE, {}) + for dep in integration.dependencies: + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( + async_setup_component(hass, dep, config), + name=f"setup {dep} as dependency of {integration.domain}", + loop=hass.loop, + ) + dependencies_tasks[dep] = fut + + to_be_loaded = hass.data.get(_DATA_SETUP_DONE, {}) # We don't want to just wait for the futures from `to_be_loaded` here. # We want to ensure that our after_dependencies are always actually # scheduled to be set up, as if for whatever reason they had not been, # we would deadlock waiting for them here. for dep in integration.after_dependencies: - if ( - dep not in dependencies_tasks - and dep in to_be_loaded - and dep not in hass.config.components - ): - dependencies_tasks[dep] = setup_futures.get(dep) or create_eager_task( + if dep not in to_be_loaded or dep in dependencies_tasks: + continue + fut = setup_futures.get(dep) + if fut is None: + if dep in hass.config.components: + continue + fut = create_eager_task( async_setup_component(hass, dep, config), name=f"setup {dep} as after dependency of {integration.domain}", loop=hass.loop, ) + dependencies_tasks[dep] = fut if not dependencies_tasks: return [] @@ -478,7 +483,7 @@ async def _async_setup_component( ) # Cleanup - hass.data[DATA_SETUP].pop(domain, None) + hass.data[_DATA_SETUP].pop(domain, None) hass.bus.async_fire_internal( EVENT_COMPONENT_LOADED, EventComponentLoaded(component=domain) @@ -568,8 +573,8 @@ async def async_process_deps_reqs( Module is a Python module of either a component or platform. """ - if (processed := hass.data.get(DATA_DEPS_REQS)) is None: - processed = hass.data[DATA_DEPS_REQS] = set() + if (processed := hass.data.get(_DATA_DEPS_REQS)) is None: + processed = hass.data[_DATA_DEPS_REQS] = set() elif integration.domain in processed: return @@ -684,7 +689,7 @@ class SetupPhases(StrEnum): """Wait time for the packages to import.""" -@singleton.singleton(DATA_SETUP_STARTED) +@singleton.singleton(_DATA_SETUP_STARTED) def _setup_started( hass: core.HomeAssistant, ) -> dict[tuple[str, str | None], float]: @@ -727,7 +732,7 @@ def async_pause_setup(hass: core.HomeAssistant, phase: SetupPhases) -> Generator ) -@singleton.singleton(DATA_SETUP_TIME) +@singleton.singleton(_DATA_SETUP_TIME) def _setup_times( hass: core.HomeAssistant, ) -> defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]: @@ -827,3 +832,11 @@ def async_get_domain_setup_times( ) -> Mapping[str | None, dict[SetupPhases, float]]: """Return timing data for each integration.""" return _setup_times(hass).get(domain, {}) + + +async def async_wait_component(hass: HomeAssistant, domain: str) -> bool: + """Wait until a component is set up if pending, then return if it is set up.""" + setup_done = hass.data.get(_DATA_SETUP_DONE, {}) + if setup_future := setup_done.get(domain): + await setup_future + return domain in hass.config.components diff --git a/requirements_all.txt b/requirements_all.txt index 658b20f6245..8c5f876f1b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.4.0 +aioshelly==13.4.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -645,7 +645,7 @@ bluetooth-data-tools==1.27.0 bond-async==0.2.1 # homeassistant.components.bosch_alarm -bosch-alarm-mode2==0.4.3 +bosch-alarm-mode2==0.4.6 # homeassistant.components.bosch_shc boschshcpy==0.2.91 @@ -1314,7 +1314,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.6 +led-ble==1.1.7 # homeassistant.components.lektrico lektricowifi==0.0.43 @@ -1489,7 +1489,7 @@ nettigo-air-monitor==4.1.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.4.0 +nexia==2.7.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1607,7 +1607,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.10.0 +opower==0.11.1 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -2005,7 +2005,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.4 +pyheos==1.0.5 # homeassistant.components.hive pyhive-integration==1.0.2 @@ -3064,7 +3064,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.26 +weheat==2025.3.7 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.20.0 diff --git a/requirements_test.txt b/requirements_test.txt index b53b1fd8840..962a113e1a0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.16.0a7 pre-commit==4.0.0 -pydantic==2.11.2 +pydantic==2.11.3 pylint==3.3.6 pylint-per-file-ignores==1.4.0 pipdeptree==2.25.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a3e632559c..13b454e58df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.4.0 +aioshelly==13.4.1 # homeassistant.components.skybell aioskybell==22.7.0 @@ -570,7 +570,7 @@ bluetooth-data-tools==1.27.0 bond-async==0.2.1 # homeassistant.components.bosch_alarm -bosch-alarm-mode2==0.4.3 +bosch-alarm-mode2==0.4.6 # homeassistant.components.bosch_shc boschshcpy==0.2.91 @@ -1114,7 +1114,7 @@ ld2410-ble==0.1.1 leaone-ble==0.1.0 # homeassistant.components.led_ble -led-ble==1.1.6 +led-ble==1.1.7 # homeassistant.components.lektrico lektricowifi==0.0.43 @@ -1253,7 +1253,7 @@ netmap==0.7.0.2 nettigo-air-monitor==4.1.0 # homeassistant.components.nexia -nexia==2.4.0 +nexia==2.7.0 # homeassistant.components.nextcloud nextcloudmonitor==1.5.1 @@ -1344,7 +1344,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.10.0 +opower==0.11.1 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1635,7 +1635,7 @@ pygti==0.9.4 pyhaversion==22.8.0 # homeassistant.components.heos -pyheos==1.0.4 +pyheos==1.0.5 # homeassistant.components.hive pyhive-integration==1.0.2 @@ -2472,7 +2472,7 @@ webio-api==0.1.11 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2025.2.26 +weheat==2025.3.7 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.20.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index acc87ec2731..b4e18ea5962 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -159,7 +159,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.2 +pydantic==2.11.3 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 @@ -241,6 +241,11 @@ async-timeout==4.0.3 # https://github.com/home-assistant/core/issues/122508 # https://github.com/home-assistant/core/issues/118004 aiofiles>=24.1.0 + +# multidict < 6.4.0 has memory leaks +# https://github.com/aio-libs/multidict/issues/1134 +# https://github.com/aio-libs/multidict/issues/1131 +multidict>=6.4.2 """ GENERATED_MESSAGE = ( diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 49da98f5872..c122856ab5c 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -703,7 +703,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "nibe_heatpump", "nice_go", "nightscout", - "niko_home_control", "nilu", "nina", "nissan_leaf", @@ -921,7 +920,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "smarttub", "smarty", "smhi", - "smlight", "sms", "smtp", "snapcast", @@ -1994,7 +1992,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "smarttub", "smarty", "smhi", - "smlight", "sms", "smtp", "snapcast", diff --git a/script/licenses.py b/script/licenses.py index 448e9dd2a67..62e1845b911 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -190,6 +190,7 @@ EXCEPTIONS = { "enocean", # https://github.com/kipe/enocean/pull/142 "imutils", # https://github.com/PyImageSearch/imutils/pull/292 "iso4217", # Public domain + "jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21 "kiwiki_client", # https://github.com/c7h/kiwiki_client/pull/6 "ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7 "maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48 diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py index e292a5b273f..0ca8a3045fb 100644 --- a/tests/components/androidtv_remote/test_media_player.py +++ b/tests/components/androidtv_remote/test_media_player.py @@ -391,7 +391,9 @@ async def test_media_player_connection_closed( assert mock_config_entry.state is ConfigEntryState.LOADED mock_api.send_key_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "media_player", "media_pause", @@ -400,7 +402,9 @@ async def test_media_player_connection_closed( ) mock_api.send_launch_app_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "media_player", "play_media", diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index b3c3ce1c283..9bd86bb3d85 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -183,7 +183,9 @@ async def test_remote_connection_closed( assert mock_config_entry.state is ConfigEntryState.LOADED mock_api.send_key_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "remote", "send_command", @@ -197,7 +199,9 @@ async def test_remote_connection_closed( assert mock_api.send_key_command.mock_calls == [call("DPAD_LEFT", "SHORT")] mock_api.send_launch_app_command.side_effect = ConnectionClosed() - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match="Connection to the Android TV device is closed" + ): await hass.services.async_call( "remote", "turn_on", diff --git a/tests/components/bosch_alarm/conftest.py b/tests/components/bosch_alarm/conftest.py index 8358624b003..02ec592d061 100644 --- a/tests/components/bosch_alarm/conftest.py +++ b/tests/components/bosch_alarm/conftest.py @@ -131,7 +131,8 @@ def area() -> Generator[Area]: mock.alarm_observer = AsyncMock(spec=Observable) mock.ready_observer = AsyncMock(spec=Observable) mock.alarms = [] - mock.faults = [] + mock.alarms_ids = [] + mock.faults = 0 mock.all_ready = True mock.part_ready = True mock.is_triggered.return_value = False diff --git a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr index 23ea722325f..459ddf7a213 100644 --- a/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr +++ b/tests/components/bosch_alarm/snapshots/test_diagnostics.ambr @@ -11,8 +11,7 @@ 'armed': False, 'arming': False, 'disarmed': True, - 'faults': list([ - ]), + 'faults': 0, 'id': 1, 'name': 'Area1', 'part_armed': False, @@ -108,8 +107,7 @@ 'armed': False, 'arming': False, 'disarmed': True, - 'faults': list([ - ]), + 'faults': 0, 'id': 1, 'name': 'Area1', 'part_armed': False, @@ -204,8 +202,7 @@ 'armed': False, 'arming': False, 'disarmed': True, - 'faults': list([ - ]), + 'faults': 0, 'id': 1, 'name': 'Area1', 'part_armed': False, diff --git a/tests/components/bosch_alarm/snapshots/test_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..def2c503a6a --- /dev/null +++ b/tests/components/bosch_alarm/snapshots/test_sensor.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_sensor[amax_3000][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[amax_3000][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[b5512][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '01JQ917ACKQ33HHM7YCFXYZX51_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[b5512][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[solution_3000][sensor.area1_faulting_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.area1_faulting_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Faulting points', + 'platform': 'bosch_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'faulting_points', + 'unique_id': '1234567890_area_1_faulting_points', + 'unit_of_measurement': 'points', + }) +# --- +# name: test_sensor[solution_3000][sensor.area1_faulting_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Area1 Faulting points', + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.area1_faulting_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/bosch_alarm/test_config_flow.py b/tests/components/bosch_alarm/test_config_flow.py index 4a1c9dad3ea..9e79d1c1f5f 100644 --- a/tests/components/bosch_alarm/test_config_flow.py +++ b/tests/components/bosch_alarm/test_config_flow.py @@ -283,3 +283,85 @@ async def test_reauth_flow_error( assert result["reason"] == "reauth_successful" compare = {**mock_config_entry.data, **config_flow_data} assert compare == mock_config_entry.data + + +async def test_reconfig_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reconfig auth.""" + await setup_integration(hass, mock_config_entry) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 7700}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config_flow_data, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 7700, + CONF_MODEL: model_name, + **config_flow_data, + } + + +@pytest.mark.parametrize("model", ["b5512"]) +async def test_reconfig_flow_incorrect_model( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_panel: AsyncMock, + model_name: str, + serial_number: str, + config_flow_data: dict[str, Any], +) -> None: + """Test reconfig fails with a different device.""" + await setup_integration(hass, mock_config_entry) + + config_flow_data = {k: f"{v}2" for k, v in config_flow_data.items()} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": mock_config_entry.entry_id, + }, + ) + + mock_panel.model = "Solution 3000" + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "0.0.0.0", CONF_PORT: 7700}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "device_mismatch" diff --git a/tests/components/bosch_alarm/test_sensor.py b/tests/components/bosch_alarm/test_sensor.py new file mode 100644 index 00000000000..02153a9656e --- /dev/null +++ b/tests/components/bosch_alarm/test_sensor.py @@ -0,0 +1,52 @@ +"""Tests for Bosch Alarm component.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import call_observable, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def platforms() -> AsyncGenerator[None]: + """Return the platforms to be loaded for this test.""" + with patch("homeassistant.components.bosch_alarm.PLATFORMS", [Platform.SENSOR]): + yield + + +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_panel: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor state.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_faulting_points( + hass: HomeAssistant, + mock_panel: AsyncMock, + area: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that area faulting point count changes after arming the panel.""" + await setup_integration(hass, mock_config_entry) + entity_id = "sensor.area1_faulting_points" + assert hass.states.get(entity_id).state == "0" + + area.faults = 1 + await call_observable(hass, area.ready_observer) + + assert hass.states.get(entity_id).state == "1" diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 55b7e35132c..9e7c2f6c003 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from pyfibaro.fibaro_device import SceneEvent import pytest from homeassistant.components.fibaro import CONF_IMPORT_PLUGINS, DOMAIN @@ -231,6 +232,26 @@ def mock_fan_device() -> Mock: return climate +@pytest.fixture +def mock_button_device() -> Mock: + """Fixture for a button device.""" + climate = Mock() + climate.fibaro_id = 8 + climate.parent_fibaro_id = 0 + climate.name = "Test button" + climate.room_id = 1 + climate.dead = False + climate.visible = True + climate.enabled = True + climate.type = "com.fibaro.remoteController" + climate.base_type = "com.fibaro.actor" + climate.properties = {"manufacturer": ""} + climate.central_scene_event = [SceneEvent(1, "Pressed")] + climate.actions = {} + climate.interfaces = ["zwaveCentralScene"] + return climate + + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/fibaro/test_event.py b/tests/components/fibaro/test_event.py new file mode 100644 index 00000000000..ced39b71197 --- /dev/null +++ b/tests/components/fibaro/test_event.py @@ -0,0 +1,35 @@ +"""Test the Fibaro event platform.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_entity_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_button_device: Mock, + mock_room: Mock, +) -> None: + """Test that the button device creates an entity.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_button_device] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.EVENT]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + entry = entity_registry.async_get("event.room_1_test_button_8_button_1") + assert entry + assert entry.unique_id == "hc2_111111.8.1" + assert entry.original_name == "Room 1 Test button Button 1" diff --git a/tests/components/fibaro/test_init.py b/tests/components/fibaro/test_init.py new file mode 100644 index 00000000000..330de74d6af --- /dev/null +++ b/tests/components/fibaro/test_init.py @@ -0,0 +1,31 @@ +"""Test init methods.""" + +from unittest.mock import Mock, patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .conftest import init_integration + +from tests.common import MockConfigEntry + + +async def test_unload_integration( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_light: Mock, + mock_room: Mock, +) -> None: + """Test unload integration stops state listener.""" + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, mock_config_entry) + # Act + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + # Assert + assert mock_fibaro_client.unregister_update_handler.call_count == 1 diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index 1e292ed22bb..c1908c12a14 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -200,6 +200,7 @@ MOCK_FB_SERVICES: dict[str, dict] = { MOCK_IPS["printer"]: {"NewDisallow": False, "NewWANAccess": "granted"} } }, + "X_AVM-DE_UPnP1": {"GetInfo": {"NewEnable": True}}, } MOCK_MESH_DATA = { diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 9b5b8c9353a..c2ca866ceb6 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -27,6 +27,7 @@ 'WLANConfiguration1', 'X_AVM-DE_Homeauto1', 'X_AVM-DE_HostFilter1', + 'X_AVM-DE_UPnP1', ]), 'is_router': True, 'last_exception': None, diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index f4c4229af74..ee3ae881b2c 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for Fritz!Tools config flow.""" +from copy import deepcopy import dataclasses from unittest.mock import patch @@ -20,6 +21,7 @@ from homeassistant.components.fritz.const import ( ERROR_AUTH_INVALID, ERROR_CANNOT_CONNECT, ERROR_UNKNOWN, + ERROR_UPNP_NOT_CONFIGURED, FRITZ_AUTH_EXCEPTIONS, ) from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER @@ -38,7 +40,9 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) +from .conftest import FritzConnectionMock from .const import ( + MOCK_FB_SERVICES, MOCK_FIRMWARE_INFO, MOCK_IPS, MOCK_REQUEST, @@ -761,3 +765,54 @@ async def test_ssdp_ipv6_link_local(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "ignore_ip6_link_local" + + +async def test_upnp_not_enabled(hass: HomeAssistant) -> None: + """Test if UPNP service is enabled on the router.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Disable UPnP + services = deepcopy(MOCK_FB_SERVICES) + services["X_AVM-DE_UPnP1"]["GetInfo"]["NewEnable"] = False + + with patch( + "homeassistant.components.fritz.config_flow.FritzConnection", + return_value=FritzConnectionMock(services), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT_SIMPLE + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == ERROR_UPNP_NOT_CONFIGURED + + # Enable UPnP + services["X_AVM-DE_UPnP1"]["GetInfo"]["NewEnable"] = True + + with ( + patch( + "homeassistant.components.fritz.config_flow.FritzConnection", + return_value=FritzConnectionMock(services), + ), + patch( + "homeassistant.components.fritz.config_flow.socket.gethostbyname", + return_value=MOCK_IPS["fritz.box"], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT_SIMPLE + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_PASSWORD] == "fake_pass" + assert result["data"][CONF_USERNAME] == "fake_user" + assert result["data"][CONF_PORT] == 49000 + assert result["data"][CONF_SSL] is False diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 9c4ecc4f9a4..75cb308d5de 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components import conversation from homeassistant.components.conversation import UserContent, async_get_chat_log, trace from homeassistant.components.google_generative_ai_conversation.conversation import ( + ERROR_GETTING_RESPONSE, _escape_decode, _format_schema, ) @@ -492,7 +493,33 @@ async def test_empty_response( assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - "Sorry, I had a problem getting a response from Google Generative AI." + ERROR_GETTING_RESPONSE + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_none_response( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test empty response.""" + with patch("google.genai.chats.AsyncChats.create") as mock_create: + mock_chat = AsyncMock() + mock_create.return_value.send_message = mock_chat + chat_response = Mock(prompt_feedback=None) + mock_chat.return_value = chat_response + chat_response.candidates = None + result = await conversation.async_converse( + hass, + "hello", + None, + Context(), + agent_id="conversation.google_generative_ai_conversation", + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + assert result.response.as_dict()["speech"]["plain"]["speech"] == ( + ERROR_GETTING_RESPONSE ) diff --git a/tests/components/hassio/common.py b/tests/components/hassio/common.py index 82d3564440b..5cf7e80b191 100644 --- a/tests/components/hassio/common.py +++ b/tests/components/hassio/common.py @@ -151,8 +151,7 @@ def mock_addon_installed( def mock_addon_running(addon_store_info: AsyncMock, addon_info: AsyncMock) -> AsyncMock: """Mock add-on already running.""" - addon_store_info.return_value.available = True - addon_store_info.return_value.installed = True + mock_addon_installed(addon_store_info, addon_info) addon_info.return_value.state = "started" return addon_info diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index a3718454538..d41954b2ab7 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -663,7 +663,7 @@ async def test_update_addon_with_error( update_addon.side_effect = SupervisorError with pytest.raises(HomeAssistantError, match=r"^Error updating test:"): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.test_update"}, @@ -711,7 +711,7 @@ async def test_update_addon_with_backup_and_error( ), pytest.raises(HomeAssistantError, match=message), ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.test_update", "backup": True}, @@ -738,7 +738,7 @@ async def test_update_os_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Operating System:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_operating_system_update"}, @@ -765,7 +765,7 @@ async def test_update_supervisor_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Supervisor:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_supervisor_update"}, @@ -792,7 +792,7 @@ async def test_update_core_with_error( with pytest.raises( HomeAssistantError, match=r"^Error updating Home Assistant Core:" ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_core_update"}, @@ -826,7 +826,7 @@ async def test_update_core_with_backup_and_error( ), pytest.raises(HomeAssistantError, match=r"^Error creating backup:"), ): - assert not await hass.services.async_call( + await hass.services.async_call( "update", "install", {"entity_id": "update.home_assistant_core_update", "backup": True}, diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 6334fb096a2..497b961c80f 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -849,9 +849,7 @@ async def test_update_core_with_backup_and_error( side_effect=BackupManagerError, ), ): - await client.send_json_auto_id( - {"type": "hassio/update/addon", "addon": "test", "backup": True} - ) + await client.send_json_auto_id({"type": "hassio/update/core", "backup": True}) result = await client.receive_json() assert not result["success"] assert result["error"] == { diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index ce879a38de5..a245372c247 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -1,7 +1,6 @@ """Tests for home_connect binary_sensor entities.""" from collections.abc import Awaitable, Callable -from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( @@ -15,17 +14,11 @@ from aiohomeconnect.model import ( from aiohomeconnect.model.error import HomeConnectApiError import pytest -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity from homeassistant.components.home_connect.const import ( - BSH_DOOR_STATE_CLOSED, - BSH_DOOR_STATE_LOCKED, - BSH_DOOR_STATE_OPEN, DOMAIN, REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( STATE_OFF, @@ -36,11 +29,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -import homeassistant.helpers.issue_registry as ir -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator @pytest.fixture @@ -179,7 +169,6 @@ async def test_binary_sensors_entity_availability( ) -> None: """Test if binary sensor entities availability are based on the appliance connection state.""" entity_ids = [ - "binary_sensor.washer_door", "binary_sensor.washer_remote_control", ] assert config_entry.state == ConfigEntryState.NOT_LOADED @@ -222,57 +211,6 @@ async def test_binary_sensors_entity_availability( assert state.state != STATE_UNAVAILABLE -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) -@pytest.mark.parametrize( - ("value", "expected"), - [ - (BSH_DOOR_STATE_CLOSED, "off"), - (BSH_DOOR_STATE_LOCKED, "off"), - (BSH_DOOR_STATE_OPEN, "on"), - ("", STATE_UNKNOWN), - ], -) -async def test_binary_sensors_door_states( - appliance: HomeAppliance, - expected: str, - value: str, - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, -) -> None: - """Tests for Appliance door states.""" - entity_id = "binary_sensor.washer_door" - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - await client.add_events( - [ - EventMessage( - appliance.ha_id, - EventType.STATUS, - ArrayOfEvents( - [ - Event( - key=EventKey.BSH_COMMON_STATUS_DOOR_STATE, - raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value, - timestamp=0, - level="", - handling="", - value=value, - ) - ], - ), - ) - ] - ) - await hass.async_block_till_done() - assert hass.states.is_state(entity_id, expected) - - @pytest.mark.parametrize( ("entity_id", "event_key", "event_value_update", "expected", "appliance"), [ @@ -403,141 +341,3 @@ async def test_connected_sensor_functionality( await hass.async_block_till_done() assert hass.states.is_state(entity_id, STATE_ON) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_create_door_binary_sensor_deprecation_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Test that we create an issue when an automation or script is using a door binary sensor entity.""" - entity_id = "binary_sensor.washer_door" - issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue(DOMAIN, issue_id) - - await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_door_binary_sensor_deprecation_issue_fix( - hass: HomeAssistant, - config_entry: MockConfigEntry, - integration_setup: Callable[[MagicMock], Awaitable[bool]], - setup_credentials: None, - client: MagicMock, - issue_registry: ir.IssueRegistry, - hass_client: ClientSessionGenerator, -) -> None: - """Test that we create an issue when an automation or script is using a door binary sensor entity.""" - entity_id = "binary_sensor.washer_door" - issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - assert config_entry.state == ConfigEntryState.NOT_LOADED - assert await integration_setup(client) - assert config_entry.state == ConfigEntryState.LOADED - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, - ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert len(issue_registry.issues) == 0 diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index c4b1cbe98d8..56208961312 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -6,11 +6,13 @@ import pytest from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.cover import CoverEntityFeature +from homeassistant.components.homekit import TYPE_AIR_PURIFIER from homeassistant.components.homekit.accessories import TYPES, get_accessory from homeassistant.components.homekit.const import ( ATTR_INTEGRATION, CONF_FEATURE_LIST, FEATURE_ON_OFF, + TYPE_FAN, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -350,6 +352,23 @@ def test_type_switches(type_name, entity_id, state, attrs, config) -> None: assert mock_type.called +@pytest.mark.parametrize( + ("type_name", "entity_id", "state", "attrs", "config"), + [ + ("Fan", "fan.test", "on", {}, {}), + ("Fan", "fan.test", "on", {}, {CONF_TYPE: TYPE_FAN}), + ("AirPurifier", "fan.test", "on", {}, {CONF_TYPE: TYPE_AIR_PURIFIER}), + ], +) +def test_type_fans(type_name, entity_id, state, attrs, config) -> None: + """Test if switch types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, config) + assert mock_type.called + + @pytest.mark.parametrize( ("type_name", "entity_id", "state", "attrs"), [ diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 0829c96ce1d..f59c5d2778b 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -21,6 +21,7 @@ from homeassistant.components.homekit import ( STATUS_RUNNING, STATUS_STOPPED, STATUS_WAIT, + TYPE_AIR_PURIFIER, HomeKit, ) from homeassistant.components.homekit.accessories import HomeBridge @@ -51,6 +52,7 @@ from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STARTED, @@ -58,6 +60,7 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_ON, EntityCategory, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -2162,6 +2165,109 @@ async def test_homekit_finds_linked_humidity_sensors( ) +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_homekit_finds_linked_air_purifier_sensors( + hass: HomeAssistant, + hk_driver, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test HomeKit start method.""" + entry = await async_init_integration(hass) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + homekit.driver = hk_driver + homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge") + + config_entry = MockConfigEntry(domain="air_purifier", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + sw_version="0.16.1", + model="Smart Air Purifier", + manufacturer="Home Assistant", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + humidity_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "humidity_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.HUMIDITY, + ) + pm25_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "pm25_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.PM25, + ) + temperature_sensor = entity_registry.async_get_or_create( + "sensor", + "air_purifier", + "temperature_sensor", + device_id=device_entry.id, + original_device_class=SensorDeviceClass.TEMPERATURE, + ) + air_purifier = entity_registry.async_get_or_create( + "fan", "air_purifier", "demo", device_id=device_entry.id + ) + + hass.states.async_set( + humidity_sensor.entity_id, + "42", + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + }, + ) + hass.states.async_set( + pm25_sensor.entity_id, + 8, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + }, + ) + hass.states.async_set( + temperature_sensor.entity_id, + 22, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + hass.states.async_set(air_purifier.entity_id, STATE_ON) + + with ( + patch.object(homekit.bridge, "add_accessory"), + patch(f"{PATH_HOMEKIT}.async_show_setup_message"), + patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, + patch("pyhap.accessory_driver.AccessoryDriver.async_start"), + ): + await homekit.async_start() + await hass.async_block_till_done() + + mock_get_acc.assert_called_with( + hass, + ANY, + ANY, + ANY, + { + "manufacturer": "Home Assistant", + "model": "Smart Air Purifier", + "platform": "air_purifier", + "sw_version": "0.16.1", + "type": TYPE_AIR_PURIFIER, + "linked_humidity_sensor": "sensor.air_purifier_humidity_sensor", + "linked_pm25_sensor": "sensor.air_purifier_pm25_sensor", + "linked_temperature_sensor": "sensor.air_purifier_temperature_sensor", + }, + ) + + @pytest.mark.usefixtures("mock_async_zeroconf") async def test_reload(hass: HomeAssistant) -> None: """Test we can reload from yaml.""" diff --git a/tests/components/homekit/test_type_air_purifiers.py b/tests/components/homekit/test_type_air_purifiers.py new file mode 100644 index 00000000000..90b0e0047de --- /dev/null +++ b/tests/components/homekit/test_type_air_purifiers.py @@ -0,0 +1,702 @@ +"""Test different accessory types: Air Purifiers.""" + +from unittest.mock import MagicMock + +from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE +import pytest + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + DOMAIN as FAN_DOMAIN, + FanEntityFeature, +) +from homeassistant.components.homekit import ( + CONF_LINKED_HUMIDITY_SENSOR, + CONF_LINKED_PM25_SENSOR, + CONF_LINKED_TEMPERATURE_SENSOR, +) +from homeassistant.components.homekit.const import ( + CONF_LINKED_FILTER_CHANGE_INDICATION, + CONF_LINKED_FILTER_LIFE_LEVEL, + THRESHOLD_FILTER_CHANGE_NEEDED, +) +from homeassistant.components.homekit.type_air_purifiers import ( + FILTER_CHANGE_FILTER, + FILTER_OK, + TARGET_STATE_AUTO, + TARGET_STATE_MANUAL, + AirPurifier, +) +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import Event, HomeAssistant + +from tests.common import async_mock_service + + +@pytest.mark.parametrize( + ("auto_preset", "preset_modes"), + [ + ("auto", ["sleep", "smart", "auto"]), + ("Auto", ["sleep", "smart", "Auto"]), + ], +) +async def test_fan_auto_manual( + hass: HomeAssistant, + hk_driver, + events: list[Event], + auto_preset: str, + preset_modes: list[str], +) -> None: + """Test switching between Auto and Manual.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: auto_preset, + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.preset_mode_chars["smart"].value == 0 + assert acc.preset_mode_chars["sleep"].value == 0 + assert acc.auto_preset is not None + + # Auto presets are handled as the target air purifier state, so + # not supposed to be exposed as a separate switch + switches = set() + for service in acc.services: + if service.display_name == "Switch": + switches.add(service.unique_id) + + assert len(switches) == len(preset_modes) - 1 + for preset in preset_modes: + if preset != auto_preset: + assert preset in switches + else: + # Auto preset should not be in switches + assert preset not in switches + + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "smart", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + + assert acc.preset_mode_chars["smart"].value == 1 + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + # Set from HomeKit + call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode") + call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage") + char_auto_iid = acc.char_target_air_purifier_state.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_auto_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + assert len(call_set_preset_mode) == 1 + assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == auto_preset + assert len(events) == 1 + assert events[-1].data["service"] == "set_preset_mode" + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_auto_iid, + HAP_REPR_VALUE: 0, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + assert len(call_set_percentage) == 1 + assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id + assert events[-1].data["service"] == "set_percentage" + assert len(events) == 2 + + +async def test_presets_no_auto( + hass: HomeAssistant, + hk_driver, + events: list[Event], +) -> None: + """Test preset without an auto mode.""" + entity_id = "fan.demo" + + preset_modes = ["sleep", "smart"] + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "smart", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.preset_mode_chars["smart"].value == 1 + assert acc.preset_mode_chars["sleep"].value == 0 + assert acc.auto_preset is None + + # Auto presets are handled as the target air purifier state, so + # not supposed to be exposed as a separate switch + switches = set() + for service in acc.services: + if service.display_name == "Switch": + switches.add(service.unique_id) + + assert len(switches) == len(preset_modes) + for preset in preset_modes: + assert preset in switches + + acc.run() + await hass.async_block_till_done() + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PRESET_MODE: "sleep", + ATTR_PRESET_MODES: preset_modes, + }, + ) + await hass.async_block_till_done() + + assert acc.preset_mode_chars["smart"].value == 0 + assert acc.preset_mode_chars["sleep"].value == 1 + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + +async def test_air_purifier_single_preset_mode( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test air purifier with a single preset mode.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + ATTR_PRESET_MODE: "auto", + ATTR_PRESET_MODES: ["auto"], + }, + ) + await hass.async_block_till_done() + acc = AirPurifier(hass, hk_driver, "Air Purifier", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_target_air_purifier_state.value == TARGET_STATE_AUTO + + acc.run() + await hass.async_block_till_done() + + # Set from HomeKit + call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode") + call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage") + + char_target_air_purifier_state_iid = acc.char_target_air_purifier_state.to_HAP()[ + HAP_REPR_IID + ] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_air_purifier_state_iid, + HAP_REPR_VALUE: TARGET_STATE_MANUAL, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_set_percentage[0] + assert call_set_percentage[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_percentage[0].data[ATTR_PERCENTAGE] == 42 + assert len(events) == 1 + assert events[-1].data["service"] == "set_percentage" + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_target_air_purifier_state_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_set_preset_mode[0] + assert call_set_preset_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_preset_mode[0].data[ATTR_PRESET_MODE] == "auto" + assert events[-1].data["service"] == "set_preset_mode" + assert len(events) == 2 + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + ATTR_PRESET_MODE: None, + ATTR_PRESET_MODES: ["auto"], + }, + ) + await hass.async_block_till_done() + assert acc.char_target_air_purifier_state.value == TARGET_STATE_MANUAL + + +async def test_expose_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that linked sensors are exposed.""" + entity_id = "fan.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + humidity_entity_id = "sensor.demo_humidity" + hass.states.async_set( + humidity_entity_id, + 50, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + + pm25_entity_id = "sensor.demo_pm25" + hass.states.async_set( + pm25_entity_id, + 10, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + + temperature_entity_id = "sensor.demo_temperature" + hass.states.async_set( + temperature_entity_id, + 25, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_TEMPERATURE_SENSOR: temperature_entity_id, + CONF_LINKED_PM25_SENSOR: pm25_entity_id, + CONF_LINKED_HUMIDITY_SENSOR: humidity_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_humidity_sensor is not None + assert acc.char_current_humidity is not None + assert acc.linked_pm25_sensor is not None + assert acc.char_pm25_density is not None + assert acc.char_air_quality is not None + assert acc.linked_temperature_sensor is not None + assert acc.char_current_temperature is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 50 + assert acc.char_pm25_density.value == 10 + assert acc.char_air_quality.value == 2 + assert acc.char_current_temperature.value == 25 + + # Updated humidity should reflect in HomeKit + broker = MagicMock() + acc.char_current_humidity.broker = broker + hass.states.async_set( + humidity_entity_id, + 60, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + humidity_entity_id, + 60, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert len(broker.mock_calls) == 0 + + # Updated PM2.5 should reflect in HomeKit + broker = MagicMock() + acc.char_pm25_density.broker = broker + acc.char_air_quality.broker = broker + hass.states.async_set( + pm25_entity_id, + 5, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + await hass.async_block_till_done() + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert len(broker.mock_calls) == 4 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + pm25_entity_id, + 5, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert len(broker.mock_calls) == 0 + + # Updated temperature should reflect in HomeKit + broker = MagicMock() + acc.char_current_temperature.broker = broker + hass.states.async_set( + temperature_entity_id, + 30, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_temperature.value == 30 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + temperature_entity_id, + 30, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + force_update=True, + ) + await hass.async_block_till_done() + assert acc.char_current_temperature.value == 30 + assert len(broker.mock_calls) == 0 + + # Should handle unavailable state, show last known value + hass.states.async_set( + humidity_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, + }, + ) + hass.states.async_set( + pm25_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.PM25, + }, + ) + hass.states.async_set( + temperature_entity_id, + STATE_UNAVAILABLE, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + }, + ) + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 60 + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert acc.char_current_temperature.value == 30 + + # Check that all goes well if we remove the linked sensors + hass.states.async_remove(humidity_entity_id) + hass.states.async_remove(pm25_entity_id) + hass.states.async_remove(temperature_entity_id) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert len(acc.char_current_humidity.broker.mock_calls) == 0 + assert len(acc.char_pm25_density.broker.mock_calls) == 0 + assert len(acc.char_air_quality.broker.mock_calls) == 0 + assert len(acc.char_current_temperature.broker.mock_calls) == 0 + + # HomeKit will show the last known values + assert acc.char_current_humidity.value == 60 + assert acc.char_pm25_density.value == 5 + assert acc.char_air_quality.value == 1 + assert acc.char_current_temperature.value == 30 + + +async def test_filter_maintenance_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter level and filter change indicator are exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_change_indicator_entity_id = "binary_sensor.demo_filter_change_indicator" + hass.states.async_set(filter_change_indicator_entity_id, STATE_OFF) + + filter_life_level_entity_id = "sensor.demo_filter_life_level" + hass.states.async_set(filter_life_level_entity_id, 50) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_CHANGE_INDICATION: filter_change_indicator_entity_id, + CONF_LINKED_FILTER_LIFE_LEVEL: filter_life_level_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is not None + assert acc.char_filter_change_indication is not None + assert acc.linked_filter_life_level_sensor is not None + assert acc.char_filter_life_level is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + assert acc.char_filter_life_level.value == 50 + + # Updated filter change indicator should reflect in HomeKit + broker = MagicMock() + acc.char_filter_change_indication.broker = broker + hass.states.async_set(filter_change_indicator_entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set( + filter_change_indicator_entity_id, STATE_ON, force_update=True + ) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert len(broker.mock_calls) == 0 + + # Updated filter life level should reflect in HomeKit + broker = MagicMock() + acc.char_filter_life_level.broker = broker + hass.states.async_set(filter_life_level_entity_id, 25) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == 25 + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + # Change to same state should not trigger update in HomeKit + hass.states.async_set(filter_life_level_entity_id, 25, force_update=True) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == 25 + assert len(broker.mock_calls) == 0 + + # Should handle unavailable state, show last known value + hass.states.async_set(filter_change_indicator_entity_id, STATE_UNAVAILABLE) + hass.states.async_set(filter_life_level_entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert acc.char_filter_life_level.value == 25 + + # Check that all goes well if we remove the linked sensors + hass.states.async_remove(filter_change_indicator_entity_id) + hass.states.async_remove(filter_life_level_entity_id) + await hass.async_block_till_done() + acc.run() + await hass.async_block_till_done() + assert len(acc.char_filter_change_indication.broker.mock_calls) == 0 + assert len(acc.char_filter_life_level.broker.mock_calls) == 0 + + # HomeKit will show the last known values + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + assert acc.char_filter_life_level.value == 25 + + +async def test_filter_maintenance_only_change_indicator_sensor( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter change indicator is exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_change_indicator_entity_id = "binary_sensor.demo_filter_change_indicator" + hass.states.async_set(filter_change_indicator_entity_id, STATE_OFF) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_CHANGE_INDICATION: filter_change_indicator_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is not None + assert acc.char_filter_change_indication is not None + assert acc.linked_filter_life_level_sensor is None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + + hass.states.async_set(filter_change_indicator_entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER + + +async def test_filter_life_level_linked_sensors( + hass: HomeAssistant, hk_driver, events: list[Event] +) -> None: + """Test that a linked filter life level sensor exposed.""" + entity_id = "fan.demo" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + }, + ) + + filter_life_level_entity_id = "sensor.demo_filter_life_level" + hass.states.async_set(filter_life_level_entity_id, 50) + + await hass.async_block_till_done() + acc = AirPurifier( + hass, + hk_driver, + "Air Purifier", + entity_id, + 1, + { + CONF_LINKED_FILTER_LIFE_LEVEL: filter_life_level_entity_id, + }, + ) + hk_driver.add_accessory(acc) + + assert acc.linked_filter_change_indicator_binary_sensor is None + assert ( + acc.char_filter_change_indication is not None + ) # calculated based on filter life level + assert acc.linked_filter_life_level_sensor is not None + assert acc.char_filter_life_level is not None + + acc.run() + await hass.async_block_till_done() + + assert acc.char_filter_change_indication.value == FILTER_OK + assert acc.char_filter_life_level.value == 50 + + hass.states.async_set( + filter_life_level_entity_id, THRESHOLD_FILTER_CHANGE_NEEDED - 1 + ) + await hass.async_block_till_done() + assert acc.char_filter_life_level.value == THRESHOLD_FILTER_CHANGE_NEEDED - 1 + assert acc.char_filter_change_indication.value == FILTER_CHANGE_FILTER diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 1da12402a56..66906c72266 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -128,6 +128,7 @@ def test_validate_entity_config() -> None: } }, {"switch.test": {CONF_TYPE: "invalid_type"}}, + {"fan.test": {CONF_TYPE: "invalid_type"}}, ] for conf in configs: diff --git a/tests/components/inkbird/test_sensor.py b/tests/components/inkbird/test_sensor.py index 00b76366b48..67e08396c79 100644 --- a/tests/components/inkbird/test_sensor.py +++ b/tests/components/inkbird/test_sensor.py @@ -12,8 +12,8 @@ from inkbird_ble import ( ) from sensor_state_data import SensorDeviceClass -from homeassistant.components.inkbird import FALLBACK_POLL_INTERVAL from homeassistant.components.inkbird.const import CONF_DEVICE_TYPE, DOMAIN +from homeassistant.components.inkbird.coordinator import FALLBACK_POLL_INTERVAL from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py index 3b97c8aa7fe..b56b2c92678 100644 --- a/tests/components/motionmount/__init__.py +++ b/tests/components/motionmount/__init__.py @@ -7,6 +7,7 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo HOST = "192.168.1.31" PORT = 23 +MAC = bytes.fromhex("c4dd57f8a55f") TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local." diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py index 49f624b5266..795495f4457 100644 --- a/tests/components/motionmount/conftest.py +++ b/tests/components/motionmount/conftest.py @@ -6,9 +6,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.motionmount.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT -from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME +from . import HOST, MAC, PORT, ZEROCONF_MAC, ZEROCONF_NAME from tests.common import MockConfigEntry @@ -24,6 +24,17 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_with_pin() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=ZEROCONF_NAME, + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_PIN: 1234}, + unique_id=ZEROCONF_MAC, + ) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" @@ -34,12 +45,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_motionmount_config_flow() -> Generator[MagicMock]: +def mock_motionmount() -> Generator[MagicMock]: """Return a mocked MotionMount config flow.""" with patch( - "homeassistant.components.motionmount.config_flow.motionmount.MotionMount", + "homeassistant.components.motionmount.motionmount.MotionMount", autospec=True, ) as motionmount_mock: client = motionmount_mock.return_value + client.name = ZEROCONF_NAME + client.mac = MAC yield client diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py index 1fa2715595d..f6c5e8d8cc3 100644 --- a/tests/components/motionmount/test_config_flow.py +++ b/tests/components/motionmount/test_config_flow.py @@ -35,10 +35,10 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_user_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() user_input = MOCK_USER_INPUT.copy() @@ -54,10 +54,10 @@ async def test_user_connection_error( async def test_user_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when an invalid hostname is provided.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() user_input = MOCK_USER_INPUT.copy() @@ -73,10 +73,10 @@ async def test_user_connection_error_invalid_hostname( async def test_user_timeout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() user_input = MOCK_USER_INPUT.copy() @@ -92,10 +92,10 @@ async def test_user_timeout_error( async def test_user_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() user_input = MOCK_USER_INPUT.copy() @@ -111,13 +111,11 @@ async def test_user_not_connected_error( async def test_user_response_error_single_device_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") user_input = MOCK_USER_INPUT.copy() @@ -139,11 +137,11 @@ async def test_user_response_error_single_device_new_ce_old_pro( async def test_user_response_error_single_device_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow creates an entry when there is a response error.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -167,13 +165,13 @@ async def test_user_response_error_single_device_new_ce_new_pro( async def test_user_response_error_multi_device_new_ce_new_pro( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there are multiple devices.""" mock_config_entry.add_to_hass(hass) - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) user_input = MOCK_USER_INPUT.copy() @@ -190,14 +188,12 @@ async def test_user_response_error_multi_device_new_ce_new_pro( async def test_user_response_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) user_input = MOCK_USER_INPUT.copy() @@ -211,12 +207,8 @@ async def test_user_response_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -236,10 +228,10 @@ async def test_user_response_authentication_needed( async def test_zeroconf_connection_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + mock_motionmount.connect.side_effect = ConnectionRefusedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -255,10 +247,10 @@ async def test_zeroconf_connection_error( async def test_zeroconf_connection_error_invalid_hostname( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is an connection error.""" - mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + mock_motionmount.connect.side_effect = socket.gaierror() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -274,10 +266,10 @@ async def test_zeroconf_connection_error_invalid_hostname( async def test_zeroconf_timout_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a timeout error.""" - mock_motionmount_config_flow.connect.side_effect = TimeoutError() + mock_motionmount.connect.side_effect = TimeoutError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -293,10 +285,10 @@ async def test_zeroconf_timout_error( async def test_zeroconf_not_connected_error( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the flow is aborted when there is a not connected error.""" - mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + mock_motionmount.connect.side_effect = motionmount.NotConnectedError() discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) @@ -312,12 +304,10 @@ async def test_zeroconf_not_connected_error( async def test_show_zeroconf_form_new_ce_old_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock( - return_value=b"\x00\x00\x00\x00\x00\x00" - ) + type(mock_motionmount).mac = PropertyMock(return_value=b"\x00\x00\x00\x00\x00\x00") discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) result = await hass.config_entries.flow.async_init( @@ -348,10 +338,10 @@ async def test_show_zeroconf_form_new_ce_old_pro( async def test_show_zeroconf_form_new_ce_new_pro( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -383,7 +373,7 @@ async def test_show_zeroconf_form_new_ce_new_pro( async def test_zeroconf_device_exists_abort( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test we abort zeroconf flow if device already configured.""" mock_config_entry.add_to_hass(hass) @@ -402,13 +392,11 @@ async def test_zeroconf_device_exists_abort( async def test_zeroconf_authentication_needed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -421,12 +409,8 @@ async def test_zeroconf_authentication_needed( assert result["step_id"] == "auth" # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -448,17 +432,13 @@ async def test_zeroconf_authentication_needed( async def test_authentication_incorrect_then_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -483,9 +463,7 @@ async def test_authentication_incorrect_then_correct_pin( assert result["errors"][CONF_PIN] == CONF_PIN # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -505,18 +483,14 @@ async def test_authentication_incorrect_then_correct_pin( async def test_authentication_first_incorrect_pin_to_backoff( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - side_effect=[True, 1] - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(side_effect=[True, 1]) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -532,7 +506,7 @@ async def test_authentication_first_incorrect_pin_to_backoff( user_input=MOCK_PIN_INPUT.copy(), ) - assert mock_motionmount_config_flow.authenticate.called + assert mock_motionmount.authenticate.called assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backoff" @@ -541,12 +515,8 @@ async def test_authentication_first_incorrect_pin_to_backoff( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -567,16 +537,14 @@ async def test_authentication_first_incorrect_pin_to_backoff( async def test_authentication_multiple_incorrect_pins( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) user_input = MOCK_USER_INPUT.copy() @@ -602,12 +570,8 @@ async def test_authentication_multiple_incorrect_pins( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -628,16 +592,14 @@ async def test_authentication_multiple_incorrect_pins( async def test_authentication_show_backoff_when_still_running( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, freezer: FrozenDateTimeFactory, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock(return_value=1) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=1) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -671,12 +633,8 @@ async def test_authentication_show_backoff_when_still_running( await hass.async_block_till_done() # Now simulate the user entered the correct pin to finalize the test - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -697,17 +655,13 @@ async def test_authentication_show_backoff_when_still_running( async def test_authentication_correct_pin( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test that authentication is requested when needed.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=False - ) - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=False) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) user_input = MOCK_USER_INPUT.copy() @@ -720,9 +674,7 @@ async def test_authentication_correct_pin( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), @@ -741,11 +693,11 @@ async def test_authentication_correct_pin( async def test_full_user_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full manual user flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -773,11 +725,11 @@ async def test_full_user_flow_implementation( async def test_full_zeroconf_flow_implementation( hass: HomeAssistant, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test the full zeroconf flow from start to finish.""" - type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) - type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + type(mock_motionmount).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount).mac = PropertyMock(return_value=MAC) discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) result = await hass.config_entries.flow.async_init( @@ -808,7 +760,7 @@ async def test_full_zeroconf_flow_implementation( async def test_full_reauth_flow_implementation( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_motionmount_config_flow: MagicMock, + mock_motionmount: MagicMock, ) -> None: """Test reauthentication.""" mock_config_entry.add_to_hass(hass) @@ -824,12 +776,8 @@ async def test_full_reauth_flow_implementation( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "auth" - type(mock_motionmount_config_flow).can_authenticate = PropertyMock( - return_value=True - ) - type(mock_motionmount_config_flow).is_authenticated = PropertyMock( - return_value=True - ) + type(mock_motionmount).can_authenticate = PropertyMock(return_value=True) + type(mock_motionmount).is_authenticated = PropertyMock(return_value=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_PIN_INPUT.copy(), diff --git a/tests/components/motionmount/test_entity.py b/tests/components/motionmount/test_entity.py new file mode 100644 index 00000000000..e335c3a913b --- /dev/null +++ b/tests/components/motionmount/test_entity.py @@ -0,0 +1,47 @@ +"""Tests for the MotionMount Entity base.""" + +from unittest.mock import MagicMock + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import format_mac + +from . import ZEROCONF_NAME + +from tests.common import MockConfigEntry + + +async def test_entity_rename( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = True + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == ZEROCONF_NAME + + # Simulate the user changed the name of the device + mock_motionmount.name = "Blub" + + for callback in mock_motionmount.add_listener.call_args_list: + callback[0][0]() + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == "Blub" diff --git a/tests/components/motionmount/test_init.py b/tests/components/motionmount/test_init.py new file mode 100644 index 00000000000..e307945d0d0 --- /dev/null +++ b/tests/components/motionmount/test_init.py @@ -0,0 +1,129 @@ +"""Tests for the MotionMount init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.motionmount import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + + +async def test_setup_entry_with_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mac = format_mac(mock_motionmount.mac.hex()) + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mac)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_without_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x00" + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + device = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device + assert device.name == mock_config_entry.title + + +async def test_setup_entry_failed_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.connect.side_effect = TimeoutError() + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_wrong_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.mac = b"\x00\x00\x00\x00\x00\x01" + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_no_pin( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config_entry.async_get_active_flows(hass, sources={SOURCE_REAUTH})) + + +async def test_setup_entry_wrong_pin( + hass: HomeAssistant, + mock_config_entry_with_pin: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Tests the state attributes.""" + mock_config_entry_with_pin.add_to_hass(hass) + + mock_motionmount.is_authenticated = False + assert not await hass.config_entries.async_setup( + mock_config_entry_with_pin.entry_id + ) + + assert mock_config_entry_with_pin.state is ConfigEntryState.SETUP_ERROR + assert any( + mock_config_entry_with_pin.async_get_active_flows(hass, sources={SOURCE_REAUTH}) + ) + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount: MagicMock, +) -> None: + """Test entries are unloaded correctly.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_motionmount.disconnect.call_count == 1 diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py index 0320e62d640..0132860727f 100644 --- a/tests/components/motionmount/test_sensor.py +++ b/tests/components/motionmount/test_sensor.py @@ -7,12 +7,10 @@ import pytest from homeassistant.core import HomeAssistant -from . import ZEROCONF_NAME +from . import MAC, ZEROCONF_NAME from tests.common import MockConfigEntry -MAC = bytes.fromhex("c4dd57f8a55f") - @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index f391236aca4..fd54e5f0643 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -835,32 +835,57 @@ async def test_entity_debug_info_message( @pytest.mark.parametrize( - "hass_config", + ("hass_config", "min_number", "max_number", "step"), [ - { - mqtt.DOMAIN: { - number.DOMAIN: { - "state_topic": "test/state_number", - "command_topic": "test/cmd_number", - "name": "Test Number", - "min": 5, - "max": 110, - "step": 20, + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 5, + "max": 110, + "step": 20, + } } - } - } + }, + 5, + 110, + 20, + ), + ( + { + mqtt.DOMAIN: { + number.DOMAIN: { + "state_topic": "test/state_number", + "command_topic": "test/cmd_number", + "name": "Test Number", + "min": 100, + "max": 100, + } + } + }, + 100, + 100, + 1, + ), ], ) async def test_min_max_step_attributes( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + min_number: float, + max_number: float, + step: float, ) -> None: """Test min/max/step attributes.""" await mqtt_mock_entry() state = hass.states.get("number.test_number") - assert state.attributes.get(ATTR_MIN) == 5 - assert state.attributes.get(ATTR_MAX) == 110 - assert state.attributes.get(ATTR_STEP) == 20 + assert state.attributes.get(ATTR_MIN) == min_number + assert state.attributes.get(ATTR_MAX) == max_number + assert state.attributes.get(ATTR_STEP) == step @pytest.mark.parametrize( @@ -885,7 +910,7 @@ async def test_invalid_min_max_attributes( ) -> None: """Test invalid min/max attributes.""" assert await mqtt_mock_entry() - assert f"'{CONF_MAX}' must be > '{CONF_MIN}'" in caplog.text + assert f"{CONF_MAX} must be >= {CONF_MIN}" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index 00285f565a6..c0532d62b2d 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -499,7 +499,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.baby_bedroom_wi_fi-entry] +# name: test_entity[sensor.baby_bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -512,7 +512,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.baby_bedroom_wi_fi', + 'entity_id': 'sensor.baby_bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -524,7 +524,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -533,16 +533,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.baby_bedroom_wi_fi-state] +# name: test_entity[sensor.baby_bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Baby Bedroom Wi-Fi', + 'friendly_name': 'Baby Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.baby_bedroom_wi_fi', + 'entity_id': 'sensor.baby_bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1033,7 +1033,7 @@ 'state': 'unavailable', }) # --- -# name: test_entity[sensor.bedroom_wi_fi-entry] +# name: test_entity[sensor.bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1046,7 +1046,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.bedroom_wi_fi', + 'entity_id': 'sensor.bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1058,7 +1058,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -1067,16 +1067,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.bedroom_wi_fi-state] +# name: test_entity[sensor.bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Bedroom Wi-Fi', + 'friendly_name': 'Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.bedroom_wi_fi', + 'entity_id': 'sensor.bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3668,7 +3668,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.kitchen_wi_fi-entry] +# name: test_entity[sensor.kitchen_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3681,7 +3681,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.kitchen_wi_fi', + 'entity_id': 'sensor.kitchen_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3693,7 +3693,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -3702,16 +3702,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.kitchen_wi_fi-state] +# name: test_entity[sensor.kitchen_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Kitchen Wi-Fi', + 'friendly_name': 'Kitchen Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.kitchen_wi_fi', + 'entity_id': 'sensor.kitchen_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4511,7 +4511,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.livingroom_wi_fi-entry] +# name: test_entity[sensor.livingroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4524,7 +4524,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.livingroom_wi_fi', + 'entity_id': 'sensor.livingroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4536,7 +4536,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -4545,16 +4545,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.livingroom_wi_fi-state] +# name: test_entity[sensor.livingroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Livingroom Wi-Fi', + 'friendly_name': 'Livingroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.livingroom_wi_fi', + 'entity_id': 'sensor.livingroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5061,7 +5061,7 @@ 'state': 'unknown', }) # --- -# name: test_entity[sensor.parents_bedroom_wi_fi-entry] +# name: test_entity[sensor.parents_bedroom_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5074,7 +5074,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.parents_bedroom_wi_fi', + 'entity_id': 'sensor.parents_bedroom_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5086,7 +5086,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -5095,16 +5095,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.parents_bedroom_wi_fi-state] +# name: test_entity[sensor.parents_bedroom_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Parents Bedroom Wi-Fi', + 'friendly_name': 'Parents Bedroom Wi-Fi strength', 'latitude': 13.377726, 'longitude': 52.516263, }), 'context': , - 'entity_id': 'sensor.parents_bedroom_wi_fi', + 'entity_id': 'sensor.parents_bedroom_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5586,7 +5586,7 @@ 'state': '55', }) # --- -# name: test_entity[sensor.villa_bathroom_radio-entry] +# name: test_entity[sensor.villa_bathroom_rf_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5599,7 +5599,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_bathroom_radio', + 'entity_id': 'sensor.villa_bathroom_rf_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5611,7 +5611,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Radio', + 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -5620,14 +5620,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_bathroom_radio-state] +# name: test_entity[sensor.villa_bathroom_rf_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Bathroom Radio', + 'friendly_name': 'Villa Bathroom RF strength', }), 'context': , - 'entity_id': 'sensor.villa_bathroom_radio', + 'entity_id': 'sensor.villa_bathroom_rf_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -5945,7 +5945,7 @@ 'state': '53', }) # --- -# name: test_entity[sensor.villa_bedroom_radio-entry] +# name: test_entity[sensor.villa_bedroom_rf_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5958,7 +5958,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_bedroom_radio', + 'entity_id': 'sensor.villa_bedroom_rf_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5970,7 +5970,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Radio', + 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -5979,14 +5979,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_bedroom_radio-state] +# name: test_entity[sensor.villa_bedroom_rf_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Bedroom Radio', + 'friendly_name': 'Villa Bedroom RF strength', }), 'context': , - 'entity_id': 'sensor.villa_bedroom_radio', + 'entity_id': 'sensor.villa_bedroom_rf_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -6429,7 +6429,7 @@ 'state': '9', }) # --- -# name: test_entity[sensor.villa_garden_radio-entry] +# name: test_entity[sensor.villa_garden_rf_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6442,7 +6442,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_garden_radio', + 'entity_id': 'sensor.villa_garden_rf_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6454,7 +6454,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Radio', + 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -6463,14 +6463,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_garden_radio-state] +# name: test_entity[sensor.villa_garden_rf_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Garden Radio', + 'friendly_name': 'Villa Garden RF strength', }), 'context': , - 'entity_id': 'sensor.villa_garden_radio', + 'entity_id': 'sensor.villa_garden_rf_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -6917,7 +6917,7 @@ 'state': 'unavailable', }) # --- -# name: test_entity[sensor.villa_outdoor_radio-entry] +# name: test_entity[sensor.villa_outdoor_rf_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -6930,7 +6930,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_outdoor_radio', + 'entity_id': 'sensor.villa_outdoor_rf_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -6942,7 +6942,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Radio', + 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -6951,14 +6951,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_outdoor_radio-state] +# name: test_entity[sensor.villa_outdoor_rf_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Outdoor Radio', + 'friendly_name': 'Villa Outdoor RF strength', }), 'context': , - 'entity_id': 'sensor.villa_outdoor_radio', + 'entity_id': 'sensor.villa_outdoor_rf_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -7382,7 +7382,7 @@ 'state': '6.9', }) # --- -# name: test_entity[sensor.villa_rain_radio-entry] +# name: test_entity[sensor.villa_rain_rf_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7395,7 +7395,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_rain_radio', + 'entity_id': 'sensor.villa_rain_rf_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7407,7 +7407,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Radio', + 'original_name': 'RF strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -7416,14 +7416,14 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_rain_radio-state] +# name: test_entity[sensor.villa_rain_rf_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Rain Radio', + 'friendly_name': 'Villa Rain RF strength', }), 'context': , - 'entity_id': 'sensor.villa_rain_radio', + 'entity_id': 'sensor.villa_rain_rf_strength', 'last_changed': , 'last_reported': , 'last_updated': , @@ -7636,7 +7636,7 @@ 'state': 'stable', }) # --- -# name: test_entity[sensor.villa_wi_fi-entry] +# name: test_entity[sensor.villa_wi_fi_strength-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -7649,7 +7649,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.villa_wi_fi', + 'entity_id': 'sensor.villa_wi_fi_strength', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -7661,7 +7661,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wi-Fi', + 'original_name': 'Wi-Fi strength', 'platform': 'netatmo', 'previous_unique_id': None, 'supported_features': 0, @@ -7670,16 +7670,16 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity[sensor.villa_wi_fi-state] +# name: test_entity[sensor.villa_wi_fi_strength-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', - 'friendly_name': 'Villa Wi-Fi', + 'friendly_name': 'Villa Wi-Fi strength', 'latitude': 46.123456, 'longitude': 6.1234567, }), 'context': , - 'entity_id': 'sensor.villa_wi_fi', + 'entity_id': 'sensor.villa_wi_fi_strength', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/netatmo/test_sensor.py b/tests/components/netatmo/test_sensor.py index 2c47cdefa60..e9e1ff4739e 100644 --- a/tests/components/netatmo/test_sensor.py +++ b/tests/components/netatmo/test_sensor.py @@ -153,7 +153,7 @@ async def test_process_health(health: int, expected: str) -> None: ("uid", "name", "expected"), [ ("12:34:56:03:1b:e4-reachable", "villa_garden_reachable", "True"), - ("12:34:56:03:1b:e4-rf_status", "villa_garden_radio", "Full"), + ("12:34:56:03:1b:e4-rf_status", "villa_garden_rf_strength", "Full"), ( "12:34:56:80:bb:26-wifi_status", "villa_wifi_strength", @@ -205,7 +205,7 @@ async def test_process_health(health: int, expected: str) -> None: ), ( "12:34:56:26:68:92-wifi_status", - "baby_bedroom_wifi", + "baby_bedroom_wifi_strength", "High", ), ("Home-max-windangle_value", "home_max_wind_angle", "17"), diff --git a/tests/components/repairs/__init__.py b/tests/components/repairs/__init__.py index e787d657e5c..7d5e4a43cd8 100644 --- a/tests/components/repairs/__init__.py +++ b/tests/components/repairs/__init__.py @@ -42,20 +42,20 @@ async def get_repairs( async def start_repair_fix_flow( - client: TestClient, handler: str, issue_id: int + client: TestClient, handler: str, issue_id: str ) -> dict[str, Any]: """Start a flow from an issue.""" url = RepairsFlowIndexView.url resp = await client.post(url, json={"handler": handler, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK, f"Error: {resp.status}, {await resp.text()}" return await resp.json() async def process_repair_fix_flow( - client: TestClient, flow_id: int, json: dict[str, Any] | None = None + client: TestClient, flow_id: str, json: dict[str, Any] | None = None ) -> dict[str, Any]: """Return the repairs list of issues.""" url = RepairsFlowResourceView.url.format(flow_id=flow_id) resp = await client.post(url, json=json) - assert resp.status == HTTPStatus.OK + assert resp.status == HTTPStatus.OK, f"Error: {resp.status}, {await resp.text()}" return await resp.json() diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8f8255235be..2a386a1628c 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -492,7 +492,9 @@ def _mock_rpc_device(version: str | None = None): initialized=True, connected=True, script_getcode=AsyncMock( - side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} + side_effect=lambda script_id, bytes_to_read: { + "data": MOCK_SCRIPTS[script_id - 1] + } ), xmod_info={}, ) @@ -514,7 +516,9 @@ def _mock_blu_rtv_device(version: str | None = None): initialized=True, connected=True, script_getcode=AsyncMock( - side_effect=lambda script_id: {"data": MOCK_SCRIPTS[script_id - 1]} + side_effect=lambda script_id, bytes_to_read: { + "data": MOCK_SCRIPTS[script_id - 1] + } ), xmod_info={}, ) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index fffffc21cae..60883ebf5bd 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -11,6 +11,7 @@ from aioshelly.exceptions import ( CustomPortNotSupported, DeviceConnectionError, InvalidAuthError, + InvalidHostError, ) import pytest @@ -308,6 +309,7 @@ async def test_form_auth( ("exc", "base_error"), [ (DeviceConnectionError, "cannot_connect"), + (InvalidHostError, "invalid_host"), (ValueError, "unknown"), ], ) diff --git a/tests/components/sma/__init__.py b/tests/components/sma/__init__.py index 80837c718a9..4a9e462501e 100644 --- a/tests/components/sma/__init__.py +++ b/tests/components/sma/__init__.py @@ -1,7 +1,17 @@ """Tests for the sma integration.""" +import unittest from unittest.mock import patch +from homeassistant.components.sma.const import CONF_GROUP +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, +) + MOCK_DEVICE = { "manufacturer": "SMA", "name": "SMA Device Name", @@ -10,15 +20,33 @@ MOCK_DEVICE = { } MOCK_USER_INPUT = { - "host": "1.1.1.1", - "ssl": True, - "verify_ssl": False, - "group": "user", - "password": "password", + CONF_HOST: "1.1.1.1", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", +} + +MOCK_DHCP_DISCOVERY_INPUT = { + # CONF_HOST: "1.1.1.2", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", +} + +MOCK_DHCP_DISCOVERY = { + CONF_HOST: "1.1.1.1", + CONF_SSL: True, + CONF_VERIFY_SSL: False, + CONF_GROUP: "user", + CONF_PASSWORD: "password", + CONF_MAC: "00:15:bb:00:ab:cd", } -def _patch_async_setup_entry(return_value=True): +def _patch_async_setup_entry(return_value=True) -> unittest.mock._patch: + """Patch async_setup_entry.""" return patch( "homeassistant.components.sma.async_setup_entry", return_value=return_value, diff --git a/tests/components/sma/conftest.py b/tests/components/sma/conftest.py index dd47a0f1055..2b4c157175b 100644 --- a/tests/components/sma/conftest.py +++ b/tests/components/sma/conftest.py @@ -17,9 +17,9 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Return the default mocked config entry.""" - return MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, title=MOCK_DEVICE["name"], unique_id=str(MOCK_DEVICE["serial"]), @@ -27,6 +27,8 @@ def mock_config_entry() -> MockConfigEntry: source=config_entries.SOURCE_IMPORT, minor_version=2, ) + entry.add_to_hass(hass) + return entry @pytest.fixture diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 93ac1783e09..5033462d0a6 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -7,13 +7,35 @@ from pysma.exceptions import ( SmaConnectionException, SmaReadException, ) +import pytest from homeassistant.components.sma.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from . import MOCK_DEVICE, MOCK_USER_INPUT, _patch_async_setup_entry +from . import ( + MOCK_DEVICE, + MOCK_DHCP_DISCOVERY, + MOCK_DHCP_DISCOVERY_INPUT, + MOCK_USER_INPUT, + _patch_async_setup_entry, +) + +from tests.conftest import MockConfigEntry + +DHCP_DISCOVERY = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456", + macaddress="0015BB00abcd", +) + +DHCP_DISCOVERY_DUPLICATE = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456789", + macaddress="0015BB00abcd", +) async def test_form(hass: HomeAssistant) -> None: @@ -43,14 +65,27 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, exception: Exception, error: str +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) with ( - patch("pysma.SMA.new_session", side_effect=SmaConnectionException), + patch( + "homeassistant.components.sma.pysma.SMA.new_session", side_effect=exception + ), _patch_async_setup_entry() as mock_setup_entry, ): result = await hass.config_entries.flow.async_configure( @@ -59,83 +94,27 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} assert len(mock_setup_entry.mock_calls) == 0 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", side_effect=SmaAuthenticationException), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_cannot_retrieve_device_info(hass: HomeAssistant) -> None: - """Test we handle cannot retrieve device info error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", return_value=True), - patch("pysma.SMA.read", side_effect=SmaReadException), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_retrieve_device_info"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_unexpected_exception(hass: HomeAssistant) -> None: - """Test we handle unexpected exception.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with ( - patch("pysma.SMA.new_session", side_effect=Exception), - _patch_async_setup_entry() as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_INPUT, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} - assert len(mock_setup_entry.mock_calls) == 0 - - -async def test_form_already_configured(hass: HomeAssistant, mock_config_entry) -> None: +async def test_form_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test starting a flow by user when already configured.""" - mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) with ( - patch("pysma.SMA.new_session", return_value=True), - patch("pysma.SMA.device_info", return_value=MOCK_DEVICE), - patch("pysma.SMA.close_session", return_value=True), + patch("homeassistant.components.sma.pysma.SMA.new_session", return_value=True), + patch( + "homeassistant.components.sma.pysma.SMA.device_info", + return_value=MOCK_DEVICE, + ), + patch( + "homeassistant.components.sma.pysma.SMA.close_session", return_value=True + ), _patch_async_setup_entry() as mock_setup_entry, ): result = await hass.config_entries.flow.async_configure( @@ -146,3 +125,99 @@ async def test_form_already_configured(hass: HomeAssistant, mock_config_entry) - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_dhcp_discovery(hass: HomeAssistant) -> None: + """Test we can setup from dhcp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + with ( + patch("homeassistant.components.sma.pysma.SMA.new_session", return_value=True), + patch( + "homeassistant.components.sma.pysma.SMA.device_info", + return_value=MOCK_DEVICE, + ), + _patch_async_setup_entry() as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DHCP_DISCOVERY["host"] + assert result["data"] == MOCK_DHCP_DISCOVERY + assert result["result"].unique_id == DHCP_DISCOVERY.hostname.replace("SMA", "") + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by dhcp when already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=DHCP_DISCOVERY_DUPLICATE + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SmaConnectionException, "cannot_connect"), + (SmaAuthenticationException, "invalid_auth"), + (SmaReadException, "cannot_retrieve_device_info"), + (Exception, "unknown"), + ], +) +async def test_dhcp_exceptions( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + + with patch( + "homeassistant.components.sma.pysma.SMA.new_session", side_effect=exception + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + with ( + patch("homeassistant.components.sma.pysma.SMA.new_session", return_value=True), + patch( + "homeassistant.components.sma.pysma.SMA.device_info", + return_value=MOCK_DEVICE, + ), + patch( + "homeassistant.components.sma.pysma.SMA.close_session", return_value=True + ), + _patch_async_setup_entry(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DHCP_DISCOVERY_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DHCP_DISCOVERY["host"] + assert result["data"] == MOCK_DHCP_DISCOVERY + assert result["result"].unique_id == DHCP_DISCOVERY.hostname.replace("SMA", "") diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 715073aa891..f57c8c107b2 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -386,3 +386,53 @@ def make_advertisement( connectable=True, tx_power=-127, ) + + +HUBMINI_MATTER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"%\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="HubMini Matter", + manufacturer_data={ + 2409: b"\xe6\xa1\xcd\x1f[e\x00\x00\x00\x00\x00\x00\x14\x01\x985\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"v\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "HubMini Matter"), + time=0, + connectable=True, + tx_power=-127, +) + + +ROLLER_SHADE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RollerShade", + manufacturer_data={ + 2409: b"\xb0\xe9\xfeT\x90\x1b,\x08\x9f\x11\x04'\x00", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b",\x00'\x9f\x11\x04"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RollerShade"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_cover.py b/tests/components/switchbot/test_cover.py index 8810963f63d..b52436f1932 100644 --- a/tests/components/switchbot/test_cover.py +++ b/tests/components/switchbot/test_cover.py @@ -24,7 +24,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State -from . import WOBLINDTILT_SERVICE_INFO, WOCURTAIN3_SERVICE_INFO, make_advertisement +from . import ( + ROLLER_SHADE_SERVICE_INFO, + WOBLINDTILT_SERVICE_INFO, + WOCURTAIN3_SERVICE_INFO, + make_advertisement, +) from tests.common import MockConfigEntry, mock_restore_cache from tests.components.bluetooth import inject_bluetooth_service_info @@ -325,3 +330,163 @@ async def test_blindtilt_controlling( state = hass.states.get(entity_id) assert state.state == CoverState.OPEN assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + +async def test_roller_shade_setup( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test setting up the RollerShade.""" + inject_bluetooth_service_info(hass, WOCURTAIN3_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + + entity_id = "cover.test_name" + mock_restore_cache( + hass, + [ + State( + entity_id, + CoverState.OPEN, + {ATTR_CURRENT_POSITION: 60}, + ) + ], + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.update", + new=AsyncMock(return_value=True), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + +async def test_roller_shade_controlling( + hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry] +) -> None: + """Test Roller Shade controlling.""" + inject_bluetooth_service_info(hass, ROLLER_SHADE_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="roller_shade") + entry.add_to_hass(hass) + info = {"battery": 39} + with ( + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ), + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.open", + new=AsyncMock(return_value=True), + ) as mock_open, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.close", + new=AsyncMock(return_value=True), + ) as mock_close, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.stop", + new=AsyncMock(return_value=True), + ) as mock_stop, + patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.set_position", + new=AsyncMock(return_value=True), + ) as mock_set_position, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_id = "cover.test_name" + address = "AA:BB:CC:DD:EE:FF" + service_data = b",\x00'\x9f\x11\x04" + + # Test open + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\xa0\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + new=AsyncMock(return_value=info), + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_open.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 68 + + # Test close + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5a\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_close.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 10 + + # Test stop + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x5f\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_stop.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 5 + + # Test set position + manufacturer_data = b"\xb0\xe9\xfeT\x90\x1b,\x08\x32\x11\x04'\x00" + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50}, + blocking=True, + ) + with patch( + "homeassistant.components.switchbot.cover.switchbot.SwitchbotRollerShade.get_basic_info", + return_value=info, + ): + inject_bluetooth_service_info( + hass, make_advertisement(address, manufacturer_data, service_data) + ) + await hass.async_block_till_done() + + mock_set_position.assert_awaited_once() + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 50 diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 5fd270b3393..72ec3a8c727 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -22,6 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import ( + HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, REMOTE_SERVICE_INFO, WOHAND_SERVICE_INFO, @@ -293,3 +294,49 @@ async def test_hub2_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hubmini_matter_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for HubMini Matter.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, HUBMINI_MATTER_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "hubmini_matter", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 3 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "24.1" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "53" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/syncthru/__init__.py b/tests/components/syncthru/__init__.py index d113c11fc19..c9105c6f2b5 100644 --- a/tests/components/syncthru/__init__.py +++ b/tests/components/syncthru/__init__.py @@ -1 +1,13 @@ """Tests for the syncthru integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/syncthru/conftest.py b/tests/components/syncthru/conftest.py new file mode 100644 index 00000000000..6563e0f7b41 --- /dev/null +++ b/tests/components/syncthru/conftest.py @@ -0,0 +1,70 @@ +"""Conftest for the SyncThru integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pysyncthru import SyncthruState +import pytest + +from homeassistant.components.syncthru import DOMAIN +from homeassistant.const import CONF_NAME, CONF_URL + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_syncthru() -> Generator[AsyncMock]: + """Mock the SyncThru class.""" + with ( + patch( + "homeassistant.components.syncthru.SyncThru", + autospec=True, + ) as mock_syncthru, + patch( + "homeassistant.components.syncthru.config_flow.SyncThru", new=mock_syncthru + ), + ): + client = mock_syncthru.return_value + client.model.return_value = "C430W" + client.is_unknown_state.return_value = False + client.url = "http://192.168.1.2" + client.model.return_value = "C430W" + client.hostname.return_value = "SEC84251907C415" + client.serial_number.return_value = "08HRB8GJ3F019DD" + client.device_status.return_value = SyncthruState(3) + client.device_status_details.return_value = "" + client.is_online.return_value = True + client.toner_status.return_value = { + "black": {"opt": 1, "remaining": 8, "cnt": 1176, "newError": "C1-5110"}, + "cyan": {"opt": 1, "remaining": 98, "cnt": 25, "newError": ""}, + "magenta": {"opt": 1, "remaining": 98, "cnt": 25, "newError": ""}, + "yellow": {"opt": 1, "remaining": 97, "cnt": 27, "newError": ""}, + } + client.drum_status.return_value = {} + client.input_tray_status.return_value = { + "tray_1": { + "opt": 1, + "paper_size1": 4, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 150, + "newError": "", + } + } + client.output_tray_status.return_value = { + 1: {"name": 1, "capacity": 50, "status": ""} + } + client.raw.return_value = load_json_object_fixture("state.json", DOMAIN) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="C430W", + data={CONF_URL: "http://192.168.1.2/", CONF_NAME: "My Printer"}, + ) diff --git a/tests/components/syncthru/fixtures/state.json b/tests/components/syncthru/fixtures/state.json new file mode 100644 index 00000000000..2e4a6202700 --- /dev/null +++ b/tests/components/syncthru/fixtures/state.json @@ -0,0 +1,182 @@ +{ + "status": { + "hrDeviceStatus": 3, + "status1": "", + "status2": "", + "status3": "", + "status4": "" + }, + "identity": { + "model_name": "C430W", + "device_name": "Samsung C430W", + "host_name": "SEC84251907C415", + "location": "Living room", + "serial_num": "08HRB8GJ3F019DD", + "ip_addr": "192.168.0.251", + "ipv6_link_addr": "", + "mac_addr": "84:25:19:07:C4:15", + "admin_email": "", + "admin_name": "", + "admin_phone": "", + "customer_support": "" + }, + "toner_black": { + "opt": 1, + "remaining": 8, + "cnt": 1176, + "newError": "C1-5110" + }, + "toner_cyan": { + "opt": 1, + "remaining": 98, + "cnt": 25, + "newError": "" + }, + "toner_magenta": { + "opt": 1, + "remaining": 98, + "cnt": 25, + "newError": "" + }, + "toner_yellow": { + "opt": 1, + "remaining": 97, + "cnt": 27, + "newError": "" + }, + "drum_black": { + "opt": 0, + "remaining": 44, + "newError": "" + }, + "drum_cyan": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_magenta": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_yellow": { + "opt": 0, + "remaining": 100, + "newError": "" + }, + "drum_color": { + "opt": 1, + "remaining": 44, + "newError": "" + }, + "tray1": { + "opt": 1, + "paper_size1": 4, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 150, + "newError": "" + }, + "tray2": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray3": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray4": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "tray5": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "0" + }, + "mp": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "paper_level": 0, + "capa": 0, + "newError": "" + }, + "manual": { + "opt": 0, + "paper_size1": 0, + "paper_size2": 0, + "paper_type1": 2, + "paper_type2": 0, + "capa": 0, + "newError": "" + }, + "GXI_INTRAY_MANUALFEEDING_TRAY_SUPPORT": 0, + "GXI_INSTALL_OPTION_MULTIBIN": 0, + "multibin": [0], + "outputTray": [[1, 50, ""]], + "capability": { + "hdd": { + "opt": 2, + "capa": 40 + }, + "ram": { + "opt": 65536, + "capa": 65536 + }, + "scanner": { + "opt": 0, + "capa": 0 + } + }, + "options": { + "hdd": 0, + "wlan": 1 + }, + "GXI_ACTIVE_ALERT_TOTAL": 2, + "GXI_ADMIN_WUI_HAS_DEFAULT_PASS": 0, + "GXI_SUPPORT_COLOR": 1, + "GXI_SYS_LUI_SUPPORT": 0, + "GXI_A3_SUPPORT": 0, + "GXI_TRAY2_MANDATORY_SUPPORT": 0, + "GXI_SWS_ADMIN_USE_AAA": 0, + "GXI_TONER_BLACK_VALID": 1, + "GXI_TONER_CYAN_VALID": 1, + "GXI_TONER_MAGENTA_VALID": 1, + "GXI_TONER_YELLOW_VALID": 1, + "GXI_IMAGING_BLACK_VALID": 1, + "GXI_IMAGING_CYAN_VALID": 1, + "GXI_IMAGING_MAGENTA_VALID": 1, + "GXI_IMAGING_YELLOW_VALID": 1, + "GXI_IMAGING_COLOR_VALID": 1, + "GXI_SUPPORT_PAPER_SETTING": 1, + "GXI_SUPPORT_PAPER_LEVEL": 0, + "GXI_SUPPORT_MULTI_PASS": 1 +} diff --git a/tests/components/syncthru/snapshots/test_binary_sensor.ambr b/tests/components/syncthru/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..82b62394a63 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.my_printer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_printer', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My Printer', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.my_printer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'My Printer', + }), + 'context': , + 'entity_id': 'binary_sensor.my_printer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.my_printer_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.my_printer_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'My Printer', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_problem', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.my_printer_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'My Printer', + }), + 'context': , + 'entity_id': 'binary_sensor.my_printer_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/syncthru/snapshots/test_sensor.ambr b/tests/components/syncthru/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..50d892b5343 --- /dev/null +++ b/tests/components/syncthru/snapshots/test_sensor.ambr @@ -0,0 +1,417 @@ +# serializer version: 1 +# name: test_all_entities[sensor.my_printer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_printer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'display_text': '', + 'friendly_name': 'My Printer', + 'icon': 'mdi:printer', + }), + 'context': , + 'entity_id': 'sensor.my_printer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'warning', + }) +# --- +# name: test_all_entities[sensor.my_printer_active_alerts-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_active_alerts', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Active Alerts', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_active_alerts', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_printer_active_alerts-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Printer Active Alerts', + 'icon': 'mdi:printer', + }), + 'context': , + 'entity_id': 'sensor.my_printer_active_alerts', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_entities[sensor.my_printer_output_tray_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_output_tray_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Output Tray 1', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_output_tray_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_printer_output_tray_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'capacity': 50, + 'friendly_name': 'My Printer Output Tray 1', + 'icon': 'mdi:printer', + 'name': 1, + 'status': '', + }), + 'context': , + 'entity_id': 'sensor.my_printer_output_tray_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ready', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_black-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_toner_black', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Toner black', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_toner_black', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_black-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 1176, + 'friendly_name': 'My Printer Toner black', + 'icon': 'mdi:printer', + 'newError': 'C1-5110', + 'opt': 1, + 'remaining': 8, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_printer_toner_black', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_cyan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_toner_cyan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Toner cyan', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_toner_cyan', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_cyan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 25, + 'friendly_name': 'My Printer Toner cyan', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 98, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_printer_toner_cyan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_magenta-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_toner_magenta', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Toner magenta', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_toner_magenta', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_magenta-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 25, + 'friendly_name': 'My Printer Toner magenta', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 98, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_printer_toner_magenta', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '98', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_yellow-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_toner_yellow', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Toner yellow', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_toner_yellow', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_printer_toner_yellow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'cnt': 27, + 'friendly_name': 'My Printer Toner yellow', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'remaining': 97, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_printer_toner_yellow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97', + }) +# --- +# name: test_all_entities[sensor.my_printer_tray_tray_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_printer_tray_tray_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:printer', + 'original_name': 'My Printer Tray tray_1', + 'platform': 'syncthru', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '08HRB8GJ3F019DD_tray_tray_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_printer_tray_tray_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'capa': 150, + 'friendly_name': 'My Printer Tray tray_1', + 'icon': 'mdi:printer', + 'newError': '', + 'opt': 1, + 'paper_level': 0, + 'paper_size1': 4, + 'paper_size2': 0, + 'paper_type1': 2, + 'paper_type2': 0, + }), + 'context': , + 'entity_id': 'sensor.my_printer_tray_tray_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Ready', + }) +# --- diff --git a/tests/components/syncthru/test_binary_sensor.py b/tests/components/syncthru/test_binary_sensor.py new file mode 100644 index 00000000000..ae5f0b6a90c --- /dev/null +++ b/tests/components/syncthru/test_binary_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Syncthru binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.syncthru.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py index 727b95563cc..c551c94506e 100644 --- a/tests/components/syncthru/test_config_flow.py +++ b/tests/components/syncthru/test_config_flow.py @@ -1,12 +1,10 @@ """Tests for syncthru config flow.""" -import re -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pysyncthru import SyncThruAPINotSupported from homeassistant import config_entries -from homeassistant.components.syncthru.config_flow import SyncThru from homeassistant.components.syncthru.const import DOMAIN from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant @@ -21,7 +19,6 @@ from homeassistant.helpers.service_info.ssdp import ( ) from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker FIXTURE_USER_INPUT = { CONF_URL: "http://192.168.1.2/", @@ -29,25 +26,7 @@ FIXTURE_USER_INPUT = { } -def mock_connection(aioclient_mock): - """Mock syncthru connection.""" - aioclient_mock.get( - re.compile("."), - text=""" -{ -\tstatus: { -\thrDeviceStatus: 2, -\tstatus1: " Sleeping... " -\t}, -\tidentity: { -\tserial_num: "000000000000000", -\t} -} - """, - ) - - -async def test_show_setup_form(hass: HomeAssistant) -> None: +async def test_show_setup_form(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: """Test that the setup form is served.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None @@ -58,7 +37,7 @@ async def test_show_setup_form(hass: HomeAssistant) -> None: async def test_already_configured_by_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, mock_syncthru: AsyncMock ) -> None: """Test we match and update already configured devices by URL.""" @@ -69,7 +48,6 @@ async def test_already_configured_by_url( title="Already configured", unique_id=udn, ).add_to_hass(hass) - mock_connection(aioclient_mock) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -83,44 +61,39 @@ async def test_already_configured_by_url( assert result["result"].unique_id == udn -async def test_syncthru_not_supported(hass: HomeAssistant) -> None: +async def test_syncthru_not_supported( + hass: HomeAssistant, mock_syncthru: AsyncMock +) -> None: """Test we show user form on unsupported device.""" - with patch.object(SyncThru, "update", side_effect=SyncThruAPINotSupported): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) + mock_syncthru.update.side_effect = SyncThruAPINotSupported + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_URL: "syncthru_not_supported"} -async def test_unknown_state(hass: HomeAssistant) -> None: +async def test_unknown_state(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: """Test we show user form on unsupported device.""" - with ( - patch.object(SyncThru, "update"), - patch.object(SyncThru, "is_unknown_state", return_value=True), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=FIXTURE_USER_INPUT, - ) + mock_syncthru.is_unknown_state.return_value = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {CONF_URL: "unknown_state"} -async def test_success( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_success(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: """Test successful flow provides entry creation data.""" - mock_connection(aioclient_mock) - with patch( "homeassistant.components.syncthru.async_setup_entry", return_value=True ) as mock_setup_entry: @@ -129,18 +102,15 @@ async def test_success( context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_URL] == FIXTURE_USER_INPUT[CONF_URL] assert len(mock_setup_entry.mock_calls) == 1 -async def test_ssdp(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_ssdp(hass: HomeAssistant, mock_syncthru: AsyncMock) -> None: """Test SSDP discovery initiates config properly.""" - mock_connection(aioclient_mock) - url = "http://192.168.1.2/" result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/syncthru/test_sensor.py b/tests/components/syncthru/test_sensor.py new file mode 100644 index 00000000000..600e2962730 --- /dev/null +++ b/tests/components/syncthru/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the Syncthru sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_syncthru: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.syncthru.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 2c9e815f7bf..2a44fb87b65 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -55,6 +55,11 @@ , ]), 'supported_features': , + 'vicare_quickmodes': list([ + 'comfort', + 'eco', + 'holiday', + ]), }), 'context': , 'entity_id': 'fan.model0_ventilation', @@ -94,7 +99,7 @@ 'options': dict({ }), 'original_device_class': None, - 'original_icon': 'mdi:fan-off', + 'original_icon': 'mdi:fan', 'original_name': 'Ventilation', 'platform': 'vicare', 'previous_unique_id': None, @@ -108,7 +113,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'model1 Ventilation', - 'icon': 'mdi:fan-off', + 'icon': 'mdi:fan', 'percentage': 0, 'percentage_step': 25.0, 'preset_mode': None, @@ -118,6 +123,11 @@ , ]), 'supported_features': , + 'vicare_quickmodes': list([ + 'comfort', + 'eco', + 'holiday', + ]), }), 'context': , 'entity_id': 'fan.model1_ventilation', @@ -179,6 +189,11 @@ , ]), 'supported_features': , + 'vicare_quickmodes': list([ + 'comfort', + 'eco', + 'holiday', + ]), }), 'context': , 'entity_id': 'fan.model2_ventilation', diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 0586d654f7f..a273900151b 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -63,16 +63,6 @@ async def update_ac_state( return hass.states.get(entity_id) -async def test_no_appliances( - hass: HomeAssistant, mock_appliances_manager_api: MagicMock -) -> None: - """Test the setup of the climate entities when there are no appliances available.""" - mock_appliances_manager_api.return_value.aircons = [] - mock_appliances_manager_api.return_value.washer_dryers = [] - await init_integration(hass) - assert len(hass.states.async_all()) == 0 - - async def test_static_attributes( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -81,7 +71,7 @@ async def test_static_attributes( await init_integration(hass) for said in ("said1", "said2"): - entity_id = f"climate.{said}" + entity_id = f"climate.aircon_{said}" entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == said @@ -138,8 +128,8 @@ async def test_dynamic_attributes( mock_instance_idx: int for clim_test_instance in ( - ClimateTestInstance("climate.said1", mock_aircon1_api, 0), - ClimateTestInstance("climate.said2", mock_aircon2_api, 1), + ClimateTestInstance("climate.aircon_said1", mock_aircon1_api, 0), + ClimateTestInstance("climate.aircon_said2", mock_aircon2_api, 1), ): entity_id = clim_test_instance.entity_id mock_instance = clim_test_instance.mock_instance @@ -225,8 +215,8 @@ async def test_service_calls( mock_instance: MagicMock for clim_test_instance in ( - ClimateInstancesData("climate.said1", mock_aircon1_api), - ClimateInstancesData("climate.said2", mock_aircon2_api), + ClimateInstancesData("climate.aircon_said1", mock_aircon1_api), + ClimateInstancesData("climate.aircon_said2", mock_aircon2_api), ): mock_instance = clim_test_instance.mock_instance entity_id = clim_test_instance.entity_id diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index e01fbc07b51..0e277ee629b 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -135,7 +135,7 @@ async def test_form_auth_error( @pytest.mark.usefixtures("mock_auth_api", "mock_appliances_manager_api") async def test_form_already_configured(hass: HomeAssistant, region, brand) -> None: - """Test we handle cannot connect error.""" + """Test that configuring the integration twice with the same data fails.""" mock_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG_INPUT | {"region": region[0], "brand": brand[0]}, diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index 5f04bf84b9e..06e82b74ba7 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -75,6 +75,16 @@ async def test_setup_brand_fallback( mock_backend_selector_api.assert_called_once_with(Brand.Whirlpool, region[1]) +async def test_setup_no_appliances( + hass: HomeAssistant, mock_appliances_manager_api: MagicMock +) -> None: + """Test setup when there are no appliances available.""" + mock_appliances_manager_api.return_value.aircons = [] + mock_appliances_manager_api.return_value.washer_dryers = [] + await init_integration(hass) + assert len(hass.states.async_all()) == 0 + + async def test_setup_http_exception( hass: HomeAssistant, mock_auth_api: MagicMock, diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 51d4b899d25..c05da654f96 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -55,7 +55,7 @@ async def test_form(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: [], CONF_REMOVE_HOLIDAYS: [], - CONF_LANGUAGE: "de", + CONF_LANGUAGE: "en_US", }, ) await hass.async_block_till_done() @@ -70,7 +70,48 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "language": "de", + "language": "en_US", + } + + +async def test_form_province_no_alias(hass: HomeAssistant) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: "Workday Sensor", + CONF_COUNTRY: "US", + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_EXCLUDES: DEFAULT_EXCLUDES, + CONF_OFFSET: DEFAULT_OFFSET, + CONF_WORKDAYS: DEFAULT_WORKDAYS, + CONF_ADD_HOLIDAYS: [], + CONF_REMOVE_HOLIDAYS: [], + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Workday Sensor" + assert result3["options"] == { + "name": "Workday Sensor", + "country": "US", + "excludes": ["sat", "sun", "holiday"], + "days_offset": 0, + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "add_holidays": [], + "remove_holidays": [], } diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index 1e0c9cbebc6..2735175b49b 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import datetime from freezegun.api import FrozenDateTimeFactory +from holidays.utils import country_holidays from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -50,3 +51,18 @@ async def test_update_options( assert entry_check.state is ConfigEntryState.LOADED state = hass.states.get("binary_sensor.workday_sensor") assert state.state == "off" + + +async def test_workday_subdiv_aliases() -> None: + """Test subdiv aliases in holidays library.""" + + country = country_holidays( + country="FR", + years=2025, + ) + subdiv_aliases = country.get_subdivision_aliases() + assert subdiv_aliases["GES"] == [ # codespell:ignore + "Alsace", + "Champagne-Ardenne", + "Lorraine", + ] diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index e7239c23de6..f62ae9c740b 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -556,6 +556,28 @@ async def test_abort_hassio_discovery_for_other_addon( assert result2["reason"] == "not_zwave_js_addon" +@pytest.mark.parametrize( + ("usb_discovery_info", "device", "discovery_name"), + [ + ( + USB_DISCOVERY_INFO, + USB_DISCOVERY_INFO.device, + "zwave radio", + ), + ( + UsbServiceInfo( + device="/dev/zwa2", + pid="303A", + vid="4001", + serial_number="1234", + description="ZWA-2 - Nabu Casa ZWA-2", + manufacturer="Nabu Casa", + ), + "/dev/zwa2", + "Home Assistant Connect ZWA-2", + ), + ], +) @pytest.mark.parametrize( "discovery_info", [ @@ -578,15 +600,19 @@ async def test_usb_discovery( get_addon_discovery_info, set_addon_options, start_addon, + usb_discovery_info: UsbServiceInfo, + device: str, + discovery_name: str, ) -> None: """Test usb discovery success path.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USB}, - data=USB_DISCOVERY_INFO, + data=usb_discovery_info, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "usb_confirm" + assert result["description_placeholders"] == {"name": discovery_name} result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -619,7 +645,7 @@ async def test_usb_discovery( "core_zwave_js", AddonsOptions( config={ - "device": USB_DISCOVERY_INFO.device, + "device": device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -652,7 +678,7 @@ async def test_usb_discovery( assert result["title"] == TITLE assert result["data"] == { "url": "ws://host1:3001", - "usb_path": USB_DISCOVERY_INFO.device, + "usb_path": device, "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 91e333f7c7d..5afdc7e1b56 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS init module.""" import asyncio +from collections.abc import Generator from copy import deepcopy import logging from typing import Any @@ -16,7 +17,7 @@ from zwave_js_server.exceptions import ( InvalidServerVersion, NotConnected, ) -from zwave_js_server.model.node import Node +from zwave_js_server.model.node import Node, NodeDataType from zwave_js_server.model.version import VersionInfo from homeassistant.components.hassio import HassioAPIError @@ -46,13 +47,17 @@ from tests.typing import WebSocketGenerator @pytest.fixture(name="connect_timeout") -def connect_timeout_fixture(): +def connect_timeout_fixture() -> Generator[int]: """Mock the connect timeout.""" with patch("homeassistant.components.zwave_js.CONNECT_TIMEOUT", new=0) as timeout: yield timeout -async def test_entry_setup_unload(hass: HomeAssistant, client, integration) -> None: +async def test_entry_setup_unload( + hass: HomeAssistant, + client: MagicMock, + integration: MockConfigEntry, +) -> None: """Test the integration set up and unload.""" entry = integration @@ -65,16 +70,19 @@ async def test_entry_setup_unload(hass: HomeAssistant, client, integration) -> N assert entry.state is ConfigEntryState.NOT_LOADED -async def test_home_assistant_stop(hass: HomeAssistant, client, integration) -> None: +@pytest.mark.usefixtures("integration") +async def test_home_assistant_stop( + hass: HomeAssistant, + client: MagicMock, +) -> None: """Test we clean up on home assistant stop.""" await hass.async_stop() assert client.disconnect.call_count == 1 -async def test_initialized_timeout( - hass: HomeAssistant, client, connect_timeout -) -> None: +@pytest.mark.usefixtures("client", "connect_timeout") +async def test_initialized_timeout(hass: HomeAssistant) -> None: """Test we handle a timeout during client initialization.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -85,7 +93,8 @@ async def test_initialized_timeout( assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_enabled_statistics(hass: HomeAssistant, client) -> None: +@pytest.mark.usefixtures("client") +async def test_enabled_statistics(hass: HomeAssistant) -> None: """Test that we enabled statistics if the entry is opted in.""" entry = MockConfigEntry( domain="zwave_js", @@ -101,8 +110,9 @@ async def test_enabled_statistics(hass: HomeAssistant, client) -> None: assert mock_cmd.called -async def test_disabled_statistics(hass: HomeAssistant, client) -> None: - """Test that we diisabled statistics if the entry is opted out.""" +@pytest.mark.usefixtures("client") +async def test_disabled_statistics(hass: HomeAssistant) -> None: + """Test that we disabled statistics if the entry is opted out.""" entry = MockConfigEntry( domain="zwave_js", data={"url": "ws://test.org", "data_collection_opted_in": False}, @@ -117,7 +127,8 @@ async def test_disabled_statistics(hass: HomeAssistant, client) -> None: assert mock_cmd.called -async def test_noop_statistics(hass: HomeAssistant, client) -> None: +@pytest.mark.usefixtures("client") +async def test_noop_statistics(hass: HomeAssistant) -> None: """Test that we don't make statistics calls if user hasn't set preference.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -347,8 +358,11 @@ async def test_listen_done_after_setup( assert client.disconnect.call_count == disconnect_call_count +@pytest.mark.usefixtures("client") async def test_new_entity_on_value_added( - hass: HomeAssistant, multisensor_6, client, integration + hass: HomeAssistant, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test we create a new entity if a value is added after the fact.""" node: Node = multisensor_6 @@ -382,12 +396,12 @@ async def test_new_entity_on_value_added( assert hass.states.get("sensor.multisensor_6_ultraviolet_10") is not None +@pytest.mark.usefixtures("integration") async def test_on_node_added_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6_state, - client, - integration, + multisensor_6_state: NodeDataType, + client: MagicMock, ) -> None: """Test we handle a node added event with a ready node.""" node = Node(client, deepcopy(multisensor_6_state)) @@ -413,13 +427,13 @@ async def test_on_node_added_ready( ) +@pytest.mark.usefixtures("integration") async def test_on_node_added_not_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111_not_ready_state, - client, - integration, + zp3111_not_ready_state: NodeDataType, + client: MagicMock, ) -> None: """Test we handle a node added event with a non-ready node.""" device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}" @@ -455,9 +469,9 @@ async def test_on_node_added_not_ready( async def test_existing_node_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - multisensor_6, - integration, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test we handle a ready node that exists during integration setup.""" node = multisensor_6 @@ -485,7 +499,7 @@ async def test_existing_node_reinterview( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client: Client, - multisensor_6_state: dict, + multisensor_6_state: NodeDataType, multisensor_6: Node, integration: MockConfigEntry, ) -> None: @@ -544,15 +558,16 @@ async def test_existing_node_not_ready( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111_not_ready, - client, - integration, + client: MagicMock, + zp3111_not_ready: Node, + integration: MockConfigEntry, ) -> None: """Test we handle a non-ready node that exists during integration setup.""" node = zp3111_not_ready device_id = f"{client.driver.controller.home_id}-{node.node_id}" device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + assert device assert device.name == f"Node {node.node_id}" assert not device.manufacturer assert not device.model @@ -573,11 +588,11 @@ async def test_existing_node_not_replaced_when_not_ready( area_registry: ar.AreaRegistry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111, - zp3111_not_ready_state, - zp3111_state, - client, - integration, + client: MagicMock, + zp3111: Node, + zp3111_not_ready_state: NodeDataType, + zp3111_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test when a node added event with a non-ready node is received. @@ -699,21 +714,23 @@ async def test_existing_node_not_replaced_when_not_ready( assert state.name == "Custom Entity Name" +@pytest.mark.usefixtures("client") async def test_null_name( - hass: HomeAssistant, client, null_name_check, integration + hass: HomeAssistant, + null_name_check: Node, + integration: MockConfigEntry, ) -> None: """Test that node without a name gets a generic node name.""" node = null_name_check assert hass.states.get(f"switch.node_{node.node_id}") +@pytest.mark.usefixtures("addon_installed", "addon_info") async def test_start_addon( hass: HomeAssistant, - addon_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test start the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -761,13 +778,12 @@ async def test_start_addon( assert start_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_not_installed", "addon_info") async def test_install_addon( hass: HomeAssistant, - addon_not_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + set_addon_options: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test install and start the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -810,14 +826,12 @@ async def test_install_addon( assert start_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_installed", "addon_info", "set_addon_options") @pytest.mark.parametrize("addon_info_side_effect", [SupervisorError("Boom")]) async def test_addon_info_failure( hass: HomeAssistant, - addon_installed, - install_addon, - addon_options, - set_addon_options, - start_addon, + install_addon: AsyncMock, + start_addon: AsyncMock, ) -> None: """Test failure to get add-on info for Z-Wave JS add-on during entry setup.""" device = "/test" @@ -837,6 +851,7 @@ async def test_addon_info_failure( assert start_addon.call_count == 0 +@pytest.mark.usefixtures("addon_running", "addon_info", "client") @pytest.mark.parametrize( ( "old_device", @@ -875,26 +890,23 @@ async def test_addon_info_failure( ) async def test_addon_options_changed( hass: HomeAssistant, - client, - addon_installed, - addon_running, - install_addon, - addon_options, - start_addon, - old_device, - new_device, - old_s0_legacy_key, - new_s0_legacy_key, - old_s2_access_control_key, - new_s2_access_control_key, - old_s2_authenticated_key, - new_s2_authenticated_key, - old_s2_unauthenticated_key, - new_s2_unauthenticated_key, - old_lr_s2_access_control_key, - new_lr_s2_access_control_key, - old_lr_s2_authenticated_key, - new_lr_s2_authenticated_key, + install_addon: AsyncMock, + addon_options: dict[str, Any], + start_addon: AsyncMock, + old_device: str, + new_device: str, + old_s0_legacy_key: str, + new_s0_legacy_key: str, + old_s2_access_control_key: str, + new_s2_access_control_key: str, + old_s2_authenticated_key: str, + new_s2_authenticated_key: str, + old_s2_unauthenticated_key: str, + new_s2_unauthenticated_key: str, + old_lr_s2_access_control_key: str, + new_lr_s2_access_control_key: str, + old_lr_s2_authenticated_key: str, + new_lr_s2_authenticated_key: str, ) -> None: """Test update config entry data on entry setup if add-on options changed.""" addon_options["device"] = new_device @@ -936,6 +948,7 @@ async def test_addon_options_changed( assert start_addon.call_count == 0 +@pytest.mark.usefixtures("addon_running") @pytest.mark.parametrize( ( "addon_version", @@ -954,20 +967,17 @@ async def test_addon_options_changed( ) async def test_update_addon( hass: HomeAssistant, - client, - addon_info, - addon_installed, - addon_running, - create_backup, - update_addon, - addon_options, - addon_version, - update_available, - update_calls, - backup_calls, - update_addon_side_effect, - create_backup_side_effect, - version_state, + client: MagicMock, + addon_info: AsyncMock, + create_backup: AsyncMock, + update_addon: AsyncMock, + addon_options: dict[str, Any], + addon_version: str, + update_available: bool, + update_calls: int, + backup_calls: int, + update_addon_side_effect: Exception | None, + create_backup_side_effect: Exception | None, ) -> None: """Test update the Z-Wave JS add-on during entry setup.""" device = "/test" @@ -1002,7 +1012,9 @@ async def test_update_addon( async def test_issue_registry( - hass: HomeAssistant, client, version_state, issue_registry: ir.IssueRegistry + hass: HomeAssistant, + client: MagicMock, + issue_registry: ir.IssueRegistry, ) -> None: """Test issue registry.""" device = "/test" @@ -1043,6 +1055,7 @@ async def test_issue_registry( assert not issue_registry.async_get_issue(DOMAIN, "invalid_server_version") +@pytest.mark.usefixtures("addon_running", "client") @pytest.mark.parametrize( ("stop_addon_side_effect", "entry_state"), [ @@ -1052,13 +1065,10 @@ async def test_issue_registry( ) async def test_stop_addon( hass: HomeAssistant, - client, - addon_installed, - addon_running, - addon_options, - stop_addon, - stop_addon_side_effect, - entry_state, + addon_options: dict[str, Any], + stop_addon: AsyncMock, + stop_addon_side_effect: Exception | None, + entry_state: ConfigEntryState, ) -> None: """Test stop the Z-Wave JS add-on on entry unload if entry is disabled.""" stop_addon.side_effect = stop_addon_side_effect @@ -1093,12 +1103,12 @@ async def test_stop_addon( assert stop_addon.call_args == call("core_zwave_js") +@pytest.mark.usefixtures("addon_installed") async def test_remove_entry( hass: HomeAssistant, - addon_installed, - stop_addon, - create_backup, - uninstall_addon, + stop_addon: AsyncMock, + create_backup: AsyncMock, + uninstall_addon: AsyncMock, caplog: pytest.LogCaptureFixture, ) -> None: """Test remove the config entry.""" @@ -1209,13 +1219,12 @@ async def test_remove_entry( assert "Failed to uninstall the Z-Wave JS add-on" in caplog.text +@pytest.mark.usefixtures("climate_radio_thermostat_ct100_plus", "lock_schlage_be469") async def test_removed_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - climate_radio_thermostat_ct100_plus, - lock_schlage_be469, - integration, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that the device registry gets updated when a device gets removed.""" driver = client.driver @@ -1245,12 +1254,11 @@ async def test_removed_device( ) +@pytest.mark.usefixtures("client", "eaton_rf9640_dimmer") async def test_suggested_area( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - eaton_rf9640_dimmer, ) -> None: """Test that suggested area works.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) @@ -1258,16 +1266,20 @@ async def test_suggested_area( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity = entity_registry.async_get(EATON_RF9640_ENTITY) - assert device_registry.async_get(entity.device_id).area_id is not None + entity_entry = entity_registry.async_get(EATON_RF9640_ENTITY) + assert entity_entry + assert entity_entry.device_id is not None + device = device_registry.async_get(entity_entry.device_id) + assert device + assert device.area_id is not None async def test_node_removed( hass: HomeAssistant, device_registry: dr.DeviceRegistry, multisensor_6_state, - client, - integration, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that device gets removed when node gets removed.""" node = Node(client, deepcopy(multisensor_6_state)) @@ -1296,10 +1308,10 @@ async def test_node_removed( async def test_replace_same_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6, - multisensor_6_state, - client, - integration, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test when a node is replaced with itself that the device remains.""" node_id = multisensor_6.node_id @@ -1406,11 +1418,11 @@ async def test_replace_same_node( async def test_replace_different_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - multisensor_6, - multisensor_6_state, - hank_binary_switch_state, - client, - integration, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + hank_binary_switch_state: NodeDataType, + client: MagicMock, + integration: MockConfigEntry, hass_ws_client: WebSocketGenerator, ) -> None: """Test when a node is replaced with a different node.""" @@ -1659,9 +1671,9 @@ async def test_node_model_change( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - zp3111, - client, - integration, + zp3111: Node, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test when a node's model is changed due to an updated device config file. @@ -1745,8 +1757,11 @@ async def test_node_model_change( assert state.name == "Custom Entity Name" +@pytest.mark.usefixtures("zp3111", "integration") async def test_disabled_node_status_entity_on_node_replaced( - hass: HomeAssistant, zp3111_state, zp3111, client, integration + hass: HomeAssistant, + zp3111_state: NodeDataType, + client: MagicMock, ) -> None: """Test when node replacement event is received, node status sensor is removed.""" node_status_entity = "sensor.4_in_1_sensor_node_status" @@ -1772,7 +1787,10 @@ async def test_disabled_node_status_entity_on_node_replaced( async def test_disabled_entity_on_value_removed( - hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, client, integration + hass: HomeAssistant, + zp3111: Node, + client: MagicMock, + integration: MockConfigEntry, ) -> None: """Test that when entity primary values are removed the entity is removed.""" idle_cover_status_button_entity = ( @@ -1903,7 +1921,10 @@ async def test_disabled_entity_on_value_removed( async def test_identify_event( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test controller identify event.""" # One config entry scenario @@ -1950,7 +1971,7 @@ async def test_identify_event( assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] -async def test_server_logging(hass: HomeAssistant, client) -> None: +async def test_server_logging(hass: HomeAssistant, client: MagicMock) -> None: """Test automatic server logging functionality.""" def _reset_mocks(): @@ -2044,10 +2065,10 @@ async def test_server_logging(hass: HomeAssistant, client) -> None: async def test_factory_reset_node( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - client, - multisensor_6, - multisensor_6_state, - integration, + client: MagicMock, + multisensor_6: Node, + multisensor_6_state: NodeDataType, + integration: MockConfigEntry, ) -> None: """Test when a node is removed because it was reset.""" # One config entry scenario diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index ca75dc51c56..7a4f9fda257 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -703,8 +703,8 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( return True with ( - patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.1), - patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.05), + patch.object(bootstrap, "LOG_SLOW_STARTUP_INTERVAL", 0.005), + patch.object(bootstrap, "SLOW_STARTUP_CHECK_INTERVAL", 0.005), patch( "homeassistant.components.frontend.async_setup", side_effect=_async_setup_that_blocks_startup, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 2d9d18a067d..5c2e2aea215 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1367,11 +1367,42 @@ async def test_async_forward_entry_setup_deprecated( ) in caplog.text -async def test_reauth_issue( +async def test_reauth_issue_flow_returns_abort( hass: HomeAssistant, manager: config_entries.ConfigEntries, issue_registry: ir.IssueRegistry, ) -> None: + """Test that we create/delete an issue when source is reauth. + + In this test, the reauth flow returns abort. + """ + issue = await _test_reauth_issue(hass, manager, issue_registry) + + result = await manager.flow.async_configure(issue.data["flow_id"], {}) + assert result["type"] == FlowResultType.ABORT + assert len(issue_registry.issues) == 0 + + +async def test_reauth_issue_flow_aborted( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that we create/delete an issue when source is reauth. + + In this test, the reauth flow is aborted. + """ + issue = await _test_reauth_issue(hass, manager, issue_registry) + + manager.flow.async_abort(issue.data["flow_id"]) + assert len(issue_registry.issues) == 0 + + +async def _test_reauth_issue( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + issue_registry: ir.IssueRegistry, +) -> ir.IssueEntry: """Test that we create/delete an issue when source is reauth.""" assert len(issue_registry.issues) == 0 @@ -1407,10 +1438,7 @@ async def test_reauth_issue( translation_key="config_entry_reauth", translation_placeholders={"name": "test_title"}, ) - - result = await hass.config_entries.flow.async_configure(issue.data["flow_id"], {}) - assert result["type"] == FlowResultType.ABORT - assert len(issue_registry.issues) == 0 + return issue async def test_loading_default_config(hass: HomeAssistant) -> None: diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 86ba5257001..bcc40251bad 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -210,6 +210,21 @@ async def test_abort_removes_instance(manager: MockFlowManager) -> None: assert len(manager.mock_created_entries) == 0 +async def test_abort_aborted_flow(manager: MockFlowManager) -> None: + """Test return abort from aborted flow.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + manager.async_abort(self.flow_id) + return self.async_abort(reason="blah") + + with pytest.raises(data_entry_flow.UnknownFlow): + await manager.async_init("test") + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + async def test_abort_calls_async_remove(manager: MockFlowManager) -> None: """Test abort calling the async_remove FlowHandler method.""" @@ -228,6 +243,23 @@ async def test_abort_calls_async_remove(manager: MockFlowManager) -> None: assert len(manager.mock_created_entries) == 0 +async def test_abort_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test abort calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_abort(reason="reason") + + manager.async_flow_removed = Mock() + await manager.async_init("test") + + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + async def test_abort_calls_async_remove_with_exception( manager: MockFlowManager, caplog: pytest.LogCaptureFixture ) -> None: @@ -272,6 +304,42 @@ async def test_create_saves_data(manager: MockFlowManager) -> None: assert entry["source"] is None +async def test_create_aborted_flow(manager: MockFlowManager) -> None: + """Test return create_entry from aborted flow.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_init(self, user_input=None): + manager.async_abort(self.flow_id) + return self.async_create_entry(title="Test Title", data="Test Data") + + with pytest.raises(data_entry_flow.UnknownFlow): + await manager.async_init("test") + assert len(manager.async_progress()) == 0 + + # No entry should be created if the flow is aborted + assert len(manager.mock_created_entries) == 0 + + +async def test_create_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test create calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_create_entry(title="Test Title", data="Test Data") + + manager.async_flow_removed = Mock() + await manager.async_init("test") + + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + + async def test_discovery_init_flow(manager: MockFlowManager) -> None: """Test a flow initialized by discovery.""" @@ -884,12 +952,34 @@ async def test_configure_raises_unknown_flow_if_not_in_progress( await manager.async_configure("wrong_flow_id") -async def test_abort_raises_unknown_flow_if_not_in_progress( +async def test_manager_abort_raises_unknown_flow_if_not_in_progress( manager: MockFlowManager, ) -> None: """Test abort raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): - await manager.async_abort("wrong_flow_id") + manager.async_abort("wrong_flow_id") + + +async def test_manager_abort_calls_async_flow_removed(manager: MockFlowManager) -> None: + """Test abort calling the async_flow_removed FlowManager method.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_show_form(step_id="init") + + manager.async_flow_removed = Mock() + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + manager.async_flow_removed.assert_not_called() + + manager.async_abort(result["flow_id"]) + manager.async_flow_removed.assert_called_once() + + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 @pytest.mark.parametrize( diff --git a/tests/test_setup.py b/tests/test_setup.py index 1f0e668d4e2..96a13017430 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -57,21 +57,21 @@ async def test_validate_component_config(hass: HomeAssistant) -> None: with assert_setup_component(0): assert not await setup.async_setup_component(hass, "comp_conf", {}) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( hass, "comp_conf", {"comp_conf": None} ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( hass, "comp_conf", {"comp_conf": {}} ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(0): assert not await setup.async_setup_component( @@ -80,7 +80,7 @@ async def test_validate_component_config(hass: HomeAssistant) -> None: {"comp_conf": {"hello": "world", "invalid": "extra"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) with assert_setup_component(1): assert await setup.async_setup_component( @@ -111,7 +111,7 @@ async def test_validate_platform_config( {"platform_conf": {"platform": "not_existing", "hello": "world"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") with assert_setup_component(1): @@ -121,7 +121,7 @@ async def test_validate_platform_config( {"platform_conf": {"platform": "whatever", "hello": "world"}}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") with assert_setup_component(1): @@ -131,7 +131,7 @@ async def test_validate_platform_config( {"platform_conf": [{"platform": "whatever", "hello": "world"}]}, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") # Any falsey platform config will be ignored (None, {}, etc) @@ -240,7 +240,7 @@ async def test_validate_platform_config_4(hass: HomeAssistant) -> None: }, ) - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("platform_conf") @@ -345,7 +345,7 @@ async def test_component_not_setup_missing_dependencies(hass: HomeAssistant) -> assert not await setup.async_setup_component(hass, "comp", {}) assert "comp" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration(hass, MockModule("comp2", dependencies=deps)) mock_integration(hass, MockModule("maybe_existing")) @@ -353,6 +353,76 @@ async def test_component_not_setup_missing_dependencies(hass: HomeAssistant) -> assert await setup.async_setup_component(hass, "comp2", {}) +async def test_component_not_setup_already_setup_dependencies( + hass: HomeAssistant, +) -> None: + """Test we do not set up component dependencies if they are already set up.""" + mock_integration( + hass, + MockModule( + "comp", + dependencies=["dep1"], + partial_manifest={"after_dependencies": ["dep2"]}, + ), + ) + mock_integration(hass, MockModule("dep1")) + mock_integration(hass, MockModule("dep2")) + + setup.async_set_domains_to_be_loaded(hass, {"comp", "dep2"}) + + hass.config.components.add("dep1") + hass.config.components.add("dep2") + + with patch( + "homeassistant.setup.async_setup_component", + side_effect=setup.async_setup_component, + ) as mock_setup: + await mock_setup(hass, "comp", {}) + + assert mock_setup.call_count == 1 + + +@pytest.mark.usefixtures("mock_handlers") +async def test_component_setup_dependencies_with_config_entry( + hass: HomeAssistant, +) -> None: + """Test we wait for a dependency with config entry.""" + calls: list[str] = [] + + async def mock_async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + await asyncio.sleep(0) + calls.append("entry") + return True + + mock_integration(hass, MockModule("comp", async_setup_entry=mock_async_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + MockConfigEntry(domain="comp").add_to_hass(hass) + + async def mock_async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + calls.append("comp") + return True + + mock_integration( + hass, + MockModule("comp2", dependencies=["comp"], async_setup=mock_async_setup), + ) + mock_integration( + hass, + MockModule("comp3", dependencies=["comp"], async_setup=mock_async_setup), + ) + + await asyncio.gather( + setup.async_setup_component(hass, "comp2", {}), + setup.async_setup_component(hass, "comp3", {}), + ) + + assert "comp" in hass.config.components + assert "comp2" in hass.config.components + assert "comp3" in hass.config.components + + assert calls == ["entry", "comp", "comp"] + + async def test_component_failing_setup(hass: HomeAssistant) -> None: """Test component that fails setup.""" mock_integration(hass, MockModule("comp", setup=lambda hass, config: False)) @@ -373,8 +443,8 @@ async def test_component_exception_setup(hass: HomeAssistant) -> None: mock_integration(hass, MockModule(domain, setup=exception_setup)) assert not await setup.async_setup_component(hass, domain, {}) - assert domain in hass.data[setup.DATA_SETUP] - assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain in hass.data[setup._DATA_SETUP] + assert domain not in hass.data[setup._DATA_SETUP_DONE] assert domain not in hass.config.components @@ -393,8 +463,8 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, "comp", {}) assert str(exc_info.value) == "fail!" - assert domain in hass.data[setup.DATA_SETUP] - assert domain not in hass.data[setup.DATA_SETUP_DONE] + assert domain in hass.data[setup._DATA_SETUP] + assert domain not in hass.data[setup._DATA_SETUP_DONE] assert domain not in hass.config.components @@ -407,12 +477,12 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: domains = {domain_good, domain_bad, domain_exception, domain_base_exception} setup.async_set_domains_to_be_loaded(hass, domains) - assert set(hass.data[setup.DATA_SETUP_DONE]) == domains - setup_done = dict(hass.data[setup.DATA_SETUP_DONE]) + assert set(hass.data[setup._DATA_SETUP_DONE]) == domains + setup_done = dict(hass.data[setup._DATA_SETUP_DONE]) # Calling async_set_domains_to_be_loaded again should not create new futures setup.async_set_domains_to_be_loaded(hass, domains) - assert setup_done == hass.data[setup.DATA_SETUP_DONE] + assert setup_done == hass.data[setup._DATA_SETUP_DONE] def good_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Success.""" @@ -445,8 +515,8 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: await setup.async_setup_component(hass, domain_base_exception, {}) # Check the result of the setup - assert not hass.data[setup.DATA_SETUP_DONE] - assert set(hass.data[setup.DATA_SETUP]) == { + assert not hass.data[setup._DATA_SETUP_DONE] + assert set(hass.data[setup._DATA_SETUP]) == { domain_bad, domain_exception, domain_base_exception, @@ -455,7 +525,7 @@ async def test_set_domains_to_be_loaded(hass: HomeAssistant) -> None: # Calling async_set_domains_to_be_loaded again should not create any new futures setup.async_set_domains_to_be_loaded(hass, domains) - assert not hass.data[setup.DATA_SETUP_DONE] + assert not hass.data[setup._DATA_SETUP_DONE] async def test_component_setup_after_dependencies(hass: HomeAssistant) -> None: @@ -538,7 +608,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: assert mock_setup.call_count == 0 assert len(mock_notify.mock_calls) == 1 - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("switch") with ( @@ -560,7 +630,7 @@ async def test_platform_specific_config_validation(hass: HomeAssistant) -> None: assert mock_setup.call_count == 0 assert len(mock_notify.mock_calls) == 1 - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("switch") with ( @@ -586,7 +656,7 @@ async def test_disable_component_if_invalid_return(hass: HomeAssistant) -> None: assert not await setup.async_setup_component(hass, "disabled_component", {}) assert "disabled_component" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration( hass, MockModule("disabled_component", setup=lambda hass, config: False), @@ -595,7 +665,7 @@ async def test_disable_component_if_invalid_return(hass: HomeAssistant) -> None: assert not await setup.async_setup_component(hass, "disabled_component", {}) assert "disabled_component" not in hass.config.components - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) mock_integration( hass, MockModule("disabled_component", setup=lambda hass, config: True) ) @@ -869,7 +939,7 @@ async def test_integration_only_setup_entry(hass: HomeAssistant) -> None: async def test_async_start_setup_running(hass: HomeAssistant) -> None: """Test setup started context manager does nothing when running.""" assert hass.state is CoreState.running - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) with setup.async_start_setup( hass, integration="august", phase=setup.SetupPhases.SETUP @@ -882,7 +952,7 @@ async def test_async_start_setup_config_entry( ) -> None: """Test setup started keeps track of setup times with a config entry.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -992,7 +1062,7 @@ async def test_async_start_setup_config_entry_late_platform( ) -> None: """Test setup started tracks config entry time with a late platform load.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1046,7 +1116,7 @@ async def test_async_start_setup_config_entry_platform_wait( ) -> None: """Test setup started tracks wait time when a platform loads inside of config entry setup.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1088,7 +1158,7 @@ async def test_async_start_setup_config_entry_platform_wait( async def test_async_start_setup_top_level_yaml(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times with modern yaml.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1104,7 +1174,7 @@ async def test_async_start_setup_top_level_yaml(hass: HomeAssistant) -> None: async def test_async_start_setup_platform_integration(hass: HomeAssistant) -> None: """Test setup started keeps track of setup times a platform integration.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1138,7 +1208,7 @@ async def test_async_start_setup_legacy_platform_integration( ) -> None: """Test setup started keeps track of setup times for a legacy platform integration.""" hass.set_state(CoreState.not_running) - setup_started = hass.data.setdefault(setup.DATA_SETUP_STARTED, {}) + setup_started = hass.data.setdefault(setup._DATA_SETUP_STARTED, {}) setup_time = setup._setup_times(hass) with setup.async_start_setup( @@ -1260,7 +1330,7 @@ async def test_setup_config_entry_from_yaml( assert await setup.async_setup_component(hass, "test_integration_only_entry", {}) assert expected_warning not in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1269,7 +1339,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1278,7 +1348,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") # There should be a warning, but setup should not fail @@ -1289,7 +1359,7 @@ async def test_setup_config_entry_from_yaml( ) assert expected_warning in caplog.text caplog.clear() - hass.data.pop(setup.DATA_SETUP) + hass.data.pop(setup._DATA_SETUP) hass.config.components.remove("test_integration_only_entry") @@ -1338,3 +1408,42 @@ async def test_async_prepare_setup_platform( await setup.async_prepare_setup_platform(hass, {}, "button", "test") is None ) assert button_platform is not None + + +async def test_async_wait_component(hass: HomeAssistant) -> None: + """Test async_wait_component.""" + setup_stall = asyncio.Event() + setup_started = asyncio.Event() + + async def mock_setup(hass: HomeAssistant, _) -> bool: + setup_started.set() + await setup_stall.wait() + return True + + mock_integration(hass, MockModule("test", async_setup=mock_setup)) + + # The integration not loaded, and is also not scheduled to load + assert await setup.async_wait_component(hass, "test") is False + + # Mark the component as scheduled to be loaded + setup.async_set_domains_to_be_loaded(hass, {"test"}) + + # Start loading the component, including its config entries + hass.async_create_task(setup.async_setup_component(hass, "test", {})) + await setup_started.wait() + + # The component is not yet loaded + assert "test" not in hass.config.components + + # Allow setup to proceed + setup_stall.set() + + # The component is scheduled to load, this will block until the config entry is loaded + assert await setup.async_wait_component(hass, "test") is True + + # The component has been loaded + assert "test" in hass.config.components + + # Clear the event, then call again to make sure we don't block + setup_stall.clear() + assert await setup.async_wait_component(hass, "test") is True