diff --git a/homeassistant/components/airly/quality_scale.yaml b/homeassistant/components/airly/quality_scale.yaml index b9b1302e69b167..d63e6312017b84 100644 --- a/homeassistant/components/airly/quality_scale.yaml +++ b/homeassistant/components/airly/quality_scale.yaml @@ -6,12 +6,8 @@ rules: appropriate-polling: done brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: The config flow test should either result in an entry being created or an abort. - config-flow: - status: todo - comment: Add data_description. + config-flow-test-coverage: done + config-flow: done dependency-transparency: done docs-actions: status: exempt diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 4c3a50b194b06d..0a98aa335764f5 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -15,7 +15,11 @@ "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]" }, - "description": "To generate API key go to {developer_registration_url}" + "data_description": { + "api_key": "To generate API key go to {developer_registration_url}.", + "latitude": "The latitude of your location.", + "longitude": "The longitude of your location." + } } } }, diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index e9bc3b4c63c786..78502db6912a6e 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==6.1.1"], + "requirements": ["brother==6.1.2"], "zeroconf": [ { "name": "brother*", diff --git a/homeassistant/components/deako/strings.json b/homeassistant/components/deako/strings.json index 1b4f6d6081d158..088e85a726d2e3 100644 --- a/homeassistant/components/deako/strings.json +++ b/homeassistant/components/deako/strings.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { diff --git a/homeassistant/components/escea/strings.json b/homeassistant/components/escea/strings.json index 0b9fcacbb7ad34..83e561d467d992 100644 --- a/homeassistant/components/escea/strings.json +++ b/homeassistant/components/escea/strings.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { diff --git a/homeassistant/components/fjaraskupan/strings.json b/homeassistant/components/fjaraskupan/strings.json index 9f02109dcf6395..70343f151df146 100644 --- a/homeassistant/components/fjaraskupan/strings.json +++ b/homeassistant/components/fjaraskupan/strings.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 932979620cf595..4945985ef4ba76 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -22,5 +22,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260826.1"] + "requirements": ["home-assistant-frontend==20260826.2"] } diff --git a/homeassistant/components/govee_light_local/strings.json b/homeassistant/components/govee_light_local/strings.json index afa664d1ae04a8..18873d294a51e5 100644 --- a/homeassistant/components/govee_light_local/strings.json +++ b/homeassistant/components/govee_light_local/strings.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index cbf1cef49405e4..1a68177c6bb9ac 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { diff --git a/homeassistant/components/hisense_aehw4a1/strings.json b/homeassistant/components/hisense_aehw4a1/strings.json index f69e25cf356d0d..c29a0fb6ce68d2 100644 --- a/homeassistant/components/hisense_aehw4a1/strings.json +++ b/homeassistant/components/hisense_aehw4a1/strings.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 78ceec20a2fb16..39470f45edd8f4 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -4,6 +4,7 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]", "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", diff --git a/homeassistant/components/lg_infrared/__init__.py b/homeassistant/components/lg_infrared/__init__.py index 0345ef85d3a5be..84f579ddb40519 100644 --- a/homeassistant/components/lg_infrared/__init__.py +++ b/homeassistant/components/lg_infrared/__init__.py @@ -11,6 +11,7 @@ Platform.CLIMATE, Platform.EVENT, Platform.MEDIA_PLAYER, + Platform.SELECT, Platform.SWITCH, ] diff --git a/homeassistant/components/lg_infrared/icons.json b/homeassistant/components/lg_infrared/icons.json index 785708909fd26a..e1f20445a73d24 100644 --- a/homeassistant/components/lg_infrared/icons.json +++ b/homeassistant/components/lg_infrared/icons.json @@ -149,6 +149,11 @@ } } }, + "select": { + "energy_limit": { + "default": "mdi:lightning-bolt-outline" + } + }, "switch": { "auto_clean": { "default": "mdi:broom" diff --git a/homeassistant/components/lg_infrared/select.py b/homeassistant/components/lg_infrared/select.py new file mode 100644 index 00000000000000..85ffee39236ec5 --- /dev/null +++ b/homeassistant/components/lg_infrared/select.py @@ -0,0 +1,78 @@ +"""Select platform for LG IR integration.""" + +from typing import override + +from infrared_protocols.codes.lg.ac import LGACCode + +from homeassistant.components.infrared import InfraredEmitterConsumerEntity +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, LGDeviceType +from .entity import LgIrEntity + +PARALLEL_UPDATES = 1 + +ENERGY_LIMIT_OFF = "off" + +# The unit caps its power draw at the selected percentage; "off" removes the cap. +_ENERGY_LIMIT_TO_CODE: dict[str, LGACCode] = { + ENERGY_LIMIT_OFF: LGACCode.ENERGY_LIMIT_OFF, + "40": LGACCode.ENERGY_LIMIT_40, + "60": LGACCode.ENERGY_LIMIT_60, + "80": LGACCode.ENERGY_LIMIT_80, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the LG AC energy-limit select from a config entry.""" + if entry.data[CONF_DEVICE_TYPE] != LGDeviceType.AC: + return + + async_add_entities( + [LgAcEnergyLimitSelect(entry, entry.data[CONF_INFRARED_ENTITY_ID])] + ) + + +class LgAcEnergyLimitSelect( + LgIrEntity, InfraredEmitterConsumerEntity, SelectEntity, RestoreEntity +): + """Selects the LG AC energy-consumption cap.""" + + _attr_assumed_state = True + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "energy_limit" + _attr_options = list(_ENERGY_LIMIT_TO_CODE) + + def __init__(self, entry: ConfigEntry, emitter_entity_id: str) -> None: + """Initialize the energy-limit select.""" + super().__init__(entry, unique_id_suffix="energy_limit", device_name="LG AC") + self._infrared_emitter_entity_id = emitter_entity_id + self._attr_current_option = ENERGY_LIMIT_OFF + + @override + async def async_added_to_hass(self) -> None: + """Restore the assumed state, as infrared cannot read it back from the AC.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if ( + last_state is not None + and last_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and last_state.state in _ENERGY_LIMIT_TO_CODE + ): + self._attr_current_option = last_state.state + + @override + async def async_select_option(self, option: str) -> None: + """Send the code for the chosen energy cap.""" + await self._send_command(_ENERGY_LIMIT_TO_CODE[option].to_command()) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/lg_infrared/strings.json b/homeassistant/components/lg_infrared/strings.json index 49b6a3c9b822ce..6afe0745338b3d 100644 --- a/homeassistant/components/lg_infrared/strings.json +++ b/homeassistant/components/lg_infrared/strings.json @@ -263,6 +263,17 @@ } } }, + "select": { + "energy_limit": { + "name": "Energy limit", + "state": { + "40": "40%", + "60": "60%", + "80": "80%", + "off": "[%key:common::state::off%]" + } + } + }, "switch": { "auto_clean": { "name": "Auto clean" diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index ea63ffe864557f..881dc82322604b 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -116,6 +116,8 @@ async def async_update(self) -> None: self._attr_media_title = track_information.get("title") if artists := track_information.get("artist"): self._attr_media_artist = artists[0] + self._attr_media_content_id = track_information.get("uri") + self._attr_media_content_type = MediaType.MUSIC if self._device.volume_enabled: self._attr_supported_features |= ( diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 13917fe9e31084..2e7efaef12b9c8 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -90,11 +90,13 @@ def purge_old_data( instance, session, events_batch_size, purge_before ) - statistics_runs = _select_statistics_runs_to_purge( + statistics_runs, statistics_runs_has_more = _select_statistics_runs_to_purge( session, purge_before, instance.max_bind_vars ) - short_term_statistics = _select_short_term_statistics_to_purge( - session, purge_before, instance.max_bind_vars + short_term_statistics, short_term_statistics_has_more = ( + _select_short_term_statistics_to_purge( + session, purge_before, instance.max_bind_vars + ) ) if statistics_runs: _purge_statistics_runs(session, statistics_runs) @@ -102,7 +104,11 @@ def purge_old_data( if short_term_statistics: _purge_short_term_statistics(session, short_term_statistics) - if has_more_to_purge or statistics_runs or short_term_statistics: + if ( + has_more_to_purge + or statistics_runs_has_more + or short_term_statistics_has_more + ): # Return false, as we might not be done yet. _LOGGER.debug("Purging hasn't fully completed yet") return False @@ -365,14 +371,16 @@ def _purge_unused_data_ids( def _select_statistics_runs_to_purge( session: Session, purge_before: datetime, max_bind_vars: int -) -> list[int]: - """Return a list of statistic runs to purge. +) -> tuple[list[int], bool]: + """Return a list of statistic runs to purge and if there may be more. Takes care to keep the newest run. """ statistic_runs = session.execute( find_statistics_runs_to_purge(purge_before, max_bind_vars) ).all() + # Only a full batch can have more rows to purge + has_more = len(statistic_runs) == max_bind_vars statistic_runs_list = [run_id for (run_id,) in statistic_runs] # Exclude the newest statistics run if ( @@ -381,18 +389,20 @@ def _select_statistics_runs_to_purge( statistic_runs_list.remove(last_run) _LOGGER.debug("Selected %s statistic runs to remove", len(statistic_runs)) - return statistic_runs_list + return statistic_runs_list, has_more def _select_short_term_statistics_to_purge( session: Session, purge_before: datetime, max_bind_vars: int -) -> list[int]: - """Return a list of short term statistics to purge.""" +) -> tuple[list[int], bool]: + """Return a list of short term statistics to purge and if there may be more.""" statistics = session.execute( find_short_term_statistics_to_purge(purge_before, max_bind_vars) ).all() _LOGGER.debug("Selected %s short term statistics to remove", len(statistics)) - return [statistic_id for (statistic_id,) in statistics] + # Only a full batch can have more rows to purge + has_more = len(statistics) == max_bind_vars + return [statistic_id for (statistic_id,) in statistics], has_more def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( diff --git a/homeassistant/components/refoss/strings.json b/homeassistant/components/refoss/strings.json index 461313b563e05d..3eaf68565423ce 100644 --- a/homeassistant/components/refoss/strings.json +++ b/homeassistant/components/refoss/strings.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 82c99a7e1d2336..b17818ddc3cbba 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "not_ipv4_address": "No IPv4 address in SSDP discovery information", "not_sonos_device": "Discovered device is not a Sonos device", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" diff --git a/homeassistant/components/wemo/strings.json b/homeassistant/components/wemo/strings.json index 77a1507cee6b45..874f4633c214b7 100644 --- a/homeassistant/components/wemo/strings.json +++ b/homeassistant/components/wemo/strings.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { diff --git a/homeassistant/components/zerproc/strings.json b/homeassistant/components/zerproc/strings.json index 2e8312544e7143..7b2ae512458d0a 100644 --- a/homeassistant/components/zerproc/strings.json +++ b/homeassistant/components/zerproc/strings.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index f53a4226fa6ab6..78a220b06f0558 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -439,6 +439,7 @@ def __init__(self, config: ConfigType) -> None: self._attrs: dict | None = None self._remove_listener: Callable[[], None] | None = None self._persons_in_zone: set[str] = set() + self._device_trackers_in_zone: set[str] = set() self._set_attrs_from_config() def _set_attrs_from_config(self) -> None: @@ -484,29 +485,47 @@ async def async_update_config(self, config: ConfigType) -> None: self.async_write_ha_state() @callback - def _person_state_change_listener(self, evt: Event[EventStateChangedData]) -> None: - person_entity_id = evt.data["entity_id"] - persons_in_zone = self._persons_in_zone - cur_count = len(persons_in_zone) + def _update_tracked_in_zone( + self, tracked_in_zone: set[str], evt: Event[EventStateChangedData] + ) -> None: + entity_id = evt.data["entity_id"] + cur_count = len(tracked_in_zone) if self._state_is_in_zone(evt.data["new_state"]): - persons_in_zone.add(person_entity_id) - elif person_entity_id in persons_in_zone: - persons_in_zone.remove(person_entity_id) + tracked_in_zone.add(entity_id) + elif entity_id in tracked_in_zone: + tracked_in_zone.remove(entity_id) - if len(persons_in_zone) != cur_count: + if len(tracked_in_zone) != cur_count: self._generate_attrs() self.async_write_ha_state() + @callback + def _person_state_change_listener(self, evt: Event[EventStateChangedData]) -> None: + self._update_tracked_in_zone(self._persons_in_zone, evt) + + @callback + def _device_tracker_state_change_listener( + self, evt: Event[EventStateChangedData] + ) -> None: + self._update_tracked_in_zone(self._device_trackers_in_zone, evt) + @override async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() - person_domain = "person" # avoid circular import + # Domains are hardcoded to avoid circular imports. + person_domain = "person" + device_tracker_domain = "device_tracker" self._persons_in_zone = { state.entity_id for state in self.hass.states.async_all(person_domain) if self._state_is_in_zone(state) } + self._device_trackers_in_zone = { + state.entity_id + for state in self.hass.states.async_all(device_tracker_domain) + if self._state_is_in_zone(state) + } self._generate_attrs() self.async_on_remove( @@ -516,6 +535,13 @@ async def async_added_to_hass(self) -> None: self._person_state_change_listener, ).async_remove ) + self.async_on_remove( + event.async_track_state_change_filtered( + self.hass, + event.TrackStates(False, set(), {device_tracker_domain}), + self._device_tracker_state_change_listener, + ).async_remove + ) @callback def _generate_attrs(self) -> None: @@ -526,6 +552,9 @@ def _generate_attrs(self) -> None: ZoneEntityStateAttribute.RADIUS: self._config[CONF_RADIUS], ZoneEntityStateAttribute.PASSIVE: self._config[CONF_PASSIVE], ZoneEntityStateAttribute.PERSONS: sorted(self._persons_in_zone), + ZoneEntityStateAttribute.DEVICE_TRACKERS: sorted( + self._device_trackers_in_zone + ), ZoneEntityStateAttribute.EDITABLE: self.editable, } diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py index 0725b2ce6fdffa..232b7557d720d9 100644 --- a/homeassistant/components/zone/const.py +++ b/homeassistant/components/zone/const.py @@ -13,6 +13,7 @@ class ZoneEntityStateAttribute(StrEnum): RADIUS = "radius" PASSIVE = "passive" PERSONS = "persons" + DEVICE_TRACKERS = "device_trackers" EDITABLE = "editable" diff --git a/homeassistant/components/zonneplan/config_flow.py b/homeassistant/components/zonneplan/config_flow.py index 0b4fd168abfae7..1ebcce4cc97ad4 100644 --- a/homeassistant/components/zonneplan/config_flow.py +++ b/homeassistant/components/zonneplan/config_flow.py @@ -13,7 +13,12 @@ ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_EMAIL, CONF_TOKEN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -104,10 +109,15 @@ async def async_step_otp( errors["base"] = "unknown" else: await self.async_set_unique_id(account.user_account.uuid) - if self.source == SOURCE_REAUTH: + if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE): self._abort_if_unique_id_mismatch() + entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) return self.async_update_reload_and_abort( - self._get_reauth_entry(), + entry, data_updates={ CONF_EMAIL: account.user_account.email, CONF_TOKEN: token.as_dict(), @@ -155,3 +165,30 @@ async def async_step_reauth_confirm( errors=errors, description_placeholders={CONF_EMAIL: email}, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Request a new OTP for the email of the entry being reconfigured.""" + email = self._get_reconfigure_entry().data[CONF_EMAIL] + errors: dict[str, str] | None = None + if user_input is not None and not ( + errors := await self._async_request_otp(user_input[CONF_EMAIL]) + ): + return await self.async_step_otp() + + return self.async_show_form( + step_id="reconfigure_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input or {CONF_EMAIL: email}, + ), + errors=errors, + description_placeholders={CONF_EMAIL: email}, + ) diff --git a/homeassistant/components/zonneplan/quality_scale.yaml b/homeassistant/components/zonneplan/quality_scale.yaml index 0ef138579d4c48..80d62b25a6d16a 100644 --- a/homeassistant/components/zonneplan/quality_scale.yaml +++ b/homeassistant/components/zonneplan/quality_scale.yaml @@ -64,7 +64,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo diff --git a/homeassistant/components/zonneplan/strings.json b/homeassistant/components/zonneplan/strings.json index a0a3e3bf200d8e..d236993dcb465f 100644 --- a/homeassistant/components/zonneplan/strings.json +++ b/homeassistant/components/zonneplan/strings.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unique_id_mismatch": "The one-time password was validated for a different Zonneplan account than the one configured." }, "error": { @@ -31,6 +32,15 @@ "description": "The stored credentials for {email} are no longer valid. Confirm your email address to receive a new one-time password.", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure_confirm": { + "data": { + "email": "[%key:common::config_flow::data::email%]" + }, + "data_description": { + "email": "[%key:component::zonneplan::config::step::user::data_description::email%]" + }, + "description": "Reconfigure the {email} account. Confirm your email address to receive a new one-time password." + }, "user": { "data": { "email": "[%key:common::config_flow::data::email%]" diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 9255db6fbd7f86..c46188a329503e 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -73,7 +73,10 @@ async def async_step_confirm( has_devices = await cast("asyncio.Future[bool]", discovery_result) if not has_devices: - return self.async_abort(reason="no_devices_found") + return self.async_abort( + reason="no_devices_found", + translation_domain=HOMEASSISTANT_DOMAIN, + ) # Cancel the discovered one. for flow in in_progress: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 408082a05d0036..912427b1350766 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ habluetooth==6.26.11 hass-nabucasa==2.7.0 hassil==3.12.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260826.1 +home-assistant-frontend==20260826.2 home-assistant-intents==2026.8.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/pylint/plugins/README.md b/pylint/plugins/README.md index 63ce62dd5f32df..25f080ab97bc7a 100644 --- a/pylint/plugins/README.md +++ b/pylint/plugins/README.md @@ -140,6 +140,8 @@ Every check has a code following the | `W7433` | [`home-assistant-missing-test-before-configure`](#w7433-home-assistant-missing-test-before-configure) | Config flow should test the connection before creating an entry | | `W7434` | [`home-assistant-config-flow-menu-missing-step`](#w7434-home-assistant-config-flow-menu-missing-step) | `async_show_menu` option has no matching `async_step_*` method | | `W7435` | [`home-assistant-json-fixture`](#w7435-home-assistant-json-fixture) | Use a JSON fixture helper instead of parsing a loaded fixture | +| `W7436` | [`home-assistant-light-missing-color-mode`](#w7436-home-assistant-light-missing-color-mode) | Light entity sets supported color modes but does not report a `color_mode` | +| `W7437` | [`home-assistant-light-missing-supported-color-modes`](#w7437-home-assistant-light-missing-supported-color-modes) | Light entity reports a `color_mode` but does not set supported color modes | ## `home_assistant_logger` checker @@ -1003,3 +1005,40 @@ Use the dedicated helper that loads and parses in one step instead: data = load_json_object_fixture("data.json", DOMAIN) data = await async_load_json_object_fixture(hass, "data.json", DOMAIN) ``` + + +## `home_assistant_light_color_mode` checker + +A `LightEntity` must report **both** `supported_color_modes` and a current +`color_mode`; setting one without the other raises `HomeAssistantError` at +runtime. These two checks flag each half of that inconsistency. A light +that sets *neither* is deliberately not flagged: the realistic both-missing +class is an abstract base, so flagging it would false-positive; the tradeoff +is that a concrete both-missing light, which also raises, is not caught. + +A value is considered *provided* by a class when its effective declaration, +resolved in MRO order and excluding `LightEntity`'s own `None` defaults, is a +non-`None` class-body `_attr_...` assignment, a `self._attr_... = ...` +assignment in a method body, or a property/method override of the public +name. Subclass shadowing is respected: a subclass that assigns the +`_attr_...` to `None` nullifies a non-`None` value inherited from an +ancestor. Mixin/abstract bases that are subclassed by another class in the +same module are exempted, on the assumption that the concrete subclass is the +runtime entity (and may supply the missing half). + +### `W7436`: `home-assistant-light-missing-color-mode` + +The light provides `supported_color_modes` but no `color_mode`. At runtime +`LightEntity.state_attributes` raises `HomeAssistantError` ("does not report a +color mode") whenever the light is on and `color_mode` is `None` -- there is +no inference of the mode from a single supported mode, so this holds even for +lights that support only `ONOFF` or `BRIGHTNESS`. Set `_attr_color_mode` or +override the `color_mode` property. + +### `W7437`: `home-assistant-light-missing-supported-color-modes` + +The light provides `color_mode` but no `supported_color_modes`. At runtime +`LightEntity._light_internal_supported_color_modes` raises `HomeAssistantError` +("does not set supported color modes") from both `state_attributes` and +`capability_attributes` whenever `supported_color_modes` is `None`. Set +`_attr_supported_color_modes` or override the `supported_color_modes` property. diff --git a/pylint/plugins/pylint_home_assistant/checkers/light_color_mode.py b/pylint/plugins/pylint_home_assistant/checkers/light_color_mode.py new file mode 100644 index 00000000000000..3951cf0d296d97 --- /dev/null +++ b/pylint/plugins/pylint_home_assistant/checkers/light_color_mode.py @@ -0,0 +1,288 @@ +"""Checker for light entities that report only one of the color-mode attributes. + +A ``LightEntity`` must report **both** ``supported_color_modes`` and +a current ``color_mode``; setting one without the other raises +``HomeAssistantError`` at runtime. These two checks flag each half of that +inconsistency. A light that sets *neither* is deliberately not flagged: the +realistic both-missing class is an abstract base (which the concrete +subclass completes), so flagging it would produce false positives — the +tradeoff is that a concrete both-missing light, which also raises at +runtime, is not caught. + +A value is considered *provided* by a class when its effective declaration, +resolved in MRO order and excluding ``LightEntity``'s own ``None`` defaults, +is one of: + +- a non-``None`` class-body ``_attr_...`` assignment, +- a ``self._attr_... = ...`` assignment in a method body, or +- a property/method override of the public name. + +Subclass shadowing is respected: a subclass that assigns the ``_attr_...`` +to ``None`` nullifies a non-``None`` value inherited from an ancestor, so +the pair is treated as unset from that subclass down. + +Mixin/abstract bases that are subclassed by another class in the same +module are exempted, on the assumption that the concrete subclass is the +runtime entity (and may supply the missing half itself). + +``W7436`` (``home-assistant-light-missing-color-mode``) +------------------------------------------------------- +Fires when ``supported_color_modes`` is provided but ``color_mode`` is not. +At runtime ``LightEntity.state_attributes`` raises ``HomeAssistantError`` +("does not report a color mode") whenever the light is on and +``color_mode`` is ``None`` — there is no inference of the mode from a +single supported mode, so this holds even for lights that support only +``ONOFF`` or ``BRIGHTNESS``. + +``W7437`` (``home-assistant-light-missing-supported-color-modes``) +------------------------------------------------------------------ +Fires when ``color_mode`` is provided but ``supported_color_modes`` is not. +At runtime ``LightEntity._light_internal_supported_color_modes`` raises +``HomeAssistantError`` ("does not set supported color modes") from both +``state_attributes`` and ``capability_attributes`` whenever +``supported_color_modes`` is ``None``. + +Known limitations: + +- A base defined in one module whose missing half is only supplied by + subclasses in a *different* module is flagged, because the subclasses are + not visible when the base's module is linted. Suppress with a + ``# pylint: disable=...`` on the base. +- A property override is treated as providing the value regardless of what + it returns; a property that returns ``None`` at runtime is the + integration's responsibility. +""" + +import astroid +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +from pylint_home_assistant.helpers.ast_utils import extended_ancestors +from pylint_home_assistant.helpers.entity_class import ( + LIGHT_ENTITY_QNAME, + collect_same_module_ancestor_qnames, + inherits_from_light_entity, +) +from pylint_home_assistant.helpers.module_info import is_integration_module + +_SUPPORTED_ATTR = "_attr_supported_color_modes" +_SUPPORTED_PROPERTY = "supported_color_modes" +_COLOR_MODE_ATTR = "_attr_color_mode" +_COLOR_MODE_PROPERTY = "color_mode" + + +def _is_non_none_value(value: nodes.NodeNG | None) -> bool: + """Return True if the AST value is present and not a literal ``None``.""" + if value is None: + return False + return not (isinstance(value, nodes.Const) and value.value is None) + + +def _is_self_attr_target(target: nodes.NodeNG, attr_name: str) -> bool: + """Return True if *target* is ``self.``.""" + match target: + case nodes.AssignAttr(attrname=name, expr=nodes.Name(name="self")) if ( + name == attr_name + ): + return True + return False + + +def _class_body_attr_state(class_node: nodes.ClassDef, attr_name: str) -> bool | None: + """Return the effect of the class body's final assignment to *attr_name*. + + ``True`` if the last source-order assignment sets a non-``None`` value, + ``False`` if it sets a literal ``None``, or ``None`` if the class body + does not assign *attr_name* at all. Later assignments win, matching + Python's class-body evaluation, so ``x = ColorMode.HS`` followed by + ``x = None`` resolves to ``False``. Annotation-only statements (``x: T`` + with no value) are not assignments and are ignored. + """ + state: bool | None = None + for item in class_node.body: + match item: + case nodes.AnnAssign(target=nodes.AssignName(name=name), value=value) if ( + name == attr_name and value is not None + ): + state = _is_non_none_value(value) + case nodes.Assign(targets=targets, value=value) if any( + isinstance(t, nodes.AssignName) and t.name == attr_name for t in targets + ): + state = _is_non_none_value(value) + return state + + +def _method_sets_self_attr(class_node: nodes.ClassDef, attr_name: str) -> bool: + """Return True if any method assigns ``self. = ``. + + The assignment need not be unconditional: any assignment means the class + reports the attribute and must therefore also report the paired value. + Assignments in nested functions or classes (which have their own + ``self``) are ignored — only the method's own scope counts. + """ + for method in class_node.body: + if not isinstance(method, nodes.FunctionDef | nodes.AsyncFunctionDef): + continue + for stmt in method.nodes_of_class((nodes.Assign, nodes.AnnAssign)): + if stmt.scope() is not method: + continue + match stmt: + case nodes.Assign(targets=targets, value=value): + target_list = list(targets) + case nodes.AnnAssign(target=target, value=value): + target_list = [target] + case _: + continue + if _is_non_none_value(value) and any( + _is_self_attr_target(t, attr_name) for t in target_list + ): + return True + return False + + +def _class_defines_method(class_node: nodes.ClassDef, method_name: str) -> bool: + """Return True if the class body overrides *method_name* (property/method).""" + return any( + isinstance(item, nodes.FunctionDef | nodes.AsyncFunctionDef) + and item.name == method_name + for item in class_node.body + ) + + +def _class_declaration( + class_node: nodes.ClassDef, attr_name: str, property_name: str +) -> bool | None: + """Return this class's *effective* declaration for the attr/property. + + ``True`` if the class provides a value, ``False`` if it nullifies an + inherited value (class-body ``_attr_... = None``), or ``None`` if the + class does not declare the pair at all. ``LightEntity`` declares the + ``None`` defaults, so it resolves to ``False`` — reaching it means no + subclass provided a value. + + Precedence within the class follows runtime resolution: a + ``property``/method override or a non-``None`` ``self._attr_...`` + assignment wins over a class-body ``_attr_... = None``. + """ + if class_node.qname() == LIGHT_ENTITY_QNAME: + return False + if _class_defines_method(class_node, property_name): + return True + if _method_sets_self_attr(class_node, attr_name): + return True + return _class_body_attr_state(class_node, attr_name) + + +def _mro(class_node: nodes.ClassDef) -> list[nodes.ClassDef]: + """Return the class's MRO, falling back to a DFS ancestor walk.""" + try: + return class_node.mro() # type: ignore[no-any-return] + except astroid.exceptions.MroError: + return [class_node, *extended_ancestors(class_node)] + + +def _provides_effective( + class_node: nodes.ClassDef, attr_name: str, property_name: str +) -> bool: + """Return True if the effective value for *class_node* is provided. + + Walks the MRO most-derived first and returns the first class that + declares the pair, so a subclass ``_attr_... = None`` shadows a + non-``None`` value set by an ancestor. + """ + for klass in _mro(class_node): + decl = _class_declaration(klass, attr_name, property_name) + if decl is not None: + return decl + return False + + +class HassLightColorModeChecker(BaseChecker): + """Flag light entities that report only one of the color-mode attributes.""" + + name = "home_assistant_light_color_mode" + priority = -1 + msgs = { + "W7436": ( + ( + "Light entity class `%s` reports supported color modes but " + "does not report a color mode; set `_attr_color_mode` or " + "override the `color_mode` property" + ), + "home-assistant-light-missing-color-mode", + ( + "Used when a LightEntity subclass provides " + "supported_color_modes (via _attr_supported_color_modes or a " + "supported_color_modes override) but neither sets " + "_attr_color_mode nor overrides the color_mode property. Such " + "a light raises HomeAssistantError at runtime because it does " + "not report a color mode when turned on." + ), + ), + "W7437": ( + ( + "Light entity class `%s` reports a color mode but does not " + "report supported color modes; set " + "`_attr_supported_color_modes` or override the " + "`supported_color_modes` property" + ), + "home-assistant-light-missing-supported-color-modes", + ( + "Used when a LightEntity subclass provides color_mode (via " + "_attr_color_mode or a color_mode override) but neither sets " + "_attr_supported_color_modes nor overrides the " + "supported_color_modes property. Such a light raises " + "HomeAssistantError at runtime because it does not set " + "supported color modes." + ), + ), + } + options = () + + _check_module: bool + _subclassed_qnames: set[str] + + def visit_module(self, node: nodes.Module) -> None: + """Cache per-module state.""" + self._check_module = is_integration_module(node.name) + self._subclassed_qnames = ( + collect_same_module_ancestor_qnames(node) if self._check_module else set() + ) + + def visit_classdef(self, node: nodes.ClassDef) -> None: + """Flag light entities reporting only one of the color-mode attributes.""" + if not self._check_module: + return + # Skip mixin / abstract bases: another class in the same module + # inherits from this one, so this class is not the runtime entity. + if node.qname() in self._subclassed_qnames: + return + if not inherits_from_light_entity(node): + return + provides_supported = _provides_effective( + node, _SUPPORTED_ATTR, _SUPPORTED_PROPERTY + ) + provides_color_mode = _provides_effective( + node, _COLOR_MODE_ATTR, _COLOR_MODE_PROPERTY + ) + # Only the XOR is flagged: reporting both is correct, and reporting + # neither is skipped to avoid false positives on abstract bases (a + # concrete both-missing light also raises but is not caught). + if provides_supported and not provides_color_mode: + self.add_message( + "home-assistant-light-missing-color-mode", + node=node, + args=(node.name,), + ) + elif provides_color_mode and not provides_supported: + self.add_message( + "home-assistant-light-missing-supported-color-modes", + node=node, + args=(node.name,), + ) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassLightColorModeChecker(linter)) diff --git a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py index 7f81038989441f..64e16f0ff7cb2b 100644 --- a/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py +++ b/pylint/plugins/pylint_home_assistant/generated/mdi_icons.py @@ -5,7 +5,7 @@ from typing import Final -FRONTEND_VERSION: Final[str] = "20260826.1" +FRONTEND_VERSION: Final[str] = "20260826.2" MDI_ICONS: Final[set[str]] = { "ab-testing", diff --git a/pylint/plugins/pylint_home_assistant/helpers/entity_class.py b/pylint/plugins/pylint_home_assistant/helpers/entity_class.py index c35dceefa89cbd..9e50c6f046a6c9 100644 --- a/pylint/plugins/pylint_home_assistant/helpers/entity_class.py +++ b/pylint/plugins/pylint_home_assistant/helpers/entity_class.py @@ -5,6 +5,7 @@ from .ast_utils import extended_ancestors ENTITY_QNAME = "homeassistant.helpers.entity.Entity" +LIGHT_ENTITY_QNAME = "homeassistant.components.light.LightEntity" def inherits_from_entity(class_node: nodes.ClassDef) -> bool: @@ -12,6 +13,11 @@ def inherits_from_entity(class_node: nodes.ClassDef) -> bool: return any(a.qname() == ENTITY_QNAME for a in extended_ancestors(class_node)) +def inherits_from_light_entity(class_node: nodes.ClassDef) -> bool: + """Return True if class inherits from ``LightEntity``.""" + return any(a.qname() == LIGHT_ENTITY_QNAME for a in extended_ancestors(class_node)) + + def collect_same_module_ancestor_qnames(module: nodes.Module) -> set[str]: """Return qnames of every class used as an ancestor in *module*. diff --git a/requirements_all.txt b/requirements_all.txt index de0f14bd80479a..009967e77a77a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -728,7 +728,7 @@ bring-api==1.1.2 broadlink==0.19.0 # homeassistant.components.brother -brother==6.1.1 +brother==6.1.2 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -1306,7 +1306,7 @@ hole==0.9.2 holidays==0.103 # homeassistant.components.frontend -home-assistant-frontend==20260826.1 +home-assistant-frontend==20260826.2 # homeassistant.components.conversation home-assistant-intents==2026.8.28 diff --git a/tests/components/airly/test_config_flow.py b/tests/components/airly/test_config_flow.py index 6da67ebf321f2e..756858ca168d67 100644 --- a/tests/components/airly/test_config_flow.py +++ b/tests/components/airly/test_config_flow.py @@ -1,8 +1,11 @@ """Define tests for the Airly config flow.""" +from collections.abc import Generator from http import HTTPStatus +from unittest.mock import AsyncMock from airly.exceptions import AirlyError +import pytest from homeassistant.components.airly.const import CONF_USE_NEAREST, DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -22,14 +25,13 @@ } -async def test_show_form(hass: HomeAssistant) -> None: - """Test that the form is served with no input.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airly.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry async def test_invalid_api_key( @@ -56,6 +58,22 @@ async def test_invalid_api_key( assert result["errors"] == {"base": "invalid_api_key"} + aioclient_mock.clear_requests() + aioclient_mock.get( + API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"][CONF_LATITUDE] == CONFIG[CONF_LATITUDE] + assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE] + assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] + assert result["data"][CONF_USE_NEAREST] is False + async def test_invalid_location( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -83,6 +101,22 @@ async def test_invalid_location( assert result["errors"] == {"base": "wrong_location"} + aioclient_mock.clear_requests() + aioclient_mock.get( + API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"][CONF_LATITUDE] == CONFIG[CONF_LATITUDE] + assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE] + assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] + assert result["data"][CONF_USE_NEAREST] is False + async def test_invalid_location_for_point_and_nearest( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -97,17 +131,16 @@ async def test_invalid_location_for_point_and_nearest( API_NEAREST_URL, text=await async_load_fixture(hass, "no_station.json", DOMAIN) ) - with patch("homeassistant.components.airly.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=CONFIG - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "wrong_location" @@ -145,17 +178,16 @@ async def test_create_entry( API_POINT_URL, text=await async_load_fixture(hass, "valid_station.json", DOMAIN) ) - with patch("homeassistant.components.airly.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=CONFIG - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == DEFAULT_NAME @@ -179,17 +211,16 @@ async def test_create_entry_with_nearest_method( text=await async_load_fixture(hass, "valid_station.json", DOMAIN), ) - with patch("homeassistant.components.airly.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=CONFIG - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONFIG + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == DEFAULT_NAME diff --git a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr index b919d0f61957f7..339695428c0589 100644 --- a/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr +++ b/tests/components/kitchen_sink/snapshots/test_device_tracker.ambr @@ -38,6 +38,10 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : list([ + 'device_tracker.demo_scanner', + 'device_tracker.demo_tracker', + ]), : True, : 'test home', : 'mdi:home', diff --git a/tests/components/lg_infrared/snapshots/test_select.ambr b/tests/components/lg_infrared/snapshots/test_select.ambr new file mode 100644 index 00000000000000..26cfd7bb47768f --- /dev/null +++ b/tests/components/lg_infrared/snapshots/test_select.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# name: test_entities[select.lg_ac_energy_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + '40', + '60', + '80', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.lg_ac_energy_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Energy limit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Energy limit', + 'platform': 'lg_infrared', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_limit', + 'unique_id': '01JTEST0000000000000000000_energy_limit', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[select.lg_ac_energy_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : 'LG AC Energy limit', + : list([ + 'off', + '40', + '60', + '80', + ]), + }), + 'context': , + 'entity_id': 'select.lg_ac_energy_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lg_infrared/test_select.py b/tests/components/lg_infrared/test_select.py new file mode 100644 index 00000000000000..087e8690d0a80c --- /dev/null +++ b/tests/components/lg_infrared/test_select.py @@ -0,0 +1,121 @@ +"""Tests for the LG Infrared select platform.""" + +from unittest.mock import patch + +from infrared_protocols.codes.lg.ac import LGACCode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lg_infrared.const import LGDeviceType +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, mock_restore_cache, snapshot_platform +from tests.components.common import assert_availability_follows_source_entity +from tests.components.infrared import EMITTER_ENTITY_ID +from tests.components.infrared.common import MockInfraredEmitterEntity + +_ENTITY_ID = "select.lg_ac_energy_limit" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Return platforms to set up.""" + return [Platform.SELECT] + + +@pytest.fixture +def device_type() -> LGDeviceType: + """Return the device type of the config entry.""" + return LGDeviceType.AC + + +@pytest.fixture +def has_receiver() -> bool: + """Return whether the config entry has an infrared receiver configured.""" + return False + + +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the select entity is created with correct attributes.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("previous_option", "option", "expected_button"), + [ + pytest.param("40", "off", LGACCode.ENERGY_LIMIT_OFF, id="off"), + pytest.param("off", "40", LGACCode.ENERGY_LIMIT_40, id="40"), + pytest.param("off", "60", LGACCode.ENERGY_LIMIT_60, id="60"), + pytest.param("off", "80", LGACCode.ENERGY_LIMIT_80, id="80"), + ], +) +async def test_select_option_sends_correct_code( + hass: HomeAssistant, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + previous_option: str, + option: str, + expected_button: LGACCode, +) -> None: + """Test selecting an energy cap sends the matching IR code.""" + # Start from a different cap, so every case has to change the state rather than + # land on the one it started in. + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _ENTITY_ID, ATTR_OPTION: previous_option}, + blocking=True, + ) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _ENTITY_ID, ATTR_OPTION: option}, + blocking=True, + ) + + assert len(mock_infrared_emitter_entity.send_command_calls) == 2 + timings = mock_infrared_emitter_entity.send_command_calls[1].get_raw_timings() + assert timings == expected_button.to_command().get_raw_timings() + + state = hass.states.get(_ENTITY_ID) + assert state is not None + assert state.state == option + + +async def test_state_restored_on_restart( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_infrared_emitter_entity: MockInfraredEmitterEntity, + platforms: list[Platform], +) -> None: + """Test the assumed selection is restored after a restart.""" + mock_restore_cache(hass, [State(_ENTITY_ID, "60")]) + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.lg_infrared.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(_ENTITY_ID) + assert state is not None + assert state.state == "60" + + +@pytest.mark.usefixtures("init_integration") +async def test_availability_follows_emitter(hass: HomeAssistant) -> None: + """Test select availability follows the infrared emitter.""" + await assert_availability_follows_source_entity(hass, _ENTITY_ID, EMITTER_ENTITY_ID) diff --git a/tests/components/person/test_recorder.py b/tests/components/person/test_recorder.py index fe7a3894a05aaa..cdec7253071868 100644 --- a/tests/components/person/test_recorder.py +++ b/tests/components/person/test_recorder.py @@ -38,7 +38,7 @@ async def test_exclude_attributes( await async_wait_recording_done(hass) states = await hass.async_add_executor_job( - get_significant_states, hass, now, None, hass.states.async_entity_ids() + get_significant_states, hass, now, None, hass.states.async_entity_ids(DOMAIN) ) assert len(states) >= 1 for entity_states in states.values(): diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 8dc9652e088fc8..b734450451971f 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -420,9 +420,6 @@ async def test_purge_old_statistics_runs( purge_before = dt_util.utcnow() # run purge_old_data() - finished = purge_old_data(recorder_mock, purge_before, repack=False) - assert not finished - finished = purge_old_data(recorder_mock, purge_before, repack=False) assert finished @@ -431,6 +428,64 @@ async def test_purge_old_statistics_runs( assert statistics_runs.count() == 1 +async def test_purge_old_statistics_runs_full_batch( + hass: HomeAssistant, recorder_mock: Recorder +) -> None: + """Test deleting old statistics runs with a full batch needs another cycle.""" + await _add_test_statistics_runs(hass) + + # make sure we start with 7 statistics runs + with session_scope(hass=hass) as session: + statistics_runs = session.query(StatisticsRuns) + assert statistics_runs.count() == 7 + + purge_before = dt_util.utcnow() + + with ( + patch.object(recorder_mock, "max_bind_vars", 6), + patch.object(recorder_mock.database_engine, "max_bind_vars", 6), + ): + # A full batch of statistics runs means purge may not be done yet + finished = purge_old_data(recorder_mock, purge_before, repack=False) + assert not finished + + finished = purge_old_data(recorder_mock, purge_before, repack=False) + assert finished + + with session_scope(hass=hass) as session: + statistics_runs = session.query(StatisticsRuns) + assert statistics_runs.count() == 1 + + +async def test_purge_old_short_term_statistics_full_batch( + hass: HomeAssistant, recorder_mock: Recorder +) -> None: + """Test deleting old short term statistics with a full batch needs another cycle.""" + await _add_test_statistics(hass) + + # make sure we start with 6 short term statistics + with session_scope(hass=hass) as session: + statistics = session.query(StatisticsShortTerm) + assert statistics.count() == 6 + + purge_before = dt_util.utcnow() + + with ( + patch.object(recorder_mock, "max_bind_vars", 5), + patch.object(recorder_mock.database_engine, "max_bind_vars", 5), + ): + # A full batch of short term statistics means purge may not be done yet + finished = purge_old_data(recorder_mock, purge_before, repack=False) + assert not finished + + finished = purge_old_data(recorder_mock, purge_before, repack=False) + assert finished + + with session_scope(hass=hass) as session: + statistics = session.query(StatisticsShortTerm) + assert statistics.count() == 0 + + @pytest.mark.parametrize("use_sqlite", [True, False], indirect=True) @pytest.mark.usefixtures("recorder_mock") async def test_purge_method( diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 68abef88714d7c..484ca80e6739c6 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -362,9 +362,6 @@ async def test_purge_old_statistics_runs( purge_before = dt_util.utcnow() # run purge_old_data() - finished = purge_old_data(recorder_mock, purge_before, repack=False) - assert not finished - finished = purge_old_data(recorder_mock, purge_before, repack=False) assert finished diff --git a/tests/components/zone/test_init.py b/tests/components/zone/test_init.py index 7df44e4d60ef77..e5b7ce468499a3 100644 --- a/tests/components/zone/test_init.py +++ b/tests/components/zone/test_init.py @@ -8,6 +8,7 @@ from homeassistant import setup from homeassistant.components import device_tracker, zone from homeassistant.components.zone import ATTR_RADIUS, DOMAIN +from homeassistant.components.zone.const import ZoneEntityStateAttribute from homeassistant.const import ( ATTR_EDITABLE, ATTR_FRIENDLY_NAME, @@ -15,7 +16,6 @@ ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_NAME, - ATTR_PERSONS, SERVICE_RELOAD, ) from homeassistant.core import Context, HomeAssistant @@ -841,8 +841,46 @@ async def test_async_get_enclosing_zones_skips_unavailable_other( assert zone.async_get_enclosing_zones(hass, "zone.inner") == [] -async def test_state(hass: HomeAssistant) -> None: - """Test the state of a zone.""" +@pytest.mark.parametrize( + ( + "tracked_domain", + "tracked_attr", + "untracked_attr", + "test_zone_states", + "home_states", + ), + [ + pytest.param( + "person", + ZoneEntityStateAttribute.PERSONS, + ZoneEntityStateAttribute.DEVICE_TRACKERS, + ["0", "1", "2", "1", "2", "1", "0"], + ["0", "0", "0", "1", "1", "0", "0"], + id="person", + ), + pytest.param( + "device_tracker", + ZoneEntityStateAttribute.DEVICE_TRACKERS, + ZoneEntityStateAttribute.PERSONS, + # Device trackers are not counted towards the zone state. + ["0", "0", "0", "0", "0", "0", "0"], + ["0", "0", "0", "0", "0", "0", "0"], + id="device_tracker", + ), + ], +) +async def test_state( + hass: HomeAssistant, + tracked_domain: str, + tracked_attr: ZoneEntityStateAttribute, + untracked_attr: ZoneEntityStateAttribute, + test_zone_states: list[str], + home_states: list[str], +) -> None: + """Test the state and the persons / device_trackers attributes of a zone.""" + one = f"{tracked_domain}.one" + two = f"{tracked_domain}.two" + info = { "name": "Test Zone", "latitude": 32.880837, @@ -851,113 +889,126 @@ async def test_state(hass: HomeAssistant) -> None: "passive": False, } assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info}) - assert len(hass.states.async_entity_ids(DOMAIN)) == 2 - state = hass.states.get("zone.test_zone") - assert state.state == "0" - assert state.attributes[ATTR_PERSONS] == [] - # Person entity enters zone + def assert_zone( + entity_id: str, expected_state: str, expected_tracked: list[str] + ) -> None: + state = hass.states.get(entity_id) + assert state + assert state.state == expected_state + assert sorted(state.attributes[tracked_attr]) == expected_tracked + # The other collection must stay empty, they are tracked independently. + assert state.attributes[untracked_attr] == [] + + assert_zone("zone.test_zone", test_zone_states[0], []) + assert_zone("zone.home", home_states[0], []) + + # Entity enters the zone hass.states.async_set( - "person.person1", - "Test Zone", - {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]}, + one, "Test Zone", {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]} ) await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[1], [one]) + assert_zone("zone.home", home_states[1], []) - state = hass.states.get("zone.test_zone") - assert state - assert state.state == "1" - assert state.attributes[ATTR_PERSONS] == ["person.person1"] - - state = hass.states.get("zone.home") - assert state - assert state.state == "0" - assert state.attributes[ATTR_PERSONS] == [] - - # Person entity enters zone (case insensitive) + # A second entity enters the zone (case insensitive state) hass.states.async_set( - "person.person2", - "TEST zone", - {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]}, + two, "TEST zone", {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]} ) await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[2], sorted([one, two])) + assert_zone("zone.home", home_states[2], []) - state = hass.states.get("zone.test_zone") - assert state - assert state.state == "2" - assert sorted(state.attributes[ATTR_PERSONS]) == [ - "person.person1", - "person.person2", - ] - - state = hass.states.get("zone.home") - assert state - assert state.state == "0" - assert state.attributes[ATTR_PERSONS] == [] + # The first entity moves to another zone + hass.states.async_set(one, "home", {device_tracker.ATTR_IN_ZONES: ["zone.home"]}) + await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[3], [two]) + assert_zone("zone.home", home_states[3], [one]) - # Person entity enters another zone + # The first entity is in two zones hass.states.async_set( - "person.person1", - "home", - {device_tracker.ATTR_IN_ZONES: ["zone.home"]}, + one, "home", {device_tracker.ATTR_IN_ZONES: ["zone.home", "zone.test_zone"]} ) await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[4], sorted([one, two])) + assert_zone("zone.home", home_states[4], [one]) - state = hass.states.get("zone.test_zone") - assert state - assert state.state == "1" - assert state.attributes[ATTR_PERSONS] == ["person.person2"] + # The first entity enters not_home + hass.states.async_set(one, "not_home", {device_tracker.ATTR_IN_ZONES: []}) + await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[5], [two]) + assert_zone("zone.home", home_states[5], []) - state = hass.states.get("zone.home") - assert state - assert state.state == "1" - assert state.attributes[ATTR_PERSONS] == ["person.person1"] + # The second entity is removed + hass.states.async_remove(two) + await hass.async_block_till_done() + assert_zone("zone.test_zone", test_zone_states[6], []) + assert_zone("zone.home", home_states[6], []) + + +async def test_persons_and_device_trackers_tracked_independently( + hass: HomeAssistant, +) -> None: + """Test persons and device trackers in the same zone don't interfere.""" + info = { + "name": "Test Zone", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + "passive": False, + } + assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info}) - # Person entity is in two zones hass.states.async_set( "person.person1", - "home", - {device_tracker.ATTR_IN_ZONES: ["zone.home", "zone.test_zone"]}, + "Test Zone", + {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]}, + ) + hass.states.async_set( + "device_tracker.tracker1", + "Test Zone", + {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]}, ) await hass.async_block_till_done() state = hass.states.get("zone.test_zone") assert state - assert state.state == "2" - assert sorted(state.attributes[ATTR_PERSONS]) == [ - "person.person1", - "person.person2", + # Only persons are counted towards the state. + assert state.state == "1" + assert state.attributes[ZoneEntityStateAttribute.PERSONS] == ["person.person1"] + assert state.attributes[ZoneEntityStateAttribute.DEVICE_TRACKERS] == [ + "device_tracker.tracker1" ] - state = hass.states.get("zone.home") - assert state - assert state.state == "1" - assert state.attributes[ATTR_PERSONS] == ["person.person1"] - # Person entity enters not_home +async def test_trackers_present_before_zone_setup(hass: HomeAssistant) -> None: + """Test persons and device trackers already in a zone at setup are picked up.""" hass.states.async_set( "person.person1", - "not_home", - {device_tracker.ATTR_IN_ZONES: []}, + "Test Zone", + {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]}, + ) + hass.states.async_set( + "device_tracker.tracker1", + "Test Zone", + {device_tracker.ATTR_IN_ZONES: ["zone.test_zone"]}, ) - await hass.async_block_till_done() - - state = hass.states.get("zone.test_zone") - assert state - assert state.state == "1" - assert state.attributes[ATTR_PERSONS] == ["person.person2"] - # Person entity removed - hass.states.async_remove("person.person2") + info = { + "name": "Test Zone", + "latitude": 32.880837, + "longitude": -117.237561, + "radius": 250, + "passive": False, + } + assert await setup.async_setup_component(hass, zone.DOMAIN, {"zone": info}) await hass.async_block_till_done() state = hass.states.get("zone.test_zone") assert state - assert state.state == "0" - assert state.attributes[ATTR_PERSONS] == [] - - state = hass.states.get("zone.home") - assert state - assert state.state == "0" - assert state.attributes[ATTR_PERSONS] == [] + assert state.state == "1" + assert state.attributes[ZoneEntityStateAttribute.PERSONS] == ["person.person1"] + assert state.attributes[ZoneEntityStateAttribute.DEVICE_TRACKERS] == [ + "device_tracker.tracker1" + ] diff --git a/tests/components/zonneplan/test_config_flow.py b/tests/components/zonneplan/test_config_flow.py index 78ae6e3ec0a4f2..c9a3716d3f845c 100644 --- a/tests/components/zonneplan/test_config_flow.py +++ b/tests/components/zonneplan/test_config_flow.py @@ -288,3 +288,113 @@ async def test_reauth_wrong_account( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unique_id_mismatch" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zonneplan_client: AsyncMock, +) -> None: + """Test the reconfigure flow refreshes the stored token.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + assert result["description_placeholders"] == {CONF_EMAIL: MOCK_EMAIL} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + mock_zonneplan_client.async_request_otp.assert_called_once() + + new_token = Token( + access_token="new-access-token", + refresh_token="new-refresh-token", + expires_at=datetime(2031, 1, 1, tzinfo=UTC), + ) + mock_zonneplan_client.async_submit_otp.return_value = new_token + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_EMAIL] == MOCK_EMAIL + assert mock_config_entry.data[CONF_TOKEN] == new_token.as_dict() + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + pytest.param(ZonneplanConnectionError("offline"), "cannot_connect"), + pytest.param(ZonneplanTimeoutError("timed out"), "timeout_connect"), + pytest.param(Exception("unexpected"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_confirm_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zonneplan_client: AsyncMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle all reconfigure confirm step exceptions.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + mock_zonneplan_client.async_request_otp.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + assert result["errors"] == {"base": reason} + + mock_zonneplan_client.async_request_otp.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "otp" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_wrong_account( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_zonneplan_client: AsyncMock, +) -> None: + """Test reconfiguring with a different account is aborted.""" + mock_config_entry.add_to_hass(hass) + mock_zonneplan_client.async_get_account.return_value = MOCK_OTHER_ACCOUNT + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"otp": "123456"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 649ca107c43954..b03dcc0a487c7f 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -90,6 +90,7 @@ async def test_user_no_devices_found( assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "no_devices_found" + assert result["translation_domain"] == HOMEASSISTANT_DOMAIN async def test_user_has_confirmation( diff --git a/tests/pylint/test_light_color_mode.py b/tests/pylint/test_light_color_mode.py new file mode 100644 index 00000000000000..1bd96b039625b5 --- /dev/null +++ b/tests/pylint/test_light_color_mode.py @@ -0,0 +1,451 @@ +"""Tests for the light_color_mode pylint checker.""" + +import json +from pathlib import Path + +import astroid +from astroid import nodes +from pylint.testutils import MessageTest, UnittestLinter +from pylint_home_assistant.checkers.light_color_mode import HassLightColorModeChecker +import pytest + +from . import assert_adds_messages, assert_no_messages, walk_checker + +_MISSING_COLOR_MODE = "home-assistant-light-missing-color-mode" +_MISSING_SUPPORTED = "home-assistant-light-missing-supported-color-modes" + + +@pytest.fixture(name="checker") +def checker_fixture(linter: UnittestLinter) -> HassLightColorModeChecker: + """Fixture to provide the W7436 + W7437 checker.""" + return HassLightColorModeChecker(linter) + + +def _make_integration(tmp_path: Path, *, domain: str = "test_integration") -> Path: + """Create a fake integration directory under components/.""" + integration_dir = tmp_path / "homeassistant" / "components" / "test_integration" + integration_dir.mkdir(parents=True) + (integration_dir / "manifest.json").write_text(json.dumps({"domain": domain})) + return integration_dir + + +def _parse( + code: str, + integration_dir: Path, + module_name: str = "homeassistant.components.test_integration.light", + file_name: str = "light.py", +) -> nodes.Module: + """Parse code as a module of the integration with .file set.""" + root_node = astroid.parse(code, module_name) + root_node.file = str(integration_dir / file_name) + return root_node + + +def _find_class(root_node: nodes.Module, name: str) -> nodes.ClassDef: + """Return the ClassDef named *name*.""" + for class_node in root_node.nodes_of_class(nodes.ClassDef): + if class_node.name == name: + return class_node + raise AssertionError(f"no class named {name} found") + + +def _expect(class_node: nodes.ClassDef, msg_id: str) -> MessageTest: + """Build the expected MessageTest for a flagged class.""" + pos = class_node.position + return MessageTest( + msg_id=msg_id, + node=class_node, + line=pos.lineno, + col_offset=pos.col_offset, + end_line=pos.end_lineno, + end_col_offset=pos.end_col_offset, + args=(class_node.name,), + ) + + +@pytest.mark.parametrize( + ("code", "class_name"), + [ + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.ONOFF} +""", + "MyLight", + id="class_body_supported_no_color_mode", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + def __init__(self, status) -> None: + modes = {ColorMode.HS} + self._attr_supported_color_modes = modes +""", + "MyLight", + id="self_assign_supported_no_color_mode", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + @property + def supported_color_modes(self): + return {ColorMode.HS} +""", + "MyLight", + id="supported_property_override_no_color_mode", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + _attr_color_mode = None +""", + "MyLight", + id="color_mode_explicitly_none", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + _attr_color_mode = ColorMode.HS + _attr_color_mode = None +""", + "MyLight", + id="color_mode_reassigned_none_wins", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + + def _factory(self): + class _Inner: + def run(self): + self._attr_color_mode = ColorMode.HS + return _Inner +""", + "MyLight", + id="color_mode_only_in_nested_class_scope", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + +class MyLight(MyBaseLight): + pass +""", + "MyLight", + id="supported_inherited_color_mode_missing", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + _attr_color_mode = ColorMode.HS + +class MyLight(MyBaseLight): + _attr_color_mode = None +""", + "MyLight", + id="subclass_nullifies_inherited_color_mode", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + pass + +class MyColorLight(MyBaseLight): + def __init__(self, status) -> None: + self._attr_supported_color_modes = {ColorMode.HS} + + @property + def color_mode(self): + return ColorMode.HS + +class MySwitchLight(MyBaseLight): + def __init__(self, status) -> None: + self._attr_supported_color_modes = {ColorMode.ONOFF} +""", + "MySwitchLight", + id="two_subclasses_only_offender", + ), + ], +) +def test_fires_w7436( + linter: UnittestLinter, + checker: HassLightColorModeChecker, + tmp_path: Path, + code: str, + class_name: str, +) -> None: + """W7436 fires when supported color modes are set but no color mode is reported.""" + integration_dir = _make_integration(tmp_path) + + root_node = _parse(code, integration_dir) + class_node = _find_class(root_node, class_name) + with assert_adds_messages(linter, _expect(class_node, _MISSING_COLOR_MODE)): + walk_checker(linter, checker, root_node) + + +@pytest.mark.parametrize( + ("code", "class_name"), + [ + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_color_mode = ColorMode.ONOFF +""", + "MyLight", + id="class_body_color_mode_no_supported", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + def __init__(self, status) -> None: + self._attr_color_mode = ColorMode.HS +""", + "MyLight", + id="self_assign_color_mode_no_supported", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + @property + def color_mode(self): + return ColorMode.HS +""", + "MyLight", + id="color_mode_property_override_no_supported", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_color_mode = ColorMode.HS + +class MyLight(MyBaseLight): + pass +""", + "MyLight", + id="color_mode_inherited_supported_missing", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + _attr_color_mode = ColorMode.HS + +class MyLight(MyBaseLight): + _attr_supported_color_modes = None +""", + "MyLight", + id="subclass_nullifies_inherited_supported", + ), + ], +) +def test_fires_w7437( + linter: UnittestLinter, + checker: HassLightColorModeChecker, + tmp_path: Path, + code: str, + class_name: str, +) -> None: + """W7437 fires when a color mode is reported but no supported modes are set.""" + integration_dir = _make_integration(tmp_path) + + root_node = _parse(code, integration_dir) + class_node = _find_class(root_node, class_name) + with assert_adds_messages(linter, _expect(class_node, _MISSING_SUPPORTED)): + walk_checker(linter, checker, root_node) + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_color_mode = ColorMode.ONOFF +""", + id="both_class_attrs", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + + @property + def color_mode(self): + return ColorMode.HS +""", + id="supported_attr_and_color_mode_property", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + def __init__(self, status) -> None: + self._attr_supported_color_modes = {ColorMode.HS} + self._attr_color_mode = ColorMode.HS +""", + id="both_self_assigned", + ), + pytest.param( + """ +from homeassistant.components.light import LightEntity + +class MyLight(LightEntity): + @property + def is_on(self) -> bool: + return True +""", + id="neither_set", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes: set[ColorMode] | None = None +""", + id="supported_explicitly_none", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_color_mode = ColorMode.HS + +class MyLight(MyBaseLight): + _attr_supported_color_modes = {ColorMode.HS} +""", + id="color_mode_from_base_supported_from_subclass", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + +class MyLight(MyBaseLight): + _attr_color_mode = ColorMode.HS +""", + id="supported_from_base_color_mode_from_subclass", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyBaseLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} + _attr_color_mode = ColorMode.HS + +class MyLight(MyBaseLight): + _attr_color_mode = None + + def __init__(self) -> None: + self._attr_color_mode = ColorMode.HS +""", + id="subclass_reassigns_inherited_color_mode_via_init", + ), + ], +) +def test_good( + linter: UnittestLinter, + checker: HassLightColorModeChecker, + tmp_path: Path, + code: str, +) -> None: + """No message when a light reports both halves, or neither. + + Covers the both-reported cases (directly or via inheritance, including a + runtime ``self._attr_...`` assignment that wins over a class-body + ``None``) and the both-missing case (legacy/abstract, deliberately not + flagged). + """ + integration_dir = _make_integration(tmp_path) + + root_node = _parse(code, integration_dir) + with assert_no_messages(linter): + walk_checker(linter, checker, root_node) + + +@pytest.mark.parametrize( + ("code", "module_name", "file_name"), + [ + pytest.param( + """ +from homeassistant.components.light import ColorMode + +class NotALight: + _attr_supported_color_modes = {ColorMode.HS} +""", + "homeassistant.components.test_integration.light", + "light.py", + id="non_light_entity_class", + ), + pytest.param( + """ +from homeassistant.components.light import ColorMode, LightEntity + +class MyLight(LightEntity): + _attr_supported_color_modes = {ColorMode.HS} +""", + "not_homeassistant.something.light", + "light.py", + id="module_outside_integration", + ), + ], +) +def test_out_of_scope_ignored( + linter: UnittestLinter, + checker: HassLightColorModeChecker, + tmp_path: Path, + code: str, + module_name: str, + file_name: str, +) -> None: + """W7436 doesn't fire for classes/modules outside the rule's scope.""" + integration_dir = _make_integration(tmp_path) + + root_node = _parse( + code, integration_dir, module_name=module_name, file_name=file_name + ) + with assert_no_messages(linter): + walk_checker(linter, checker, root_node)