diff --git a/CODEOWNERS b/CODEOWNERS index bc3fd1b495f6f1..80ab4744d5229c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -442,8 +442,6 @@ build.json @home-assistant/supervisor /tests/components/energyzero/ @klaasnicolaas /homeassistant/components/enigma2/ @autinerd /tests/components/enigma2/ @autinerd -/homeassistant/components/enocean/ @bdurrer -/tests/components/enocean/ @bdurrer /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /homeassistant/components/entur_public_transport/ @hfurubotten diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py index 7593365c573f49..210993b22037ab 100644 --- a/homeassistant/components/aosmith/__init__.py +++ b/homeassistant/components/aosmith/__init__.py @@ -16,7 +16,7 @@ AOSmithStatusCoordinator, ) -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER] +PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR, Platform.WATER_HEATER] async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> bool: diff --git a/homeassistant/components/aosmith/icons.json b/homeassistant/components/aosmith/icons.json index e31a68464cec7d..a7dcfc4adc9259 100644 --- a/homeassistant/components/aosmith/icons.json +++ b/homeassistant/components/aosmith/icons.json @@ -1,5 +1,10 @@ { "entity": { + "select": { + "hot_water_plus_level": { + "default": "mdi:water-plus" + } + }, "sensor": { "hot_water_availability": { "default": "mdi:water-thermometer" diff --git a/homeassistant/components/aosmith/select.py b/homeassistant/components/aosmith/select.py new file mode 100644 index 00000000000000..e85bd8b702a52c --- /dev/null +++ b/homeassistant/components/aosmith/select.py @@ -0,0 +1,70 @@ +"""The select platform for the A. O. Smith integration.""" + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AOSmithConfigEntry +from .coordinator import AOSmithStatusCoordinator +from .entity import AOSmithStatusEntity + +HWP_LEVEL_HA_TO_AOSMITH = { + "off": 0, + "level1": 1, + "level2": 2, + "level3": 3, +} +HWP_LEVEL_AOSMITH_TO_HA = {value: key for key, value in HWP_LEVEL_HA_TO_AOSMITH.items()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AOSmithConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up A. O. Smith select platform.""" + data = entry.runtime_data + + async_add_entities( + AOSmithHotWaterPlusSelectEntity(data.status_coordinator, device.junction_id) + for device in data.status_coordinator.data.values() + if device.supports_hot_water_plus + ) + + +class AOSmithHotWaterPlusSelectEntity(AOSmithStatusEntity, SelectEntity): + """Class for the Hot Water+ select entity.""" + + _attr_translation_key = "hot_water_plus_level" + _attr_options = list(HWP_LEVEL_HA_TO_AOSMITH) + + def __init__(self, coordinator: AOSmithStatusCoordinator, junction_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self._attr_unique_id = f"hot_water_plus_level_{junction_id}" + + @property + def suggested_object_id(self) -> str | None: + """Override the suggested object id to make '+' get converted to 'plus' in the entity id.""" + return "hot_water_plus_level" + + @property + def current_option(self) -> str | None: + """Return the current Hot Water+ mode.""" + hot_water_plus_level = self.device.status.hot_water_plus_level + return ( + None + if hot_water_plus_level is None + else HWP_LEVEL_AOSMITH_TO_HA.get(hot_water_plus_level) + ) + + async def async_select_option(self, option: str) -> None: + """Set the Hot Water+ mode.""" + aosmith_hwp_level = HWP_LEVEL_HA_TO_AOSMITH[option] + await self.client.update_mode( + junction_id=self.junction_id, + mode=self.device.status.current_mode, + hot_water_plus_level=aosmith_hwp_level, + ) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json index c88b9cab783e2c..fa2d5a67020248 100644 --- a/homeassistant/components/aosmith/strings.json +++ b/homeassistant/components/aosmith/strings.json @@ -26,6 +26,17 @@ } }, "entity": { + "select": { + "hot_water_plus_level": { + "name": "Hot Water+ level", + "state": { + "off": "[%key:common::state::off%]", + "level1": "Level 1", + "level2": "Level 2", + "level3": "Level 3" + } + } + }, "sensor": { "hot_water_availability": { "name": "Hot water availability" diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index a590f30fc7a11f..11f06b77ef5ebb 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import replace from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory, Platform @@ -64,15 +65,36 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity): translation_key="pipeline", entity_category=EntityCategory.CONFIG, ) + _attr_should_poll = False _attr_current_option = OPTION_PREFERRED _attr_options = [OPTION_PREFERRED] - def __init__(self, hass: HomeAssistant, domain: str, unique_id_prefix: str) -> None: + def __init__( + self, + hass: HomeAssistant, + domain: str, + unique_id_prefix: str, + index: int = 0, + ) -> None: """Initialize a pipeline selector.""" + if index < 1: + # Keep compatibility + key_suffix = "" + placeholder = "" + else: + key_suffix = f"_{index + 1}" + placeholder = f" {index + 1}" + + self.entity_description = replace( + self.entity_description, + key=f"pipeline{key_suffix}", + translation_placeholders={"index": placeholder}, + ) + self._domain = domain self._unique_id_prefix = unique_id_prefix - self._attr_unique_id = f"{unique_id_prefix}-pipeline" + self._attr_unique_id = f"{unique_id_prefix}-{self.entity_description.key}" self.hass = hass self._update_options() diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json index 804d43c3a0a579..abcd6cbd21e4b0 100644 --- a/homeassistant/components/assist_pipeline/strings.json +++ b/homeassistant/components/assist_pipeline/strings.json @@ -7,7 +7,7 @@ }, "select": { "pipeline": { - "name": "Assistant", + "name": "Assistant{index}", "state": { "preferred": "Preferred" } diff --git a/homeassistant/components/enocean/manifest.json b/homeassistant/components/enocean/manifest.json index 2faba47e1264db..bd79d591f6bc75 100644 --- a/homeassistant/components/enocean/manifest.json +++ b/homeassistant/components/enocean/manifest.json @@ -1,7 +1,7 @@ { "domain": "enocean", "name": "EnOcean", - "codeowners": ["@bdurrer"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/enocean", "iot_class": "local_push", diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index adddacd3998285..aa565fa610744d 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -127,27 +127,39 @@ def __init__(self, entry: ESPHomeConfigEntry) -> None: available_wake_words=[], active_wake_words=[], max_active_wake_words=1 ) - @property - def pipeline_entity_id(self) -> str | None: - """Return the entity ID of the pipeline to use for the next conversation.""" - assert self._entry_data.device_info is not None + self._active_pipeline_index = 0 + + def _get_entity_id(self, suffix: str) -> str | None: + """Return the entity id for pipeline select, etc.""" + if self._entry_data.device_info is None: + return None + ent_reg = er.async_get(self.hass) return ent_reg.async_get_entity_id( Platform.SELECT, DOMAIN, - f"{self._entry_data.device_info.mac_address}-pipeline", + f"{self._entry_data.device_info.mac_address}-{suffix}", ) + @property + def pipeline_entity_id(self) -> str | None: + """Return the entity ID of the primary pipeline to use for the next conversation.""" + return self.get_pipeline_entity(self._active_pipeline_index) + + def get_pipeline_entity(self, index: int) -> str | None: + """Return the entity ID of a pipeline by index.""" + id_suffix = "" if index < 1 else f"_{index + 1}" + return self._get_entity_id(f"pipeline{id_suffix}") + + def get_wake_word_entity(self, index: int) -> str | None: + """Return the entity ID of a wake word by index.""" + id_suffix = "" if index < 1 else f"_{index + 1}" + return self._get_entity_id(f"wake_word{id_suffix}") + @property def vad_sensitivity_entity_id(self) -> str | None: """Return the entity ID of the VAD sensitivity to use for the next conversation.""" - assert self._entry_data.device_info is not None - ent_reg = er.async_get(self.hass) - return ent_reg.async_get_entity_id( - Platform.SELECT, - DOMAIN, - f"{self._entry_data.device_info.mac_address}-vad_sensitivity", - ) + return self._get_entity_id("vad_sensitivity") @callback def async_get_configuration( @@ -235,6 +247,7 @@ async def async_added_to_hass(self) -> None: ) ) + assert self._attr_supported_features is not None if feature_flags & VoiceAssistantFeature.ANNOUNCE: # Device supports announcements self._attr_supported_features |= ( @@ -257,8 +270,8 @@ async def async_added_to_hass(self) -> None: # Update wake word select when config is updated self.async_on_remove( - self._entry_data.async_register_assist_satellite_set_wake_word_callback( - self.async_set_wake_word + self._entry_data.async_register_assist_satellite_set_wake_words_callback( + self.async_set_wake_words ) ) @@ -482,8 +495,31 @@ async def handle_pipeline_start( # ANNOUNCEMENT format from media player self._update_tts_format() - # Run the pipeline - _LOGGER.debug("Running pipeline from %s to %s", start_stage, end_stage) + # Run the appropriate pipeline. + self._active_pipeline_index = 0 + + maybe_pipeline_index = 0 + while True: + if not (ww_entity_id := self.get_wake_word_entity(maybe_pipeline_index)): + break + + if not (ww_state := self.hass.states.get(ww_entity_id)): + continue + + if ww_state.state == wake_word_phrase: + # First match + self._active_pipeline_index = maybe_pipeline_index + break + + # Try next wake word select + maybe_pipeline_index += 1 + + _LOGGER.debug( + "Running pipeline %s from %s to %s", + self._active_pipeline_index + 1, + start_stage, + end_stage, + ) self._pipeline_task = self.config_entry.async_create_background_task( self.hass, self.async_accept_pipeline_from_satellite( @@ -514,6 +550,7 @@ async def handle_pipeline_stop(self, abort: bool) -> None: def handle_pipeline_finished(self) -> None: """Handle when pipeline has finished running.""" self._stop_udp_server() + self._active_pipeline_index = 0 _LOGGER.debug("Pipeline finished") def handle_timer_event( @@ -542,15 +579,15 @@ async def handle_announcement_finished( self.tts_response_finished() @callback - def async_set_wake_word(self, wake_word_id: str) -> None: - """Set active wake word and update config on satellite.""" - self._satellite_config.active_wake_words = [wake_word_id] + def async_set_wake_words(self, wake_word_ids: list[str]) -> None: + """Set active wake words and update config on satellite.""" + self._satellite_config.active_wake_words = wake_word_ids self.config_entry.async_create_background_task( self.hass, self.async_set_configuration(self._satellite_config), "esphome_voice_assistant_set_config", ) - _LOGGER.debug("Setting active wake word: %s", wake_word_id) + _LOGGER.debug("Setting active wake word(s): %s", wake_word_ids) def _update_tts_format(self) -> None: """Update the TTS format from the first media player.""" diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 385c88d6eb9e9d..86688ebb8a623b 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -25,3 +25,5 @@ # ESPHome always uses .0 for the changelog URL STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0" DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html" + +NO_WAKE_WORD: Final[str] = "no_wake_word" diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index eddd4d523c9e10..820492661756e3 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -177,9 +177,10 @@ class RuntimeEntryData: assist_satellite_config_update_callbacks: list[ Callable[[AssistSatelliteConfiguration], None] ] = field(default_factory=list) - assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field( - default_factory=list + assist_satellite_set_wake_words_callbacks: list[Callable[[list[str]], None]] = ( + field(default_factory=list) ) + assist_satellite_wake_words: dict[int, str] = field(default_factory=dict) device_id_to_name: dict[int, str] = field(default_factory=dict) entity_removal_callbacks: dict[EntityInfoKey, list[CALLBACK_TYPE]] = field( default_factory=dict @@ -501,19 +502,28 @@ def async_assist_satellite_config_updated( callback_(config) @callback - def async_register_assist_satellite_set_wake_word_callback( + def async_register_assist_satellite_set_wake_words_callback( self, - callback_: Callable[[str], None], + callback_: Callable[[list[str]], None], ) -> CALLBACK_TYPE: """Register to receive callbacks when the Assist satellite's wake word is set.""" - self.assist_satellite_set_wake_word_callbacks.append(callback_) - return partial(self.assist_satellite_set_wake_word_callbacks.remove, callback_) + self.assist_satellite_set_wake_words_callbacks.append(callback_) + return partial(self.assist_satellite_set_wake_words_callbacks.remove, callback_) @callback - def async_assist_satellite_set_wake_word(self, wake_word_id: str) -> None: - """Notify listeners that the Assist satellite wake word has been set.""" - for callback_ in self.assist_satellite_set_wake_word_callbacks.copy(): - callback_(wake_word_id) + def async_assist_satellite_set_wake_word( + self, wake_word_index: int, wake_word_id: str | None + ) -> None: + """Notify listeners that the Assist satellite wake words have been set.""" + if wake_word_id: + self.assist_satellite_wake_words[wake_word_index] = wake_word_id + else: + self.assist_satellite_wake_words.pop(wake_word_index, None) + + wake_word_ids = list(self.assist_satellite_wake_words.values()) + + for callback_ in self.assist_satellite_set_wake_words_callbacks.copy(): + callback_(wake_word_ids) @callback def async_register_entity_removal_callback( diff --git a/homeassistant/components/esphome/icons.json b/homeassistant/components/esphome/icons.json index fc0595b028e00f..f4ac1872f5f899 100644 --- a/homeassistant/components/esphome/icons.json +++ b/homeassistant/components/esphome/icons.json @@ -9,11 +9,17 @@ "pipeline": { "default": "mdi:filter-outline" }, + "pipeline_2": { + "default": "mdi:filter-outline" + }, "vad_sensitivity": { "default": "mdi:volume-high" }, "wake_word": { "default": "mdi:microphone" + }, + "wake_word_2": { + "default": "mdi:microphone" } } } diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 3834e4251ea8fc..4ecde9c5113743 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import replace + from aioesphomeapi import EntityInfo, SelectInfo, SelectState from homeassistant.components.assist_pipeline.select import ( @@ -15,7 +17,7 @@ from homeassistant.helpers import restore_state from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from .const import DOMAIN, NO_WAKE_WORD from .entity import ( EsphomeAssistEntity, EsphomeEntity, @@ -50,9 +52,11 @@ async def async_setup_entry( ): async_add_entities( [ - EsphomeAssistPipelineSelect(hass, entry_data), + EsphomeAssistPipelineSelect(hass, entry_data, index=0), + EsphomeAssistPipelineSelect(hass, entry_data, index=1), EsphomeVadSensitivitySelect(hass, entry_data), - EsphomeAssistSatelliteWakeWordSelect(entry_data), + EsphomeAssistSatelliteWakeWordSelect(entry_data, index=0), + EsphomeAssistSatelliteWakeWordSelect(entry_data, index=1), ] ) @@ -84,10 +88,14 @@ async def async_select_option(self, option: str) -> None: class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): """Pipeline selector for esphome devices.""" - def __init__(self, hass: HomeAssistant, entry_data: RuntimeEntryData) -> None: + def __init__( + self, hass: HomeAssistant, entry_data: RuntimeEntryData, index: int = 0 + ) -> None: """Initialize a pipeline selector.""" EsphomeAssistEntity.__init__(self, entry_data) - AssistPipelineSelect.__init__(self, hass, DOMAIN, self._device_info.mac_address) + AssistPipelineSelect.__init__( + self, hass, DOMAIN, self._device_info.mac_address, index=index + ) class EsphomeVadSensitivitySelect(EsphomeAssistEntity, VadSensitivitySelect): @@ -109,28 +117,47 @@ class EsphomeAssistSatelliteWakeWordSelect( translation_key="wake_word", entity_category=EntityCategory.CONFIG, ) + _attr_current_option: str | None = None - _attr_options: list[str] = [] + _attr_options: list[str] = [NO_WAKE_WORD] - def __init__(self, entry_data: RuntimeEntryData) -> None: + def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None: """Initialize a wake word selector.""" + if index < 1: + # Keep compatibility + key_suffix = "" + placeholder = "" + else: + key_suffix = f"_{index + 1}" + placeholder = f" {index + 1}" + + self.entity_description = replace( + self.entity_description, + key=f"wake_word{key_suffix}", + translation_placeholders={"index": placeholder}, + ) + EsphomeAssistEntity.__init__(self, entry_data) unique_id_prefix = self._device_info.mac_address - self._attr_unique_id = f"{unique_id_prefix}-wake_word" + self._attr_unique_id = f"{unique_id_prefix}-{self.entity_description.key}" # name -> id self._wake_words: dict[str, str] = {} + self._wake_word_index = index @property def available(self) -> bool: """Return if entity is available.""" - return bool(self._attr_options) + return len(self._attr_options) > 1 # more than just NO_WAKE_WORD async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() + if last_state := await self.async_get_last_state(): + self._attr_current_option = last_state.state + # Update options when config is updated self.async_on_remove( self._entry_data.async_register_assist_satellite_config_updated_callback( @@ -140,33 +167,49 @@ async def async_added_to_hass(self) -> None: async def async_select_option(self, option: str) -> None: """Select an option.""" - if wake_word_id := self._wake_words.get(option): - # _attr_current_option will be updated on - # async_satellite_config_updated after the device sets the wake - # word. - self._entry_data.async_assist_satellite_set_wake_word(wake_word_id) + self._attr_current_option = option + self.async_write_ha_state() + + wake_word_id = self._wake_words.get(option) + self._entry_data.async_assist_satellite_set_wake_word( + self._wake_word_index, wake_word_id + ) def async_satellite_config_updated( self, config: AssistSatelliteConfiguration ) -> None: """Update options with available wake words.""" if (not config.available_wake_words) or (config.max_active_wake_words < 1): - self._attr_current_option = None + # No wake words self._wake_words.clear() + self._attr_current_option = NO_WAKE_WORD + self._attr_options = [NO_WAKE_WORD] + self._entry_data.assist_satellite_wake_words.pop( + self._wake_word_index, None + ) self.async_write_ha_state() return self._wake_words = {w.wake_word: w.id for w in config.available_wake_words} - self._attr_options = sorted(self._wake_words) - - if config.active_wake_words: - # Select first active wake word - wake_word_id = config.active_wake_words[0] - for wake_word in config.available_wake_words: - if wake_word.id == wake_word_id: - self._attr_current_option = wake_word.wake_word - else: - # Select first available wake word - self._attr_current_option = config.available_wake_words[0].wake_word + self._attr_options = [NO_WAKE_WORD, *sorted(self._wake_words)] + + option = self._attr_current_option + if ( + (option is None) + or ((wake_word_id := self._wake_words.get(option)) is None) + or (wake_word_id not in config.active_wake_words) + ): + option = NO_WAKE_WORD + self._attr_current_option = option self.async_write_ha_state() + + # Keep entry data in sync + if wake_word_id := self._wake_words.get(option): + self._entry_data.assist_satellite_wake_words[self._wake_word_index] = ( + wake_word_id + ) + else: + self._entry_data.assist_satellite_wake_words.pop( + self._wake_word_index, None + ) diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index eab88e8df95f38..77cd7ccb35adc9 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -119,8 +119,9 @@ } }, "wake_word": { - "name": "Wake word", + "name": "Wake word{index}", "state": { + "no_wake_word": "No wake word", "okay_nabu": "Okay Nabu" } } diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py index dc91525677b135..e5bbf2db9f2aee 100644 --- a/homeassistant/components/radio_browser/media_source.py +++ b/homeassistant/components/radio_browser/media_source.py @@ -66,7 +66,7 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: # Register "click" with Radio Browser await radios.station_click(uuid=station.uuid) - return PlayMedia(station.url, mime_type) + return PlayMedia(station.url_resolved, mime_type) async def async_browse_media( self, diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 0268bd8b207185..419c4df4f84dd0 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -361,25 +361,30 @@ def capability_attributes(self) -> dict[str, Any] | None: def _is_valid_suggested_unit(self, suggested_unit_of_measurement: str) -> bool: """Validate the suggested unit. - Validate that a unit converter exists for the sensor's device class and that the - unit converter supports both the native and the suggested units of measurement. + Validate that the native unit of measurement can be converted to the + suggested unit of measurement, either because they are the same or + because a unit converter supports both. """ - # Make sure we can convert the units - if self.native_unit_of_measurement != suggested_unit_of_measurement and ( - (unit_converter := UNIT_CONVERTERS.get(self.device_class)) is None - or self.__native_unit_of_measurement_compat - not in unit_converter.VALID_UNITS - or suggested_unit_of_measurement not in unit_converter.VALID_UNITS + # No need to check the unit converter if the units are the same + if self.native_unit_of_measurement == suggested_unit_of_measurement: + return True + + # Make sure there is a unit converter and it supports both units + if ( + (unit_converter := UNIT_CONVERTERS.get(self.device_class)) + and self.__native_unit_of_measurement_compat in unit_converter.VALID_UNITS + and suggested_unit_of_measurement in unit_converter.VALID_UNITS ): - if not self._invalid_suggested_unit_of_measurement_reported: - self._invalid_suggested_unit_of_measurement_reported = True - raise ValueError( - f"Entity {type(self)} suggest an incorrect " - f"unit of measurement: {suggested_unit_of_measurement}." - ) - return False + return True - return True + # Report invalid suggested unit only once per entity + if not self._invalid_suggested_unit_of_measurement_reported: + self._invalid_suggested_unit_of_measurement_reported = True + raise ValueError( + f"Entity {type(self)} suggest an incorrect " + f"unit of measurement: {suggested_unit_of_measurement}." + ) + return False def _get_initial_suggested_unit(self) -> str | UndefinedType: """Return the initial unit.""" diff --git a/homeassistant/components/ssdp/server.py b/homeassistant/components/ssdp/server.py index a9cea01a51788a..366c6adb95b305 100644 --- a/homeassistant/components/ssdp/server.py +++ b/homeassistant/components/ssdp/server.py @@ -31,9 +31,6 @@ from .common import async_build_source_set -UPNP_SERVER_MIN_PORT = 40000 -UPNP_SERVER_MAX_PORT = 40100 - _LOGGER = logging.getLogger(__name__) @@ -95,26 +92,17 @@ async def _async_find_next_available_port( ) -> tuple[int, socket.socket]: """Get a free TCP port.""" family = socket.AF_INET if is_ipv4_address(source) else socket.AF_INET6 - # We use an ExitStack to ensure the socket is closed if we fail to find a port. - with ExitStack() as stack: - test_socket = stack.enter_context(socket.socket(family, socket.SOCK_STREAM)) + test_socket = socket.socket(family, socket.SOCK_STREAM) + try: test_socket.setblocking(False) - test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT): - addr = (source[0], port, *source[2:]) - try: - test_socket.bind(addr) - except OSError: - if port == UPNP_SERVER_MAX_PORT - 1: - raise - else: - # The socket will be dealt by the caller, so we detach it from the stack - # before returning it to prevent it from being closed. - stack.pop_all() - return port, test_socket - - raise RuntimeError("unreachable") + + addr = (source[0], 0, *source[2:]) + test_socket.bind(addr) + port = test_socket.getsockname()[1] + except BaseException: + test_socket.close() + raise + return port, test_socket class Server: diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 4bd4c6e81f7c58..20289ff1394e19 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -28,6 +28,7 @@ "Apple Inc.": "apple", "Aqara": "aqara_gateway", "eero": "eero", + "GL.iNET Inc.": "glinet", "Google Inc.": "google", "HomeAssistant": "homeassistant", "Home Assistant": "homeassistant", diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py index 2929d743d34b7f..c11d13ff8cd021 100644 --- a/tests/components/aosmith/conftest.py +++ b/tests/components/aosmith/conftest.py @@ -29,7 +29,11 @@ def build_device_fixture( - heat_pump: bool, mode_pending: bool, setpoint_pending: bool, has_vacation_mode: bool + heat_pump: bool, + mode_pending: bool, + setpoint_pending: bool, + has_vacation_mode: bool, + supports_hot_water_plus: bool, ): """Build a fixture for a device.""" supported_modes: list[SupportedOperationModeInfo] = [ @@ -37,7 +41,7 @@ def build_device_fixture( mode=OperationMode.ELECTRIC, original_name="ELECTRIC", has_day_selection=True, - supports_hot_water_plus=False, + supports_hot_water_plus=supports_hot_water_plus, ), ] @@ -47,7 +51,7 @@ def build_device_fixture( mode=OperationMode.HYBRID, original_name="HYBRID", has_day_selection=False, - supports_hot_water_plus=False, + supports_hot_water_plus=supports_hot_water_plus, ) ) supported_modes.append( @@ -55,7 +59,7 @@ def build_device_fixture( mode=OperationMode.HEAT_PUMP, original_name="HEAT_PUMP", has_day_selection=False, - supports_hot_water_plus=False, + supports_hot_water_plus=supports_hot_water_plus, ) ) @@ -69,17 +73,18 @@ def build_device_fixture( ) ) - device_type = ( - DeviceType.NEXT_GEN_HEAT_PUMP if heat_pump else DeviceType.RE3_CONNECTED - ) - current_mode = OperationMode.HEAT_PUMP if heat_pump else OperationMode.ELECTRIC - model = "HPTS-50 200 202172000" if heat_pump else "EE12-50H55DVF 100,3806368" + if heat_pump and supports_hot_water_plus: + device_type = DeviceType.RE3_PREMIUM + elif heat_pump: + device_type = DeviceType.NEXT_GEN_HEAT_PUMP + else: + device_type = DeviceType.RE3_CONNECTED return Device( brand="aosmith", - model=model, + model="Example model", device_type=device_type, dsn="dsn", junction_id="junctionId", @@ -87,7 +92,7 @@ def build_device_fixture( serial="serial", install_location="Basement", supported_modes=supported_modes, - supports_hot_water_plus=False, + supports_hot_water_plus=supports_hot_water_plus, status=DeviceStatus( firmware_version="2.14", is_online=True, @@ -98,7 +103,7 @@ def build_device_fixture( temperature_setpoint_previous=130, temperature_setpoint_maximum=130, hot_water_status=90, - hot_water_plus_level=None, + hot_water_plus_level=1 if supports_hot_water_plus else None, ), ) @@ -165,6 +170,12 @@ def get_devices_fixture_has_vacation_mode() -> bool: return True +@pytest.fixture +def get_devices_fixture_supports_hot_water_plus() -> bool: + """Return whether to include hot water plus support in the get_devices fixture.""" + return False + + @pytest.fixture async def mock_client( hass: HomeAssistant, @@ -172,6 +183,7 @@ async def mock_client( get_devices_fixture_mode_pending: bool, get_devices_fixture_setpoint_pending: bool, get_devices_fixture_has_vacation_mode: bool, + get_devices_fixture_supports_hot_water_plus: bool, ) -> Generator[MagicMock]: """Return a mocked client.""" get_devices_fixture = [ @@ -180,6 +192,7 @@ async def mock_client( mode_pending=get_devices_fixture_mode_pending, setpoint_pending=get_devices_fixture_setpoint_pending, has_vacation_mode=get_devices_fixture_has_vacation_mode, + supports_hot_water_plus=get_devices_fixture_supports_hot_water_plus, ) ] get_all_device_info_fixture = await async_load_json_object_fixture( diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr index c4c1b0b1b9300c..057619a02463c7 100644 --- a/tests/components/aosmith/snapshots/test_device.ambr +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -20,7 +20,7 @@ 'labels': set({ }), 'manufacturer': 'A. O. Smith', - 'model': 'HPTS-50 200 202172000', + 'model': 'Example model', 'model_id': None, 'name': 'My water heater', 'name_by_user': None, diff --git a/tests/components/aosmith/snapshots/test_select.ambr b/tests/components/aosmith/snapshots/test_select.ambr new file mode 100644 index 00000000000000..9e0c10319c385c --- /dev/null +++ b/tests/components/aosmith/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_state[True][select.my_water_heater_hot_water_plus_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'level1', + 'level2', + 'level3', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.my_water_heater_hot_water_plus_level', + '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': 'Hot Water+ level', + 'platform': 'aosmith', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hot_water_plus_level', + 'unique_id': 'hot_water_plus_level_junctionId', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[True][select.my_water_heater_hot_water_plus_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My water heater Hot Water+ level', + 'options': list([ + 'off', + 'level1', + 'level2', + 'level3', + ]), + }), + 'context': , + 'entity_id': 'select.my_water_heater_hot_water_plus_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level1', + }) +# --- diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py index 940b0cbc6b5ccc..975e6b2a061b06 100644 --- a/tests/components/aosmith/test_init.py +++ b/tests/components/aosmith/test_init.py @@ -56,6 +56,7 @@ async def test_config_entry_not_ready_get_energy_use_data_error( mode_pending=False, setpoint_pending=False, has_vacation_mode=True, + supports_hot_water_plus=False, ) ] diff --git a/tests/components/aosmith/test_select.py b/tests/components/aosmith/test_select.py new file mode 100644 index 00000000000000..75444b7d8c954e --- /dev/null +++ b/tests/components/aosmith/test_select.py @@ -0,0 +1,77 @@ +"""Tests for the select platform of the A. O. Smith integration.""" + +from collections.abc import AsyncGenerator +from unittest.mock import MagicMock, patch + +from py_aosmith.models import OperationMode +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +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.aosmith.PLATFORMS", [Platform.SELECT]): + yield + + +@pytest.mark.parametrize( + ("get_devices_fixture_supports_hot_water_plus"), + [True], +) +async def test_state( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the state of the select entity.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +@pytest.mark.parametrize( + ("get_devices_fixture_supports_hot_water_plus"), + [True], +) +@pytest.mark.parametrize( + ("hass_level", "aosmith_level"), + [ + ("off", 0), + ("level1", 1), + ("level2", 2), + ("level3", 3), + ], +) +async def test_set_hot_water_plus_level( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_level: str, + aosmith_level: int, +) -> None: + """Test setting the Hot Water+ level.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_water_heater_hot_water_plus_level", + ATTR_OPTION: hass_level, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with( + junction_id="junctionId", + mode=OperationMode.HEAT_PUMP, + hot_water_plus_level=aosmith_level, + ) diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 2fdf53dc5ea7da..525f56603ad5be 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -28,6 +28,7 @@ tts, ) from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType +from homeassistant.components.assist_pipeline.pipeline import KEY_ASSIST_PIPELINE from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, AssistSatelliteEntityFeature, @@ -37,6 +38,7 @@ # pylint: disable-next=hass-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.esphome.assist_satellite import VoiceAssistantUDPServer +from homeassistant.components.esphome.const import NO_WAKE_WORD from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -45,6 +47,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, intent as intent_helper from homeassistant.helpers.network import get_url +from homeassistant.setup import async_setup_component from .common import get_satellite_entity from .conftest import MockESPHomeDeviceType @@ -1737,7 +1740,7 @@ async def test_get_set_configuration( AssistSatelliteWakeWord("5678", "hey jarvis", ["en"]), ], active_wake_words=["1234"], - max_active_wake_words=1, + max_active_wake_words=2, ) mock_client.get_voice_assistant_configuration.return_value = expected_config @@ -1857,7 +1860,7 @@ async def test_wake_word_select( AssistSatelliteWakeWord("hey_mycroft", "Hey Mycroft", ["en"]), ], active_wake_words=["hey_jarvis"], - max_active_wake_words=1, + max_active_wake_words=2, ) mock_client.get_voice_assistant_configuration.return_value = device_config @@ -1884,10 +1887,10 @@ async def wrapper(*args, **kwargs): assert satellite is not None assert satellite.async_get_configuration().active_wake_words == ["hey_jarvis"] - # Active wake word should be selected + # No wake word should be selected by default state = hass.states.get("select.test_wake_word") assert state is not None - assert state.state == "Hey Jarvis" + assert state.state == NO_WAKE_WORD # Changing the select should set the active wake word await hass.services.async_call( @@ -1908,3 +1911,162 @@ async def wrapper(*args, **kwargs): # Satellite config should have been updated assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"] + + # No secondary wake word should be selected by default + state = hass.states.get("select.test_wake_word_2") + assert state is not None + assert state.state == NO_WAKE_WORD + + # Changing the secondary select should add an active wake word + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word_2", "option": "Hey Jarvis"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.test_wake_word_2") + assert state is not None + assert state.state == "Hey Jarvis" + + # Wait for device config to be updated + async with asyncio.timeout(1): + await configuration_set.wait() + + # Satellite config should have been updated + assert set(satellite.async_get_configuration().active_wake_words) == { + "okay_nabu", + "hey_jarvis", + } + + # Remove the secondary wake word + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word_2", "option": NO_WAKE_WORD}, + blocking=True, + ) + await hass.async_block_till_done() + + async with asyncio.timeout(1): + await configuration_set.wait() + + # Only primary wake word remains + assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"] + + +async def test_secondary_pipeline( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that the secondary pipeline is used when the secondary wake word is given.""" + assert await async_setup_component(hass, "assist_pipeline", {}) + pipeline_data = hass.data[KEY_ASSIST_PIPELINE] + pipeline_id_to_name: dict[str, str] = {} + for pipeline_name in ("Primary Pipeline", "Secondary Pipeline"): + pipeline = await pipeline_data.pipeline_store.async_create_item( + { + "name": pipeline_name, + "language": "en-US", + "conversation_engine": None, + "conversation_language": "en-US", + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "stt_engine": None, + "stt_language": None, + "wake_word_entity": None, + "wake_word_id": None, + } + ) + pipeline_id_to_name[pipeline.id] = pipeline_name + + device_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]), + AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]), + AssistSatelliteWakeWord("hey_mycroft", "Hey Mycroft", ["en"]), + ], + active_wake_words=["hey_jarvis"], + max_active_wake_words=2, + ) + mock_client.get_voice_assistant_configuration.return_value = device_config + + # Wrap mock so we can tell when it's done + configuration_set = asyncio.Event() + + async def wrapper(*args, **kwargs): + # Update device config because entity will request it after update + device_config.active_wake_words = kwargs["active_wake_words"] + configuration_set.set() + + mock_client.set_voice_assistant_configuration = AsyncMock(side_effect=wrapper) + + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # Set primary/secondary wake words and assistants + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word", "option": "Okay Nabu"}, + blocking=True, + ) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_assistant", "option": "Primary Pipeline"}, + blocking=True, + ) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.test_wake_word_2", "option": "Hey Jarvis"}, + blocking=True, + ) + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_assistant_2", + "option": "Secondary Pipeline", + }, + blocking=True, + ) + await hass.async_block_till_done() + + async def get_pipeline(wake_word_phrase): + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + ) as mock_pipeline_from_audio_stream: + await satellite.handle_pipeline_start( + conversation_id="", + flags=0, + audio_settings=VoiceAssistantAudioSettings(), + wake_word_phrase=wake_word_phrase, + ) + + mock_pipeline_from_audio_stream.assert_called_once() + kwargs = mock_pipeline_from_audio_stream.call_args_list[0].kwargs + return pipeline_id_to_name[kwargs["pipeline_id"]] + + # Primary pipeline is the default + for wake_word_phrase in (None, "Okay Nabu"): + assert (await get_pipeline(wake_word_phrase)) == "Primary Pipeline" + + # Secondary pipeline requires secondary wake word + assert (await get_pipeline("Hey Jarvis")) == "Secondary Pipeline" + + # Primary pipeline should be restored after + assert (await get_pipeline(None)) == "Primary Pipeline" diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 14673f5ffb9f89..db41b164c2decd 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -9,6 +9,7 @@ AssistSatelliteConfiguration, AssistSatelliteWakeWord, ) +from homeassistant.components.esphome.const import NO_WAKE_WORD from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -32,6 +33,17 @@ async def test_pipeline_selector( assert state.state == "preferred" +@pytest.mark.usefixtures("mock_voice_assistant_v1_entry") +async def test_secondary_pipeline_selector( + hass: HomeAssistant, +) -> None: + """Test secondary assist pipeline selector.""" + + state = hass.states.get("select.test_assistant_2") + assert state is not None + assert state.state == "preferred" + + @pytest.mark.usefixtures("mock_voice_assistant_v1_entry") async def test_vad_sensitivity_select( hass: HomeAssistant, @@ -56,6 +68,16 @@ async def test_wake_word_select( assert state.state == STATE_UNAVAILABLE +@pytest.mark.usefixtures("mock_voice_assistant_v1_entry") +async def test_secondary_wake_word_select( + hass: HomeAssistant, +) -> None: + """Test that secondary wake word select is unavailable initially.""" + state = hass.states.get("select.test_wake_word_2") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + async def test_select_generic_entity( hass: HomeAssistant, mock_client: APIClient, @@ -117,10 +139,11 @@ async def test_wake_word_select_no_wake_words( assert satellite is not None assert not satellite.async_get_configuration().available_wake_words - # Select should be unavailable - state = hass.states.get("select.test_wake_word") - assert state is not None - assert state.state == STATE_UNAVAILABLE + # Selects should be unavailable + for entity_id in ("select.test_wake_word", "select.test_wake_word_2"): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE async def test_wake_word_select_zero_max_wake_words( @@ -151,10 +174,11 @@ async def test_wake_word_select_zero_max_wake_words( assert satellite is not None assert satellite.async_get_configuration().max_active_wake_words == 0 - # Select should be unavailable - state = hass.states.get("select.test_wake_word") - assert state is not None - assert state.state == STATE_UNAVAILABLE + # Selects should be unavailable + for entity_id in ("select.test_wake_word", "select.test_wake_word_2"): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE async def test_wake_word_select_no_active_wake_words( @@ -186,7 +210,8 @@ async def test_wake_word_select_no_active_wake_words( assert satellite is not None assert not satellite.async_get_configuration().active_wake_words - # First available wake word should be selected - state = hass.states.get("select.test_wake_word") - assert state is not None - assert state.state == "Okay Nabu" + # No wake words should be selected + for entity_id in ("select.test_wake_word", "select.test_wake_word_2"): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == NO_WAKE_WORD