diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index e53a479d7d4..1b6289efe7c 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -52,7 +52,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( RECOMMENDED_OPTIONS = { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, } @@ -134,9 +134,8 @@ class AnthropicOptionsFlow(OptionsFlow): if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) - + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) if user_input.get( CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): @@ -151,12 +150,16 @@ class AnthropicOptionsFlow(OptionsFlow): options = { CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), } suggested_values = options.copy() if not suggested_values.get(CONF_PROMPT): suggested_values[CONF_PROMPT] = llm.DEFAULT_INSTRUCTIONS_PROMPT + if ( + suggested_llm_apis := suggested_values.get(CONF_LLM_HASS_API) + ) and isinstance(suggested_llm_apis, str): + suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis] schema = self.add_suggested_values_to_schema( vol.Schema(anthropic_config_option_schema(self.hass, options)), @@ -176,24 +179,18 @@ def anthropic_config_option_schema( ) -> dict: """Return a schema for Anthropic completion options.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) + ] schema = { vol.Optional(CONF_PROMPT): TemplateSelector(), - vol.Optional(CONF_LLM_HASS_API, default="none"): SelectSelector( - SelectSelectorConfig(options=hass_apis) - ), + vol.Optional( + CONF_LLM_HASS_API, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 151c1512b74..caf5cc7541d 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bluesound", "iot_class": "local_polling", - "requirements": ["pyblu==2.0.0"], + "requirements": ["pyblu==2.0.1"], "zeroconf": [ { "type": "_musc._tcp.local." diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 0addcc1daac..337dc3d3a33 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -330,7 +330,12 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity if self._status.input_id is not None: for input_ in self._inputs: - if input_.id == self._status.input_id: + # the input might not have an id => also try to match on the stream_url/url + # we have to use both because neither matches all the time + if ( + input_.id == self._status.input_id + or input_.url == self._status.stream_url + ): return input_.text for preset in self._presets: diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 8d8a17a5259..c78f41f3c5c 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -197,6 +197,7 @@ class ChatLog: ( "?", ";", # Greek question mark + "?", # Chinese question mark ) ) ) diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py index de3e0cebfb7..927e725460c 100644 --- a/homeassistant/components/cover/reproduce_state.py +++ b/homeassistant/components/cover/reproduce_state.py @@ -73,14 +73,14 @@ async def _async_set_position( Returns True if the position was set, False if there is no supported method for setting the position. """ - if target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features: - await service_call(SERVICE_CLOSE_COVER, service_data) - elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features: - await service_call(SERVICE_OPEN_COVER, service_data) - elif CoverEntityFeature.SET_POSITION in features: + if CoverEntityFeature.SET_POSITION in features: await service_call( SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: target_position} ) + elif target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features: + await service_call(SERVICE_CLOSE_COVER, service_data) + elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features: + await service_call(SERVICE_OPEN_COVER, service_data) else: # Requested a position but the cover doesn't support it return False @@ -98,15 +98,17 @@ async def _async_set_tilt_position( Returns True if the tilt position was set, False if there is no supported method for setting the tilt position. """ - if target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features: - await service_call(SERVICE_CLOSE_COVER_TILT, service_data) - elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features: - await service_call(SERVICE_OPEN_COVER_TILT, service_data) - elif CoverEntityFeature.SET_TILT_POSITION in features: + if CoverEntityFeature.SET_TILT_POSITION in features: await service_call( SERVICE_SET_COVER_TILT_POSITION, service_data | {ATTR_TILT_POSITION: target_tilt_position}, ) + elif ( + target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features + ): + await service_call(SERVICE_CLOSE_COVER_TILT, service_data) + elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features: + await service_call(SERVICE_OPEN_COVER_TILT, service_data) else: # Requested a tilt position but the cover doesn't support it return False @@ -183,12 +185,12 @@ async def _async_reproduce_state( current_attrs = cur_state.attributes target_attrs = state.attributes - current_position = current_attrs.get(ATTR_CURRENT_POSITION) - target_position = target_attrs.get(ATTR_CURRENT_POSITION) + current_position: int | None = current_attrs.get(ATTR_CURRENT_POSITION) + target_position: int | None = target_attrs.get(ATTR_CURRENT_POSITION) position_matches = current_position == target_position - current_tilt_position = current_attrs.get(ATTR_CURRENT_TILT_POSITION) - target_tilt_position = target_attrs.get(ATTR_CURRENT_TILT_POSITION) + current_tilt_position: int | None = current_attrs.get(ATTR_CURRENT_TILT_POSITION) + target_tilt_position: int | None = target_attrs.get(ATTR_CURRENT_TILT_POSITION) tilt_position_matches = current_tilt_position == target_tilt_position state_matches = cur_state.state == target_state @@ -214,19 +216,11 @@ async def _async_reproduce_state( ) service_data = {ATTR_ENTITY_ID: entity_id} - set_position = ( - not position_matches - and target_position is not None - and await _async_set_position( - service_call, service_data, features, target_position - ) + set_position = target_position is not None and await _async_set_position( + service_call, service_data, features, target_position ) - set_tilt = ( - not tilt_position_matches - and target_tilt_position is not None - and await _async_set_tilt_position( - service_call, service_data, features, target_tilt_position - ) + set_tilt = target_tilt_position is not None and await _async_set_tilt_position( + service_call, service_data, features, target_tilt_position ) if target_state in CLOSING_STATES: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index e69869e772b..2b1babfc0ba 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -23,6 +23,7 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_REAUTH, + SOURCE_RECONFIGURE, ConfigEntry, ConfigFlow, ConfigFlowResult, @@ -44,6 +45,7 @@ from .const import ( CONF_SUBSCRIBE_LOGS, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + DEFAULT_PORT, DOMAIN, ) from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info @@ -63,6 +65,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: ConfigEntry + _reconfig_entry: ConfigEntry def __init__(self) -> None: """Initialize flow.""" @@ -88,7 +91,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): fields: dict[Any, type] = OrderedDict() fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str - fields[vol.Optional(CONF_PORT, default=self._port or 6053)] = int + fields[vol.Optional(CONF_PORT, default=self._port or DEFAULT_PORT)] = int errors = {} if error is not None: @@ -140,7 +143,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle reauthorization flow when encryption was removed.""" if user_input is not None: self._noise_psk = None - return await self._async_get_entry_or_resolve_conflict() + return await self._async_validated_connection() return self.async_show_form( step_id="reauth_encryption_removed_confirm", @@ -172,6 +175,18 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): description_placeholders={"name": self._name}, ) + async def async_step_reconfigure( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle a flow initialized by a reconfig request.""" + self._reconfig_entry = self._get_reconfigure_entry() + data = self._reconfig_entry.data + self._host = data[CONF_HOST] + self._port = data.get(CONF_PORT, DEFAULT_PORT) + self._noise_psk = data.get(CONF_NOISE_PSK) + self._device_name = data.get(CONF_DEVICE_NAME) + return await self._async_step_user_base() + @property def _name(self) -> str: return self.__name or "ESPHome" @@ -230,7 +245,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_authenticate() self._password = "" - return await self._async_get_entry_or_resolve_conflict() + return await self._async_validated_connection() async def async_step_discovery_confirm( self, user_input: dict[str, Any] | None = None @@ -270,13 +285,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): await self._async_validate_mac_abort_configured( mac_address, self._host, self._port ) - return await self.async_step_discovery_confirm() async def _async_validate_mac_abort_configured( self, formatted_mac: str, host: str, port: int | None ) -> None: """Validate if the MAC address is already configured.""" + assert self.unique_id is not None if not ( entry := self.hass.config_entries.async_entry_for_domain_unique_id( self.handler, formatted_mac @@ -393,7 +408,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): data={ **self._entry_with_name_conflict.data, CONF_HOST: self._host, - CONF_PORT: self._port or 6053, + CONF_PORT: self._port or DEFAULT_PORT, CONF_PASSWORD: self._password or "", CONF_NOISE_PSK: self._noise_psk or "", }, @@ -417,20 +432,24 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): await self.hass.config_entries.async_remove( self._entry_with_name_conflict.entry_id ) - return self._async_get_entry() - - async def _async_get_entry_or_resolve_conflict(self) -> ConfigFlowResult: - """Return the entry or resolve a conflict.""" - if self.source != SOURCE_REAUTH: - for entry in self._async_current_entries(include_ignore=False): - if entry.data.get(CONF_DEVICE_NAME) == self._device_name: - self._entry_with_name_conflict = entry - return await self.async_step_name_conflict() - return self._async_get_entry() + return self._async_create_entry() @callback - def _async_get_entry(self) -> ConfigFlowResult: - config_data = { + def _async_create_entry(self) -> ConfigFlowResult: + """Create the config entry.""" + assert self._name is not None + return self.async_create_entry( + title=self._name, + data=self._async_make_config_data(), + options={ + CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + }, + ) + + @callback + def _async_make_config_data(self) -> dict[str, Any]: + """Return config data for the entry.""" + return { CONF_HOST: self._host, CONF_PORT: self._port, # The API uses protobuf, so empty string denotes absence @@ -438,19 +457,99 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): CONF_NOISE_PSK: self._noise_psk or "", CONF_DEVICE_NAME: self._device_name, } - config_options = { - CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, - } - if self.source == SOURCE_REAUTH: - return self.async_update_reload_and_abort( - self._reauth_entry, data=self._reauth_entry.data | config_data - ) - assert self._name is not None - return self.async_create_entry( - title=self._name, - data=config_data, - options=config_options, + async def _async_validated_connection(self) -> ConfigFlowResult: + """Handle validated connection.""" + if self.source == SOURCE_RECONFIGURE: + return await self._async_reconfig_validated_connection() + if self.source == SOURCE_REAUTH: + return await self._async_reauth_validated_connection() + for entry in self._async_current_entries(include_ignore=False): + if entry.data.get(CONF_DEVICE_NAME) == self._device_name: + self._entry_with_name_conflict = entry + return await self.async_step_name_conflict() + return self._async_create_entry() + + async def _async_reauth_validated_connection(self) -> ConfigFlowResult: + """Handle reauth validated connection.""" + assert self._reauth_entry.unique_id is not None + if self.unique_id == self._reauth_entry.unique_id: + return self.async_update_reload_and_abort( + self._reauth_entry, + data=self._reauth_entry.data | self._async_make_config_data(), + ) + assert self._host is not None + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NOISE_PSK: self._noise_psk, + } + ) + # Reauth was triggered a while ago, and since than + # a new device resides at the same IP address. + assert self._device_name is not None + return self.async_abort( + reason="reauth_unique_id_changed", + description_placeholders={ + "name": self._reauth_entry.data.get( + CONF_DEVICE_NAME, self._reauth_entry.title + ), + "host": self._host, + "expected_mac": format_mac(self._reauth_entry.unique_id), + "unexpected_mac": format_mac(self.unique_id), + "unexpected_device_name": self._device_name, + }, + ) + + async def _async_reconfig_validated_connection(self) -> ConfigFlowResult: + """Handle reconfigure validated connection.""" + assert self._reconfig_entry.unique_id is not None + assert self._host is not None + assert self._device_name is not None + if not ( + unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id) + ): + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NOISE_PSK: self._noise_psk, + } + ) + for entry in self._async_current_entries(include_ignore=False): + if ( + entry.entry_id != self._reconfig_entry.entry_id + and entry.data.get(CONF_DEVICE_NAME) == self._device_name + ): + return self.async_abort( + reason="reconfigure_name_conflict", + description_placeholders={ + "name": self._reconfig_entry.data[CONF_DEVICE_NAME], + "host": self._host, + "expected_mac": format_mac(self._reconfig_entry.unique_id), + "existing_title": entry.title, + }, + ) + if unique_id_matches: + return self.async_update_reload_and_abort( + self._reconfig_entry, + data=self._reconfig_entry.data | self._async_make_config_data(), + ) + if self._reconfig_entry.data.get(CONF_DEVICE_NAME) == self._device_name: + self._entry_with_name_conflict = self._reconfig_entry + return await self.async_step_name_conflict() + return self.async_abort( + reason="reconfigure_unique_id_changed", + description_placeholders={ + "name": self._reconfig_entry.data.get( + CONF_DEVICE_NAME, self._reconfig_entry.title + ), + "host": self._host, + "expected_mac": format_mac(self._reconfig_entry.unique_id), + "unexpected_mac": format_mac(self.unique_id), + "unexpected_device_name": self._device_name, + }, ) async def async_step_encryption_key( @@ -481,7 +580,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): error = await self.try_login() if error: return await self.async_step_authenticate(error=error) - return await self._async_get_entry_or_resolve_conflict() + return await self._async_validated_connection() errors = {} if error is not None: @@ -501,12 +600,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): zeroconf_instance = await zeroconf.async_get_instance(self.hass) cli = APIClient( host, - port or 6053, + port or DEFAULT_PORT, "", zeroconf_instance=zeroconf_instance, noise_psk=noise_psk, ) - try: await cli.connect() self._device_info = await cli.device_info() @@ -541,9 +639,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): assert self._device_info is not None mac_address = format_mac(self._device_info.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) - if self.source != SOURCE_REAUTH: + if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE): self._abort_if_unique_id_configured( - updates={CONF_HOST: self._host, CONF_PORT: self._port} + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NOISE_PSK: self._noise_psk, + } ) return None diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 1fab0ab325d..f793fd16bfe 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,5 +1,7 @@ """ESPHome constants.""" +from typing import Final + from awesomeversion import AwesomeVersion DOMAIN = "esphome" @@ -13,6 +15,7 @@ CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address" DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False +DEFAULT_PORT: Final = 6053 STABLE_BLE_VERSION_STR = "2025.2.2" STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index b28decc7c70..313785fd2df 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -198,6 +198,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): """Define a base esphome entity.""" _attr_should_poll = False + _attr_has_entity_name = True _static_info: _InfoT _state: _StateT _has_state: bool @@ -223,24 +224,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) - # - # If `friendly_name` is set, we use the Friendly naming rules, if - # `friendly_name` is not set we make an exception to the naming rules for - # backwards compatibility and use the Legacy naming rules. - # - # Friendly naming - # - Friendly name is prepended to entity names - # - Device Name is prepended to entity ids - # - Entity id is constructed from device name and object id - # - # Legacy naming - # - Device name is not prepended to entity names - # - Device name is not prepended to entity ids - # - Entity id is constructed from entity name - # - if not device_info.friendly_name: - return - self._attr_has_entity_name = True self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 62963178a8e..c173a3ada63 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -520,6 +520,15 @@ class ESPHomeManager: if device_info.name: reconnect_logic.name = device_info.name + if not device_info.friendly_name: + _LOGGER.info( + "No `friendly_name` set in the `esphome:` section of the " + "YAML config for device '%s' (MAC: %s); It's recommended " + "to add one for easier identification and better alignment " + "with Home Assistant naming conventions", + device_info.name, + device_mac, + ) self.device_id = _async_setup_device_registry(hass, entry, entry_data) entry_data.async_update_device_state() @@ -756,7 +765,7 @@ def _async_setup_device_registry( config_entry_id=entry.entry_id, configuration_url=configuration_url, connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, - name=entry_data.friendly_name, + name=entry_data.friendly_name or entry_data.name, manufacturer=manufacturer, model=model, sw_version=sw_version, diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index e265620d2e4..6c10a2e5fe8 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -10,7 +10,11 @@ "mqtt_missing_api": "Missing API port in MQTT properties.", "mqtt_missing_ip": "Missing IP address in MQTT properties.", "mqtt_missing_payload": "Missing MQTT Payload.", - "name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`." + "name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).", + "reconfigure_name_conflict": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a device named `{name}` (MAC: `{expected_mac}`), which is already in use by another configuration entry: `{existing_title}`.", + "reconfigure_unique_id_changed": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`)." }, "error": { "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ee980c9bf48..ec476d940d1 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -183,10 +183,10 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) if not ( - user_input.get(CONF_LLM_HASS_API, "none") != "none" + user_input.get(CONF_LLM_HASS_API) and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True ): # Don't allow to save options that enable the Google Seearch tool with an Assist API @@ -213,18 +213,16 @@ async def google_generative_ai_config_option_schema( ) -> dict: """Return a schema for Google Generative AI completion options.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) + ] + if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance( + suggested_llm_apis, str + ): + suggested_llm_apis = [suggested_llm_apis] schema = { vol.Optional( @@ -237,9 +235,8 @@ async def google_generative_ai_config_option_schema( ): TemplateSelector(), vol.Optional( CONF_LLM_HASS_API, - description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - default="none", - ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 3a3eb0e945c..a2f5a43b69c 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -46,6 +46,8 @@ RESPONSE_HEADERS_FILTER = { MIN_COMPRESSED_SIZE = 128 MAX_SIMPLE_RESPONSE_SIZE = 4194000 +DISABLED_TIMEOUT = ClientTimeout(total=None) + @callback def async_setup_ingress_view(hass: HomeAssistant, host: str) -> None: @@ -167,7 +169,7 @@ class HassIOIngress(HomeAssistantView): params=request.query, allow_redirects=False, data=request.content if request.method != "GET" else None, - timeout=ClientTimeout(total=None), + timeout=DISABLED_TIMEOUT, skip_auto_headers={hdrs.CONTENT_TYPE}, ) as result: headers = _response_header(result) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index c57e766eaed..3761c935992 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -52,7 +52,7 @@ class HistoryLiveStream: subscriptions: list[CALLBACK_TYPE] end_time_unsub: CALLBACK_TYPE | None = None task: asyncio.Task | None = None - wait_sync_task: asyncio.Task | None = None + wait_sync_future: asyncio.Future[None] | None = None @callback @@ -491,8 +491,8 @@ async def ws_stream( subscriptions.clear() if live_stream.task: live_stream.task.cancel() - if live_stream.wait_sync_task: - live_stream.wait_sync_task.cancel() + if live_stream.wait_sync_future: + live_stream.wait_sync_future.cancel() if live_stream.end_time_unsub: live_stream.end_time_unsub() live_stream.end_time_unsub = None @@ -554,10 +554,12 @@ async def ws_stream( ) ) - live_stream.wait_sync_task = create_eager_task( - get_instance(hass).async_block_till_done() - ) - await live_stream.wait_sync_task + if sync_future := get_instance(hass).async_get_commit_future(): + # Set the future so we can cancel it if the client + # unsubscribes before the commit is done so we don't + # query the database needlessly + live_stream.wait_sync_future = sync_future + await live_stream.wait_sync_future # # Fetch any states from the database that have diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index d44ba15507e..c10cbe5be5b 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -12,7 +12,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.const import ATTR_TEMPERATURE, EntityCategory, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -43,7 +43,6 @@ async def async_setup_entry( class InComfortClimate(IncomfortEntity, ClimateEntity): """Representation of an InComfort/InTouch climate device.""" - _attr_entity_category = EntityCategory.CONFIG _attr_min_temp = 5.0 _attr_max_temp = 30.0 _attr_name = None diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 7cb281b3af4..78201d9e015 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -197,6 +197,11 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): super().__init__(config_entry, description) self._attrs: dict[str, str] = {} + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + await self.async_update() + async def async_update(self) -> None: """Update the state of the sensor.""" now = dt_util.now() diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index b871f2eb23a..51a939391a8 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -40,6 +40,7 @@ PLATFORMS = [ Platform.CALENDAR, Platform.NUMBER, Platform.SELECT, + Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, ] diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index 2c45104859a..98cf7cf222e 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -4,8 +4,9 @@ from collections.abc import Callable from dataclasses import dataclass from typing import cast +from pylamarzocco import LaMarzoccoMachine from pylamarzocco.const import BackFlushStatus, MachineState, WidgetType -from pylamarzocco.models import BackFlush, BaseWidgetOutput, MachineStatus +from pylamarzocco.models import BackFlush, MachineStatus from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -30,7 +31,7 @@ class LaMarzoccoBinarySensorEntityDescription( ): """Description of a La Marzocco binary sensor.""" - is_on_fn: Callable[[dict[WidgetType, BaseWidgetOutput]], bool | None] + is_on_fn: Callable[[LaMarzoccoMachine], bool | None] ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( @@ -38,7 +39,7 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( key="water_tank", translation_key="water_tank", device_class=BinarySensorDeviceClass.PROBLEM, - is_on_fn=lambda config: WidgetType.CM_NO_WATER in config, + is_on_fn=lambda machine: WidgetType.CM_NO_WATER in machine.dashboard.config, entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoBinarySensorEntityDescription( @@ -46,8 +47,8 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( translation_key="brew_active", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=( - lambda config: cast( - MachineStatus, config[WidgetType.CM_MACHINE_STATUS] + lambda machine: cast( + MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS] ).status is MachineState.BREWING ), @@ -59,11 +60,21 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( translation_key="backflush_enabled", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=( - lambda config: cast(BackFlush, config[WidgetType.CM_BACK_FLUSH]).status + lambda machine: cast( + BackFlush, machine.dashboard.config[WidgetType.CM_BACK_FLUSH] + ).status is BackFlushStatus.REQUESTED ), entity_category=EntityCategory.DIAGNOSTIC, ), + LaMarzoccoBinarySensorEntityDescription( + key="websocket_connected", + translation_key="websocket_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on_fn=(lambda machine: machine.websocket.connected), + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ) @@ -90,6 +101,4 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self.entity_description.is_on_fn( - self.coordinator.device.dashboard.config - ) + return self.entity_description.is_on_fn(self.coordinator.device) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 6808fc3e419..e352e337d0b 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -49,6 +49,7 @@ from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import LaMarzoccoConfigEntry CONF_MACHINE = "machine" +BT_MODEL_PREFIXES = ("MICRA", "MINI", "GS3") _LOGGER = logging.getLogger(__name__) @@ -105,7 +106,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): self._config = data if self.source == SOURCE_REAUTH: return self.async_update_reload_and_abort( - self._get_reauth_entry(), data=data + self._get_reauth_entry(), data_updates=data ) if self._discovered: if self._discovered[CONF_MACHINE] not in self._things: @@ -169,10 +170,15 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: if self.source == SOURCE_RECONFIGURE: for service_info in async_discovered_service_info(self.hass): - self._discovered[service_info.name] = service_info.address + if service_info.name.startswith(BT_MODEL_PREFIXES): + self._discovered[service_info.name] = service_info.address if self._discovered: return await self.async_step_bluetooth_selection() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self._config, + ) return self.async_create_entry( title=selected_device.name, @@ -217,8 +223,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: return self.async_update_reload_and_abort( self._get_reconfigure_entry(), - data={ - **self._config, + data_updates={ CONF_MAC: user_input[CONF_MAC], }, ) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index 7a42bcd6028..2964f48ecbd 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -36,6 +36,15 @@ }, "smart_standby_time": { "default": "mdi:timer" + }, + "preinfusion_time": { + "default": "mdi:water" + }, + "prebrew_time_on": { + "default": "mdi:water" + }, + "prebrew_time_off": { + "default": "mdi:water-off" } }, "select": { @@ -63,6 +72,14 @@ } } }, + "sensor": { + "coffee_boiler_ready_time": { + "default": "mdi:av-timer" + }, + "steam_boiler_ready_time": { + "default": "mdi:av-timer" + } + }, "switch": { "main": { "default": "mdi:power", diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 6b849f1783d..81a03b4d6ee 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -5,9 +5,9 @@ from dataclasses import dataclass from typing import Any, cast from pylamarzocco import LaMarzoccoMachine -from pylamarzocco.const import WidgetType +from pylamarzocco.const import ModelName, PreExtractionMode, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.models import CoffeeBoiler +from pylamarzocco.models import CoffeeBoiler, PreBrewing from homeassistant.components.number import ( NumberDeviceClass, @@ -77,6 +77,123 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( ), native_value_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes, ), + LaMarzoccoNumberEntityDescription( + key="preinfusion_off", + translation_key="preinfusion_time", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_TENTHS, + native_min_value=0, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=( + lambda machine, value: machine.set_pre_extraction_times( + seconds_on=0, + seconds_off=float(value), + ) + ), + native_value_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_infusion[0] + .seconds.seconds_out + ), + available_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ).mode + is PreExtractionMode.PREINFUSION + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ) + ), + ), + LaMarzoccoNumberEntityDescription( + key="prebrew_on", + translation_key="prebrew_time_on", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_step=PRECISION_TENTHS, + native_min_value=0, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=( + lambda machine, value: machine.set_pre_extraction_times( + seconds_on=float(value), + seconds_off=cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_out, + ) + ), + native_value_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_in + ), + available_fn=lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ).mode + is PreExtractionMode.PREBREWING, + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ) + ), + ), + LaMarzoccoNumberEntityDescription( + key="prebrew_off", + translation_key="prebrew_time_off", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + native_step=PRECISION_TENTHS, + native_min_value=0, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=( + lambda machine, value: machine.set_pre_extraction_times( + seconds_on=cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_in, + seconds_off=float(value), + ) + ), + native_value_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ) + .times.pre_brewing[0] + .seconds.seconds_out + ), + available_fn=( + lambda machine: cast( + PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING] + ).mode + is PreExtractionMode.PREBREWING + ), + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in ( + ModelName.LINEA_MICRA, + ModelName.LINEA_MINI, + ModelName.LINEA_MINI_R, + ) + ), + ), ) diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py new file mode 100644 index 00000000000..17f11534483 --- /dev/null +++ b/homeassistant/components/lamarzocco/sensor.py @@ -0,0 +1,115 @@ +"""Sensor platform for La Marzocco espresso machines.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import cast + +from pylamarzocco.const import ModelName, WidgetType +from pylamarzocco.models import ( + BaseWidgetOutput, + CoffeeBoiler, + SteamBoilerLevel, + SteamBoilerTemperature, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import LaMarzoccoConfigEntry +from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoSensorEntityDescription( + LaMarzoccoEntityDescription, + SensorEntityDescription, +): + """Description of a La Marzocco sensor.""" + + value_fn: Callable[ + [dict[WidgetType, BaseWidgetOutput]], StateType | datetime | None + ] + + +ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( + LaMarzoccoSensorEntityDescription( + key="coffee_boiler_ready_time", + translation_key="coffee_boiler_ready_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + CoffeeBoiler, config[WidgetType.CM_COFFEE_BOILER] + ).ready_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + LaMarzoccoSensorEntityDescription( + key="steam_boiler_ready_time", + translation_key="steam_boiler_ready_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + SteamBoilerLevel, config[WidgetType.CM_STEAM_BOILER_LEVEL] + ).ready_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.LINEA_MICRA, ModelName.LINEA_MINI_R) + ), + ), + LaMarzoccoSensorEntityDescription( + key="steam_boiler_ready_time", + translation_key="steam_boiler_ready_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=( + lambda config: cast( + SteamBoilerTemperature, config[WidgetType.CM_STEAM_BOILER_TEMPERATURE] + ).ready_start_time + ), + entity_category=EntityCategory.DIAGNOSTIC, + supported_fn=( + lambda coordinator: coordinator.device.dashboard.model_name + in (ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LaMarzoccoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor entities.""" + coordinator = entry.runtime_data.config_coordinator + + async_add_entities( + LaMarzoccoSensorEntity(coordinator, description) + for description in ENTITIES + if description.supported_fn(coordinator) + ) + + +class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): + """Sensor representing espresso machine water reservoir status.""" + + entity_description: LaMarzoccoSensorEntityDescription + + @property + def native_value(self) -> StateType | datetime | None: + """Return value of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.device.dashboard.config + ) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index fe7475a23c9..7a77b8ad72c 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -83,6 +83,9 @@ }, "water_tank": { "name": "Water tank empty" + }, + "websocket_connected": { + "name": "WebSocket connected" } }, "button": { @@ -101,6 +104,15 @@ }, "smart_standby_time": { "name": "Smart standby time" + }, + "preinfusion_time": { + "name": "Preinfusion time" + }, + "prebrew_time_on": { + "name": "Prebrew on time" + }, + "prebrew_time_off": { + "name": "Prebrew off time" } }, "select": { @@ -128,6 +140,14 @@ } } }, + "sensor": { + "coffee_boiler_ready_time": { + "name": "Coffee boiler ready time" + }, + "steam_boiler_ready_time": { + "name": "Steam boiler ready time" + } + }, "switch": { "auto_on_off": { "name": "Auto on/off ({id})" diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index 487cef042c9..632c66a8b66 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -1,9 +1,10 @@ """Support for La Marzocco update entities.""" +import asyncio from dataclasses import dataclass from typing import Any -from pylamarzocco.const import FirmwareType +from pylamarzocco.const import FirmwareType, UpdateCommandStatus from pylamarzocco.exceptions import RequestNotSuccessful from homeassistant.components.update import ( @@ -22,6 +23,7 @@ from .coordinator import LaMarzoccoConfigEntry from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription PARALLEL_UPDATES = 1 +MAX_UPDATE_WAIT = 150 @dataclass(frozen=True, kw_only=True) @@ -71,7 +73,11 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): """Entity representing the update state.""" entity_description: LaMarzoccoUpdateEntityDescription - _attr_supported_features = UpdateEntityFeature.INSTALL + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) @property def installed_version(self) -> str: @@ -94,15 +100,40 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): """Return the release notes URL.""" return "https://support-iot.lamarzocco.com/firmware-updates/" + def release_notes(self) -> str | None: + """Return the release notes for the latest firmware version.""" + if available_update := self.coordinator.device.settings.firmwares[ + self.entity_description.component + ].available_update: + return available_update.change_log + return None + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" + self._attr_in_progress = True self.async_write_ha_state() + + counter = 0 + + def _raise_timeout_error() -> None: # to avoid TRY301 + raise TimeoutError("Update timed out") + try: await self.coordinator.device.update_firmware() - except RequestNotSuccessful as exc: + while ( + update_progress := await self.coordinator.device.get_firmware() + ).command_status is UpdateCommandStatus.IN_PROGRESS: + if counter >= MAX_UPDATE_WAIT: + _raise_timeout_error() + self._attr_update_percentage = update_progress.progress_percentage + self.async_write_ha_state() + await asyncio.sleep(3) + counter += 1 + + except (TimeoutError, RequestNotSuccessful) as exc: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="update_failed", @@ -110,5 +141,6 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity): "key": self.entity_description.key, }, ) from exc - self._attr_in_progress = False - await self.coordinator.async_request_refresh() + finally: + self._attr_in_progress = False + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index f83cbadf925..47282b6cc22 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -22,7 +22,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval -from .const import CONF_CONNECT_CLIENT_ID, MQTT_SUBSCRIPTION_INTERVAL +from .const import CONF_CONNECT_CLIENT_ID, DOMAIN, MQTT_SUBSCRIPTION_INTERVAL from .coordinator import DeviceDataUpdateCoordinator, async_setup_device_coordinator from .mqtt import ThinQMQTT @@ -137,7 +137,15 @@ async def async_setup_mqtt( entry.runtime_data.mqtt_client = mqtt_client # Try to connect. - result = await mqtt_client.async_connect() + try: + result = await mqtt_client.async_connect() + except (AttributeError, ThinQAPIException, TypeError, ValueError) as exc: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="failed_to_connect_mqtt", + translation_placeholders={"error": str(exc)}, + ) from exc + if not result: _LOGGER.error("Failed to set up mqtt connection") return diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py index 025f80f78b1..d6ff1f72b8f 100644 --- a/homeassistant/components/lg_thinq/mqtt.py +++ b/homeassistant/components/lg_thinq/mqtt.py @@ -43,19 +43,16 @@ class ThinQMQTT: async def async_connect(self) -> bool: """Create a mqtt client and then try to connect.""" - try: - self.client = await ThinQMQTTClient( - self.thinq_api, self.client_id, self.on_message_received - ) - if self.client is None: - return False - # Connect to server and create certificate. - return await self.client.async_prepare_mqtt() - except (ThinQAPIException, TypeError, ValueError): - _LOGGER.exception("Failed to connect") + self.client = await ThinQMQTTClient( + self.thinq_api, self.client_id, self.on_message_received + ) + if self.client is None: return False + # Connect to server and create certificate. + return await self.client.async_prepare_mqtt() + async def async_disconnect(self, event: Event | None = None) -> None: """Unregister client and disconnects handlers.""" await self.async_end_subscribes() diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index f609be91de5..a5fb81e3818 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -1034,5 +1034,10 @@ } } } + }, + "exceptions": { + "failed_to_connect_mqtt": { + "message": "Failed to connect MQTT: {error}" + } } } diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index e3d0d8a29fa..4b767f66d69 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -47,7 +47,7 @@ class LogbookLiveStream: subscriptions: list[CALLBACK_TYPE] end_time_unsub: CALLBACK_TYPE | None = None task: asyncio.Task | None = None - wait_sync_task: asyncio.Task | None = None + wait_sync_future: asyncio.Future[None] | None = None @callback @@ -329,8 +329,8 @@ async def ws_event_stream( subscriptions.clear() if live_stream.task: live_stream.task.cancel() - if live_stream.wait_sync_task: - live_stream.wait_sync_task.cancel() + if live_stream.wait_sync_future: + live_stream.wait_sync_future.cancel() if live_stream.end_time_unsub: live_stream.end_time_unsub() live_stream.end_time_unsub = None @@ -399,10 +399,12 @@ async def ws_event_stream( ) ) - live_stream.wait_sync_task = create_eager_task( - get_instance(hass).async_block_till_done() - ) - await live_stream.wait_sync_task + if sync_future := get_instance(hass).async_get_commit_future(): + # Set the future so we can cancel it if the client + # unsubscribes before the commit is done so we don't + # query the database needlessly + live_stream.wait_sync_future = sync_future + await live_stream.wait_sync_future # # Fetch any events from the database that have diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 21d1fc3bf54..ac359de1a5b 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -169,6 +169,8 @@ browse_media: target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.BROWSE_MEDIA fields: media_content_type: required: false diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 102d1bf012c..5c8ab674bef 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -154,9 +154,8 @@ class OpenAIOptionsFlow(OptionsFlow): if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if user_input[CONF_LLM_HASS_API] == "none": - user_input.pop(CONF_LLM_HASS_API) - + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: errors[CONF_CHAT_MODEL] = "model_not_supported" @@ -178,7 +177,7 @@ class OpenAIOptionsFlow(OptionsFlow): options = { CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), } schema = openai_config_option_schema(self.hass, options) @@ -248,19 +247,16 @@ def openai_config_option_schema( ) -> VolDictType: """Return a schema for OpenAI completion options.""" hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label="No control", - value="none", - ) - ] - hass_apis.extend( SelectOptionDict( label=api.name, value=api.id, ) for api in llm.async_get_apis(hass) - ) - + ] + if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance( + suggested_llm_apis, str + ): + suggested_llm_apis = [suggested_llm_apis] schema: VolDictType = { vol.Optional( CONF_PROMPT, @@ -272,9 +268,8 @@ def openai_config_option_schema( ): TemplateSelector(), vol.Optional( CONF_LLM_HASS_API, - description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - default="none", - ): SelectSelector(SelectSelectorConfig(options=hass_apis)), + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 09319d59932..5db96e17322 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -49,6 +49,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [ key=OverkizState.CORE_WATER_DETECTION, name="Water", icon="mdi:water", + device_class=BinarySensorDeviceClass.MOISTURE, value_fn=lambda state: state == OverkizCommandParam.DETECTED, ), # AirSensor/AirFlowSensor diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 83c0e7cf7a8..70028f138b7 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -14,7 +14,7 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -172,6 +172,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ native_max_value=7, set_native_value=_async_set_native_value_boost_mode_duration, entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, ), # DomesticHotWaterProduction - away mode in days (0 - 6) OverkizNumberDescription( @@ -182,6 +184,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ native_min_value=0, native_max_value=6, entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, ), ] diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index cec0d0d2571..b0a15b3970e 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( EntityCategory, UnitOfEnergy, UnitOfPower, + UnitOfSpeed, UnitOfTemperature, UnitOfTime, UnitOfVolume, @@ -126,6 +127,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Outlet engine", icon="mdi:fan-chevron-down", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, state_class=SensorStateClass.MEASUREMENT, ), OverkizSensorDescription( @@ -152,14 +154,23 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ OverkizSensorDescription( key=OverkizState.CORE_FOSSIL_ENERGY_CONSUMPTION, name="Fossil energy consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_GAS_CONSUMPTION, name="Gas consumption", + native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, + device_class=SensorDeviceClass.GAS, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_THERMAL_ENERGY_CONSUMPTION, name="Thermal energy consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), # LightSensor/LuminanceSensor OverkizSensorDescription( @@ -204,7 +215,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF2, @@ -213,7 +224,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF3, @@ -222,7 +233,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF4, @@ -231,7 +242,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF5, @@ -240,7 +251,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF6, @@ -249,7 +260,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF7, @@ -258,7 +269,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF8, @@ -267,7 +278,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), OverkizSensorDescription( key=OverkizState.CORE_CONSUMPTION_TARIFF9, @@ -276,7 +287,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ # core:MeasuredValueType = core:ElectricalEnergyInWh native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), # HumiditySensor/RelativeHumiditySensor OverkizSensorDescription( @@ -342,6 +353,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Sun energy", native_value=lambda value: round(cast(float, value), 2), icon="mdi:solar-power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), # WindSensor/WindSpeedSensor @@ -350,6 +363,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ name="Wind speed", native_value=lambda value: round(cast(float, value), 2), icon="mdi:weather-windy", + device_class=SensorDeviceClass.WIND_SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, ), # SmokeSensor/SmokeSensor @@ -398,6 +413,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ native_value=lambda value: OVERKIZ_STATE_TO_TRANSLATION.get( cast(str, value), cast(str, value) ), + device_class=SensorDeviceClass.ENUM, + options=["dead", "low_battery", "maintenance_required", "no_defect"], ), # DomesticHotWaterProduction/WaterHeatingSystem OverkizSensorDescription( diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 7b8043b9201..34fa6a62d44 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1307,11 +1307,17 @@ class Recorder(threading.Thread): async def async_block_till_done(self) -> None: """Async version of block_till_done.""" + if future := self.async_get_commit_future(): + await future + + @callback + def async_get_commit_future(self) -> asyncio.Future[None] | None: + """Return a future that will wait for the next commit or None if nothing pending.""" if self._queue.empty() and not self._event_session_has_pending_writes: - return - event = asyncio.Event() - self.queue_task(SynchronizeTask(event)) - await event.wait() + return None + future: asyncio.Future[None] = self.hass.loop.create_future() + self.queue_task(SynchronizeTask(future)) + return future def block_till_done(self) -> None: """Block till all events processed. diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 4eb9547ee9d..f5ad7f2a3d9 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -317,13 +317,18 @@ class SynchronizeTask(RecorderTask): """Ensure all pending data has been committed.""" # commit_before is the default - event: asyncio.Event + future: asyncio.Future def run(self, instance: Recorder) -> None: """Handle the task.""" # Does not use a tracked task to avoid # blocking shutdown if the recorder is broken - instance.hass.loop.call_soon_threadsafe(self.event.set) + instance.hass.loop.call_soon_threadsafe(self._set_result_if_not_done) + + def _set_result_if_not_done(self) -> None: + """Set the result if not done.""" + if not self.future.done(): + self.future.set_result(None) @dataclass(slots=True) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py index 05f8099b168..1dffededf38 100644 --- a/homeassistant/components/renault/const.py +++ b/homeassistant/components/renault/const.py @@ -7,7 +7,9 @@ DOMAIN = "renault" CONF_LOCALE = "locale" CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" -DEFAULT_SCAN_INTERVAL = 420 # 7 minutes +# normal number of allowed calls per hour to the API +# for a single car and the 7 coordinator, it is a scan every 7mn +MAX_CALLS_PER_HOURS = 60 # If throttled time to pause the updates, in seconds COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index e5168fc81fd..1f883435dee 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -32,9 +32,9 @@ from time import time from .const import ( CONF_KAMEREON_ACCOUNT_ID, COOLING_UPDATES_SECONDS, - DEFAULT_SCAN_INTERVAL, + MAX_CALLS_PER_HOURS, ) -from .renault_vehicle import RenaultVehicleProxy +from .renault_vehicle import COORDINATORS, RenaultVehicleProxy LOGGER = logging.getLogger(__name__) @@ -82,7 +82,6 @@ class RenaultHub: async def async_initialise(self, config_entry: RenaultConfigEntry) -> None: """Set up proxy.""" account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] - scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) self._account = await self._client.get_api_account(account_id) vehicles = await self._account.get_vehicles() @@ -94,6 +93,12 @@ class RenaultHub: raise ConfigEntryNotReady( "Failed to retrieve vehicle details from Renault servers" ) + + num_call_per_scan = len(COORDINATORS) * len(vehicles.vehicleLinks) + scan_interval = timedelta( + seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS + ) + device_registry = dr.async_get(self._hass) await asyncio.gather( *( @@ -108,6 +113,21 @@ class RenaultHub: ) ) + # all vehicles have been initiated with the right number of active coordinators + num_call_per_scan = 0 + for vehicle_link in vehicles.vehicleLinks: + vehicle = self._vehicles[str(vehicle_link.vin)] + num_call_per_scan += len(vehicle.coordinators) + + new_scan_interval = timedelta( + seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS + ) + if new_scan_interval != scan_interval: + # we need to change the vehicles with the right scan interval + for vehicle_link in vehicles.vehicleLinks: + vehicle = self._vehicles[str(vehicle_link.vin)] + vehicle.update_scan_interval(new_scan_interval) + async def async_initialise_vehicle( self, vehicle_link: KamereonVehiclesLink, diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 1ab9bf0bd5a..8d096a734e1 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -91,6 +91,13 @@ class RenaultVehicleProxy: self._scan_interval = scan_interval self._hub = hub + def update_scan_interval(self, scan_interval: timedelta) -> None: + """Set the scan interval for the vehicle.""" + if scan_interval != self._scan_interval: + self._scan_interval = scan_interval + for coordinator in self.coordinators.values(): + coordinator.update_interval = scan_interval + @property def details(self) -> models.KamereonVehicleDetails: """Return the specs of the vehicle.""" diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index df65d16b0b8..dfad97ae4ea 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from datetime import datetime import logging from typing import TYPE_CHECKING, Any @@ -105,91 +104,96 @@ SERVICES = [ ] -def setup_services(hass: HomeAssistant) -> None: - """Register the Renault services.""" +async def ac_cancel(service_call: ServiceCall) -> None: + """Cancel A/C.""" + proxy = get_vehicle_proxy(service_call) - async def ac_cancel(service_call: ServiceCall) -> None: - """Cancel A/C.""" - proxy = get_vehicle_proxy(service_call.data) + LOGGER.debug("A/C cancel attempt") + result = await proxy.set_ac_stop() + LOGGER.debug("A/C cancel result: %s", result) - LOGGER.debug("A/C cancel attempt") - result = await proxy.set_ac_stop() - LOGGER.debug("A/C cancel result: %s", result) - async def ac_start(service_call: ServiceCall) -> None: - """Start A/C.""" - temperature: float = service_call.data[ATTR_TEMPERATURE] - when: datetime | None = service_call.data.get(ATTR_WHEN) - proxy = get_vehicle_proxy(service_call.data) +async def ac_start(service_call: ServiceCall) -> None: + """Start A/C.""" + temperature: float = service_call.data[ATTR_TEMPERATURE] + when: datetime | None = service_call.data.get(ATTR_WHEN) + proxy = get_vehicle_proxy(service_call) - LOGGER.debug("A/C start attempt: %s / %s", temperature, when) - result = await proxy.set_ac_start(temperature, when) - LOGGER.debug("A/C start result: %s", result.raw_data) + LOGGER.debug("A/C start attempt: %s / %s", temperature, when) + result = await proxy.set_ac_start(temperature, when) + LOGGER.debug("A/C start result: %s", result.raw_data) - async def charge_set_schedules(service_call: ServiceCall) -> None: - """Set charge schedules.""" - schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] - proxy = get_vehicle_proxy(service_call.data) - charge_schedules = await proxy.get_charging_settings() - for schedule in schedules: - charge_schedules.update(schedule) - if TYPE_CHECKING: - assert charge_schedules.schedules is not None - LOGGER.debug("Charge set schedules attempt: %s", schedules) - result = await proxy.set_charge_schedules(charge_schedules.schedules) +async def charge_set_schedules(service_call: ServiceCall) -> None: + """Set charge schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call) + charge_schedules = await proxy.get_charging_settings() + for schedule in schedules: + charge_schedules.update(schedule) - LOGGER.debug("Charge set schedules result: %s", result) - LOGGER.debug( - "It may take some time before these changes are reflected in your vehicle" - ) + if TYPE_CHECKING: + assert charge_schedules.schedules is not None + LOGGER.debug("Charge set schedules attempt: %s", schedules) + result = await proxy.set_charge_schedules(charge_schedules.schedules) - async def ac_set_schedules(service_call: ServiceCall) -> None: - """Set A/C schedules.""" - schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] - proxy = get_vehicle_proxy(service_call.data) - hvac_schedules = await proxy.get_hvac_settings() + LOGGER.debug("Charge set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) - for schedule in schedules: - hvac_schedules.update(schedule) - if TYPE_CHECKING: - assert hvac_schedules.schedules is not None - LOGGER.debug("HVAC set schedules attempt: %s", schedules) - result = await proxy.set_hvac_schedules(hvac_schedules.schedules) +async def ac_set_schedules(service_call: ServiceCall) -> None: + """Set A/C schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call) + hvac_schedules = await proxy.get_hvac_settings() - LOGGER.debug("HVAC set schedules result: %s", result) - LOGGER.debug( - "It may take some time before these changes are reflected in your vehicle" - ) + for schedule in schedules: + hvac_schedules.update(schedule) - def get_vehicle_proxy(service_call_data: Mapping) -> RenaultVehicleProxy: - """Get vehicle from service_call data.""" - device_registry = dr.async_get(hass) - device_id = service_call_data[ATTR_VEHICLE] - device_entry = device_registry.async_get(device_id) - if device_entry is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_device_id", - translation_placeholders={"device_id": device_id}, - ) + if TYPE_CHECKING: + assert hvac_schedules.schedules is not None + LOGGER.debug("HVAC set schedules attempt: %s", schedules) + result = await proxy.set_hvac_schedules(hvac_schedules.schedules) - loaded_entries: list[RenaultConfigEntry] = [ - entry - for entry in hass.config_entries.async_loaded_entries(DOMAIN) - if entry.entry_id in device_entry.config_entries - ] - for entry in loaded_entries: - for vin, vehicle in entry.runtime_data.vehicles.items(): - if (DOMAIN, vin) in device_entry.identifiers: - return vehicle + LOGGER.debug("HVAC set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) + + +def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy: + """Get vehicle from service_call data.""" + device_registry = dr.async_get(service_call.hass) + device_id = service_call.data[ATTR_VEHICLE] + device_entry = device_registry.async_get(device_id) + if device_entry is None: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="no_config_entry_for_device", - translation_placeholders={"device_id": device_entry.name or device_id}, + translation_key="invalid_device_id", + translation_placeholders={"device_id": device_id}, ) + loaded_entries: list[RenaultConfigEntry] = [ + entry + for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries + ] + for entry in loaded_entries: + for vin, vehicle in entry.runtime_data.vehicles.items(): + if (DOMAIN, vin) in device_entry.identifiers: + return vehicle + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_config_entry_for_device", + translation_placeholders={"device_id": device_entry.name or device_id}, + ) + + +def setup_services(hass: HomeAssistant) -> None: + """Register the Renault services.""" + hass.services.async_register( DOMAIN, SERVICE_AC_CANCEL, diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 0fe0e7fe919..74d561f08ac 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -59,10 +59,11 @@ CAPABILITY_TO_SENSORS: dict[ Category.DOOR: BinarySensorDeviceClass.DOOR, Category.WINDOW: BinarySensorDeviceClass.WINDOW, }, - exists_fn=lambda key: key in {"freezer", "cooler"}, + exists_fn=lambda key: key in {"freezer", "cooler", "cvroom"}, component_translation_key={ "freezer": "freezer_door", "cooler": "cooler_door", + "cvroom": "cool_select_plus_door", }, deprecated_fn=( lambda status: "fridge_door" diff --git a/homeassistant/components/smartthings/media_player.py b/homeassistant/components/smartthings/media_player.py index 9a676d2efb6..335e8255ae4 100644 --- a/homeassistant/components/smartthings/media_player.py +++ b/homeassistant/components/smartthings/media_player.py @@ -23,7 +23,6 @@ from .entity import SmartThingsEntity MEDIA_PLAYER_CAPABILITIES = ( Capability.AUDIO_MUTE, Capability.AUDIO_VOLUME, - Capability.MEDIA_PLAYBACK, ) CONTROLLABLE_SOURCES = ["bluetooth", "wifi"] @@ -100,27 +99,25 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): ) def _determine_features(self) -> MediaPlayerEntityFeature: - flags = MediaPlayerEntityFeature(0) - playback_commands = self.get_attribute_value( - Capability.MEDIA_PLAYBACK, Attribute.SUPPORTED_PLAYBACK_COMMANDS + flags = ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE ) - if "play" in playback_commands: - flags |= MediaPlayerEntityFeature.PLAY - if "pause" in playback_commands: - flags |= MediaPlayerEntityFeature.PAUSE - if "stop" in playback_commands: - flags |= MediaPlayerEntityFeature.STOP - if "rewind" in playback_commands: - flags |= MediaPlayerEntityFeature.PREVIOUS_TRACK - if "fastForward" in playback_commands: - flags |= MediaPlayerEntityFeature.NEXT_TRACK - if self.supports_capability(Capability.AUDIO_VOLUME): - flags |= ( - MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_STEP + if self.supports_capability(Capability.MEDIA_PLAYBACK): + playback_commands = self.get_attribute_value( + Capability.MEDIA_PLAYBACK, Attribute.SUPPORTED_PLAYBACK_COMMANDS ) - if self.supports_capability(Capability.AUDIO_MUTE): - flags |= MediaPlayerEntityFeature.VOLUME_MUTE + if "play" in playback_commands: + flags |= MediaPlayerEntityFeature.PLAY + if "pause" in playback_commands: + flags |= MediaPlayerEntityFeature.PAUSE + if "stop" in playback_commands: + flags |= MediaPlayerEntityFeature.STOP + if "rewind" in playback_commands: + flags |= MediaPlayerEntityFeature.PREVIOUS_TRACK + if "fastForward" in playback_commands: + flags |= MediaPlayerEntityFeature.NEXT_TRACK if self.supports_capability(Capability.SWITCH): flags |= ( MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF @@ -270,6 +267,13 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity): def state(self) -> MediaPlayerState | None: """State of the media player.""" if self.supports_capability(Capability.SWITCH): + if not self.supports_capability(Capability.MEDIA_PLAYBACK): + if ( + self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) + == "on" + ): + return MediaPlayerState.ON + return MediaPlayerState.OFF if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on": if ( self.source is not None diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index e081f35d0e0..d5a465b8ccc 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -194,13 +194,7 @@ CAPABILITY_TO_SENSORS: dict[ native_unit_of_measurement=PERCENTAGE, deprecated=( lambda status: "media_player" - if all( - capability in status - for capability in ( - Capability.AUDIO_MUTE, - Capability.MEDIA_PLAYBACK, - ) - ) + if Capability.AUDIO_MUTE in status else None ), ) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fb88aa5e4a0..384264b0595 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -48,6 +48,9 @@ "cooler_door": { "name": "Cooler door" }, + "cool_select_plus_door": { + "name": "CoolSelect+ door" + }, "remote_control": { "name": "Remote control" }, diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 4e62957d3d4..ff53082ac7c 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -38,7 +38,6 @@ AC_CAPABILITIES = ( MEDIA_PLAYER_CAPABILITIES = ( Capability.AUDIO_MUTE, Capability.AUDIO_VOLUME, - Capability.MEDIA_PLAYBACK, ) diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json index 89443fc7e27..0af692b800c 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smhi", "iot_class": "cloud_polling", "loggers": ["pysmhi"], - "requirements": ["pysmhi==1.0.1"] + "requirements": ["pysmhi==1.0.2"] } diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index d0ba48d281e..a5ea30e014d 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -6,8 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import cast -from teslemetry_stream import Signal -from teslemetry_stream.const import WindowState +from teslemetry_stream.vehicle import TeslemetryStreamVehicle from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -32,6 +31,12 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData PARALLEL_UPDATES = 0 +WINDOW_STATES = { + "Opened": True, + "PartiallyOpen": True, + "Closed": False, +} + @dataclass(frozen=True, kw_only=True) class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -39,11 +44,14 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription): polling_value_fn: Callable[[StateType], bool | None] = bool polling: bool = False - streaming_key: Signal | None = None + streaming_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[bool | None], None]], + Callable[[], None], + ] + | None + ) = None streaming_firmware: str = "2024.26" - streaming_value_fn: Callable[[StateType], bool | None] = ( - lambda x: x is True or x == "true" - ) VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( @@ -56,7 +64,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="charge_state_battery_heater_on", polling=True, - streaming_key=Signal.BATTERY_HEATER_ON, + streaming_listener=lambda x, y: x.listen_BatteryHeaterOn(y), device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -64,15 +72,16 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="charge_state_charger_phases", polling=True, - streaming_key=Signal.CHARGER_PHASES, + streaming_listener=lambda x, y: x.listen_ChargerPhases( + lambda z: y(None if z is None else z > 1) + ), polling_value_fn=lambda x: cast(int, x) > 1, - streaming_value_fn=lambda x: cast(int, x) > 1, entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_state_preconditioning_enabled", polling=True, - streaming_key=Signal.PRECONDITIONING_ENABLED, + streaming_listener=lambda x, y: x.listen_PreconditioningEnabled(y), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -85,7 +94,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="charge_state_scheduled_charging_pending", polling=True, - streaming_key=Signal.SCHEDULED_CHARGING_PENDING, + streaming_listener=lambda x, y: x.listen_ScheduledChargingPending(y), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -153,32 +162,36 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( TeslemetryBinarySensorEntityDescription( key="vehicle_state_fd_window", polling=True, - streaming_key=Signal.FD_WINDOW, - streaming_value_fn=lambda x: WindowState.get(x) != "Closed", + streaming_listener=lambda x, y: x.listen_FrontDriverWindow( + lambda z: y(WINDOW_STATES.get(z)) + ), device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_fp_window", polling=True, - streaming_key=Signal.FP_WINDOW, - streaming_value_fn=lambda x: WindowState.get(x) != "Closed", + streaming_listener=lambda x, y: x.listen_FrontPassengerWindow( + lambda z: y(WINDOW_STATES.get(z)) + ), device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_rd_window", polling=True, - streaming_key=Signal.RD_WINDOW, - streaming_value_fn=lambda x: WindowState.get(x) != "Closed", + streaming_listener=lambda x, y: x.listen_RearDriverWindow( + lambda z: y(WINDOW_STATES.get(z)) + ), device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_rp_window", polling=True, - streaming_key=Signal.RP_WINDOW, - streaming_value_fn=lambda x: WindowState.get(x) != "Closed", + streaming_listener=lambda x, y: x.listen_RearPassengerWindow( + lambda z: y(WINDOW_STATES.get(z)) + ), device_class=BinarySensorDeviceClass.WINDOW, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -186,180 +199,177 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( key="vehicle_state_df", polling=True, device_class=BinarySensorDeviceClass.DOOR, - streaming_key=Signal.DOOR_STATE, - streaming_value_fn=lambda x: cast(dict, x).get("DriverFront"), + streaming_listener=lambda x, y: x.listen_FrontDriverDoor(y), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_dr", polling=True, device_class=BinarySensorDeviceClass.DOOR, - streaming_key=Signal.DOOR_STATE, - streaming_value_fn=lambda x: cast(dict, x).get("DriverRear"), + streaming_listener=lambda x, y: x.listen_RearDriverDoor(y), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pf", polling=True, device_class=BinarySensorDeviceClass.DOOR, - streaming_key=Signal.DOOR_STATE, - streaming_value_fn=lambda x: cast(dict, x).get("PassengerFront"), + streaming_listener=lambda x, y: x.listen_FrontPassengerDoor(y), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="vehicle_state_pr", polling=True, device_class=BinarySensorDeviceClass.DOOR, - streaming_key=Signal.DOOR_STATE, - streaming_value_fn=lambda x: cast(dict, x).get("PassengerRear"), + streaming_listener=lambda x, y: x.listen_RearPassengerDoor(y), entity_category=EntityCategory.DIAGNOSTIC, ), TeslemetryBinarySensorEntityDescription( key="automatic_blind_spot_camera", - streaming_key=Signal.AUTOMATIC_BLIND_SPOT_CAMERA, + streaming_listener=lambda x, y: x.listen_AutomaticBlindSpotCamera(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="automatic_emergency_braking_off", - streaming_key=Signal.AUTOMATIC_EMERGENCY_BRAKING_OFF, + streaming_listener=lambda x, y: x.listen_AutomaticEmergencyBrakingOff(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="blind_spot_collision_warning_chime", - streaming_key=Signal.BLIND_SPOT_COLLISION_WARNING_CHIME, + streaming_listener=lambda x, y: x.listen_BlindSpotCollisionWarningChime(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="bms_full_charge_complete", - streaming_key=Signal.BMS_FULL_CHARGE_COMPLETE, + streaming_listener=lambda x, y: x.listen_BmsFullchargecomplete(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="brake_pedal", - streaming_key=Signal.BRAKE_PEDAL, + streaming_listener=lambda x, y: x.listen_BrakePedal(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="charge_port_cold_weather_mode", - streaming_key=Signal.CHARGE_PORT_COLD_WEATHER_MODE, + streaming_listener=lambda x, y: x.listen_ChargePortColdWeatherMode(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="service_mode", - streaming_key=Signal.SERVICE_MODE, + streaming_listener=lambda x, y: x.listen_ServiceMode(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="pin_to_drive_enabled", - streaming_key=Signal.PIN_TO_DRIVE_ENABLED, + streaming_listener=lambda x, y: x.listen_PinToDriveEnabled(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="drive_rail", - streaming_key=Signal.DRIVE_RAIL, + streaming_listener=lambda x, y: x.listen_DriveRail(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="driver_seat_belt", - streaming_key=Signal.DRIVER_SEAT_BELT, + streaming_listener=lambda x, y: x.listen_DriverSeatBelt(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="driver_seat_occupied", - streaming_key=Signal.DRIVER_SEAT_OCCUPIED, + streaming_listener=lambda x, y: x.listen_DriverSeatOccupied(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="passenger_seat_belt", - streaming_key=Signal.PASSENGER_SEAT_BELT, + streaming_listener=lambda x, y: x.listen_PassengerSeatBelt(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="fast_charger_present", - streaming_key=Signal.FAST_CHARGER_PRESENT, + streaming_listener=lambda x, y: x.listen_FastChargerPresent(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="gps_state", - streaming_key=Signal.GPS_STATE, + streaming_listener=lambda x, y: x.listen_GpsState(y), entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), TeslemetryBinarySensorEntityDescription( key="guest_mode_enabled", - streaming_key=Signal.GUEST_MODE_ENABLED, + streaming_listener=lambda x, y: x.listen_GuestModeEnabled(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="dc_dc_enable", - streaming_key=Signal.DCDC_ENABLE, + streaming_listener=lambda x, y: x.listen_DCDCEnable(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="emergency_lane_departure_avoidance", - streaming_key=Signal.EMERGENCY_LANE_DEPARTURE_AVOIDANCE, + streaming_listener=lambda x, y: x.listen_EmergencyLaneDepartureAvoidance(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="supercharger_session_trip_planner", - streaming_key=Signal.SUPERCHARGER_SESSION_TRIP_PLANNER, + streaming_listener=lambda x, y: x.listen_SuperchargerSessionTripPlanner(y), entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="wiper_heat_enabled", - streaming_key=Signal.WIPER_HEAT_ENABLED, + streaming_listener=lambda x, y: x.listen_WiperHeatEnabled(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="rear_display_hvac_enabled", - streaming_key=Signal.REAR_DISPLAY_HVAC_ENABLED, + streaming_listener=lambda x, y: x.listen_RearDisplayHvacEnabled(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="offroad_lightbar_present", - streaming_key=Signal.OFFROAD_LIGHTBAR_PRESENT, + streaming_listener=lambda x, y: x.listen_OffroadLightbarPresent(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="homelink_nearby", - streaming_key=Signal.HOMELINK_NEARBY, + streaming_listener=lambda x, y: x.listen_HomelinkNearby(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="europe_vehicle", - streaming_key=Signal.EUROPE_VEHICLE, + streaming_listener=lambda x, y: x.listen_EuropeVehicle(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="right_hand_drive", - streaming_key=Signal.RIGHT_HAND_DRIVE, + streaming_listener=lambda x, y: x.listen_RightHandDrive(y), streaming_firmware="2024.44.25", entity_registry_enabled_default=False, ), TeslemetryBinarySensorEntityDescription( key="located_at_home", - streaming_key=Signal.LOCATED_AT_HOME, + streaming_listener=lambda x, y: x.listen_LocatedAtHome(y), streaming_firmware="2024.44.32", ), TeslemetryBinarySensorEntityDescription( key="located_at_work", - streaming_key=Signal.LOCATED_AT_WORK, + streaming_listener=lambda x, y: x.listen_LocatedAtWork(y), streaming_firmware="2024.44.32", ), TeslemetryBinarySensorEntityDescription( key="located_at_favorite", - streaming_key=Signal.LOCATED_AT_FAVORITE, + streaming_listener=lambda x, y: x.listen_LocatedAtFavorite(y), streaming_firmware="2024.44.32", entity_registry_enabled_default=False, ), ) + ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription(key="backup_capable"), BinarySensorEntityDescription(key="grid_services_active"), @@ -386,7 +396,7 @@ async def async_setup_entry( for description in VEHICLE_DESCRIPTIONS: if ( not vehicle.api.pre2021 - and description.streaming_key + and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): entities.append( @@ -453,8 +463,7 @@ class TeslemetryVehicleStreamingBinarySensorEntity( ) -> None: """Initialize the sensor.""" self.entity_description = description - assert description.streaming_key - super().__init__(data, description.key, description.streaming_key) + super().__init__(data, description.key) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -462,11 +471,18 @@ class TeslemetryVehicleStreamingBinarySensorEntity( if (state := await self.async_get_last_state()) is not None: self._attr_is_on = state.state == STATE_ON - def _async_value_from_stream(self, value) -> None: + assert self.entity_description.streaming_listener + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._async_value_from_stream + ) + ) + + def _async_value_from_stream(self, value: bool | None) -> None: """Update the value of the entity.""" self._attr_available = value is not None - if self._attr_available: - self._attr_is_on = self.entity_description.streaming_value_fn(value) + self._attr_is_on = value + self.async_write_ha_state() class TeslemetryEnergyLiveBinarySensorEntity( diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 1ba4536ac2b..fb653314bc5 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -7,8 +7,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from propcache.api import cached_property -from teslemetry_stream import Signal, TeslemetryStreamVehicle -from teslemetry_stream.const import ShiftState +from teslemetry_stream import TeslemetryStreamVehicle from homeassistant.components.sensor import ( RestoreSensor, @@ -70,8 +69,13 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription): polling: bool = False polling_value_fn: Callable[[StateType], StateType] = lambda x: x nullable: bool = False - streaming_key: Signal | None = None - streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x + streaming_listener: ( + Callable[ + [TeslemetryStreamVehicle, Callable[[StateType], None]], + Callable[[], None], + ] + | None + ) = None streaming_firmware: str = "2024.26" @@ -79,18 +83,17 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charging_state", polling=True, - streaming_key=Signal.DETAILED_CHARGE_STATE, - polling_value_fn=lambda value: CHARGE_STATES.get(str(value)), - streaming_value_fn=lambda value: CHARGE_STATES.get( - str(value).replace("DetailedChargeState", "") + streaming_listener=lambda x, y: x.listen_DetailedChargeState( + lambda z: None if z is None else y(z.lower()) ), + polling_value_fn=lambda value: CHARGE_STATES.get(str(value)), options=list(CHARGE_STATES.values()), device_class=SensorDeviceClass.ENUM, ), TeslemetryVehicleSensorEntityDescription( key="charge_state_battery_level", polling=True, - streaming_key=Signal.BATTERY_LEVEL, + streaming_listener=lambda x, y: x.listen_BatteryLevel(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -99,15 +102,17 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_usable_battery_level", polling=True, + streaming_listener=lambda x, y: x.listen_Soc(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_registry_enabled_default=False, + suggested_display_precision=1, ), TeslemetryVehicleSensorEntityDescription( key="charge_state_charge_energy_added", polling=True, - streaming_key=Signal.AC_CHARGING_ENERGY_IN, + streaming_listener=lambda x, y: x.listen_ACChargingEnergyIn(y), state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -116,7 +121,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_power", polling=True, - streaming_key=Signal.AC_CHARGING_POWER, + streaming_listener=lambda x, y: x.listen_ACChargingPower(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, @@ -124,7 +129,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_voltage", polling=True, - streaming_key=Signal.CHARGER_VOLTAGE, + streaming_listener=lambda x, y: x.listen_ChargerVoltage(y), streaming_firmware="2024.44.32", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -134,7 +139,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_charger_actual_current", polling=True, - streaming_key=Signal.CHARGE_AMPS, + streaming_listener=lambda x, y: x.listen_ChargeAmps(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -151,14 +156,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_conn_charge_cable", polling=True, - streaming_key=Signal.CHARGING_CABLE_TYPE, + streaming_listener=lambda x, y: x.listen_ChargingCableType(y), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), TeslemetryVehicleSensorEntityDescription( key="charge_state_fast_charger_type", polling=True, - streaming_key=Signal.FAST_CHARGER_TYPE, + streaming_listener=lambda x, y: x.listen_FastChargerType(y), entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -173,7 +178,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_est_battery_range", polling=True, - streaming_key=Signal.EST_BATTERY_RANGE, + streaming_listener=lambda x, y: x.listen_EstBatteryRange(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -183,7 +188,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="charge_state_ideal_battery_range", polling=True, - streaming_key=Signal.IDEAL_BATTERY_RANGE, + streaming_listener=lambda x, y: x.listen_IdealBatteryRange(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -194,7 +199,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( key="drive_state_speed", polling=True, polling_value_fn=lambda value: value or 0, - streaming_key=Signal.VEHICLE_SPEED, + streaming_listener=lambda x, y: x.listen_VehicleSpeed(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, device_class=SensorDeviceClass.SPEED, @@ -213,10 +218,11 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_shift_state", polling=True, - nullable=True, polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"), - streaming_key=Signal.GEAR, - streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(), + nullable=True, + streaming_listener=lambda x, y: x.listen_Gear( + lambda z: y("p" if z is None else z.lower()) + ), options=list(SHIFT_STATES.values()), device_class=SensorDeviceClass.ENUM, entity_registry_enabled_default=False, @@ -224,7 +230,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_odometer", polling=True, - streaming_key=Signal.ODOMETER, + streaming_listener=lambda x, y: x.listen_Odometer(y), state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -235,7 +241,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fl", polling=True, - streaming_key=Signal.TPMS_PRESSURE_FL, + streaming_listener=lambda x, y: x.listen_TpmsPressureFl(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -247,7 +253,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_fr", polling=True, - streaming_key=Signal.TPMS_PRESSURE_FR, + streaming_listener=lambda x, y: x.listen_TpmsPressureFr(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -259,7 +265,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rl", polling=True, - streaming_key=Signal.TPMS_PRESSURE_RL, + streaming_listener=lambda x, y: x.listen_TpmsPressureRl(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -271,7 +277,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="vehicle_state_tpms_pressure_rr", polling=True, - streaming_key=Signal.TPMS_PRESSURE_RR, + streaming_listener=lambda x, y: x.listen_TpmsPressureRr(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.BAR, suggested_unit_of_measurement=UnitOfPressure.PSI, @@ -283,7 +289,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="climate_state_inside_temp", polling=True, - streaming_key=Signal.INSIDE_TEMP, + streaming_listener=lambda x, y: x.listen_InsideTemp(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -292,7 +298,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="climate_state_outside_temp", polling=True, - streaming_key=Signal.OUTSIDE_TEMP, + streaming_listener=lambda x, y: x.listen_OutsideTemp(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -321,7 +327,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_traffic_minutes_delay", polling=True, - streaming_key=Signal.ROUTE_TRAFFIC_MINUTES_DELAY, + streaming_listener=lambda x, y: x.listen_RouteTrafficMinutesDelay(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MINUTES, device_class=SensorDeviceClass.DURATION, @@ -330,7 +336,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_energy_at_arrival", polling=True, - streaming_key=Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL, + streaming_listener=lambda x, y: x.listen_ExpectedEnergyPercentAtTripArrival(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -340,7 +346,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="drive_state_active_route_miles_to_arrival", polling=True, - streaming_key=Signal.MILES_TO_ARRIVAL, + streaming_listener=lambda x, y: x.listen_MilesToArrival(y), state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfLength.MILES, device_class=SensorDeviceClass.DISTANCE, @@ -358,14 +364,14 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription): Callable[[], None], ] streaming_firmware: str = "2024.26" - streaming_value_fn: Callable[[float], float] = lambda x: x + streaming_unit: str VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( TeslemetryTimeEntityDescription( key="charge_state_minutes_to_full_charge", - streaming_value_fn=lambda x: x * 60, streaming_listener=lambda x, y: x.listen_TimeToFullCharge(y), + streaming_unit="hours", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, variance=4, @@ -373,6 +379,7 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = ( TeslemetryTimeEntityDescription( key="drive_state_active_route_minutes_to_arrival", streaming_listener=lambda x, y: x.listen_MinutesToArrival(y), + streaming_unit="minutes", device_class=SensorDeviceClass.TIMESTAMP, variance=1, ), @@ -547,7 +554,7 @@ async def async_setup_entry( for description in VEHICLE_DESCRIPTIONS: if ( not vehicle.api.pre2021 - and description.streaming_key + and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): entities.append(TeslemetryStreamSensorEntity(vehicle, description)) @@ -613,8 +620,7 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) ) -> None: """Initialize the sensor.""" self.entity_description = description - assert description.streaming_key - super().__init__(data, description.key, description.streaming_key) + super().__init__(data, description.key) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -623,17 +629,22 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor) if (sensor_data := await self.async_get_last_sensor_data()) is not None: self._attr_native_value = sensor_data.native_value + if self.entity_description.streaming_listener is not None: + self.async_on_remove( + self.entity_description.streaming_listener( + self.vehicle.stream_vehicle, self._async_value_from_stream + ) + ) + @cached_property def available(self) -> bool: """Return True if entity is available.""" return self.stream.connected - def _async_value_from_stream(self, value) -> None: + def _async_value_from_stream(self, value: StateType) -> None: """Update the value of the entity.""" - if self.entity_description.nullable or value is not None: - self._attr_native_value = self.entity_description.streaming_value_fn(value) - else: - self._attr_native_value = None + self._attr_native_value = value + self.async_write_ha_state() class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): @@ -676,7 +687,7 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti self.entity_description = description self._get_timestamp = ignore_variance( func=lambda value: dt_util.now() - + timedelta(minutes=description.streaming_value_fn(value)), + + timedelta(**{self.entity_description.streaming_unit: value}), ignored_variance=timedelta(minutes=description.variance), ) super().__init__(data, description.key) @@ -696,6 +707,7 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti self._attr_native_value = None else: self._attr_native_value = self._get_timestamp(value) + self.async_write_ha_state() class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 60e55c214fe..1e3c37b55b3 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -72,7 +72,7 @@ class TimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Time entity.""" entity_description: TimeEntityDescription - _attr_native_value: time | None + _attr_native_value: time | None = None _attr_device_class: None = None _attr_state: None = None diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 8bc2d11d047..60bae9bfd2e 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -31,6 +31,7 @@ from .const import ( ATTR_MINUTES_DAY_SLEEP, ATTR_MINUTES_NIGHT_SLEEP, ATTR_MINUTES_REST, + ATTR_POWER_SAVING, ATTR_SLEEP_LABEL, ATTR_TRACKER_STATE, CLIENT_ID, @@ -277,6 +278,7 @@ class TractiveClient: payload = { ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"], ATTR_TRACKER_STATE: event["tracker_state"].lower(), + ATTR_POWER_SAVING: event.get("tracker_state_reason") == "POWER_SAVING", ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING", } self._dispatch_tracker_event( diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index 2978d369344..9ded1f699c3 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any from homeassistant.components.binary_sensor import ( @@ -14,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import Trackables, TractiveClient, TractiveConfigEntry -from .const import TRACKER_HARDWARE_STATUS_UPDATED +from .const import ATTR_POWER_SAVING, TRACKER_HARDWARE_STATUS_UPDATED from .entity import TractiveEntity @@ -25,7 +27,7 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): self, client: TractiveClient, item: Trackables, - description: BinarySensorEntityDescription, + description: TractiveBinarySensorEntityDescription, ) -> None: """Initialize sensor entity.""" super().__init__( @@ -47,12 +49,27 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): super().handle_status_update(event) -SENSOR_TYPE = BinarySensorEntityDescription( - key=ATTR_BATTERY_CHARGING, - translation_key="tracker_battery_charging", - device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - entity_category=EntityCategory.DIAGNOSTIC, -) +@dataclass(frozen=True, kw_only=True) +class TractiveBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Tractive binary sensor entities.""" + + supported: Callable[[dict], bool] = lambda _: True + + +SENSOR_TYPES = [ + TractiveBinarySensorEntityDescription( + key=ATTR_BATTERY_CHARGING, + translation_key="tracker_battery_charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + supported=lambda details: details.get("charging_state") is not None, + ), + TractiveBinarySensorEntityDescription( + key=ATTR_POWER_SAVING, + translation_key="tracker_power_saving", + entity_category=EntityCategory.DIAGNOSTIC, + ), +] async def async_setup_entry( @@ -65,9 +82,10 @@ async def async_setup_entry( trackables = entry.runtime_data.trackables entities = [ - TractiveBinarySensor(client, item, SENSOR_TYPE) + TractiveBinarySensor(client, item, description) + for description in SENSOR_TYPES for item in trackables - if item.tracker_details.get("charging_state") is not None + if description.supported(item.tracker_details) ] async_add_entities(entities) diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index cb5d4066dd9..9b925015772 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -16,6 +16,7 @@ ATTR_MINUTES_ACTIVE = "minutes_active" ATTR_MINUTES_DAY_SLEEP = "minutes_day_sleep" ATTR_MINUTES_NIGHT_SLEEP = "minutes_night_sleep" ATTR_MINUTES_REST = "minutes_rest" +ATTR_POWER_SAVING = "power_saving" ATTR_SLEEP_LABEL = "sleep_label" ATTR_TRACKER_STATE = "tracker_state" diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json index 0690328c99c..a56a2982057 100644 --- a/homeassistant/components/tractive/strings.json +++ b/homeassistant/components/tractive/strings.json @@ -22,6 +22,9 @@ "binary_sensor": { "tracker_battery_charging": { "name": "Tracker battery charging" + }, + "tracker_power_saving": { + "name": "Tracker power saving" } }, "device_tracker": { diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index cb207643471..8182d375f96 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -14,8 +14,6 @@ import mimetypes import os import re import secrets -import subprocess -import tempfile from time import monotonic from typing import Any, Final @@ -309,80 +307,73 @@ async def _async_convert_audio( ) -> AsyncGenerator[bytes]: """Convert audio to a preferred format using ffmpeg.""" ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) - audio_bytes = b"".join([chunk async for chunk in audio_bytes_gen]) - data = await hass.async_add_executor_job( - lambda: _convert_audio( - ffmpeg_manager.binary, - from_extension, - audio_bytes, - to_extension, - to_sample_rate=to_sample_rate, - to_sample_channels=to_sample_channels, - to_sample_bytes=to_sample_bytes, - ) + + command = [ + ffmpeg_manager.binary, + "-hide_banner", + "-loglevel", + "error", + "-f", + from_extension, + "-i", + "pipe:", + "-f", + to_extension, + ] + if to_sample_rate is not None: + command.extend(["-ar", str(to_sample_rate)]) + if to_sample_channels is not None: + command.extend(["-ac", str(to_sample_channels)]) + if to_extension == "mp3": + # Max quality for MP3. + command.extend(["-q:a", "0"]) + if to_sample_bytes == 2: + # 16-bit samples. + command.extend(["-sample_fmt", "s16"]) + command.append("pipe:1") # Send output to stdout. + + process = await asyncio.create_subprocess_exec( + *command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) - yield data + async def write_input() -> None: + assert process.stdin + try: + async for chunk in audio_bytes_gen: + process.stdin.write(chunk) + await process.stdin.drain() + finally: + if process.stdin: + process.stdin.close() -def _convert_audio( - ffmpeg_binary: str, - from_extension: str, - audio_bytes: bytes, - to_extension: str, - to_sample_rate: int | None = None, - to_sample_channels: int | None = None, - to_sample_bytes: int | None = None, -) -> bytes: - """Convert audio to a preferred format using ffmpeg.""" + writer_task = hass.async_create_background_task( + write_input(), "tts_ffmpeg_conversion" + ) - # We have to use a temporary file here because some formats like WAV store - # the length of the file in the header, and therefore cannot be written in a - # streaming fashion. - with tempfile.NamedTemporaryFile( - mode="wb+", suffix=f".{to_extension}" - ) as output_file: - # input - command = [ - ffmpeg_binary, - "-y", # overwrite temp file - "-f", - from_extension, - "-i", - "pipe:", # input from stdin - ] - - # output - command.extend(["-f", to_extension]) - - if to_sample_rate is not None: - command.extend(["-ar", str(to_sample_rate)]) - - if to_sample_channels is not None: - command.extend(["-ac", str(to_sample_channels)]) - - if to_extension == "mp3": - # Max quality for MP3 - command.extend(["-q:a", "0"]) - - if to_sample_bytes == 2: - # 16-bit samples - command.extend(["-sample_fmt", "s16"]) - - command.append(output_file.name) - - with subprocess.Popen( - command, stdin=subprocess.PIPE, stderr=subprocess.PIPE - ) as proc: - _stdout, stderr = proc.communicate(input=audio_bytes) - if proc.returncode != 0: - _LOGGER.error(stderr.decode()) - raise RuntimeError( - f"Unexpected error while running ffmpeg with arguments: {command}." - "See log for details." - ) - - output_file.seek(0) - return output_file.read() + assert process.stdout + chunk_size = 4096 + try: + while True: + chunk = await process.stdout.read(chunk_size) + if not chunk: + break + yield chunk + finally: + # Ensure we wait for the input writer to complete. + await writer_task + # Wait for process termination and check for errors. + retcode = await process.wait() + if retcode != 0: + assert process.stderr + stderr_data = await process.stderr.read() + _LOGGER.error(stderr_data.decode()) + raise RuntimeError( + f"Unexpected error while running ffmpeg with arguments: {command}. " + "See log for details." + ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 5440b2bebeb..88939f0ba77 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -178,7 +178,11 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._pipeline_ended_event.set() self.device.set_is_active(False) elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: - self.hass.add_job(self._client.write_event(Detect().event())) + self.config_entry.async_create_background_task( + self.hass, + self._client.write_event(Detect().event()), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END: # Wake word detection # Inform client of wake word detection @@ -187,46 +191,59 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): name=wake_word_output["wake_word_id"], timestamp=wake_word_output.get("timestamp"), ) - self.hass.add_job(self._client.write_event(detection.event())) + self.config_entry.async_create_background_task( + self.hass, + self._client.write_event(detection.event()), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.STT_START: # Speech-to-text self.device.set_is_active(True) if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( Transcribe(language=event.data["metadata"]["language"]).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START: # User started speaking if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( VoiceStarted(timestamp=event.data["timestamp"]).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END: # User stopped speaking if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( VoiceStopped(timestamp=event.data["timestamp"]).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.STT_END: # Speech-to-text transcript if event.data: # Inform client of transript stt_text = event.data["stt_output"]["text"] - self.hass.add_job( - self._client.write_event(Transcript(text=stt_text).event()) + self.config_entry.async_create_background_task( + self.hass, + self._client.write_event(Transcript(text=stt_text).event()), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.TTS_START: # Text-to-speech text if event.data: # Inform client of text - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( Synthesize( text=event.data["tts_input"], @@ -235,22 +252,32 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): language=event.data.get("language"), ), ).event() - ) + ), + f"{self.entity_id} {event.type}", ) elif event.type == assist_pipeline.PipelineEventType.TTS_END: # TTS stream - if event.data and (tts_output := event.data["tts_output"]): - media_id = tts_output["media_id"] - self.hass.add_job(self._stream_tts(media_id)) + if ( + event.data + and (tts_output := event.data["tts_output"]) + and (stream := tts.async_get_stream(self.hass, tts_output["token"])) + ): + self.config_entry.async_create_background_task( + self.hass, + self._stream_tts(stream), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.ERROR: # Pipeline error if event.data: - self.hass.add_job( + self.config_entry.async_create_background_task( + self.hass, self._client.write_event( Error( text=event.data["message"], code=event.data["code"] ).event() - ) + ), + f"{self.entity_id} {event.type}", ) async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: @@ -662,13 +689,16 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): await self._client.disconnect() self._client = None - async def _stream_tts(self, media_id: str) -> None: + async def _stream_tts(self, tts_result: tts.ResultStream) -> None: """Stream TTS WAV audio to satellite in chunks.""" assert self._client is not None - extension, data = await tts.async_get_media_source_audio(self.hass, media_id) - if extension != "wav": - raise ValueError(f"Cannot stream audio format to satellite: {extension}") + if tts_result.extension != "wav": + raise ValueError( + f"Cannot stream audio format to satellite: {tts_result.extension}" + ) + + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: sample_rate = wav_file.getframerate() diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 1c2d6556271..04f3658d924 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.55"], + "requirements": ["zha==0.0.56"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 43429bdb1d2..2b4da38b15e 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -629,6 +629,10 @@ class _ScriptRun: self, script: Script, *, parallel: bool = False ) -> None: """Execute a script.""" + if not script.enabled: + self._log("Skipping disabled script: %s", script.name) + trace_set_result(enabled=False) + return result = await self._async_run_long_action( self._hass.async_create_task_internal( script.async_run( @@ -1442,8 +1446,12 @@ class Script: script_mode: str = DEFAULT_SCRIPT_MODE, top_level: bool = True, variables: ScriptVariables | None = None, + enabled: bool = True, ) -> None: - """Initialize the script.""" + """Initialize the script. + + enabled attribute is only used for non-top-level scripts. + """ if not (all_scripts := hass.data.get(DATA_SCRIPTS)): all_scripts = hass.data[DATA_SCRIPTS] = [] hass.bus.async_listen_once( @@ -1462,6 +1470,7 @@ class Script: self.name = name self.unique_id = f"{domain}.{name}-{id(self)}" self.domain = domain + self.enabled = enabled self.running_description = running_description or f"{domain} script" self._change_listener = change_listener self._change_listener_job = ( @@ -2002,6 +2011,7 @@ class Script: max_runs=self.max_runs, logger=self._logger, top_level=False, + enabled=parallel_script.get(CONF_ENABLED, True), ) parallel_script.change_listener = partial( self._chain_change_listener, parallel_script diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 43b9b1fdb3f..51148108cd4 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -127,6 +127,7 @@ "discharging": "Discharging", "disconnected": "Disconnected", "enabled": "Enabled", + "error": "Error", "high": "High", "home": "Home", "idle": "Idle", diff --git a/requirements_all.txt b/requirements_all.txt index 6c1b3fc6a42..f34ab4a2d55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1852,7 +1852,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.0 +pyblu==2.0.1 # homeassistant.components.neato pybotvac==0.0.26 @@ -2331,7 +2331,7 @@ pysmartthings==3.0.4 pysmarty2==0.10.2 # homeassistant.components.smhi -pysmhi==1.0.1 +pysmhi==1.0.2 # homeassistant.components.edl21 pysml==0.0.12 @@ -3158,7 +3158,7 @@ zeroconf==0.146.5 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.55 +zha==0.0.56 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test.txt b/requirements_test.txt index 53590eb0e68..80be991cfcd 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -35,19 +35,19 @@ requests-mock==1.12.1 respx==0.22.0 syrupy==4.8.1 tqdm==4.67.1 -types-aiofiles==24.1.0.20241221 +types-aiofiles==24.1.0.20250326 types-atomicwrites==1.4.5.1 -types-croniter==5.0.1.20241205 +types-croniter==6.0.0.20250411 types-caldav==1.3.0.20241107 types-chardet==0.1.5 -types-decorator==5.1.8.20250121 +types-decorator==5.2.0.20250324 types-pexpect==4.9.0.20241208 -types-protobuf==5.29.1.20241207 -types-psutil==6.1.0.20241221 -types-pyserial==3.5.0.20250130 +types-protobuf==5.29.1.20250403 +types-psutil==7.0.0.20250401 +types-pyserial==3.5.0.20250326 types-python-dateutil==2.9.0.20241206 types-python-slugify==8.0.2.20240310 -types-pytz==2025.1.0.20250204 -types-PyYAML==6.0.12.20241230 +types-pytz==2025.2.0.20250326 +types-PyYAML==6.0.12.20250402 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47403cf14d6..82d6baed915 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1530,7 +1530,7 @@ pybalboa==1.1.3 pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==2.0.0 +pyblu==2.0.1 # homeassistant.components.neato pybotvac==0.0.26 @@ -1904,7 +1904,7 @@ pysmartthings==3.0.4 pysmarty2==0.10.2 # homeassistant.components.smhi -pysmhi==1.0.1 +pysmhi==1.0.2 # homeassistant.components.edl21 pysml==0.0.12 @@ -2554,7 +2554,7 @@ zeroconf==0.146.5 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.55 +zha==0.0.56 # homeassistant.components.zwave_js zwave-js-server-python==0.62.0 diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 30aba6e1b1f..1f41b7df2c7 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -196,13 +196,13 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ( { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "none", CONF_PROMPT: "bla", }, { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.3, + CONF_LLM_HASS_API: [], }, { CONF_RECOMMENDED: False, @@ -224,15 +224,32 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, ), + ( + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: "assist", + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + ), ], ) async def test_options_switching( diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index e07b580acb2..e0b491e8f66 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -23,8 +23,7 @@ from . import ( @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") def disable_bluez_manager_socket(): """Mock the bluez manager socket.""" - with patch.object(bleak_manager, "get_global_bluez_manager_with_timeout"): - yield + bleak_manager.get_global_bluez_manager_with_timeout._has_dbus_socket = False @pytest.fixture(name="disable_dbus_socket", autouse=True, scope="package") diff --git a/tests/components/cover/test_reproduce_state.py b/tests/components/cover/test_reproduce_state.py index 57fc5aed5e9..dfc22abac91 100644 --- a/tests/components/cover/test_reproduce_state.py +++ b/tests/components/cover/test_reproduce_state.py @@ -178,6 +178,22 @@ async def test_reproducing_states( | CoverEntityFeature.OPEN, }, ) + hass.states.async_set( + "cover.closed_supports_all_features", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + ATTR_CURRENT_TILT_POSITION: 0, + ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, + }, + ) hass.states.async_set( "cover.tilt_only_open", CoverState.OPEN, @@ -249,6 +265,14 @@ async def test_reproducing_states( await async_reproduce_state( hass, [ + State( + "cover.closed_supports_all_features", + CoverState.CLOSED, + { + ATTR_CURRENT_POSITION: 0, + ATTR_CURRENT_TILT_POSITION: 0, + }, + ), State("cover.entity_close", CoverState.CLOSED), State("cover.closed_only_supports_close_open", CoverState.CLOSED), State("cover.closed_only_supports_tilt_close_open", CoverState.CLOSED), @@ -364,6 +388,11 @@ async def test_reproducing_states( await async_reproduce_state( hass, [ + State( + "cover.closed_supports_all_features", + CoverState.CLOSED, + {ATTR_CURRENT_POSITION: 0, ATTR_CURRENT_TILT_POSITION: 50}, + ), State("cover.entity_close", CoverState.OPEN), State( "cover.closed_only_supports_close_open", @@ -458,7 +487,6 @@ async def test_reproducing_states( valid_close_calls = [ {"entity_id": "cover.entity_open"}, {"entity_id": "cover.entity_open_attr"}, - {"entity_id": "cover.entity_entirely_open"}, {"entity_id": "cover.open_only_supports_close_open"}, {"entity_id": "cover.open_missing_all_features"}, ] @@ -481,11 +509,8 @@ async def test_reproducing_states( valid_open_calls.remove(call.data) valid_close_tilt_calls = [ - {"entity_id": "cover.entity_open_tilt"}, - {"entity_id": "cover.entity_entirely_open"}, {"entity_id": "cover.tilt_only_open"}, {"entity_id": "cover.entity_open_attr"}, - {"entity_id": "cover.tilt_only_tilt_position_100"}, {"entity_id": "cover.open_only_supports_tilt_close_open"}, ] assert len(close_tilt_calls) == len(valid_close_tilt_calls) @@ -495,9 +520,7 @@ async def test_reproducing_states( valid_close_tilt_calls.remove(call.data) valid_open_tilt_calls = [ - {"entity_id": "cover.entity_close_tilt"}, {"entity_id": "cover.tilt_only_closed"}, - {"entity_id": "cover.tilt_only_tilt_position_0"}, {"entity_id": "cover.closed_only_supports_tilt_close_open"}, ] assert len(open_tilt_calls) == len(valid_open_tilt_calls) @@ -523,6 +546,14 @@ async def test_reproducing_states( "entity_id": "cover.open_only_supports_position", ATTR_POSITION: 0, }, + { + "entity_id": "cover.closed_supports_all_features", + ATTR_POSITION: 0, + }, + { + "entity_id": "cover.entity_entirely_open", + ATTR_POSITION: 0, + }, ] assert len(position_calls) == len(valid_position_calls) for call in position_calls: @@ -551,7 +582,34 @@ async def test_reproducing_states( "entity_id": "cover.tilt_partial_open_only_supports_tilt_position", ATTR_TILT_POSITION: 70, }, + { + "entity_id": "cover.closed_supports_all_features", + ATTR_TILT_POSITION: 50, + }, + { + "entity_id": "cover.entity_close_tilt", + ATTR_TILT_POSITION: 100, + }, + { + "entity_id": "cover.entity_open_tilt", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.entity_entirely_open", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.tilt_only_tilt_position_100", + ATTR_TILT_POSITION: 0, + }, + { + "entity_id": "cover.tilt_only_tilt_position_0", + ATTR_TILT_POSITION: 100, + }, ] + for call in position_tilt_calls: + if ATTR_TILT_POSITION not in call.data: + continue assert len(position_tilt_calls) == len(valid_position_tilt_calls) for call in position_tilt_calls: assert call.domain == "cover" diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 440e52700b1..9d400ba618b 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -813,12 +813,15 @@ async def test_reauth_confirm_valid( entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} ) @@ -828,6 +831,48 @@ async def test_reauth_confirm_valid( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reauth_attempt_to_change_mac_aborts( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth initiation with valid PSK attempting to change mac. + + This can happen if reauth starts, but they don't finish it before + a new device takes the place of the old one at the same IP. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unique_id_changed" + assert CONF_NOISE_PSK not in entry.data + assert result["description_placeholders"] == { + "expected_mac": "11:22:33:44:55:aa", + "host": "127.0.0.1", + "name": "test", + "unexpected_device_name": "test", + "unexpected_mac": "11:22:33:44:55:bb", + } + + @pytest.mark.usefixtures("mock_zeroconf") async def test_reauth_fixed_via_dashboard( hass: HomeAssistant, @@ -845,10 +890,13 @@ async def test_reauth_fixed_via_dashboard( CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) mock_dashboard["configured"].append( { @@ -883,7 +931,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( """Test reauth fixed automatically via dashboard with password removed.""" mock_client.device_info.side_effect = ( InvalidAuthAPIError, - DeviceInfo(uses_password=False, name="test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), ) mock_dashboard["configured"].append( @@ -917,7 +965,9 @@ async def test_reauth_fixed_via_remove_password( mock_setup_entry: None, ) -> None: """Test reauth fixed automatically by seeing password removed.""" - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) result = await mock_config_entry.start_reauth_flow(hass) @@ -943,10 +993,13 @@ async def test_reauth_fixed_via_dashboard_at_confirm( CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) result = await entry.start_reauth_flow(hass) @@ -984,6 +1037,7 @@ async def test_reauth_confirm_invalid( entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) @@ -1000,7 +1054,9 @@ async def test_reauth_confirm_invalid( assert result["errors"]["base"] == "invalid_psk" mock_client.device_info = AsyncMock( - return_value=DeviceInfo(uses_password=False, name="test") + return_value=DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1019,7 +1075,7 @@ async def test_reauth_confirm_invalid_with_unique_id( entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, - unique_id="test", + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) @@ -1036,7 +1092,9 @@ async def test_reauth_confirm_invalid_with_unique_id( assert result["errors"]["base"] == "invalid_psk" mock_client.device_info = AsyncMock( - return_value=DeviceInfo(uses_password=False, name="test") + return_value=DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1049,7 +1107,7 @@ async def test_reauth_confirm_invalid_with_unique_id( @pytest.mark.usefixtures("mock_zeroconf") async def test_reauth_encryption_key_removed( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None ) -> None: """Test reauth when the encryption key was removed.""" entry = MockConfigEntry( @@ -1060,7 +1118,7 @@ async def test_reauth_encryption_key_removed( CONF_PASSWORD: "", CONF_NOISE_PSK: VALID_NOISE_PSK, }, - unique_id="test", + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) @@ -1660,7 +1718,11 @@ async def test_user_flow_name_conflict_migrate( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "name_conflict_migrated" - + assert result["description_placeholders"] == { + "existing_mac": "11:22:33:44:55:cc", + "mac": "11:22:33:44:55:aa", + "name": "test", + } assert existing_entry.data == { CONF_HOST: "127.0.0.1", CONF_PORT: 6053, @@ -1715,3 +1777,321 @@ async def test_user_flow_name_conflict_overwrite( CONF_DEVICE_NAME: "test", } assert result["context"]["unique_id"] == "11:22:33:44:55:aa" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_with_same_ip_new_name( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with same ip and new name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_DEVICE_NAME] == "other" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_with_new_ip_new_name( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with new ip and new name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.2" + assert entry.data[CONF_DEVICE_NAME] == "other" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_success_with_new_ip_same_name( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with new ip and same name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + CONF_NOISE_PSK: VALID_NOISE_PSK, + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data[CONF_HOST] == "127.0.0.1" + assert entry.data[CONF_DEVICE_NAME] == "test" + assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_with_existing_entry( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig with a name conflict with an existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + entry2 = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "other", + }, + unique_id="11:22:33:44:55:bb", + ) + entry2.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:aa" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.3", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_name_conflict" + assert result["description_placeholders"] == { + "existing_title": "Mock Title", + "expected_mac": "11:22:33:44:55:aa", + "host": "127.0.0.3", + "name": "test", + } + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_attempt_to_change_mac_aborts( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with valid PSK attempting to change mac.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_unique_id_changed" + assert CONF_NOISE_PSK not in entry.data + assert result["description_placeholders"] == { + "expected_mac": "11:22:33:44:55:aa", + "host": "127.0.0.2", + "name": "test", + "unexpected_device_name": "other", + "unexpected_mac": "11:22:33:44:55:bb", + } + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_mac_used_by_other_entry( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig when there is another entry for the mac.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + entry2 = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test4", + }, + unique_id="11:22:33:44:55:bb", + ) + entry2.add_to_hass(hass) + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_migrate( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation when device has been replaced.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_migrate"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "name_conflict_migrated" + + assert entry.data == { + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert entry.unique_id == "11:22:33:44:55:bb" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_overwrite( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation when device has been replaced.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert result["context"]["unique_id"] == "11:22:33:44:55:bb" + assert ( + hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, "11:22:33:44:55:aa" + ) + is None + ) diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index f2d77a18618..1f675a10b82 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -193,7 +193,7 @@ async def test_new_dashboard_fix_reauth( """Test config entries waiting for reauth are triggered.""" mock_client.device_info.side_effect = ( InvalidAuthAPIError, - DeviceInfo(uses_password=False, name="test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"), ) with patch( diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 977ec50ab30..5c82337e71b 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -500,6 +500,6 @@ async def test_esphome_device_without_friendly_name( states=states, device_info={"friendly_name": None}, ) - state = hass.states.get("binary_sensor.my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index c897377f719..a4cef909fcc 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1577,3 +1577,51 @@ async def test_entry_missing_bluetooth_mac_address( ) await hass.async_block_till_done() assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC" + + +async def test_device_adds_friendly_name( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a device with user services that change arguments.""" + entity_info = [] + states = [] + device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=[], + device_info={"name": "nofriendlyname", "friendly_name": ""}, + states=states, + ) + await hass.async_block_till_done() + dev_reg = dr.async_get(hass) + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.entry.unique_id)} + ) + assert dev.name == "Nofriendlyname" + assert ( + "No `friendly_name` set in the `esphome:` section of " + "the YAML config for device 'nofriendlyname'" + ) in caplog.text + caplog.clear() + + await device.mock_disconnect(True) + await hass.async_block_till_done() + device.device_info = DeviceInfo( + **{**device.device_info.to_dict(), "friendly_name": "I have a friendly name"} + ) + mock_client.device_info = AsyncMock(return_value=device.device_info) + await device.mock_connect() + await hass.async_block_till_done() + dev = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.entry.unique_id)} + ) + assert dev.name == "I have a friendly name" + assert ( + "No `friendly_name` set in the `esphome:` section of the YAML config for device" + ) not in caplog.text diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 8fda02b335d..13063580c95 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -125,7 +125,6 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: ( { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "none", CONF_PROMPT: "bla", }, { @@ -162,12 +161,12 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, None, @@ -235,7 +234,7 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.3, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, @@ -263,6 +262,24 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: }, {CONF_USE_GOOGLE_SEARCH_TOOL: "invalid_google_search_option"}, ), + ( + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: "assist", + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "", + CONF_LLM_HASS_API: ["assist"], + }, + None, + ), ], ) @pytest.mark.usefixtures("mock_init_component") diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index df3fe3f710b..d435bac81eb 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -17,7 +17,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -84,7 +84,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -151,7 +151,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, @@ -218,7 +218,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'climate', - 'entity_category': , + 'entity_category': None, 'entity_id': 'climate.thermostat_1', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index dc66c1e0d7d..d6928c189e8 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -1,57 +1 @@ """Tests for the jewish_calendar component.""" - -from collections import namedtuple -from datetime import datetime - -from homeassistant.components import jewish_calendar -from homeassistant.util import dt as dt_util - -_LatLng = namedtuple("_LatLng", ["lat", "lng"]) # noqa: PYI024 - -HDATE_DEFAULT_ALTITUDE = 754 -NYC_LATLNG = _LatLng(40.7128, -74.0060) -JERUSALEM_LATLNG = _LatLng(31.778, 35.235) - - -def make_nyc_test_params(dtime, results, havdalah_offset=0): - """Make test params for NYC.""" - if isinstance(results, dict): - time_zone = dt_util.get_time_zone("America/New_York") - results = { - key: value.replace(tzinfo=time_zone) - if isinstance(value, datetime) - else value - for key, value in results.items() - } - return ( - dtime, - jewish_calendar.DEFAULT_CANDLE_LIGHT, - havdalah_offset, - True, - "America/New_York", - NYC_LATLNG.lat, - NYC_LATLNG.lng, - results, - ) - - -def make_jerusalem_test_params(dtime, results, havdalah_offset=0): - """Make test params for Jerusalem.""" - if isinstance(results, dict): - time_zone = dt_util.get_time_zone("Asia/Jerusalem") - results = { - key: value.replace(tzinfo=time_zone) - if isinstance(value, datetime) - else value - for key, value in results.items() - } - return ( - dtime, - 40, - havdalah_offset, - False, - "Asia/Jerusalem", - JERUSALEM_LATLNG.lat, - JERUSALEM_LATLNG.lng, - results, - ) diff --git a/tests/components/jewish_calendar/conftest.py b/tests/components/jewish_calendar/conftest.py index 97909291f27..6bab16833ed 100644 --- a/tests/components/jewish_calendar/conftest.py +++ b/tests/components/jewish_calendar/conftest.py @@ -1,22 +1,39 @@ """Common fixtures for the jewish_calendar tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator, Iterable +import datetime as dt +from typing import NamedTuple from unittest.mock import AsyncMock, patch +from freezegun import freeze_time import pytest -from homeassistant.components.jewish_calendar.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.jewish_calendar.const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE, CONF_TIME_ZONE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return the default mocked config entry.""" - return MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - ) +class _LocationData(NamedTuple): + timezone: str + diaspora: bool + lat: float + lng: float + candle_lighting: int + + +LOCATIONS = { + "Jerusalem": _LocationData("Asia/Jerusalem", False, 31.7683, 35.2137, 40), + "New York": _LocationData("America/New_York", True, 40.7128, -74.006, 18), +} @pytest.fixture @@ -26,3 +43,109 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.jewish_calendar.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def location_data(request: pytest.FixtureRequest) -> _LocationData | None: + """Return data based on location name.""" + if not hasattr(request, "param"): + return None + + return LOCATIONS[request.param] + + +@pytest.fixture +def tz_info(hass: HomeAssistant, location_data: _LocationData | None) -> dt.tzinfo: + """Return time zone info.""" + if location_data is None: + return dt_util.get_time_zone(hass.config.time_zone) + return dt_util.get_time_zone(location_data.timezone) + + +@pytest.fixture(name="test_time") +def _test_time( + request: pytest.FixtureRequest, tz_info: dt.tzinfo +) -> dt.datetime | None: + """Return localized test time based.""" + if not hasattr(request, "param"): + return None + + return request.param.replace(tzinfo=tz_info) + + +@pytest.fixture +def results(request: pytest.FixtureRequest, tz_info: dt.tzinfo) -> Iterable: + """Return localized results.""" + if not hasattr(request, "param"): + return None + + if isinstance(request.param, dict): + return { + key: value.replace(tzinfo=tz_info) + if isinstance(value, dt.datetime) + else value + for key, value in request.param.items() + } + return request.param + + +@pytest.fixture +def havdalah_offset() -> int | None: + """Return None if default havdalah offset is not specified.""" + return None + + +@pytest.fixture +def language() -> str: + """Return default language value, unless language is parametrized.""" + return "english" + + +@pytest.fixture(autouse=True) +async def setup_hass(hass: HomeAssistant, location_data: _LocationData | None) -> None: + """Set up Home Assistant for testing the jewish_calendar integration.""" + + if location_data: + await hass.config.async_set_time_zone(location_data.timezone) + hass.config.latitude = location_data.lat + hass.config.longitude = location_data.lng + + +@pytest.fixture +def config_entry( + location_data: _LocationData | None, + language: str, + havdalah_offset: int | None, +) -> MockConfigEntry: + """Set up the jewish_calendar integration for testing.""" + param_data = {} + param_options = {} + + if location_data: + param_data = { + CONF_DIASPORA: location_data.diaspora, + CONF_TIME_ZONE: location_data.timezone, + } + param_options[CONF_CANDLE_LIGHT_MINUTES] = location_data.candle_lighting + + if havdalah_offset: + param_options[CONF_HAVDALAH_OFFSET_MINUTES] = havdalah_offset + + return MockConfigEntry( + title=DEFAULT_NAME, + domain=DOMAIN, + data={CONF_LANGUAGE: language, **param_data}, + options=param_options, + ) + + +@pytest.fixture +async def setup_at_time( + test_time: dt.datetime, hass: HomeAssistant, config_entry: MockConfigEntry +) -> AsyncGenerator[None]: + """Set up the jewish_calendar integration at a specific time.""" + with freeze_time(test_time): + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + yield diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 194e6fe9d01..46f5fdfcc7d 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -1,301 +1,145 @@ """The tests for the Jewish calendar binary sensors.""" from datetime import datetime as dt, timedelta -import logging +from typing import Any -from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.jewish_calendar.const import ( - CONF_CANDLE_LIGHT_MINUTES, - CONF_DIASPORA, - CONF_HAVDALAH_OFFSET_MINUTES, - DEFAULT_NAME, - DOMAIN, -) -from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util - -from . import make_jerusalem_test_params, make_nyc_test_params - -from tests.common import MockConfigEntry, async_fire_time_changed - -_LOGGER = logging.getLogger(__name__) +from tests.common import async_fire_time_changed MELACHA_PARAMS = [ - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 16, 0), - { - "state": STATE_ON, - "update": dt(2018, 9, 1, 20, 14), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 9, 1, 20, 14), "new_state": STATE_OFF}, + id="currently_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 20, 21), - { - "state": STATE_OFF, - "update": dt(2018, 9, 2, 6, 21), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 2, 6, 21), "new_state": STATE_OFF}, + id="after_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 7, 13, 1), - { - "state": STATE_OFF, - "update": dt(2018, 9, 7, 19, 4), - "new_state": STATE_ON, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 7, 19, 4), "new_state": STATE_ON}, + id="friday_upcoming_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 8, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 9, 9, 6, 27), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 9, 6, 27), "new_state": STATE_OFF}, + id="upcoming_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 9, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 10, 6, 28), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 9, 10, 6, 28), "new_state": STATE_ON}, + id="currently_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 10, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 11, 6, 29), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 9, 11, 6, 29), "new_state": STATE_ON}, + id="second_day_rosh_hashana_night", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 11, 11, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 11, 19, 57), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 9, 11, 19, 57), "new_state": STATE_OFF}, + id="second_day_rosh_hashana_day", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 29, 16, 25), - { - "state": STATE_ON, - "update": dt(2018, 9, 29, 19, 25), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 9, 29, 19, 25), "new_state": STATE_OFF}, + id="currently_shabbat_chol_hamoed", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 29, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 9, 30, 6, 48), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 48), "new_state": STATE_OFF}, + id="upcoming_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 30, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 10, 1, 6, 49), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 10, 1, 6, 49), "new_state": STATE_ON}, + id="currently_first_day_of_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 10, 1, 21, 25), - { - "state": STATE_ON, - "update": dt(2018, 10, 2, 6, 50), - "new_state": STATE_ON, - }, + {"state": STATE_ON, "update": dt(2018, 10, 2, 6, 50), "new_state": STATE_ON}, + id="currently_second_day_of_two_day_yomtov_in_diaspora", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 9, 29, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 9, 30, 6, 29), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 29), "new_state": STATE_OFF}, + id="upcoming_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 10, 1, 11, 25), - { - "state": STATE_ON, - "update": dt(2018, 10, 1, 19, 2), - "new_state": STATE_OFF, - }, + {"state": STATE_ON, "update": dt(2018, 10, 1, 19, 2), "new_state": STATE_OFF}, + id="currently_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 10, 1, 21, 25), - { - "state": STATE_OFF, - "update": dt(2018, 10, 2, 6, 31), - "new_state": STATE_OFF, - }, + {"state": STATE_OFF, "update": dt(2018, 10, 2, 6, 31), "new_state": STATE_OFF}, + id="after_one_day_yom_tov_in_israel", ), ] -MELACHA_TEST_IDS = [ - "currently_first_shabbat", - "after_first_shabbat", - "friday_upcoming_shabbat", - "upcoming_rosh_hashana", - "currently_rosh_hashana", - "second_day_rosh_hashana_night", - "second_day_rosh_hashana_day", - "currently_shabbat_chol_hamoed", - "upcoming_two_day_yomtov_in_diaspora", - "currently_first_day_of_two_day_yomtov_in_diaspora", - "currently_second_day_of_two_day_yomtov_in_diaspora", - "upcoming_one_day_yom_tov_in_israel", - "currently_one_day_yom_tov_in_israel", - "after_one_day_yom_tov_in_israel", -] - @pytest.mark.parametrize( - ( - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ), - MELACHA_PARAMS, - ids=MELACHA_TEST_IDS, + ("location_data", "test_time", "results"), MELACHA_PARAMS, indirect=True ) +@pytest.mark.usefixtures("setup_at_time") async def test_issur_melacha_sensor( - hass: HomeAssistant, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any] ) -> None: """Test Issur Melacha sensor output.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) + sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect" + assert hass.states.get(sensor_id).state == results["state"] - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with freeze_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: "english", - CONF_DIASPORA: diaspora, - CONF_CANDLE_LIGHT_MINUTES: candle_lighting, - CONF_HAVDALAH_OFFSET_MINUTES: havdalah, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result["state"] - ) - - with freeze_time(result["update"]): - async_fire_time_changed(hass, result["update"]) - await hass.async_block_till_done() - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result["new_state"] - ) + freezer.move_to(results["update"]) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(sensor_id).state == results["new_state"] @pytest.mark.parametrize( - ( - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ), + ("location_data", "test_time", "results"), [ - make_nyc_test_params( - dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON] - ), - make_nyc_test_params( - dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF] - ), + ("New York", dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON]), + ("New York", dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF]), ], ids=["before_candle_lighting", "before_havdalah"], + indirect=True, ) +@pytest.mark.usefixtures("setup_at_time") async def test_issur_melacha_sensor_update( - hass: HomeAssistant, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, + hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: list[str] ) -> None: """Test Issur Melacha sensor output.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) + sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect" + assert hass.states.get(sensor_id).state == results[0] - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with freeze_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: "english", - CONF_DIASPORA: diaspora, - CONF_CANDLE_LIGHT_MINUTES: candle_lighting, - CONF_HAVDALAH_OFFSET_MINUTES: havdalah, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result[0] - ) - - test_time += timedelta(microseconds=1) - with freeze_time(test_time): - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() - assert ( - hass.states.get( - "binary_sensor.jewish_calendar_issur_melacha_in_effect" - ).state - == result[1] - ) + freezer.tick(timedelta(microseconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(sensor_id).state == results[1] async def test_no_discovery_info( diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index e00fe41749f..7a8b6b8df1e 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -57,10 +57,10 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No async def test_single_instance_allowed( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + config_entry: MockConfigEntry, ) -> None: """Test we abort if already setup.""" - mock_config_entry.add_to_hass(hass) + config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -70,11 +70,11 @@ async def test_single_instance_allowed( assert result.get("reason") == "single_instance_allowed" -async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: +async def test_options(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test updating options.""" - mock_config_entry.add_to_hass(hass) + config_entry.add_to_hass(hass) - result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" @@ -95,16 +95,16 @@ async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry) async def test_options_reconfigure( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test that updating the options of the Jewish Calendar integration triggers a value update.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert CONF_CANDLE_LIGHT_MINUTES not in mock_config_entry.options + assert CONF_CANDLE_LIGHT_MINUTES not in config_entry.options # Update the CONF_CANDLE_LIGHT_MINUTES option to a new value - result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ @@ -114,21 +114,17 @@ async def test_options_reconfigure( assert result["result"] # The value of the "upcoming_shabbat_candle_lighting" sensor should be the new value - assert ( - mock_config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1 - ) + assert config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1 -async def test_reconfigure( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: +async def test_reconfigure(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test starting a reconfigure flow.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() # init user flow - result = await mock_config_entry.start_reconfigure_flow(hass) + result = await config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" @@ -141,4 +137,4 @@ async def test_reconfigure( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert mock_config_entry.data[CONF_DIASPORA] is not DEFAULT_DIASPORA + assert config_entry.data[CONF_DIASPORA] is not DEFAULT_DIASPORA diff --git a/tests/components/jewish_calendar/test_init.py b/tests/components/jewish_calendar/test_init.py index 6a4f57513fa..88ba1334210 100644 --- a/tests/components/jewish_calendar/test_init.py +++ b/tests/components/jewish_calendar/test_init.py @@ -21,24 +21,24 @@ from tests.common import MockConfigEntry async def test_migrate_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, old_key: str, new_key: str, ) -> None: """Test unique id migration.""" - entry = MockConfigEntry(domain=DOMAIN, data={}) - entry.add_to_hass(hass) + config_entry.add_to_hass(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( domain=SENSOR_DOMAIN, platform=DOMAIN, - unique_id=f"{entry.entry_id}-{old_key}", - config_entry=entry, + unique_id=f"{config_entry.entry_id}-{old_key}", + config_entry=config_entry, ) assert entity.unique_id.endswith(f"-{old_key}") - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated - assert entity_migrated.unique_id == f"{entry.entry_id}-{new_key}" + assert entity_migrated.unique_id == f"{config_entry.entry_id}-{new_key}" diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index bc9e69a9717..e70fdd49452 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -1,282 +1,184 @@ """The tests for the Jewish calendar sensors.""" -from datetime import datetime as dt, timedelta +from datetime import datetime as dt +from typing import Any -from freezegun import freeze_time from hdate.holidays import HolidayDatabase from hdate.parasha import Parasha import pytest -from homeassistant.components.jewish_calendar.const import ( - CONF_CANDLE_LIGHT_MINUTES, - CONF_DIASPORA, - CONF_HAVDALAH_OFFSET_MINUTES, - DEFAULT_NAME, - DOMAIN, -) +from homeassistant.components.jewish_calendar.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM +from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import make_jerusalem_test_params, make_nyc_test_params - -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry -async def test_jewish_calendar_min_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("language", ["english", "hebrew"]) +async def test_min_config(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: """Test minimum jewish calendar configuration.""" - entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN, data={}) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get("sensor.jewish_calendar_date") is not None - - -async def test_jewish_calendar_hebrew(hass: HomeAssistant) -> None: - """Test jewish calendar sensor with language set to hebrew.""" - entry = MockConfigEntry( - title=DEFAULT_NAME, domain=DOMAIN, data={"language": "hebrew"} - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert hass.states.get("sensor.jewish_calendar_date") is not None TEST_PARAMS = [ - ( + pytest.param( + "Jerusalem", dt(2018, 9, 3), - "UTC", - 31.778, - 35.235, + {"state": "23 Elul 5778"}, "english", "date", - False, - "23 Elul 5778", - None, + id="date_output", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 3), - "UTC", - 31.778, - 35.235, + {"state": 'כ"ג אלול ה\' תשע"ח'}, "hebrew", "date", - False, - 'כ"ג אלול ה\' תשע"ח', - None, + id="date_output_hebrew", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 10), - "UTC", - 31.778, - 35.235, + {"state": "א' ראש השנה"}, "hebrew", "holiday", - False, - "א' ראש השנה", - None, + id="holiday", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 10), - "UTC", - 31.778, - 35.235, - "english", - "holiday", - False, - "Rosh Hashana I", { - "device_class": "enum", - "friendly_name": "Jewish Calendar Holiday", - "icon": "mdi:calendar-star", - "id": "rosh_hashana_i", - "type": "YOM_TOV", - "options": HolidayDatabase(False).get_all_names("english"), + "state": "Rosh Hashana I", + "attr": { + "device_class": "enum", + "friendly_name": "Jewish Calendar Holiday", + "icon": "mdi:calendar-star", + "id": "rosh_hashana_i", + "type": "YOM_TOV", + "options": HolidayDatabase(False).get_all_names("english"), + }, }, + "english", + "holiday", + id="holiday_english", ), - ( + pytest.param( + "Jerusalem", dt(2024, 12, 31), - "UTC", - 31.778, - 35.235, + { + "state": "Chanukah, Rosh Chodesh", + "attr": { + "device_class": "enum", + "friendly_name": "Jewish Calendar Holiday", + "icon": "mdi:calendar-star", + "id": "chanukah, rosh_chodesh", + "type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH", + "options": HolidayDatabase(False).get_all_names("english"), + }, + }, "english", "holiday", - False, - "Chanukah, Rosh Chodesh", - { - "device_class": "enum", - "friendly_name": "Jewish Calendar Holiday", - "icon": "mdi:calendar-star", - "id": "chanukah, rosh_chodesh", - "type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH", - "options": HolidayDatabase(False).get_all_names("english"), - }, + id="holiday_multiple", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 8), - "UTC", - 31.778, - 35.235, + { + "state": "נצבים", + "attr": { + "device_class": "enum", + "friendly_name": "Jewish Calendar Parshat Hashavua", + "icon": "mdi:book-open-variant", + "options": list(Parasha), + }, + }, "hebrew", "parshat_hashavua", - False, - "נצבים", - { - "device_class": "enum", - "friendly_name": "Jewish Calendar Parshat Hashavua", - "icon": "mdi:book-open-variant", - "options": list(Parasha), - }, + id="torah_reading", ), - ( + pytest.param( + "New York", dt(2018, 9, 8), - "America/New_York", - 40.7128, - -74.0060, + {"state": dt(2018, 9, 8, 19, 47)}, "hebrew", "t_set_hakochavim", - True, - dt(2018, 9, 8, 19, 47), - None, + id="first_stars_ny", ), - ( + pytest.param( + "Jerusalem", dt(2018, 9, 8), - "Asia/Jerusalem", - 31.778, - 35.235, + {"state": dt(2018, 9, 8, 19, 21)}, "hebrew", "t_set_hakochavim", - False, - dt(2018, 9, 8, 19, 21), - None, + id="first_stars_jerusalem", ), - ( + pytest.param( + "Jerusalem", dt(2018, 10, 14), - "Asia/Jerusalem", - 31.778, - 35.235, + {"state": "לך לך"}, "hebrew", "parshat_hashavua", - False, - "לך לך", - None, + id="torah_reading_weekday", ), - ( + pytest.param( + "Jerusalem", dt(2018, 10, 14, 17, 0, 0), - "Asia/Jerusalem", - 31.778, - 35.235, + {"state": "ה' מרחשוון ה' תשע\"ט"}, "hebrew", "date", - False, - "ה' מרחשוון ה' תשע\"ט", - None, + id="date_before_sunset", ), - ( + pytest.param( + "Jerusalem", dt(2018, 10, 14, 19, 0, 0), - "Asia/Jerusalem", - 31.778, - 35.235, + { + "state": "ו' מרחשוון ה' תשע\"ט", + "attr": { + "hebrew_year": "5779", + "hebrew_month_name": "מרחשוון", + "hebrew_day": "6", + "icon": "mdi:star-david", + "friendly_name": "Jewish Calendar Date", + }, + }, "hebrew", "date", - False, - "ו' מרחשוון ה' תשע\"ט", - { - "hebrew_year": "5779", - "hebrew_month_name": "מרחשוון", - "hebrew_day": "6", - "icon": "mdi:star-david", - "friendly_name": "Jewish Calendar Date", - }, + id="date_after_sunset", ), ] -TEST_IDS = [ - "date_output", - "date_output_hebrew", - "holiday", - "holiday_english", - "holiday_multiple", - "torah_reading", - "first_stars_ny", - "first_stars_jerusalem", - "torah_reading_weekday", - "date_before_sunset", - "date_after_sunset", -] - @pytest.mark.parametrize( - ( - "now", - "tzname", - "latitude", - "longitude", - "language", - "sensor", - "diaspora", - "result", - "attrs", - ), + ("location_data", "test_time", "results", "language", "sensor"), TEST_PARAMS, - ids=TEST_IDS, + indirect=["location_data", "test_time", "results"], ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") async def test_jewish_calendar_sensor( - hass: HomeAssistant, - now, - tzname, - latitude, - longitude, - language, - sensor, - diaspora, - result, - attrs, + hass: HomeAssistant, results: dict[str, Any], sensor: str ) -> None: """Test Jewish calendar sensor output.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) - - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with freeze_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: language, - CONF_DIASPORA: diaspora, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = test_time + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - result = ( - dt_util.as_utc(result.replace(tzinfo=time_zone)).isoformat() - if isinstance(result, dt) - else result - ) + result = results["state"] + if isinstance(result, dt): + result = dt_util.as_utc(result).isoformat() sensor_object = hass.states.get(f"sensor.jewish_calendar_{sensor}") assert sensor_object.state == result - if attrs: + if attrs := results.get("attr"): assert sensor_object.attributes == attrs SHABBAT_PARAMS = [ - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 16, 0), { "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), @@ -286,8 +188,11 @@ SHABBAT_PARAMS = [ "english_parshat_hashavua": "Ki Tavo", "hebrew_parshat_hashavua": "כי תבוא", }, + None, + id="currently_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 16, 0), { "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), @@ -297,9 +202,11 @@ SHABBAT_PARAMS = [ "english_parshat_hashavua": "Ki Tavo", "hebrew_parshat_hashavua": "כי תבוא", }, - havdalah_offset=50, + 50, # Havdalah offset + id="currently_first_shabbat_with_havdalah_offset", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 20, 0), { "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), @@ -309,8 +216,11 @@ SHABBAT_PARAMS = [ "english_parshat_hashavua": "Ki Tavo", "hebrew_parshat_hashavua": "כי תבוא", }, + None, + id="currently_first_shabbat_bein_hashmashot_lagging_date", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 1, 20, 21), { "english_upcoming_candle_lighting": dt(2018, 9, 7, 19), @@ -320,8 +230,11 @@ SHABBAT_PARAMS = [ "english_parshat_hashavua": "Nitzavim", "hebrew_parshat_hashavua": "נצבים", }, + None, + id="after_first_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 7, 13, 1), { "english_upcoming_candle_lighting": dt(2018, 9, 7, 19), @@ -331,8 +244,11 @@ SHABBAT_PARAMS = [ "english_parshat_hashavua": "Nitzavim", "hebrew_parshat_hashavua": "נצבים", }, + None, + id="friday_upcoming_shabbat", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 8, 21, 25), { "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), @@ -344,8 +260,11 @@ SHABBAT_PARAMS = [ "english_holiday": "Erev Rosh Hashana", "hebrew_holiday": "ערב ראש השנה", }, + None, + id="upcoming_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 9, 21, 25), { "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), @@ -357,8 +276,11 @@ SHABBAT_PARAMS = [ "english_holiday": "Rosh Hashana I", "hebrew_holiday": "א' ראש השנה", }, + None, + id="currently_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 10, 21, 25), { "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), @@ -370,8 +292,11 @@ SHABBAT_PARAMS = [ "english_holiday": "Rosh Hashana II", "hebrew_holiday": "ב' ראש השנה", }, + None, + id="second_day_rosh_hashana", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 28, 21, 25), { "english_upcoming_candle_lighting": dt(2018, 9, 28, 18, 25), @@ -381,8 +306,11 @@ SHABBAT_PARAMS = [ "english_parshat_hashavua": "none", "hebrew_parshat_hashavua": "none", }, + None, + id="currently_shabbat_chol_hamoed", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 29, 21, 25), { "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), @@ -394,8 +322,11 @@ SHABBAT_PARAMS = [ "english_holiday": "Hoshana Raba", "hebrew_holiday": "הושענא רבה", }, + None, + id="upcoming_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 9, 30, 21, 25), { "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), @@ -407,8 +338,11 @@ SHABBAT_PARAMS = [ "english_holiday": "Shmini Atzeret", "hebrew_holiday": "שמיני עצרת", }, + None, + id="currently_first_day_of_two_day_yomtov_in_diaspora", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2018, 10, 1, 21, 25), { "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), @@ -420,11 +354,14 @@ SHABBAT_PARAMS = [ "english_holiday": "Simchat Torah", "hebrew_holiday": "שמחת תורה", }, + None, + id="currently_second_day_of_two_day_yomtov_in_diaspora", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 9, 29, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 45), + "english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 46), "english_upcoming_havdalah": dt(2018, 10, 1, 19, 1), "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), @@ -433,11 +370,14 @@ SHABBAT_PARAMS = [ "english_holiday": "Hoshana Raba", "hebrew_holiday": "הושענא רבה", }, + None, + id="upcoming_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 9, 30, 21, 25), { - "english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 45), + "english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 46), "english_upcoming_havdalah": dt(2018, 10, 1, 19, 1), "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), @@ -446,8 +386,11 @@ SHABBAT_PARAMS = [ "english_holiday": "Shmini Atzeret, Simchat Torah", "hebrew_holiday": "שמיני עצרת, שמחת תורה", }, + None, + id="currently_one_day_yom_tov_in_israel", ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2018, 10, 1, 21, 25), { "english_upcoming_candle_lighting": dt(2018, 10, 5, 17, 39), @@ -457,8 +400,11 @@ SHABBAT_PARAMS = [ "english_parshat_hashavua": "Bereshit", "hebrew_parshat_hashavua": "בראשית", }, + None, + id="after_one_day_yom_tov_in_israel", ), - make_nyc_test_params( + pytest.param( + "New York", dt(2016, 6, 11, 8, 25), { "english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), @@ -470,8 +416,11 @@ SHABBAT_PARAMS = [ "english_holiday": "Erev Shavuot", "hebrew_holiday": "ערב שבועות", }, + None, + id="currently_first_day_of_three_day_type1_yomtov_in_diaspora", # Type 1 = Sat/Sun/Mon ), - make_nyc_test_params( + pytest.param( + "New York", dt(2016, 6, 12, 8, 25), { "english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), @@ -483,8 +432,11 @@ SHABBAT_PARAMS = [ "english_holiday": "Shavuot", "hebrew_holiday": "שבועות", }, + None, + id="currently_second_day_of_three_day_type1_yomtov_in_diaspora", # Type 1 = Sat/Sun/Mon ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2017, 9, 21, 8, 25), { "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), @@ -496,8 +448,11 @@ SHABBAT_PARAMS = [ "english_holiday": "Rosh Hashana I", "hebrew_holiday": "א' ראש השנה", }, + None, + id="currently_first_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2017, 9, 22, 8, 25), { "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), @@ -509,8 +464,11 @@ SHABBAT_PARAMS = [ "english_holiday": "Rosh Hashana II", "hebrew_holiday": "ב' ראש השנה", }, + None, + id="currently_second_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat ), - make_jerusalem_test_params( + pytest.param( + "Jerusalem", dt(2017, 9, 23, 8, 25), { "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), @@ -522,179 +480,70 @@ SHABBAT_PARAMS = [ "english_holiday": "", "hebrew_holiday": "", }, + None, + id="currently_third_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat ), ] -SHABBAT_TEST_IDS = [ - "currently_first_shabbat", - "currently_first_shabbat_with_havdalah_offset", - "currently_first_shabbat_bein_hashmashot_lagging_date", - "after_first_shabbat", - "friday_upcoming_shabbat", - "upcoming_rosh_hashana", - "currently_rosh_hashana", - "second_day_rosh_hashana", - "currently_shabbat_chol_hamoed", - "upcoming_two_day_yomtov_in_diaspora", - "currently_first_day_of_two_day_yomtov_in_diaspora", - "currently_second_day_of_two_day_yomtov_in_diaspora", - "upcoming_one_day_yom_tov_in_israel", - "currently_one_day_yom_tov_in_israel", - "after_one_day_yom_tov_in_israel", - # Type 1 = Sat/Sun/Mon - "currently_first_day_of_three_day_type1_yomtov_in_diaspora", - "currently_second_day_of_three_day_type1_yomtov_in_diaspora", - # Type 2 = Thurs/Fri/Sat - "currently_first_day_of_three_day_type2_yomtov_in_israel", - "currently_second_day_of_three_day_type2_yomtov_in_israel", - "currently_third_day_of_three_day_type2_yomtov_in_israel", -] - @pytest.mark.parametrize("language", ["english", "hebrew"]) @pytest.mark.parametrize( - ( - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ), + ("location_data", "test_time", "results", "havdalah_offset"), SHABBAT_PARAMS, - ids=SHABBAT_TEST_IDS, + indirect=("location_data", "test_time", "results"), ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") async def test_shabbat_times_sensor( - hass: HomeAssistant, - language, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, + hass: HomeAssistant, results: dict[str, Any], language: str ) -> None: """Test sensor output for upcoming shabbat/yomtov times.""" - time_zone = dt_util.get_time_zone(tzname) - test_time = now.replace(tzinfo=time_zone) - - await hass.config.async_set_time_zone(tzname) - hass.config.latitude = latitude - hass.config.longitude = longitude - - with freeze_time(test_time): - entry = MockConfigEntry( - title=DEFAULT_NAME, - domain=DOMAIN, - data={ - CONF_LANGUAGE: language, - CONF_DIASPORA: diaspora, - }, - options={ - CONF_CANDLE_LIGHT_MINUTES: candle_lighting, - CONF_HAVDALAH_OFFSET_MINUTES: havdalah, - }, - ) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = test_time + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - for sensor_type, result_value in result.items(): + for sensor_type, result_value in results.items(): if not sensor_type.startswith(language): continue sensor_type = sensor_type.replace(f"{language}_", "") - result_value = ( - dt_util.as_utc(result_value).isoformat() - if isinstance(result_value, dt) - else result_value - ) + if isinstance(result_value, dt): + result_value = dt_util.as_utc(result_value).isoformat() assert hass.states.get(f"sensor.jewish_calendar_{sensor_type}").state == str( result_value ), f"Value for {sensor_type}" -OMER_PARAMS = [ - (dt(2019, 4, 21, 0), "1"), - (dt(2019, 4, 21, 23), "2"), - (dt(2019, 5, 23, 0), "33"), - (dt(2019, 6, 8, 0), "49"), - (dt(2019, 6, 9, 0), "0"), - (dt(2019, 1, 1, 0), "0"), -] -OMER_TEST_IDS = [ - "first_day_of_omer", - "first_day_of_omer_after_tzeit", - "lag_baomer", - "last_day_of_omer", - "shavuot_no_omer", - "jan_1st_no_omer", -] - - -@pytest.mark.parametrize(("test_time", "result"), OMER_PARAMS, ids=OMER_TEST_IDS) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_omer_sensor(hass: HomeAssistant, test_time, result) -> None: +@pytest.mark.parametrize( + ("test_time", "results"), + [ + pytest.param(dt(2019, 4, 21, 0), "1", id="first_day_of_omer"), + pytest.param(dt(2019, 4, 21, 23), "2", id="first_day_of_omer_after_tzeit"), + pytest.param(dt(2019, 5, 23, 0), "33", id="lag_baomer"), + pytest.param(dt(2019, 6, 8, 0), "49", id="last_day_of_omer"), + pytest.param(dt(2019, 6, 9, 0), "0", id="shavuot_no_omer"), + pytest.param(dt(2019, 1, 1, 0), "0", id="jan_1st_no_omer"), + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") +async def test_omer_sensor(hass: HomeAssistant, results: str) -> None: """Test Omer Count sensor output.""" - test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) - - with freeze_time(test_time): - entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = test_time + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.jewish_calendar_day_of_the_omer").state == result + assert hass.states.get("sensor.jewish_calendar_day_of_the_omer").state == results -DAFYOMI_PARAMS = [ - (dt(2014, 4, 28, 0), "Beitzah 29"), - (dt(2020, 1, 4, 0), "Niddah 73"), - (dt(2020, 1, 5, 0), "Berachos 2"), - (dt(2020, 3, 7, 0), "Berachos 64"), - (dt(2020, 3, 8, 0), "Shabbos 2"), -] -DAFYOMI_TEST_IDS = [ - "randomly_picked_date", - "end_of_cycle13", - "start_of_cycle14", - "cycle14_end_of_berachos", - "cycle14_start_of_shabbos", -] - - -@pytest.mark.parametrize(("test_time", "result"), DAFYOMI_PARAMS, ids=DAFYOMI_TEST_IDS) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_dafyomi_sensor(hass: HomeAssistant, test_time, result) -> None: +@pytest.mark.parametrize( + ("test_time", "results"), + [ + pytest.param(dt(2014, 4, 28, 0), "Beitzah 29", id="randomly_picked_date"), + pytest.param(dt(2020, 1, 4, 0), "Niddah 73", id="end_of_cycle13"), + pytest.param(dt(2020, 1, 5, 0), "Berachos 2", id="start_of_cycle14"), + pytest.param(dt(2020, 3, 7, 0), "Berachos 64", id="cycle14_end_of_berachos"), + pytest.param(dt(2020, 3, 8, 0), "Shabbos 2", id="cycle14_start_of_shabbos"), + ], + indirect=True, +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time") +async def test_dafyomi_sensor(hass: HomeAssistant, results: str) -> None: """Test Daf Yomi sensor output.""" - test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) - - with freeze_time(test_time): - entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - future = test_time + timedelta(seconds=30) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == result + assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == results async def test_no_discovery_info( diff --git a/tests/components/jewish_calendar/test_service.py b/tests/components/jewish_calendar/test_service.py index 9eb80e5e7f0..fd8a96bf69b 100644 --- a/tests/components/jewish_calendar/test_service.py +++ b/tests/components/jewish_calendar/test_service.py @@ -33,15 +33,15 @@ from tests.common import MockConfigEntry ) async def test_get_omer_blessing( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + config_entry: MockConfigEntry, test_date: dt.date, nusach: str, language: Language, expected: str, ) -> None: """Test get omer blessing.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() result = await hass.services.async_call( diff --git a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr index 2abf182095e..0e772fb9653 100644 --- a/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_binary_sensor.ambr @@ -143,3 +143,51 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.gs012345_websocket_connected-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.gs012345_websocket_connected', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'WebSocket connected', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'websocket_connected', + 'unique_id': 'GS012345_websocket_connected', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.gs012345_websocket_connected-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'GS012345 WebSocket connected', + }), + 'context': , + 'entity_id': 'binary_sensor.gs012345_websocket_connected', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index d9a644567d5..8f59ce4a6fa 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -115,3 +115,177 @@ 'unit_of_measurement': , }) # --- +# name: test_prebrew_off[Linea Micra] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'MR012345 Prebrew off time', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mr012345_prebrew_off_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_prebrew_off[Linea Micra].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mr012345_prebrew_off_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Prebrew off time', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'prebrew_time_off', + 'unique_id': 'MR012345_prebrew_off', + 'unit_of_measurement': , + }) +# --- +# name: test_prebrew_on[Linea Micra] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'MR012345 Prebrew on time', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mr012345_prebrew_on_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_prebrew_on[Linea Micra].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mr012345_prebrew_on_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Prebrew on time', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'prebrew_time_on', + 'unique_id': 'MR012345_prebrew_on', + 'unit_of_measurement': , + }) +# --- +# name: test_preinfusion[Linea Micra] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'MR012345 Preinfusion time', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mr012345_preinfusion_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_preinfusion[Linea Micra].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.mr012345_preinfusion_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Preinfusion time', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'preinfusion_time', + 'unique_id': 'MR012345_preinfusion_off', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..311e7416b1c --- /dev/null +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_sensors[sensor.gs012345_coffee_boiler_ready_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_coffee_boiler_ready_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Coffee boiler ready time', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'coffee_boiler_ready_time', + 'unique_id': 'GS012345_coffee_boiler_ready_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.gs012345_coffee_boiler_ready_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Coffee boiler ready time', + }), + 'context': , + 'entity_id': 'sensor.gs012345_coffee_boiler_ready_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[sensor.gs012345_steam_boiler_ready_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gs012345_steam_boiler_ready_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Steam boiler ready time', + 'platform': 'lamarzocco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'steam_boiler_ready_time', + 'unique_id': 'GS012345_steam_boiler_ready_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.gs012345_steam_boiler_ready_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'GS012345 Steam boiler ready time', + }), + 'context': , + 'entity_id': 'sensor.gs012345_steam_boiler_ready_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lamarzocco/snapshots/test_update.ambr b/tests/components/lamarzocco/snapshots/test_update.ambr index d1ca030ab8c..508d0d36911 100644 --- a/tests/components/lamarzocco/snapshots/test_update.ambr +++ b/tests/components/lamarzocco/snapshots/test_update.ambr @@ -27,7 +27,7 @@ 'original_name': 'Gateway firmware', 'platform': 'lamarzocco', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'gateway_firmware', 'unique_id': 'GS012345_gateway_firmware', 'unit_of_measurement': None, @@ -47,7 +47,7 @@ 'release_summary': None, 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', 'skipped_version': None, - 'supported_features': , + 'supported_features': , 'title': None, 'update_percentage': None, }), @@ -87,7 +87,7 @@ 'original_name': 'Machine firmware', 'platform': 'lamarzocco', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'machine_firmware', 'unique_id': 'GS012345_machine_firmware', 'unit_of_measurement': None, @@ -107,7 +107,7 @@ 'release_summary': None, 'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/', 'skipped_version': None, - 'supported_features': , + 'supported_features': , 'title': None, 'update_percentage': None, }), diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py index bf4c3fc4a33..2fbd58eab85 100644 --- a/tests/components/lamarzocco/test_binary_sensor.py +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from pylamarzocco.exceptions import RequestNotSuccessful +import pytest from syrupy import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE, Platform @@ -16,6 +17,7 @@ from . import async_init_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 40b44806c62..38cdc10d8ab 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -20,6 +20,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_ADDRESS, CONF_MAC, CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import USER_INPUT, async_init_integration, get_bluetooth_service_info @@ -259,6 +260,61 @@ async def test_reconfigure_flow( } +@pytest.mark.parametrize( + "discovered", + [ + [], + [ + BluetoothServiceInfo( + name="SomeDevice", + address="aa:bb:cc:dd:ee:ff", + rssi=-63, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", + ) + ], + ], +) +async def test_reconfigure_flow_no_machines( + hass: HomeAssistant, + mock_cloud_client: MagicMock, + mock_config_entry: MockConfigEntry, + discovered: list[BluetoothServiceInfo], +) -> None: + """Testing reconfgure flow.""" + mock_config_entry.add_to_hass(hass) + + data = deepcopy(dict(mock_config_entry.data)) + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await __do_successful_user_step(hass, result, mock_cloud_client) + + with ( + patch( + "homeassistant.components.lamarzocco.config_flow.async_discovered_service_info", + return_value=discovered, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_MACHINE: "GS012345", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert mock_config_entry.title == "My LaMarzocco" + assert CONF_MAC not in mock_config_entry.data + assert dict(mock_config_entry.data) == data + + async def test_bluetooth_discovery( hass: HomeAssistant, mock_lamarzocco: MagicMock, diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py index d70b99c7f57..e4be04f4ce4 100644 --- a/tests/components/lamarzocco/test_number.py +++ b/tests/components/lamarzocco/test_number.py @@ -3,7 +3,12 @@ from typing import Any from unittest.mock import MagicMock -from pylamarzocco.const import SmartStandByType +from pylamarzocco.const import ( + ModelName, + PreExtractionMode, + SmartStandByType, + WidgetType, +) from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion @@ -85,6 +90,140 @@ async def test_general_numbers( mock_func.assert_called_once_with(**kwargs) +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) +async def test_preinfusion( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test preinfusion number.""" + + await async_init_integration(hass, mock_config_entry) + serial_number = mock_lamarzocco.serial_number + entity_id = f"number.{serial_number}_preinfusion_time" + + state = hass.states.get(entity_id) + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 5.3, + }, + blocking=True, + ) + + mock_lamarzocco.set_pre_extraction_times.assert_called_once_with( + seconds_off=5.3, + seconds_on=0, + ) + + +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) +async def test_prebrew_on( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test prebrew on number.""" + + mock_lamarzocco.dashboard.config[ + WidgetType.CM_PRE_BREWING + ].mode = PreExtractionMode.PREBREWING + + await async_init_integration(hass, mock_config_entry) + serial_number = mock_lamarzocco.serial_number + entity_id = f"number.{serial_number}_prebrew_on_time" + + state = hass.states.get(entity_id) + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 5.3, + }, + blocking=True, + ) + + mock_lamarzocco.set_pre_extraction_times.assert_called_once_with( + seconds_on=5.3, + seconds_off=mock_lamarzocco.dashboard.config[WidgetType.CM_PRE_BREWING] + .times.pre_brewing[0] + .seconds.seconds_out, + ) + + +@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA]) +async def test_prebrew_off( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test prebrew off number.""" + mock_lamarzocco.dashboard.config[ + WidgetType.CM_PRE_BREWING + ].mode = PreExtractionMode.PREBREWING + + await async_init_integration(hass, mock_config_entry) + serial_number = mock_lamarzocco.serial_number + entity_id = f"number.{serial_number}_prebrew_off_time" + + state = hass.states.get(entity_id) + + assert state + assert state == snapshot + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot + + # service call + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 7, + }, + blocking=True, + ) + + mock_lamarzocco.set_pre_extraction_times.assert_called_once_with( + seconds_off=7, + seconds_on=mock_lamarzocco.dashboard.config[WidgetType.CM_PRE_BREWING] + .times.pre_brewing[0] + .seconds.seconds_in, + ) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_error( hass: HomeAssistant, diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py new file mode 100644 index 00000000000..0b050dd7788 --- /dev/null +++ b/tests/components/lamarzocco/test_sensor.py @@ -0,0 +1,52 @@ +"""Tests for La Marzocco sensors.""" + +from unittest.mock import MagicMock, patch + +from pylamarzocco.const import ModelName +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_init_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the La Marzocco sensors.""" + + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]): + await async_init_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "device_fixture", + [ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI, ModelName.LINEA_MICRA], +) +async def test_steam_ready_entity_for_all_machines( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the La Marzocco steam ready sensor for all machines.""" + + serial_number = mock_lamarzocco.serial_number + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get(f"sensor.{serial_number}_steam_boiler_ready_time") + + assert state + + entry = entity_registry.async_get(state.entity_id) + assert entry diff --git a/tests/components/lamarzocco/test_update.py b/tests/components/lamarzocco/test_update.py index 544dcdfd03d..3dbc5e98bee 100644 --- a/tests/components/lamarzocco/test_update.py +++ b/tests/components/lamarzocco/test_update.py @@ -1,8 +1,16 @@ """Tests for the La Marzocco Update Entities.""" -from unittest.mock import MagicMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch +from pylamarzocco.const import ( + FirmwareType, + UpdateCommandStatus, + UpdateProgressInfo, + UpdateStatus, +) from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.models import UpdateDetails import pytest from syrupy import SnapshotAssertion @@ -15,6 +23,17 @@ from homeassistant.helpers import entity_registry as er from . import async_init_integration from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +def mock_sleep() -> Generator[AsyncMock]: + """Mock asyncio.sleep.""" + with patch( + "homeassistant.components.lamarzocco.update.asyncio.sleep", + return_value=AsyncMock(), + ) as mock_sleep: + yield mock_sleep async def test_update( @@ -29,17 +48,51 @@ async def test_update( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -async def test_update_entites( +async def test_update_process( hass: HomeAssistant, mock_lamarzocco: MagicMock, mock_config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, ) -> None: """Test the La Marzocco update entities.""" serial_number = mock_lamarzocco.serial_number + mock_lamarzocco.get_firmware.side_effect = [ + UpdateDetails( + status=UpdateStatus.TO_UPDATE, + command_status=UpdateCommandStatus.IN_PROGRESS, + progress_info=UpdateProgressInfo.STARTING_PROCESS, + progress_percentage=0, + ), + UpdateDetails( + status=UpdateStatus.UPDATED, + command_status=None, + progress_info=None, + progress_percentage=None, + ), + ] + await async_init_integration(hass, mock_config_entry) + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": f"update.{serial_number}_gateway_firmware", + } + ) + result = await client.receive_json() + assert ( + mock_lamarzocco.settings.firmwares[ + FirmwareType.GATEWAY + ].available_update.change_log + in result["result"] + ) + await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, @@ -76,3 +129,35 @@ async def test_update_error( blocking=True, ) assert exc_info.value.translation_key == "update_failed" + + +async def test_update_times_out( + hass: HomeAssistant, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error during update.""" + mock_lamarzocco.get_firmware.return_value = UpdateDetails( + status=UpdateStatus.TO_UPDATE, + command_status=UpdateCommandStatus.IN_PROGRESS, + progress_info=UpdateProgressInfo.STARTING_PROCESS, + progress_percentage=0, + ) + await async_init_integration(hass, mock_config_entry) + + state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_gateway_firmware") + assert state + + with ( + patch("homeassistant.components.lamarzocco.update.MAX_UPDATE_WAIT", 0), + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_gateway_firmware", + }, + blocking=True, + ) + assert exc_info.value.translation_key == "update_failed" diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index 05cb3164137..17bbf068305 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -68,7 +68,7 @@ def mock_uuid() -> Generator[AsyncMock]: @pytest.fixture -def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: +def mock_config_thinq_api() -> Generator[AsyncMock]: """Mock a thinq api.""" with ( patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api, @@ -77,6 +77,26 @@ def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: new=mock_api, ), ): + thinq_api = mock_api.return_value + thinq_api.async_get_device_list.return_value = ["air_conditioner"] + yield thinq_api + + +@pytest.fixture +def mock_invalid_thinq_api(mock_config_thinq_api: AsyncMock) -> AsyncMock: + """Mock an invalid thinq api.""" + mock_config_thinq_api.async_get_device_list = AsyncMock( + side_effect=ThinQAPIException( + code="1309", message="Not allowed api call", headers=None + ) + ) + return mock_config_thinq_api + + +@pytest.fixture +def mock_thinq_api() -> Generator[AsyncMock]: + """Mock a thinq api.""" + with patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api: thinq_api = mock_api.return_value thinq_api.async_get_device_list.return_value = [ load_json_object_fixture("air_conditioner/device.json", DOMAIN) @@ -92,19 +112,10 @@ def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: @pytest.fixture def mock_thinq_mqtt_client() -> Generator[AsyncMock]: - """Mock a thinq api.""" + """Mock a thinq mqtt client.""" with patch( - "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", autospec=True - ) as mock_api: - yield mock_api - - -@pytest.fixture -def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock: - """Mock an invalid thinq api.""" - mock_thinq_api.async_get_device_list = AsyncMock( - side_effect=ThinQAPIException( - code="1309", message="Not allowed api call", headers=None - ) - ) - return mock_thinq_api + "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", + autospec=True, + return_value=True, + ): + yield diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index 8c5afb4dac7..d1530ed29cd 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -15,7 +15,7 @@ from tests.common import MockConfigEntry async def test_config_flow( hass: HomeAssistant, - mock_thinq_api: AsyncMock, + mock_config_thinq_api: AsyncMock, mock_uuid: AsyncMock, mock_setup_entry: AsyncMock, ) -> None: @@ -37,11 +37,12 @@ async def test_config_flow( CONF_CONNECT_CLIENT_ID: MOCK_CONNECT_CLIENT_ID, } - mock_thinq_api.async_get_device_list.assert_called_once() + mock_config_thinq_api.async_get_device_list.assert_called_once() async def test_config_flow_invalid_pat( - hass: HomeAssistant, mock_invalid_thinq_api: AsyncMock + hass: HomeAssistant, + mock_invalid_thinq_api: AsyncMock, ) -> None: """Test that an thinq flow should be aborted with an invalid PAT.""" result = await hass.config_entries.flow.async_init( @@ -55,7 +56,9 @@ async def test_config_flow_invalid_pat( async def test_config_flow_already_configured( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_thinq_api: AsyncMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_config_thinq_api: AsyncMock, ) -> None: """Test that thinq flow should be aborted when already configured.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/lg_thinq/test_init.py b/tests/components/lg_thinq/test_init.py index 7da7e79fec0..bf24704d379 100644 --- a/tests/components/lg_thinq/test_init.py +++ b/tests/components/lg_thinq/test_init.py @@ -1,22 +1,29 @@ """Tests for the LG ThinQ integration.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch + +import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry async def test_load_unload_entry( hass: HomeAssistant, mock_thinq_api: AsyncMock, + mock_thinq_mqtt_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test load and unload entry.""" - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.lg_thinq.ThinQMQTT.async_connect", + return_value=True, + ): + await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -24,3 +31,21 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize("exception", [AttributeError(), TypeError(), ValueError()]) +async def test_config_not_ready( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_thinq_mqtt_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test for setup failure exception occurred.""" + with patch( + "homeassistant.components.lg_thinq.ThinQMQTT.async_connect", + side_effect=exception, + ): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 17a5aad6478..9cf27b4f147 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -111,7 +111,7 @@ async def test_options_unsupported_model( CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_CHAT_MODEL: "o1-mini", - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], }, ) await hass.async_block_till_done() @@ -168,7 +168,6 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ( { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "none", CONF_PROMPT: "bla", }, { @@ -202,6 +201,18 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", CONF_WEB_SEARCH_USER_LOCATION: False, }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), + ( { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: "assist", @@ -209,7 +220,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, ), @@ -338,7 +354,7 @@ async def test_options_web_search_unsupported_model( CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_CHAT_MODEL: "o1-pro", - CONF_LLM_HASS_API: "assist", + CONF_LLM_HASS_API: ["assist"], CONF_WEB_SEARCH: True, }, ) diff --git a/tests/components/renault/fixtures/vehicle_multi.json b/tests/components/renault/fixtures/vehicle_multi.json new file mode 100644 index 00000000000..18374a8cbd1 --- /dev/null +++ b/tests/components/renault/fixtures/vehicle_multi.json @@ -0,0 +1,291 @@ +{ + "accountId": "account-id-2", + "country": "IT", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777999", + "status": "ACTIVE", + "linkType": "OWNER", + "garageBrand": "RENAULT", + "annualMileage": 16000, + "mileage": 26464, + "startDate": "2017-08-07", + "createdDate": "2019-05-23T21:38:16.409008Z", + "lastModifiedDate": "2020-11-17T08:41:40.497400Z", + "ownershipStartDate": "2017-08-01", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2019-06-17T09:49:06.880627Z", + "lastModifiedDate": "2019-06-17T09:49:06.880627Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777999", + "registrationDate": "2017-08-01", + "firstRegistrationDate": "2017-08-01", + "engineType": "5AQ", + "engineRatio": "601", + "modelSCR": "ZOE", + "deliveryCountry": { + "code": "FR", + "label": "FRANCE" + }, + "family": { + "code": "X10", + "label": "FAMILLE X10", + "group": "007" + }, + "tcu": { + "code": "TCU0G2", + "label": "TCU VER 0 GEN 2", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "NAV3G5", + "label": "LEVEL 3 TYPE 5 NAVIGATION", + "group": "408" + }, + "battery": { + "code": "BT4AR1", + "label": "BATTERIE BT4AR1", + "group": "968" + }, + "radioType": { + "code": "RAD37A", + "label": "RADIO 37A", + "group": "425" + }, + "registrationCountry": { + "code": "FR" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "X101VE", + "label": "ZOE", + "group": "971" + }, + "gearbox": { + "code": "BVEL", + "label": "BOITE A VARIATEUR ELECTRIQUE", + "group": "427" + }, + "version": { + "code": "INT MB 10R" + }, + "energy": { + "code": "ELEC", + "label": "ELECTRIQUE", + "group": "019" + }, + "registrationNumber": "REG-NUMBER", + "vcd": "SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + }, + { + "assetType": "PDF", + "assetRole": "GUIDE", + "title": "PDF Guide", + "description": "", + "renditions": [ + { + "url": "https://cdn.group.renault.com/ren/gb/myr/assets/x101ve/manual.pdf.asset.pdf/1558704861676.pdf" + } + ] + }, + { + "assetType": "URL", + "assetRole": "GUIDE", + "title": "e-guide", + "description": "", + "renditions": [ + { + "url": "http://gb.e-guide.renault.com/eng/Zoe" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "10 Fundamentals about getting the best out of your electric vehicle", + "description": "", + "renditions": [ + { + "url": "39r6QEKcOM4" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Automatic Climate Control", + "description": "", + "renditions": [ + { + "url": "Va2FnZFo_GE" + } + ] + }, + { + "assetType": "URL", + "assetRole": "CAR", + "title": "More videos", + "description": "", + "renditions": [ + { + "url": "https://www.youtube.com/watch?v=wfpCMkK1rKI" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Charging the battery", + "description": "", + "renditions": [ + { + "url": "RaEad8DjUJs" + } + ] + }, + { + "assetType": "VIDEO", + "assetRole": "CAR", + "title": "Charging the battery at a station with a flap", + "description": "", + "renditions": [ + { + "url": "zJfd7fJWtr0" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "RLINK1", + "easyConnectStore": false, + "electrical": true, + "rlinkStore": false, + "deliveryDate": "2017-08-11", + "retrievedFromDhs": false, + "engineEnergyType": "ELEC", + "radioCode": "1234" + } + }, + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777123", + "status": "ACTIVE", + "linkType": "USER", + "garageBrand": "RENAULT", + "mileage": 346, + "startDate": "2020-06-12", + "createdDate": "2020-06-12T15:02:00.555432Z", + "lastModifiedDate": "2020-06-15T06:21:43.762467Z", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2020-06-15T06:20:39.107794Z", + "lastModifiedDate": "2020-06-15T06:20:39.107794Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777123", + "engineType": "H5H", + "engineRatio": "470", + "modelSCR": "CP1", + "deliveryCountry": { + "code": "BE", + "label": "BELGIQUE" + }, + "family": { + "code": "XJB", + "label": "FAMILLE B+X OVER", + "group": "007" + }, + "tcu": { + "code": "AIVCT", + "label": "AVEC BOITIER CONNECT AIVC", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "", + "label": "", + "group": "" + }, + "battery": { + "code": "SANBAT", + "label": "SANS BATTERIE", + "group": "968" + }, + "radioType": { + "code": "NA406", + "label": "A-IVIMINDL, 2BO + 2BI + 2T, MICRO-DOUBLE, FM1/DAB+FM2", + "group": "425" + }, + "registrationCountry": { + "code": "BE" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "XJB1SU", + "label": "CAPTUR II", + "group": "971" + }, + "gearbox": { + "code": "BVA7", + "label": "BOITE DE VITESSE AUTOMATIQUE 7 RAPPORTS", + "group": "427" + }, + "version": { + "code": "ITAMFHA 6TH" + }, + "energy": { + "code": "ESS", + "label": "ESSENCE", + "group": "019" + }, + "registrationNumber": "REG-NUMBER", + "vcd": "ADR00/DLIGM2/PGPRT2/FEUAR3/CDVIT1/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET04/DANGMO/ECOMOD/SSRCAR/AIVCT/AVGSI/TPRPE/TSGNE/2TON/ITPK7/MLEXP1/SPERTA/SSPERG/SPERTP/VOLCHA/SREACT/AVOSP1/SWALBO/DWGE01/AVC1A/1234Y/AEBS07/PRAHL/AVCAM/STANDA/XJB/HJB/EA3/MF/ESS/DG/TEMP/TR4X2/AFURGE/RVDIST/ABS/SBARTO/CA02/TOPAN/PBNCH/LAC/VSTLAR/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV02/SGAR02/BIXPE/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/SCACBA/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETIN2/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGACHA/BEL01/APL03/FSTPO/ALOUC5/CMAR3P/FIPOU2/NA406/BVA7/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRGAC/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06T/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/SSTYAD/HYB01/SSCABA/SANBAT/VEC012/XJB1SU/SSNBT/H5H", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "NONE", + "easyConnectStore": false, + "electrical": false, + "rlinkStore": false, + "deliveryDate": "2020-06-17", + "retrievedFromDhs": false, + "engineEnergyType": "OTHER", + "radioCode": "1234" + } + } + ] +} diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index bce50ec4fbf..6d71d2e6412 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -2,11 +2,15 @@ from collections.abc import Generator import datetime -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from renault_api.kamereon.exceptions import QuotaLimitException +from renault_api.kamereon.exceptions import ( + AccessDeniedException, + NotSupportedException, + QuotaLimitException, +) from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -241,3 +245,89 @@ async def test_sensor_throttling_after_init( assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE) assert "Renault API throttled" not in caplog.text assert "Renault hub currently throttled: scan skipped" not in caplog.text + + +# scan interval in seconds = (3600 * num_calls) / MAX_CALLS_PER_HOURS +# MAX_CALLS_PER_HOURS being a constant, for now 60 calls per hour +# num_calls = num_coordinator_car_0 + num_coordinator_car_1 + ... + num_coordinator_car_n +@pytest.mark.parametrize( + ("vehicle_type", "vehicle_count", "scan_interval"), + [ + ("zoe_40", 1, 300), # 5 coordinators => 5 minutes interval + ("captur_fuel", 1, 240), # 4 coordinators => 4 minutes interval + ("multi", 2, 540), # 9 coordinators => 9 minutes interval + ], + indirect=["vehicle_type"], +) +async def test_dynamic_scan_interval( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_count: int, + scan_interval: int, + freezer: FrozenDateTimeFactory, + fixtures_with_data: dict[str, AsyncMock], +) -> None: + """Test scan interval.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds before the expected scan interval > not called + freezer.tick(datetime.timedelta(seconds=scan_interval - 2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds after the expected scan interval > called + freezer.tick(datetime.timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count * 2 + + +# scan interval in seconds = (3600 * num_calls) / MAX_CALLS_PER_HOURS +# MAX_CALLS_PER_HOURS being a constant, for now 60 calls per hour +# num_calls = num_coordinator_car_0 + num_coordinator_car_1 + ... + num_coordinator_car_n +@pytest.mark.parametrize( + ("vehicle_type", "vehicle_count", "scan_interval"), + [ + ("zoe_40", 1, 240), # (5-1) coordinators => 4 minutes interval + ("captur_fuel", 1, 180), # (4-1) coordinators => 3 minutes interval + ("multi", 2, 420), # (9-2) coordinators => 7 minutes interval + ], + indirect=["vehicle_type"], +) +async def test_dynamic_scan_interval_failed_coordinator( + hass: HomeAssistant, + config_entry: ConfigEntry, + vehicle_count: int, + scan_interval: int, + freezer: FrozenDateTimeFactory, + fixtures_with_data: dict[str, AsyncMock], +) -> None: + """Test scan interval.""" + fixtures_with_data["battery_status"].side_effect = NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + fixtures_with_data["lock_status"].side_effect = AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds before the expected scan interval > not called + freezer.tick(datetime.timedelta(seconds=scan_interval - 2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count + + # 2 seconds after the expected scan interval > called + freezer.tick(datetime.timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert fixtures_with_data["cockpit"].call_count == vehicle_count * 2 diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 26af812fe1f..aa29a610620 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -107,7 +107,9 @@ def mock_smartthings() -> Generator[AsyncMock]: "centralite", "da_ref_normal_000001", "da_ref_normal_01011", + "da_ref_normal_01001", "vd_network_audio_002s", + "vd_network_audio_003s", "vd_sensor_light_2023", "iphone", "da_sac_ehs_000001_sub", diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01001.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01001.json new file mode 100644 index 00000000000..aa73068f8bd --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01001.json @@ -0,0 +1,929 @@ +{ + "components": { + "pantry-01": { + "samsungce.foodDefrost": { + "supportedOptions": { + "value": null + }, + "foodType": { + "value": null + }, + "weight": { + "value": null + }, + "operationTime": { + "value": null + }, + "remainingTime": { + "value": null + } + }, + "samsungce.fridgePantryInfo": { + "name": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.meatAging", "samsungce.foodDefrost"], + "timestamp": "2022-02-07T10:54:05.580Z" + } + }, + "samsungce.meatAging": { + "zoneInfo": { + "value": null + }, + "supportedMeatTypes": { + "value": null + }, + "supportedAgingMethods": { + "value": null + }, + "status": { + "value": null + } + }, + "samsungce.fridgePantryMode": { + "mode": { + "value": null + }, + "supportedModes": { + "value": null + } + } + }, + "icemaker": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:54:05.580Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-02-07T12:01:52.528Z" + } + } + }, + "scale-10": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:54:05.580Z" + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + }, + "samsungce.scaleSettings": { + "enabled": { + "value": null + } + }, + "samsungce.weightMeasurementCalibration": {} + }, + "scale-11": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-02-07T10:54:05.580Z" + } + }, + "samsungce.weightMeasurement": { + "weight": { + "value": null + } + } + }, + "camera-01": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["switch"], + "timestamp": "2023-12-17T11:19:18.845Z" + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "cooler": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T00:23:41.655Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-06T12:35:50.411Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2024-06-17T06:16:33.918Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 37, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": 34, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + }, + "maximumSetpoint": { + "value": 44, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": 34, + "maximum": 44, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + }, + "coolingSetpoint": { + "value": 37, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + } + }, + "freezer": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T00:00:44.267Z" + } + }, + "samsungce.unavailableCapabilities": { + "unavailableCommands": { + "value": [], + "timestamp": "2024-11-06T12:35:50.411Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": ["samsungce.freezerConvertMode"], + "timestamp": "2024-11-06T09:00:29.743Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 0, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + }, + "custom.thermostatSetpointControl": { + "minimumSetpoint": { + "value": -8, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + }, + "maximumSetpoint": { + "value": 5, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + }, + "samsungce.freezerConvertMode": { + "supportedFreezerConvertModes": { + "value": [], + "timestamp": "2025-02-01T19:39:00.448Z" + }, + "freezerConvertMode": { + "value": null + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": { + "minimum": -8, + "maximum": 5, + "step": 1 + }, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + }, + "coolingSetpoint": { + "value": 0, + "unit": "F", + "timestamp": "2025-02-01T19:39:00.493Z" + } + } + }, + "main": { + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-09T00:23:41.655Z" + } + }, + "samsungce.viewInside": { + "supportedFocusAreas": { + "value": ["mainShelves"], + "timestamp": "2025-02-01T19:39:00.946Z" + }, + "contents": { + "value": [ + { + "fileId": "d3e1f875-f8b3-a031-737b-366eaa227773", + "mimeType": "image/jpeg", + "expiredTime": "2025-01-20T16:17:04Z", + "focusArea": "mainShelves" + }, + { + "fileId": "9fccb6b4-e71f-6c7f-9935-f6082bb6ccfe", + "mimeType": "image/jpeg", + "expiredTime": "2025-01-20T16:17:04Z", + "focusArea": "mainShelves" + }, + { + "fileId": "20b57a4d-b7fc-17fc-3a03-0fb84fb4efab", + "mimeType": "image/jpeg", + "expiredTime": "2025-01-20T16:17:05Z", + "focusArea": "mainShelves" + } + ], + "timestamp": "2025-01-20T16:07:05.423Z" + }, + "lastUpdatedTime": { + "value": "2025-02-07T12:01:52Z", + "timestamp": "2025-02-07T12:01:52.585Z" + } + }, + "samsungce.fridgeFoodList": { + "outOfSyncChanges": { + "value": null + }, + "refreshResult": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": null + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": null + }, + "description": { + "value": null + }, + "releaseYear": { + "value": 19, + "timestamp": "2024-11-06T09:00:29.743Z" + }, + "binaryId": { + "value": "24K_REF_LCD_FHUB9.0", + "timestamp": "2025-02-07T12:01:53.067Z" + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-02-01T19:39:01.848Z" + } + }, + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": null + }, + "supportedFridgeModes": { + "value": null + } + }, + "ocf": { + "st": { + "value": "2024-11-08T11:56:59Z", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnfv": { + "value": "20240616.213423", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "di": { + "value": "7d3feb98-8a36-4351-c362-5e21ad3a78dd", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "n": { + "value": "Family Hub", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnmo": { + "value": "24K_REF_LCD_FHUB9.0|00113141|0002034e051324200103000000000000", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "vid": { + "value": "DA-REF-NORMAL-01001", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnpv": { + "value": "7.0", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "pi": { + "value": "7d3feb98-8a36-4351-c362-5e21ad3a78dd", + "timestamp": "2025-01-02T12:37:43.756Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-01-02T12:37:43.756Z" + } + }, + "samsungce.fridgeVacationMode": { + "vacationMode": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "thermostatCoolingSetpoint", + "temperatureMeasurement", + "custom.fridgeMode", + "custom.deviceReportStateConfiguration", + "samsungce.fridgeFoodList", + "samsungce.runestoneHomeContext", + "demandResponseLoadControl", + "samsungce.fridgeVacationMode", + "samsungce.sabbathMode" + ], + "timestamp": "2025-02-08T23:57:45.739Z" + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 24090102, + "timestamp": "2024-11-06T09:00:29.743Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "endpoint": { + "value": "SSM", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "500", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "protocolType": { + "value": "wifi_https", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "tsId": { + "value": null + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-02-01T19:39:00.523Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": null + } + }, + "custom.deviceReportStateConfiguration": { + "reportStateRealtimePeriod": { + "value": null + }, + "reportStateRealtime": { + "value": { + "state": "disabled" + }, + "timestamp": "2025-02-01T19:39:00.345Z" + }, + "reportStatePeriod": { + "value": "enabled", + "timestamp": "2025-02-01T19:39:00.345Z" + } + }, + "thermostatCoolingSetpoint": { + "coolingSetpointRange": { + "value": null + }, + "coolingSetpoint": { + "value": null + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": [ + "icemaker-02", + "icemaker-03", + "pantry-01", + "camera-01", + "scale-10", + "scale-11" + ], + "timestamp": "2025-02-07T12:01:52.638Z" + } + }, + "demandResponseLoadControl": { + "drlcStatus": { + "value": { + "drlcType": 1, + "drlcLevel": 0, + "duration": 0, + "override": false + }, + "timestamp": "2025-02-01T19:38:59.899Z" + } + }, + "samsungce.sabbathMode": { + "supportedActions": { + "value": null + }, + "status": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 4381422, + "deltaEnergy": 27, + "power": 144, + "powerEnergy": 27.01890500307083, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-02-09T00:13:39Z", + "end": "2025-02-09T00:25:23Z" + }, + "timestamp": "2025-02-09T00:25:23.843Z" + } + }, + "refresh": {}, + "samsungce.runestoneHomeContext": { + "supportedContexts": { + "value": [ + { + "context": "HOME_IN", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "ASLEEP", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "AWAKE", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "COOKING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_COOKING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "EATING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_EATING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "DOING_LAUNDRY", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_DOING_LAUNDRY", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "CLEANING_HOUSE", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_CLEANING_HOUSE", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "MUSIC_LISTENING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_MUSIC_LISTENING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "AIR_CONDITIONING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_AIR_CONDITIONING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "WASHING_DISHES", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_WASHING_DISHES", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "CARING_CLOTHING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_CARING_CLOTHING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "WATCHING_TV", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_WATCHING_TV", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "BEFORE_BEDTIME", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "BEFORE_COOKING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "BEFORE_HOME_OUT", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "ORDERING_DELIVERY_FOOD", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_ORDERING_DELIVERY_FOOD", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "ONLINE_GROCERY_SHOPPING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + }, + { + "context": "FINISH_ONLINE_GROCERY_SHOPPING", + "place": "HOME", + "startTime": "99:99", + "endTime": "99:99" + } + ], + "timestamp": "2025-02-01T19:39:02.150Z" + } + }, + "execute": { + "data": { + "value": { + "payload": { + "rt": ["x.com.samsung.da.fridge"], + "if": ["oic.if.a"], + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off" + } + }, + "data": { + "href": "/refrigeration/vs/0" + }, + "timestamp": "2024-03-26T09:06:17.169Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-02-01T19:39:01.951Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-02-01T19:39:01.951Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G", "5G"], + "timestamp": "2025-02-01T19:39:01.951Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK"], + "timestamp": "2025-02-01T19:39:01.951Z" + }, + "protocolType": { + "value": ["helper_hotspot"], + "timestamp": "2025-02-01T19:39:01.951Z" + } + }, + "refrigeration": { + "defrost": { + "value": "off", + "timestamp": "2025-02-01T19:38:59.276Z" + }, + "rapidCooling": { + "value": "off", + "timestamp": "2025-02-01T19:39:00.497Z" + }, + "rapidFreezing": { + "value": "off", + "timestamp": "2025-02-01T19:39:00.497Z" + } + }, + "samsungce.powerCool": { + "activated": { + "value": false, + "timestamp": "2025-02-01T19:39:00.497Z" + } + }, + "custom.energyType": { + "energyType": { + "value": "2.0", + "timestamp": "2022-02-07T10:54:05.580Z" + }, + "energySavingSupport": { + "value": false, + "timestamp": "2022-02-07T10:57:35.490Z" + }, + "drMaxDuration": { + "value": 1440, + "unit": "min", + "timestamp": "2022-02-07T11:50:40.228Z" + }, + "energySavingLevel": { + "value": null + }, + "energySavingInfo": { + "value": null + }, + "supportedEnergySavingLevels": { + "value": null + }, + "energySavingOperation": { + "value": null + }, + "notificationTemplateID": { + "value": null + }, + "energySavingOperationSupport": { + "value": false, + "timestamp": "2022-02-07T11:50:40.228Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": {}, + "timestamp": "2025-02-01T19:39:00.200Z" + }, + "otnDUID": { + "value": "2DCEZFTFQZPMO", + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-02-01T19:39:00.523Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-02-01T19:39:00.200Z" + }, + "operatingState": { + "value": null + }, + "progress": { + "value": null + } + }, + "samsungce.powerFreeze": { + "activated": { + "value": false, + "timestamp": "2025-02-01T19:39:00.497Z" + } + }, + "custom.waterFilter": { + "waterFilterUsageStep": { + "value": 1, + "timestamp": "2025-02-01T19:38:59.973Z" + }, + "waterFilterResetType": { + "value": ["replaceable"], + "timestamp": "2025-02-01T19:38:59.973Z" + }, + "waterFilterCapacity": { + "value": null + }, + "waterFilterLastResetDate": { + "value": null + }, + "waterFilterUsage": { + "value": 52, + "timestamp": "2025-02-08T05:06:45.769Z" + }, + "waterFilterStatus": { + "value": "normal", + "timestamp": "2025-02-01T19:38:59.973Z" + } + } + }, + "cvroom": { + "custom.fridgeMode": { + "fridgeModeValue": { + "value": null + }, + "fridgeMode": { + "value": "CV_FDR_DELI", + "timestamp": "2025-02-01T19:39:00.448Z" + }, + "supportedFridgeModes": { + "value": [ + "CV_FDR_WINE", + "CV_FDR_DELI", + "CV_FDR_BEVERAGE", + "CV_FDR_MEAT" + ], + "timestamp": "2025-02-01T19:39:00.448Z" + } + }, + "contactSensor": { + "contact": { + "value": "closed", + "timestamp": "2025-02-08T23:22:04.631Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2021-07-27T01:19:43.145Z" + } + } + }, + "icemaker-02": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2022-07-28T18:47:07.039Z" + } + }, + "switch": { + "switch": { + "value": null + } + } + }, + "icemaker-03": { + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [], + "timestamp": "2023-12-15T01:05:09.803Z" + } + }, + "switch": { + "switch": { + "value": null + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/vd_network_audio_003s.json b/tests/components/smartthings/fixtures/device_status/vd_network_audio_003s.json new file mode 100644 index 00000000000..e635f6c793a --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/vd_network_audio_003s.json @@ -0,0 +1,231 @@ +{ + "components": { + "main": { + "samsungvd.soundFrom": { + "mode": { + "value": 29, + "timestamp": "2025-04-05T13:51:47.865Z" + }, + "detailName": { + "value": "None", + "timestamp": "2025-04-05T13:51:50.230Z" + } + }, + "audioVolume": { + "volume": { + "value": 6, + "unit": "%", + "timestamp": "2025-04-17T11:17:25.272Z" + } + }, + "samsungvd.audioGroupInfo": { + "role": { + "value": null + }, + "channel": { + "value": null + }, + "status": { + "value": null + } + }, + "refresh": {}, + "audioNotification": {}, + "execute": { + "data": { + "value": null + } + }, + "samsungvd.audioInputSource": { + "supportedInputSources": { + "value": ["D.IN", "BT", "WIFI"], + "timestamp": "2025-03-18T19:11:54.071Z" + }, + "inputSource": { + "value": "D.IN", + "timestamp": "2025-04-17T11:18:02.048Z" + } + }, + "switch": { + "switch": { + "value": "off", + "timestamp": "2025-04-17T14:42:04.704Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-03-18T19:11:54.484Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-03-18T19:11:54.484Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G", "5G"], + "timestamp": "2025-03-18T19:11:54.484Z" + }, + "supportedAuthType": { + "value": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "EAP", + "SAE", + "OWE", + "FT-PSK" + ], + "timestamp": "2025-03-18T19:11:54.484Z" + }, + "protocolType": { + "value": ["ble_ocf"], + "timestamp": "2025-03-18T19:11:54.484Z" + } + }, + "ocf": { + "st": { + "value": "1970-01-01T00:00:47Z", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mndt": { + "value": "2024-01-01", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnfv": { + "value": "SAT-MT8532D24WWC-1016.0", + "timestamp": "2025-02-21T16:47:38.134Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "di": { + "value": "a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "n": { + "value": "Soundbar", + "timestamp": "2025-02-21T16:47:38.134Z" + }, + "mnmo": { + "value": "HW-S60D", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "vid": { + "value": "VD-NetworkAudio-003S", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnpv": { + "value": "8.0", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "pi": { + "value": "a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6", + "timestamp": "2025-02-21T15:09:52.348Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-02-21T15:09:52.348Z" + } + }, + "samsungvd.supportsFeatures": { + "mediaOutputSupported": { + "value": null + }, + "imeAdvSupported": { + "value": null + }, + "wifiUpdateSupport": { + "value": true, + "timestamp": "2025-03-18T19:11:53.853Z" + }, + "executableServiceList": { + "value": null + }, + "remotelessSupported": { + "value": null + }, + "artSupported": { + "value": null + }, + "mobileCamSupported": { + "value": null + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "endpoint": { + "value": "PIPER", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "301", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "tsId": { + "value": "VD02", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "mnId": { + "value": "0AJK", + "timestamp": "2025-03-18T19:11:54.336Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-03-18T19:11:54.336Z" + } + }, + "audioMute": { + "mute": { + "value": "muted", + "timestamp": "2025-04-17T11:36:04.814Z" + } + }, + "samsungvd.thingStatus": { + "updatedTime": { + "value": 1744900925, + "timestamp": "2025-04-17T14:42:04.770Z" + }, + "status": { + "value": "Idle", + "timestamp": "2025-03-18T19:11:54.101Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01001.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01001.json new file mode 100644 index 00000000000..ade24657f26 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01001.json @@ -0,0 +1,433 @@ +{ + "items": [ + { + "deviceId": "7d3feb98-8a36-4351-c362-5e21ad3a78dd", + "name": "Family Hub", + "label": "Refrigerator", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-REF-NORMAL-01001", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "2487472a-06c4-4bce-8f4c-700c5f8644f8", + "ownerId": "b603d7e8-6066-4e10-8102-afa752a63816", + "roomId": "acaa060a-7c19-4579-8a4a-5ad891a2f0c1", + "deviceTypeName": "Samsung OCF Refrigerator", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "demandResponseLoadControl", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "refrigeration", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.deviceReportStateConfiguration", + "version": 1 + }, + { + "id": "custom.energyType", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.waterFilter", + "version": 1 + }, + { + "id": "samsungce.fridgeFoodList", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.fridgeVacationMode", + "version": 1 + }, + { + "id": "samsungce.powerCool", + "version": 1 + }, + { + "id": "samsungce.powerFreeze", + "version": 1 + }, + { + "id": "samsungce.sabbathMode", + "version": 1 + }, + { + "id": "samsungce.viewInside", + "version": 1 + }, + { + "id": "samsungce.runestoneHomeContext", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "Refrigerator", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "freezer", + "label": "freezer", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.freezerConvertMode", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cooler", + "label": "cooler", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "thermostatCoolingSetpoint", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.thermostatSetpointControl", + "version": 1 + }, + { + "id": "samsungce.unavailableCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "cvroom", + "label": "cvroom", + "capabilities": [ + { + "id": "contactSensor", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.fridgeMode", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker", + "label": "icemaker", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-02", + "label": "icemaker-02", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "icemaker-03", + "label": "icemaker-03", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "scale-10", + "label": "scale-10", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "samsungce.weightMeasurementCalibration", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "samsungce.scaleSettings", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "scale-11", + "label": "scale-11", + "capabilities": [ + { + "id": "samsungce.weightMeasurement", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "pantry-01", + "label": "pantry-01", + "capabilities": [ + { + "id": "samsungce.fridgePantryInfo", + "version": 1 + }, + { + "id": "samsungce.fridgePantryMode", + "version": 1 + }, + { + "id": "samsungce.meatAging", + "version": 1 + }, + { + "id": "samsungce.foodDefrost", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + }, + { + "id": "camera-01", + "label": "camera-01", + "capabilities": [ + { + "id": "switch", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ] + } + ], + "createTime": "2021-07-27T01:19:42.051Z", + "profile": { + "id": "4c654f1b-8ef4-35b0-920e-c12568554213" + }, + "ocf": { + "ocfDeviceType": "oic.d.refrigerator", + "name": "Family Hub", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "24K_REF_LCD_FHUB9.0|00113141|0002034e051324200103000000000000", + "platformVersion": "7.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20240616.213423", + "vendorId": "DA-REF-NORMAL-01001", + "vendorResourceClientServerVersion": "4.0.22", + "locale": "", + "lastSignupTime": "2021-07-27T01:19:40.244392Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": [], + "executionContext": "CLOUD" + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/vd_network_audio_003s.json b/tests/components/smartthings/fixtures/devices/vd_network_audio_003s.json new file mode 100644 index 00000000000..428b0e635d5 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/vd_network_audio_003s.json @@ -0,0 +1,115 @@ +{ + "items": [ + { + "deviceId": "a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6", + "name": "Soundbar", + "label": "Soundbar", + "manufacturerName": "Samsung Electronics", + "presentationId": "VD-NetworkAudio-003S", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "6bdf6730-8167-488b-8645-d0c5046ff763", + "ownerId": "15f0ae72-da51-14e2-65cf-ef59ae867e7f", + "roomId": "3b0fe9a8-51d6-49cf-b64a-8a719013c0a7", + "deviceTypeName": "Samsung OCF Network Audio Player", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "ocf", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "audioMute", + "version": 1 + }, + { + "id": "samsungvd.audioInputSource", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "samsungvd.soundFrom", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "samsungvd.thingStatus", + "version": 1 + }, + { + "id": "samsungvd.supportsFeatures", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + }, + { + "id": "samsungvd.audioGroupInfo", + "version": 1, + "ephemeral": true + } + ], + "categories": [ + { + "name": "NetworkAudio", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-02-21T14:25:21.843Z", + "profile": { + "id": "25504ad5-8563-3b07-8770-e52ad29a9c5a" + }, + "ocf": { + "ocfDeviceType": "oic.d.networkaudio", + "name": "Soundbar", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "HW-S60D", + "platformVersion": "8.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "SAT-MT8532D24WWC-1016.0", + "vendorId": "VD-NetworkAudio-003S", + "vendorResourceClientServerVersion": "4.0.26", + "lastSignupTime": "2025-03-18T19:11:51.176292902Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 2419a154e05..3aac14c819d 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -761,6 +761,150 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_cooler_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_cooler_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cooler door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cooler_door', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cooler_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_cooler_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Cooler door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_cooler_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_coolselect_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_coolselect_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'CoolSelect+ door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cool_select_plus_door', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cvroom_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_coolselect_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator CoolSelect+ door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_coolselect_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_freezer_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.refrigerator_freezer_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Freezer door', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'freezer_door', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_freezer_contactSensor_contact_contact', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_freezer_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Refrigerator Freezer door', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_freezer_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr index 2c9dbd008af..4a7c582f608 100644 --- a/tests/components/smartthings/snapshots/test_button.ambr +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -187,3 +187,50 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ref_normal_01001][button.refrigerator_reset_water_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.refrigerator_reset_water_filter', + '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': 'Reset water filter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_water_filter', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_custom.waterFilter_resetWaterFilter', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][button.refrigerator_reset_water_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Reset water filter', + }), + 'context': , + 'entity_id': 'button.refrigerator_reset_water_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index db8c3a6ccc5..59ad2cff19b 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -629,6 +629,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_ref_normal_01001] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '7d3feb98-8a36-4351-c362-5e21ad3a78dd', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': '24K_REF_LCD_FHUB9.0', + 'model_id': None, + 'name': 'Refrigerator', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '20240616.213423', + 'via_device_id': None, + }) +# --- # name: test_devices[da_ref_normal_01011] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -1652,6 +1685,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[vd_network_audio_003s] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'HW-S60D', + 'model_id': None, + 'name': 'Soundbar', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': 'SAT-MT8532D24WWC-1016.0', + 'via_device_id': None, + }) +# --- # name: test_devices[vd_sensor_light_2023] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_media_player.ambr b/tests/components/smartthings/snapshots/test_media_player.ambr index 83f9d19b9fa..8eca654abe3 100644 --- a/tests/components/smartthings/snapshots/test_media_player.ambr +++ b/tests/components/smartthings/snapshots/test_media_player.ambr @@ -231,6 +231,56 @@ 'state': 'on', }) # --- +# name: test_all_entities[vd_network_audio_003s][media_player.soundbar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.soundbar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[vd_network_audio_003s][media_player.soundbar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Soundbar', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.soundbar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[vd_stv_2017_k][media_player.tv_samsung_8_series_49-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index e9441f2e408..0abd65ef242 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -4049,6 +4049,283 @@ 'state': '0.0135559777781698', }) # --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4381.422', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_difference-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy_difference', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.027', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_saved-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_energy_saved', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Refrigerator Power', + 'power_consumption_end': '2025-02-09T00:25:23Z', + 'power_consumption_start': '2025-02-09T00:13:39Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '144', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.refrigerator_power_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Refrigerator Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.refrigerator_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0270189050030708', + }) +# --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index d14d4d02aa4..395a9943f98 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -93,6 +93,53 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.refrigerator_ice_maker', + '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': 'Ice maker', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ice_maker', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_icemaker_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Ice maker', + }), + 'context': , + 'entity_id': 'switch.refrigerator_ice_maker', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index f57c8c107b2..bb7f950b0da 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -436,3 +436,30 @@ ROLLER_SHADE_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + + +HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Humidifier", + manufacturer_data={ + 741: b"\xacg\xb2\xcd\xfa\xbe", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"e\x80\x00\xf9\x80Bc\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Humidifier", + manufacturer_data={ + 741: b"\xacg\xb2\xcd\xfa\xbe", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"e\x80\x00\xf9\x80Bc\x00" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Humidifier"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_humidifier.py b/tests/components/switchbot/test_humidifier.py new file mode 100644 index 00000000000..cb2882a7475 --- /dev/null +++ b/tests/components/switchbot/test_humidifier.py @@ -0,0 +1,123 @@ +"""Test the switchbot humidifiers.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_NORMAL, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import HUMIDIFIER_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ( + "service", + "service_data", + "mock_method", + "expected_args", + ), + [ + ( + SERVICE_TURN_OFF, + {}, + "turn_off", + (), + ), + ( + SERVICE_TURN_ON, + {}, + "turn_on", + (), + ), + ( + SERVICE_SET_HUMIDITY, + {ATTR_HUMIDITY: 50}, + "set_humidity_level", + (50,), + ), + ( + SERVICE_SET_MODE, + {ATTR_MODE: MODE_AUTO}, + "set_auto_mode", + (), + ), + ( + SERVICE_SET_MODE, + {ATTR_MODE: MODE_NORMAL}, + "set_manual_mode", + (), + ), + ], +) +async def test_humidifier_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: tuple, +) -> None: + """Test all humidifier services with proper parameters.""" + inject_bluetooth_service_info(hass, HUMIDIFIER_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="humidifier") + entry.add_to_hass(hass) + entity_id = "humidifier.test_name" + + with ( + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.set_level", + new=AsyncMock(return_value=True), + ) as mock_set_humidity_level, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.async_set_auto", + new=AsyncMock(return_value=True), + ) as mock_set_auto_mode, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.async_set_manual", + new=AsyncMock(return_value=True), + ) as mock_set_manual_mode, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.turn_off", + new=AsyncMock(return_value=True), + ) as mock_turn_off, + patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotHumidifier.turn_on", + new=AsyncMock(return_value=True), + ) as mock_turn_on, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mock_map = { + "turn_off": mock_turn_off, + "turn_on": mock_turn_on, + "set_humidity_level": mock_set_humidity_level, + "set_auto_mode": mock_set_auto_mode, + "set_manual_mode": mock_set_manual_mode, + } + mock_instance = mock_map[mock_method] + mock_instance.assert_awaited_once_with(*expected_args) diff --git a/tests/components/switchbot/test_switch.py b/tests/components/switchbot/test_switch.py new file mode 100644 index 00000000000..2d572fd9996 --- /dev/null +++ b/tests/components/switchbot/test_switch.py @@ -0,0 +1,47 @@ +"""Test the switchbot switches.""" + +from collections.abc import Callable +from unittest.mock import patch + +from homeassistant.components.switch import STATE_ON +from homeassistant.core import HomeAssistant, State + +from . import WOHAND_SERVICE_INFO + +from tests.common import MockConfigEntry, mock_restore_cache +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_switchbot_switch_with_restore_state( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], +) -> None: + """Test that Switchbot Switch restores state correctly after reboot.""" + inject_bluetooth_service_info(hass, WOHAND_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="bot") + entity_id = "switch.test_name" + + mock_restore_cache( + hass, + [ + State( + entity_id, + STATE_ON, + {"last_run_success": True}, + ) + ], + ) + + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.switch.switchbot.Switchbot.switch_mode", + return_value=False, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes["last_run_success"] is True diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index a295dc16344..9521b313a2d 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -3290,5 +3290,11 @@ 'off' # --- # name: test_binary_sensors_streaming[binary_sensor.test_front_passenger_window-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_rear_driver_window-state] + 'off' +# --- +# name: test_binary_sensors_streaming[binary_sensor.test_rear_passenger_window-state] 'on' # --- diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index c5d98abc95c..0a992c213b8 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -4499,6 +4499,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index 5a7126afe1b..456449bb2ca 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -73,6 +73,8 @@ async def test_binary_sensors_streaming( "data": { Signal.FD_WINDOW: "WindowStateOpened", Signal.FP_WINDOW: "INVALID_VALUE", + Signal.RD_WINDOW: "WindowStateClosed", + Signal.RP_WINDOW: "WindowStatePartiallyOpen", Signal.DOOR_STATE: { "DoorState": { "DriverFront": True, @@ -98,6 +100,8 @@ async def test_binary_sensors_streaming( for entity_id in ( "binary_sensor.test_front_driver_window", "binary_sensor.test_front_passenger_window", + "binary_sensor.test_rear_driver_window", + "binary_sensor.test_rear_passenger_window", "binary_sensor.test_front_driver_door", "binary_sensor.test_front_passenger_door", "binary_sensor.test_driver_seat_belt", diff --git a/tests/components/tractive/conftest.py b/tests/components/tractive/conftest.py index 88c68a4b62f..f32aaa84349 100644 --- a/tests/components/tractive/conftest.py +++ b/tests/components/tractive/conftest.py @@ -29,6 +29,7 @@ def mock_tractive_client() -> Generator[AsyncMock]: "tracker_id": "device_id_123", "hardware": {"battery_level": 88}, "tracker_state": "operational", + "tracker_state_reason": "POWER_SAVING", "charging_state": "CHARGING", } entry.runtime_data.client._send_hardware_update(event) diff --git a/tests/components/tractive/snapshots/test_binary_sensor.ambr b/tests/components/tractive/snapshots/test_binary_sensor.ambr index 761626347a7..c7252da7a3b 100644 --- a/tests/components/tractive/snapshots/test_binary_sensor.ambr +++ b/tests/components/tractive/snapshots/test_binary_sensor.ambr @@ -47,3 +47,50 @@ 'state': 'on', }) # --- +# name: test_binary_sensor[binary_sensor.test_pet_tracker_power_saving-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_pet_tracker_power_saving', + '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': 'Tracker power saving', + 'platform': 'tractive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tracker_power_saving', + 'unique_id': 'pet_id_123_power_saving', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.test_pet_tracker_power_saving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Pet Tracker power saving', + }), + 'context': , + 'entity_id': 'binary_sensor.test_pet_tracker_power_saving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 0e4bb3da78c..800870f4604 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -35,6 +35,7 @@ from homeassistant.setup import async_setup_component from . import SATELLITE_INFO, WAKE_WORD_INFO, MockAsyncTcpClient from tests.common import MockConfigEntry +from tests.components.tts.common import MockResultStream async def setup_config_entry(hass: HomeAssistant) -> MockConfigEntry: @@ -259,10 +260,6 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", async_pipeline_from_audio_stream, ), - patch( - "homeassistant.components.wyoming.assist_satellite.tts.async_get_media_source_audio", - return_value=("wav", get_test_wav()), - ), patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), ): entry = await setup_config_entry(hass) @@ -411,10 +408,11 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: assert mock_client.synthesize.voice.name == "test voice" # Text-to-speech media + mock_tts_result_stream = MockResultStream(hass, "wav", get_test_wav()) pipeline_event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.TTS_END, - {"tts_output": {"media_id": "test media id"}}, + {"tts_output": {"token": mock_tts_result_stream.token}}, ) ) async with asyncio.timeout(1): @@ -435,12 +433,6 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None: ) assert not device.is_active - # The client should have received another ping by now - async with asyncio.timeout(1): - await mock_client.ping_event.wait() - - assert mock_client.ping is not None - # Pipeline should automatically restart async with asyncio.timeout(1): await run_pipeline_called.wait() @@ -746,10 +738,6 @@ async def test_tts_not_wav(hass: HomeAssistant) -> None: "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", wraps=_async_pipeline_from_audio_stream, ) as mock_run_pipeline, - patch( - "homeassistant.components.wyoming.assist_satellite.tts.async_get_media_source_audio", - return_value=("mp3", bytes(1)), - ), patch( "homeassistant.components.wyoming.assist_satellite.WyomingAssistSatellite._stream_tts", _stream_tts, @@ -779,10 +767,11 @@ async def test_tts_not_wav(hass: HomeAssistant) -> None: await mock_client.synthesize_event.wait() # Text-to-speech media + mock_tts_result_stream = MockResultStream(hass, "mp3", bytes(1)) event_callback( assist_pipeline.PipelineEvent( assist_pipeline.PipelineEventType.TTS_END, - {"tts_output": {"media_id": "test media id"}}, + {"tts_output": {"token": mock_tts_result_stream.token}}, ) ) diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 6e0edc022c0..c52b1391038 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -117,7 +117,6 @@ async def test_get_tts_audio_different_formats( assert wav_file.getframerate() == 48000 assert wav_file.getsampwidth() == 2 assert wav_file.getnchannels() == 2 - assert wav_file.getnframes() == wav_file.getframerate() # one second assert mock_client.written == snapshot diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 4c707590528..4a50cb9399f 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -6658,3 +6658,41 @@ async def test_calling_service_backwards_compatible( ], } ) + + +async def test_enabled_sequence_in_parallel( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test to ensure sequence inside parallel follows enabled tag.""" + event = "test_event" + events = async_capture_events(hass, event) + sequence = cv.SCRIPT_SCHEMA( + { + "parallel": [ + { + "sequence": [{"event": event, "event_data": {"value": "disabled"}}], + "enabled": "false", + }, + { + "sequence": [{"event": event, "event_data": {"value": "enabled"}}], + "enabled": "true", + }, + ] + } + ) + + script_obj = script.Script(hass, sequence, "Test Name", "test_domain") + + await script_obj.async_run(context=Context()) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["value"] == "enabled" + + expected_trace = { + "0": [{"result": {"enabled": False}}], + "0/parallel/1/sequence/0": [ + {"result": {"event": "test_event", "event_data": {"value": "enabled"}}} + ], + } + assert_action_trace(expected_trace)