diff --git a/Dockerfile.dev b/Dockerfile.dev index 1e21b8c815c591..773444399ee7f1 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -35,25 +35,22 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv USER vscode -COPY .python-version ./ -RUN uv python install - ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" -RUN uv venv $VIRTUAL_ENV +RUN --mount=type=bind,source=.python-version,target=.python-version \ + uv python install \ + && uv venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" -WORKDIR /tmp - # Setup hass-release RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \ && uv pip install -e ~/hass-release/ # Install Python dependencies from requirements -COPY requirements.txt ./ -COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt -RUN uv pip install -r requirements.txt -COPY requirements_test.txt requirements_test_pre_commit.txt ./ -RUN uv pip install -r requirements_test.txt +RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \ + --mount=type=bind,source=homeassistant/package_constraints.txt,target=homeassistant/package_constraints.txt \ + --mount=type=bind,source=requirements_test.txt,target=requirements_test.txt \ + --mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \ + uv pip install -r requirements.txt -r requirements_test.txt WORKDIR /workspaces diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index dae88bbcb15cf3..ac4e3522c269ca 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -17,7 +17,7 @@ ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_CHAT_MODEL, DEFAULT, DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER +from .const import DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -37,14 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) ) try: - # Use model from first conversation subentry for validation - subentries = list(entry.subentries.values()) - if subentries: - model_id = subentries[0].data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]) - else: - model_id = DEFAULT[CONF_CHAT_MODEL] - model = await client.models.retrieve(model_id=model_id, timeout=10.0) - LOGGER.debug("Anthropic model: %s", model.display_name) + await client.models.list(timeout=10.0) except anthropic.AuthenticationError as err: LOGGER.error("Invalid API key: %s", err) return False diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index cee8c2753e2e07..2c71c2527ed569 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -583,7 +583,7 @@ def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> Non identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="Anthropic", - model="Claude", + model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]), entry_type=dr.DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 59d7adcfc78b78..2dd168ff8a144f 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -17,8 +17,12 @@ class BangOlufsenSource: """Class used for associating device source ids with friendly names. May not include all sources.""" + DEEZER: Final[Source] = Source(name="Deezer", id="deezer") LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn") + NET_RADIO: Final[Source] = Source(name="B&O Radio", id="netRadio") SPDIF: Final[Source] = Source(name="Optical", id="spdif") + TIDAL: Final[Source] = Source(name="Tidal", id="tidal") + UNKNOWN: Final[Source] = Source(name="Unknown Source", id="unknown") URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer") @@ -78,6 +82,16 @@ class BangOlufsenModel(StrEnum): BEOREMOTE_ONE = "Beoremote One" +class BangOlufsenAttribute(StrEnum): + """Enum for extra_state_attribute keys.""" + + BEOLINK = "beolink" + BEOLINK_PEERS = "peers" + BEOLINK_SELF = "self" + BEOLINK_LEADER = "leader" + BEOLINK_LISTENERS = "listeners" + + # Physical "buttons" on devices class BangOlufsenButtons(StrEnum): """Enum for device buttons.""" diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index e3060680c35b8d..618c0f1808de3f 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -82,6 +82,7 @@ FALLBACK_SOURCES, MANUFACTURER, VALID_MEDIA_TYPES, + BangOlufsenAttribute, BangOlufsenMediaType, BangOlufsenSource, WebsocketNotification, @@ -224,7 +225,8 @@ def __init__(self, entry: ConfigEntry, client: MozartClient) -> None: # Beolink compatible sources self._beolink_sources: dict[str, bool] = {} self._remote_leader: BeolinkLeader | None = None - # Extra state attributes for showing Beolink: peer(s), listener(s), leader and self + # Extra state attributes: + # Beolink: peer(s), listener(s), leader and self self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {} async def async_added_to_hass(self) -> None: @@ -436,7 +438,10 @@ async def _async_update_name_and_beolink(self) -> None: await self._async_update_beolink() async def _async_update_beolink(self) -> None: - """Update the current Beolink leader, listeners, peers and self.""" + """Update the current Beolink leader, listeners, peers and self. + + Updates Home Assistant state. + """ self._beolink_attributes = {} @@ -445,18 +450,24 @@ async def _async_update_beolink(self) -> None: # Add Beolink self self._beolink_attributes = { - "beolink": {"self": {self.device_entry.name: self._beolink_jid}} + BangOlufsenAttribute.BEOLINK: { + BangOlufsenAttribute.BEOLINK_SELF: { + self.device_entry.name: self._beolink_jid + } + } } # Add Beolink peers peers = await self._client.get_beolink_peers() if len(peers) > 0: - self._beolink_attributes["beolink"]["peers"] = {} + self._beolink_attributes[BangOlufsenAttribute.BEOLINK][ + BangOlufsenAttribute.BEOLINK_PEERS + ] = {} for peer in peers: - self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = ( - peer.jid - ) + self._beolink_attributes[BangOlufsenAttribute.BEOLINK][ + BangOlufsenAttribute.BEOLINK_PEERS + ][peer.friendly_name] = peer.jid # Add Beolink listeners / leader self._remote_leader = self._playback_metadata.remote_leader @@ -477,7 +488,9 @@ async def _async_update_beolink(self) -> None: # Add self group_members.append(self.entity_id) - self._beolink_attributes["beolink"]["leader"] = { + self._beolink_attributes[BangOlufsenAttribute.BEOLINK][ + BangOlufsenAttribute.BEOLINK_LEADER + ] = { self._remote_leader.friendly_name: self._remote_leader.jid, } @@ -514,9 +527,9 @@ async def _async_update_beolink(self) -> None: beolink_listener.jid ) break - self._beolink_attributes["beolink"]["listeners"] = ( - beolink_listeners_attribute - ) + self._beolink_attributes[BangOlufsenAttribute.BEOLINK][ + BangOlufsenAttribute.BEOLINK_LISTENERS + ] = beolink_listeners_attribute self._attr_group_members = group_members @@ -615,11 +628,18 @@ def is_volume_muted(self) -> bool | None: return None @property - def media_content_type(self) -> str: + def media_content_type(self) -> MediaType | str | None: """Return the current media type.""" - # Hard to determine content type - if self._source_change.id == BangOlufsenSource.URI_STREAMER.id: - return MediaType.URL + content_type = { + BangOlufsenSource.URI_STREAMER.id: MediaType.URL, + BangOlufsenSource.DEEZER.id: BangOlufsenMediaType.DEEZER, + BangOlufsenSource.TIDAL.id: BangOlufsenMediaType.TIDAL, + BangOlufsenSource.NET_RADIO.id: BangOlufsenMediaType.RADIO, + } + # Hard to determine content type. + if self._source_change.id in content_type: + return content_type[self._source_change.id] + return MediaType.MUSIC @property @@ -632,6 +652,11 @@ def media_position(self) -> int | None: """Return the current playback progress.""" return self._playback_progress.progress + @property + def media_content_id(self) -> str | None: + """Return internal ID of Deezer, Tidal and radio stations.""" + return self._playback_metadata.source_internal_id + @property def media_image_url(self) -> str | None: """Return URL of the currently playing music.""" diff --git a/homeassistant/components/climate/icons.json b/homeassistant/components/climate/icons.json index c26f1bb894b0e9..1922c95348cbec 100644 --- a/homeassistant/components/climate/icons.json +++ b/homeassistant/components/climate/icons.json @@ -98,6 +98,12 @@ } }, "triggers": { + "started_cooling": { + "trigger": "mdi:snowflake" + }, + "started_drying": { + "trigger": "mdi:water-percent" + }, "started_heating": { "trigger": "mdi:fire" }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index fb7efeadb7d2c5..c3801b84f67653 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -298,6 +298,28 @@ }, "title": "Climate", "triggers": { + "started_cooling": { + "description": "Triggers when a climate started cooling.", + "description_configured": "[%key:component::climate::triggers::started_cooling::description%]", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::trigger_behavior_description%]", + "name": "[%key:component::climate::common::trigger_behavior_name%]" + } + }, + "name": "When a climate started cooling" + }, + "started_drying": { + "description": "Triggers when a climate started drying.", + "description_configured": "[%key:component::climate::triggers::started_drying::description%]", + "fields": { + "behavior": { + "description": "[%key:component::climate::common::trigger_behavior_description%]", + "name": "[%key:component::climate::common::trigger_behavior_name%]" + } + }, + "name": "When a climate started drying" + }, "started_heating": { "description": "Triggers when a climate starts to heat.", "description_configured": "[%key:component::climate::triggers::started_heating::description%]", diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index c45922088ad06e..a8699a4ab47bd4 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -11,6 +11,12 @@ from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode TRIGGERS: dict[str, type[Trigger]] = { + "started_cooling": make_entity_state_attribute_trigger( + DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING + ), + "started_drying": make_entity_state_attribute_trigger( + DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING + ), "turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF), "turned_on": make_conditional_entity_state_trigger( DOMAIN, diff --git a/homeassistant/components/climate/triggers.yaml b/homeassistant/components/climate/triggers.yaml index bed00d43d87064..5d4f9c189769c2 100644 --- a/homeassistant/components/climate/triggers.yaml +++ b/homeassistant/components/climate/triggers.yaml @@ -14,6 +14,8 @@ - last - any +started_cooling: *trigger_common +started_drying: *trigger_common started_heating: *trigger_common turned_off: *trigger_common turned_on: *trigger_common diff --git a/homeassistant/components/cloud/ai_task.py b/homeassistant/components/cloud/ai_task.py index 4b6d0223f49d88..ff57144805ea45 100644 --- a/homeassistant/components/cloud/ai_task.py +++ b/homeassistant/components/cloud/ai_task.py @@ -6,6 +6,7 @@ from json import JSONDecodeError import logging +from hass_nabucasa import NabuCasaBaseError from hass_nabucasa.llm import ( LLMAuthenticationError, LLMError, @@ -93,10 +94,11 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Home Assistant Cloud AI Task entity.""" - cloud = hass.data[DATA_CLOUD] + if not (cloud := hass.data[DATA_CLOUD]).is_logged_in: + return try: await cloud.llm.async_ensure_token() - except LLMError: + except (LLMError, NabuCasaBaseError): return async_add_entities([CloudLLMTaskEntity(cloud, config_entry)]) diff --git a/homeassistant/components/cloud/conversation.py b/homeassistant/components/cloud/conversation.py index 64dd11d50d1fe1..c7f197a392355e 100644 --- a/homeassistant/components/cloud/conversation.py +++ b/homeassistant/components/cloud/conversation.py @@ -4,6 +4,7 @@ from typing import Literal +from hass_nabucasa import NabuCasaBaseError from hass_nabucasa.llm import LLMError from homeassistant.components import conversation @@ -23,10 +24,11 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Home Assistant Cloud conversation entity.""" - cloud = hass.data[DATA_CLOUD] + if not (cloud := hass.data[DATA_CLOUD]).is_logged_in: + return try: await cloud.llm.async_ensure_token() - except LLMError: + except (LLMError, NabuCasaBaseError): return async_add_entities([CloudConversationEntity(cloud, config_entry)]) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index c5a761e710feaf..1b4539dfca63a6 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==1.6.1"], + "requirements": ["hass-nabucasa==1.6.2"], "single_config_entry": true } diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 5a516f068a4aa8..7ace0b860bcc80 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -66,6 +66,7 @@ entity_registry as er, floor_registry as fr, intent, + llm, start as ha_start, template, translation, @@ -76,7 +77,7 @@ from homeassistant.util.json import JsonObjectType, json_loads_object from .agent_manager import get_agent_manager -from .chat_log import AssistantContent, ChatLog +from .chat_log import AssistantContent, ChatLog, ToolResultContent from .const import ( DOMAIN, METADATA_CUSTOM_FILE, @@ -430,6 +431,8 @@ async def _async_handle_message( ) -> ConversationResult: """Handle a message.""" response: intent.IntentResponse | None = None + tool_input: llm.ToolInput | None = None + tool_result: dict[str, Any] = {} # Check if a trigger matched if trigger_result := await self.async_recognize_sentence_trigger(user_input): @@ -438,6 +441,16 @@ async def _async_handle_message( trigger_result, user_input ) + # Create tool result + tool_input = llm.ToolInput( + tool_name="trigger_sentence", + tool_args={}, + external=True, + ) + tool_result = { + "response": response_text, + } + # Convert to conversation result response = intent.IntentResponse( language=user_input.language or self.hass.config.language @@ -447,10 +460,44 @@ async def _async_handle_message( if response is None: # Match intents intent_result = await self.async_recognize_intent(user_input) + response = await self._async_process_intent_result( intent_result, user_input ) + if response.response_type != intent.IntentResponseType.ERROR: + assert intent_result is not None + assert intent_result.intent is not None + # Create external tool call for the intent + tool_input = llm.ToolInput( + tool_name=intent_result.intent.name, + tool_args={ + entity.name: entity.value or entity.text + for entity in intent_result.entities_list + }, + external=True, + ) + # Create tool result from intent response + tool_result = llm.IntentResponseDict(response) + + # Add tool call and result to chat log if we have one + if tool_input is not None: + chat_log.async_add_assistant_content_without_tools( + AssistantContent( + agent_id=user_input.agent_id, + content=None, + tool_calls=[tool_input], + ) + ) + chat_log.async_add_assistant_content_without_tools( + ToolResultContent( + agent_id=user_input.agent_id, + tool_call_id=tool_input.id, + tool_name=tool_input.tool_name, + tool_result=tool_result, + ) + ) + speech: str = response.speech.get("plain", {}).get("speech", "") chat_log.async_add_assistant_content_without_tools( AssistantContent( diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index 52fdfaaca3f229..ef3bb99f39df28 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -8,6 +8,10 @@ from pycoolmasternet_async import SWING_MODES from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -31,7 +35,16 @@ HA_STATE_TO_CM = {value: key for key, value in CM_TO_HA_STATE.items()} -FAN_MODES = ["low", "med", "high", "auto"] +CM_TO_HA_FAN = { + "low": FAN_LOW, + "med": FAN_MEDIUM, + "high": FAN_HIGH, + "auto": FAN_AUTO, +} + +HA_FAN_TO_CM = {value: key for key, value in CM_TO_HA_FAN.items()} + +FAN_MODES = list(CM_TO_HA_FAN.values()) _LOGGER = logging.getLogger(__name__) @@ -111,7 +124,7 @@ def hvac_mode(self): @property def fan_mode(self): """Return the fan setting.""" - return self._unit.fan_speed + return CM_TO_HA_FAN[self._unit.fan_speed] @property def fan_modes(self): @@ -138,7 +151,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" _LOGGER.debug("Setting fan mode of %s to %s", self.unique_id, fan_mode) - self._unit = await self._unit.set_fan_speed(fan_mode) + self._unit = await self._unit.set_fan_speed(HA_FAN_TO_CM[fan_mode]) self.async_write_ha_state() async def async_set_swing_mode(self, swing_mode: str) -> None: diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 8cbdafd9f87bf0..5044dd4415555e 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -285,16 +285,14 @@ def _new_sensor(sensor: EcoWittSensor) -> None: name=sensor.name, ) - # Hourly rain doesn't reset to fixed hours, it must be measurement state classes + # Only total rain needs state class for long-term statistics if sensor.key in ( - "hrain_piezomm", - "hrain_piezo", - "hourlyrainmm", - "hourlyrainin", + "totalrainin", + "totalrainmm", ): description = dataclasses.replace( description, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ) async_add_entities([EcowittSensorEntity(sensor, description)]) diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py index 1f8c59784b623a..1ef3d5706e8182 100644 --- a/homeassistant/components/essent/sensor.py +++ b/homeassistant/components/essent/sensor.py @@ -102,6 +102,7 @@ def _get_current_tariff_groups( key="average_today", translation_key="average_today", value_fn=lambda energy_data: energy_data.avg_price, + energy_types=(EnergyType.ELECTRICITY,), ), EssentSensorEntityDescription( key="lowest_price_today", diff --git a/homeassistant/components/essent/strings.json b/homeassistant/components/essent/strings.json index b9b2fae5ee7529..1b6f692a697ce3 100644 --- a/homeassistant/components/essent/strings.json +++ b/homeassistant/components/essent/strings.json @@ -44,9 +44,6 @@ "electricity_next_price": { "name": "Next electricity price" }, - "gas_average_today": { - "name": "Average gas price today" - }, "gas_current_price": { "name": "Current gas price" }, diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 9d9f3613b538ba..0ac3e237e7b507 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.5.0"] + "requirements": ["renault-api==0.5.1"] } diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 8dc2490d742ef7..499c610a2b2dcb 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -20,6 +20,9 @@ from .coordinator import SFRConfigEntry from .entity import SFRCoordinatorEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class SFRBoxBinarySensorEntityDescription[_T](BinarySensorEntityDescription): diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index 324e17b45d37de..46809971fb15c7 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -24,6 +24,10 @@ from .coordinator import SFRConfigEntry from .entity import SFREntity +# Coordinator is used to centralize the data updates +# but better to queue action calls to avoid conflicts +PARALLEL_UPDATES = 1 + def with_error_wrapping[**_P, _R]( func: Callable[Concatenate[SFRBoxButton, _P], Awaitable[_R]], diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index b130fb2deaac8c..9084401d36273a 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -26,6 +26,9 @@ from .coordinator import SFRConfigEntry from .entity import SFRCoordinatorEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class SFRBoxSensorEntityDescription[_T](SensorEntityDescription): diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 69c2d5c33de17b..a8cdc86efc169b 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -79,6 +79,7 @@ get_rpc_device_wakeup_period, get_rpc_ws_url, get_shelly_model_name, + is_rpc_ble_scanner_supported, update_device_fw_info, ) @@ -726,6 +727,7 @@ async def _async_connected(self) -> None: """Handle device connected.""" async with self._connection_lock: if self.connected: # Already connected + LOGGER.debug("Device %s already connected", self.name) return self.connected = True try: @@ -743,10 +745,7 @@ async def _async_run_connected_events(self) -> None: is updated. """ if not self.sleep_period: - if ( - self.config_entry.runtime_data.rpc_supports_scripts - and not self.config_entry.runtime_data.rpc_zigbee_firmware - ): + if is_rpc_ble_scanner_supported(self.config_entry): await self._async_connect_ble_scanner() else: await self._async_setup_outbound_websocket() @@ -776,6 +775,10 @@ async def _async_connect_ble_scanner(self) -> None: if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting # the scanner since it will be disconnected anyway + LOGGER.debug( + "Device %s BLE enable required a reboot, skipping scanner connect", + self.name, + ) return assert self.device_id is not None self._disconnected_callbacks.append( @@ -844,21 +847,14 @@ async def shutdown(self) -> None: """Shutdown the coordinator.""" if self.device.connected: try: - if not self.sleep_period: + if not self.sleep_period and is_rpc_ble_scanner_supported( + self.config_entry + ): await async_stop_scanner(self.device) await super().shutdown() except InvalidAuthError: self.config_entry.async_start_reauth(self.hass) return - except RpcCallError as err: - # Ignore 404 (No handler for) error - if err.code != 404: - LOGGER.debug( - "Error during shutdown for device %s: %s", - self.name, - err.message, - ) - return except DeviceConnectionError as err: # If the device is restarting or has gone offline before # the ping/pong timeout happens, the shutdown command diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index f1f9786c4a3178..fde4c71cf2252a 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -994,3 +994,11 @@ def async_migrate_rpc_virtual_components_unique_ids( } return None + + +def is_rpc_ble_scanner_supported(entry: ConfigEntry) -> bool: + """Return true if BLE scanner is supported.""" + return ( + entry.runtime_data.rpc_supports_scripts + and not entry.runtime_data.rpc_zigbee_firmware + ) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index ee28c3228c8b33..f5198c4c4dbc5c 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -48,6 +48,7 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator +from .entity import AbstractTemplateEntity from .helpers import ( async_setup_template_entry, async_setup_template_platform, @@ -168,11 +169,27 @@ def async_create_preview_binary_sensor( ) -class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity): +class AbstractTemplateBinarySensor( + AbstractTemplateEntity, BinarySensorEntity, RestoreEntity +): + """Representation of a template binary sensor features.""" + + _entity_id_format = ENTITY_ID_FORMAT + + # The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__. + # This ensures that the __init__ on AbstractTemplateEntity is not called twice. + def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called + """Initialize the features.""" + + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._template: template.Template = config[CONF_STATE] + self._delay_cancel: CALLBACK_TYPE | None = None + + +class StateBinarySensorEntity(TemplateEntity, AbstractTemplateBinarySensor): """A virtual binary sensor that triggers from another sensor.""" _attr_should_poll = False - _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -182,19 +199,19 @@ def __init__( ) -> None: """Initialize the Template binary sensor.""" TemplateEntity.__init__(self, hass, config, unique_id) - - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._template: template.Template = config[CONF_STATE] - self._delay_cancel = None + AbstractTemplateBinarySensor.__init__(self, config) self._delay_on = None - self._delay_on_raw = config.get(CONF_DELAY_ON) + self._delay_on_template = config.get(CONF_DELAY_ON) self._delay_off = None - self._delay_off_raw = config.get(CONF_DELAY_OFF) + self._delay_off_template = config.get(CONF_DELAY_OFF) async def async_added_to_hass(self) -> None: """Restore state.""" if ( - (self._delay_on_raw is not None or self._delay_off_raw is not None) + ( + self._delay_on_template is not None + or self._delay_off_template is not None + ) and (last_state := await self.async_get_last_state()) is not None and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) ): @@ -206,20 +223,20 @@ def _async_setup_templates(self) -> None: """Set up templates.""" self.add_template_attribute("_state", self._template, None, self._update_state) - if self._delay_on_raw is not None: + if self._delay_on_template is not None: try: - self._delay_on = cv.positive_time_period(self._delay_on_raw) + self._delay_on = cv.positive_time_period(self._delay_on_template) except vol.Invalid: self.add_template_attribute( - "_delay_on", self._delay_on_raw, cv.positive_time_period + "_delay_on", self._delay_on_template, cv.positive_time_period ) - if self._delay_off_raw is not None: + if self._delay_off_template is not None: try: - self._delay_off = cv.positive_time_period(self._delay_off_raw) + self._delay_off = cv.positive_time_period(self._delay_off_template) except vol.Invalid: self.add_template_attribute( - "_delay_off", self._delay_off_raw, cv.positive_time_period + "_delay_off", self._delay_off_template, cv.positive_time_period ) super()._async_setup_templates() @@ -259,12 +276,10 @@ def _set_state(_): self._delay_cancel = async_call_later(self.hass, delay, _set_state) -class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity): +class TriggerBinarySensorEntity(TriggerEntity, AbstractTemplateBinarySensor): """Sensor entity based on trigger data.""" - _entity_id_format = ENTITY_ID_FORMAT domain = BINARY_SENSOR_DOMAIN - extra_template_keys = (CONF_STATE,) def __init__( self, @@ -273,7 +288,8 @@ def __init__( config: dict, ) -> None: """Initialize the entity.""" - super().__init__(hass, coordinator, config) + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateBinarySensor.__init__(self, config) for key in (CONF_STATE, CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF): if isinstance(config.get(key), template.Template): @@ -282,7 +298,6 @@ def __init__( self._last_delay_from: bool | None = None self._last_delay_to: bool | None = None - self._delay_cancel: CALLBACK_TYPE | None = None self._auto_off_cancel: CALLBACK_TYPE | None = None self._auto_off_time: datetime | None = None diff --git a/homeassistant/components/transmission/quality_scale.yaml b/homeassistant/components/transmission/quality_scale.yaml index 65b9a0c91244b5..c7128fbe4aa9ba 100644 --- a/homeassistant/components/transmission/quality_scale.yaml +++ b/homeassistant/components/transmission/quality_scale.yaml @@ -32,10 +32,7 @@ rules: log-when-unavailable: done parallel-updates: todo reauthentication-flow: done - test-coverage: - status: todo - comment: | - Change to mock_setup_entry to avoid repetition when expanding tests. + test-coverage: done # Gold devices: diff --git a/homeassistant/components/tuya/raw_data_models.py b/homeassistant/components/tuya/raw_data_models.py new file mode 100644 index 00000000000000..c0ba9947fef074 --- /dev/null +++ b/homeassistant/components/tuya/raw_data_models.py @@ -0,0 +1,60 @@ +"""Parsers for RAW (base64-encoded bytes) values.""" + +from dataclasses import dataclass +import struct +from typing import Self + + +@dataclass(kw_only=True) +class ElectricityData: + """Electricity RAW value.""" + + current: float + power: float + voltage: float + + @classmethod + def from_bytes(cls, raw: bytes) -> Self | None: + """Parse bytes and return an ElectricityValue object.""" + # Format: + # - legacy: 8 bytes + # - v01: [ver=0x01][len=0x0F][data(15 bytes)] + # - v02: [ver=0x02][len=0x0F][data(15 bytes)][sign_bitmap(1 byte)] + # Data layout (big-endian): + # - voltage: 2B, unit 0.1 V + # - current: 3B, unit 0.001 A (i.e., mA) + # - active power: 3B, unit 0.001 kW (i.e., W) + # - reactive power: 3B, unit 0.001 kVar + # - apparent power: 3B, unit 0.001 kVA + # - power factor: 1B, unit 0.01 + # Sign bitmap (v02 only, 1 bit means negative): + # - bit0 current + # - bit1 active power + # - bit2 reactive + # - bit3 power factor + + is_v1 = len(raw) == 17 and raw[0:2] == b"\x01\x0f" + is_v2 = len(raw) == 18 and raw[0:2] == b"\x02\x0f" + if is_v1 or is_v2: + data = raw[2:17] + + voltage = struct.unpack(">H", data[0:2])[0] / 10.0 + current = struct.unpack(">L", b"\x00" + data[2:5])[0] + power = struct.unpack(">L", b"\x00" + data[5:8])[0] + + if is_v2: + sign_bitmap = raw[17] + if sign_bitmap & 0x01: + current = -current + if sign_bitmap & 0x02: + power = -power + + return cls(current=current, power=power, voltage=voltage) + + if len(raw) >= 8: + voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 + current = struct.unpack(">L", b"\x00" + raw[2:5])[0] + power = struct.unpack(">L", b"\x00" + raw[5:8])[0] + return cls(current=current, power=power, voltage=voltage) + + return None diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 95557de6bf25f5..8304d3dfb9f1c1 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass -import struct from tuya_sharing import CustomerDevice, Manager @@ -49,6 +48,7 @@ DPCodeWrapper, EnumTypeData, ) +from .raw_data_models import ElectricityData class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeData]): @@ -120,42 +120,52 @@ def read_device_status(self, device: CustomerDevice) -> float | None: return raw_value.get("voltage") -class _RawElectricityCurrentWrapper(DPCodeBase64Wrapper): - """Custom DPCode Wrapper for extracting electricity current from base64.""" +class _RawElectricityDataWrapper(DPCodeBase64Wrapper): + """Custom DPCode Wrapper for extracting ElectricityData from base64.""" - native_unit = UnitOfElectricCurrent.MILLIAMPERE - suggested_unit = UnitOfElectricCurrent.AMPERE + def _convert(self, value: ElectricityData) -> float: + """Extract specific value from T.""" + raise NotImplementedError def read_device_status(self, device: CustomerDevice) -> float | None: """Read the device value for the dpcode.""" - if (raw_value := super().read_bytes(device)) is None: + if (raw_value := super().read_bytes(device)) is None or ( + value := ElectricityData.from_bytes(raw_value) + ) is None: return None - return struct.unpack(">L", b"\x00" + raw_value[2:5])[0] + return self._convert(value) + + +class _RawElectricityCurrentWrapper(_RawElectricityDataWrapper): + """Custom DPCode Wrapper for extracting electricity current from base64.""" + + native_unit = UnitOfElectricCurrent.MILLIAMPERE + suggested_unit = UnitOfElectricCurrent.AMPERE + + def _convert(self, value: ElectricityData) -> float: + """Extract specific value from ElectricityData.""" + return value.current -class _RawElectricityPowerWrapper(DPCodeBase64Wrapper): +class _RawElectricityPowerWrapper(_RawElectricityDataWrapper): """Custom DPCode Wrapper for extracting electricity power from base64.""" native_unit = UnitOfPower.WATT suggested_unit = UnitOfPower.KILO_WATT - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (raw_value := super().read_bytes(device)) is None: - return None - return struct.unpack(">L", b"\x00" + raw_value[5:8])[0] + def _convert(self, value: ElectricityData) -> float: + """Extract specific value from ElectricityData.""" + return value.power -class _RawElectricityVoltageWrapper(DPCodeBase64Wrapper): +class _RawElectricityVoltageWrapper(_RawElectricityDataWrapper): """Custom DPCode Wrapper for extracting electricity voltage from base64.""" native_unit = UnitOfElectricPotential.VOLT - def read_device_status(self, device: CustomerDevice) -> float | None: - """Read the device value for the dpcode.""" - if (raw_value := super().read_bytes(device)) is None: - return None - return struct.unpack(">H", raw_value[0:2])[0] / 10.0 + def _convert(self, value: ElectricityData) -> float: + """Extract specific value from ElectricityData.""" + return value.voltage CURRENT_WRAPPER = (_RawElectricityCurrentWrapper, _JsonElectricityCurrentWrapper) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 5182d04bfdca6d..fb9967e321a227 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -9,6 +9,7 @@ from homeassistant.components import onboarding from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -16,6 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN @@ -52,6 +54,19 @@ async def async_step_user( await self.async_set_unique_id( device.info.mac_address, raise_on_progress=False ) + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + self._abort_if_unique_id_mismatch( + reason="unique_id_mismatch", + description_placeholders={ + "expected_mac": format_mac(entry.unique_id).upper(), + "actual_mac": format_mac(self.unique_id).upper(), + }, + ) + return self.async_update_reload_and_abort( + entry, + data_updates=user_input, + ) self._abort_if_unique_id_configured( updates={CONF_HOST: user_input[CONF_HOST]} ) @@ -61,13 +76,26 @@ async def async_step_user( CONF_HOST: user_input[CONF_HOST], }, ) + data_schema = vol.Schema({vol.Required(CONF_HOST): str}) + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + data_schema = self.add_suggested_values_to_schema( + data_schema, + entry.data, + ) return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + data_schema=data_schema, errors=errors or {}, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow for WLED entry.""" + return await self.async_step_user(user_input) + async def async_step_zeroconf( self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 2ca460ee81ffda..fc84d5084909b8 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -14,7 +14,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -120,6 +122,16 @@ async def _async_update_data(self) -> WLEDDevice: translation_placeholders={"error": str(error)}, ) from error + if device.info.mac_address != self.config_entry.unique_id: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="mac_address_mismatch", + translation_placeholders={ + "expected_mac": format_mac(self.config_entry.unique_id).upper(), + "actual_mac": format_mac(device.info.mac_address).upper(), + }, + ) + # If the device supports a WebSocket, try activating it. if ( device.info.websocket is not None diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 6c4f93a088ce5f..4cea5ef235f2b7 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "MAC address does not match the configured device. Expected to connect to device with MAC: `{expected_mac}`, but connected to device with MAC: `{actual_mac}`. \n\nPlease ensure you reconfigure against the same device." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" @@ -133,6 +135,9 @@ }, "invalid_response_wled_error": { "message": "Invalid response from WLED API: {error}" + }, + "mac_address_mismatch": { + "message": "MAC address does not match the configured device. Expected to connect to device with MAC: {expected_mac}, but connected to device with MAC: {actual_mac}." } }, "options": { diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e3bd7b7b757457..98540bfc4b12a6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ fnv-hash-fast==1.6.0 go2rtc-client==0.3.0 ha-ffmpeg==3.2.2 habluetooth==5.7.0 -hass-nabucasa==1.6.1 +hass-nabucasa==1.6.2 hassil==3.4.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20251126.0 diff --git a/pyproject.toml b/pyproject.toml index 2c9d07a92c74cc..5c3103c75aeb26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "fnv-hash-fast==1.6.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==1.6.1", + "hass-nabucasa==1.6.2", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index 7ac3f2ca7d9cd6..60d82789b00ae8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 fnv-hash-fast==1.6.0 -hass-nabucasa==1.6.1 +hass-nabucasa==1.6.2 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c835ca20ea035b..5c199f4c458baa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1160,7 +1160,7 @@ habluetooth==5.7.0 hanna-cloud==0.0.6 # homeassistant.components.cloud -hass-nabucasa==1.6.1 +hass-nabucasa==1.6.2 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -2714,7 +2714,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.5.0 +renault-api==0.5.1 # homeassistant.components.renson renson-endura-delta==1.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c62762724d1e8a..7abed6d76dad85 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1030,7 +1030,7 @@ habluetooth==5.7.0 hanna-cloud==0.0.6 # homeassistant.components.cloud -hass-nabucasa==1.6.1 +hass-nabucasa==1.6.2 # homeassistant.components.assist_satellite # homeassistant.components.conversation @@ -2268,7 +2268,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.5.0 +renault-api==0.5.1 # homeassistant.components.renson renson-endura-delta==1.7.2 diff --git a/script/bootstrap b/script/bootstrap index c903cd6c2a29f9..7301d948442259 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -9,9 +9,8 @@ cd "$(realpath "$(dirname "$0")/..")" echo "Installing development dependencies..." uv pip install \ -e . \ - -r requirements_test.txt \ + -r requirements_test_all.txt \ colorlog \ - --constraint homeassistant/package_constraints.txt \ --upgrade \ --config-settings editable_mode=compat diff --git a/tests/components/adguard/conftest.py b/tests/components/adguard/conftest.py index 1430eb957d303d..a2e248536968dc 100644 --- a/tests/components/adguard/conftest.py +++ b/tests/components/adguard/conftest.py @@ -2,7 +2,14 @@ from unittest.mock import AsyncMock -from adguardhome.update import AdGuardHomeAvailableUpdate +from adguardhome import AdGuardHome +from adguardhome.filtering import AdGuardHomeFiltering +from adguardhome.parental import AdGuardHomeParental +from adguardhome.querylog import AdGuardHomeQueryLog +from adguardhome.safebrowsing import AdGuardHomeSafeBrowsing +from adguardhome.safesearch import AdGuardHomeSafeSearch +from adguardhome.stats import AdGuardHomeStats +from adguardhome.update import AdGuardHomeAvailableUpdate, AdGuardHomeUpdate import pytest from homeassistant.components.adguard import DOMAIN @@ -38,7 +45,14 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture async def mock_adguard() -> AsyncMock: """Fixture for setting up the component.""" - adguard_mock = AsyncMock() + adguard_mock = AsyncMock(spec=AdGuardHome) + adguard_mock.filtering = AsyncMock(spec=AdGuardHomeFiltering) + adguard_mock.parental = AsyncMock(spec=AdGuardHomeParental) + adguard_mock.querylog = AsyncMock(spec=AdGuardHomeQueryLog) + adguard_mock.safebrowsing = AsyncMock(spec=AdGuardHomeSafeBrowsing) + adguard_mock.safesearch = AsyncMock(spec=AdGuardHomeSafeSearch) + adguard_mock.stats = AsyncMock(spec=AdGuardHomeStats) + adguard_mock.update = AsyncMock(spec=AdGuardHomeUpdate) # static properties adguard_mock.host = "127.0.0.1" @@ -48,6 +62,10 @@ async def mock_adguard() -> AsyncMock: # async method mocks adguard_mock.version = AsyncMock(return_value="v0.107.50") + adguard_mock.protection_enabled = AsyncMock(return_value=True) + adguard_mock.parental.enabled = AsyncMock(return_value=True) + adguard_mock.safesearch.enabled = AsyncMock(return_value=True) + adguard_mock.safebrowsing.enabled = AsyncMock(return_value=True) adguard_mock.stats.dns_queries = AsyncMock(return_value=666) adguard_mock.stats.blocked_filtering = AsyncMock(return_value=1337) adguard_mock.stats.blocked_percentage = AsyncMock(return_value=200.75) @@ -56,11 +74,8 @@ async def mock_adguard() -> AsyncMock: adguard_mock.stats.replaced_safesearch = AsyncMock(return_value=18) adguard_mock.stats.avg_processing_time = AsyncMock(return_value=31.41) adguard_mock.filtering.rules_count = AsyncMock(return_value=100) - adguard_mock.filtering.add_url = AsyncMock() - adguard_mock.filtering.remove_url = AsyncMock() - adguard_mock.filtering.enable_url = AsyncMock() - adguard_mock.filtering.disable_url = AsyncMock() - adguard_mock.filtering.refresh = AsyncMock() + adguard_mock.filtering.enabled = AsyncMock(return_value=True) + adguard_mock.querylog.enabled = AsyncMock(return_value=True) adguard_mock.update.update_available = AsyncMock( return_value=AdGuardHomeAvailableUpdate( new_version="v0.107.59", @@ -70,6 +85,5 @@ async def mock_adguard() -> AsyncMock: disabled=False, ) ) - adguard_mock.update.begin_update = AsyncMock() return adguard_mock diff --git a/tests/components/adguard/snapshots/test_switch.ambr b/tests/components/adguard/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..b98165d76534f7 --- /dev/null +++ b/tests/components/adguard/snapshots/test_switch.ambr @@ -0,0 +1,289 @@ +# serializer version: 1 +# name: test_switch[switch.adguard_home_filtering-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.adguard_home_filtering', + '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': 'Filtering', + 'platform': 'adguard', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filtering', + 'unique_id': 'adguard_127.0.0.1_3000_switch_filtering', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.adguard_home_filtering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AdGuard Home Filtering', + }), + 'context': , + 'entity_id': 'switch.adguard_home_filtering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.adguard_home_parental_control-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.adguard_home_parental_control', + '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': 'Parental control', + 'platform': 'adguard', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'parental', + 'unique_id': 'adguard_127.0.0.1_3000_switch_parental', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.adguard_home_parental_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AdGuard Home Parental control', + }), + 'context': , + 'entity_id': 'switch.adguard_home_parental_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.adguard_home_protection-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.adguard_home_protection', + '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': 'Protection', + 'platform': 'adguard', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'protection', + 'unique_id': 'adguard_127.0.0.1_3000_switch_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.adguard_home_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AdGuard Home Protection', + }), + 'context': , + 'entity_id': 'switch.adguard_home_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.adguard_home_query_log-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.adguard_home_query_log', + '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': 'Query log', + 'platform': 'adguard', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'query_log', + 'unique_id': 'adguard_127.0.0.1_3000_switch_querylog', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.adguard_home_query_log-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AdGuard Home Query log', + }), + 'context': , + 'entity_id': 'switch.adguard_home_query_log', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.adguard_home_safe_browsing-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.adguard_home_safe_browsing', + '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': 'Safe browsing', + 'platform': 'adguard', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'safe_browsing', + 'unique_id': 'adguard_127.0.0.1_3000_switch_safebrowsing', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.adguard_home_safe_browsing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AdGuard Home Safe browsing', + }), + 'context': , + 'entity_id': 'switch.adguard_home_safe_browsing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.adguard_home_safe_search-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.adguard_home_safe_search', + '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': 'Safe search', + 'platform': 'adguard', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'safe_search', + 'unique_id': 'adguard_127.0.0.1_3000_switch_safesearch', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.adguard_home_safe_search-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AdGuard Home Safe search', + }), + 'context': , + 'entity_id': 'switch.adguard_home_safe_search', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/adguard/test_switch.py b/tests/components/adguard/test_switch.py new file mode 100644 index 00000000000000..00014a6e5d8105 --- /dev/null +++ b/tests/components/adguard/test_switch.py @@ -0,0 +1,161 @@ +"""Tests for the AdGuard Home switch entity.""" + +from collections.abc import Callable +import logging +from typing import Any +from unittest.mock import AsyncMock, patch + +from adguardhome import AdGuardHomeError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_switch( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the adguard switch platform.""" + with patch("homeassistant.components.adguard.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry, mock_adguard) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("switch_name", "service", "call_assertion"), + [ + ( + "protection", + SERVICE_TURN_ON, + lambda mock: mock.enable_protection.assert_called_once(), + ), + ( + "protection", + SERVICE_TURN_OFF, + lambda mock: mock.disable_protection.assert_called_once(), + ), + ( + "parental_control", + SERVICE_TURN_ON, + lambda mock: mock.parental.enable.assert_called_once(), + ), + ( + "parental_control", + SERVICE_TURN_OFF, + lambda mock: mock.parental.disable.assert_called_once(), + ), + ( + "safe_search", + SERVICE_TURN_ON, + lambda mock: mock.safesearch.enable.assert_called_once(), + ), + ( + "safe_search", + SERVICE_TURN_OFF, + lambda mock: mock.safesearch.disable.assert_called_once(), + ), + ( + "safe_browsing", + SERVICE_TURN_ON, + lambda mock: mock.safebrowsing.enable.assert_called_once(), + ), + ( + "safe_browsing", + SERVICE_TURN_OFF, + lambda mock: mock.safebrowsing.disable.assert_called_once(), + ), + ( + "filtering", + SERVICE_TURN_ON, + lambda mock: mock.filtering.enable.assert_called_once(), + ), + ( + "filtering", + SERVICE_TURN_OFF, + lambda mock: mock.filtering.disable.assert_called_once(), + ), + ( + "query_log", + SERVICE_TURN_ON, + lambda mock: mock.querylog.enable.assert_called_once(), + ), + ( + "query_log", + SERVICE_TURN_OFF, + lambda mock: mock.querylog.disable.assert_called_once(), + ), + ], +) +async def test_switch_actions( + hass: HomeAssistant, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, + switch_name: str, + service: str, + call_assertion: Callable[[AsyncMock], Any], +) -> None: + """Test the adguard switch actions.""" + with patch("homeassistant.components.adguard.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry, mock_adguard) + + await hass.services.async_call( + "switch", + service, + {ATTR_ENTITY_ID: f"switch.adguard_home_{switch_name}"}, + blocking=True, + ) + + call_assertion(mock_adguard) + + +@pytest.mark.parametrize( + ("service", "expected_message"), + [ + ( + SERVICE_TURN_ON, + "An error occurred while turning on AdGuard Home switch", + ), + ( + SERVICE_TURN_OFF, + "An error occurred while turning off AdGuard Home switch", + ), + ], +) +async def test_switch_action_failed( + hass: HomeAssistant, + mock_adguard: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + service: str, + expected_message: str, +) -> None: + """Test the adguard switch actions.""" + caplog.set_level(logging.ERROR) + + with patch("homeassistant.components.adguard.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry, mock_adguard) + + mock_adguard.enable_protection.side_effect = AdGuardHomeError("Boom") + mock_adguard.disable_protection.side_effect = AdGuardHomeError("Boom") + + await hass.services.async_call( + "switch", + service, + {ATTR_ENTITY_ID: "switch.adguard_home_protection"}, + blocking=True, + ) + assert expected_message in caplog.text diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index 207e1d410e6ad8..5b40b1d0c381ad 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -20,8 +20,8 @@ from anthropic.types.raw_message_delta_event import Delta import pytest -from homeassistant.components.anthropic import CONF_CHAT_MODEL from homeassistant.components.anthropic.const import ( + CONF_CHAT_MODEL, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -184,13 +184,10 @@ async def mock_init_component( ), ] ) - with ( - patch("anthropic.resources.models.AsyncModels.retrieve"), - patch( - "anthropic.resources.models.AsyncModels.list", - new_callable=AsyncMock, - return_value=model_list, - ), + with patch( + "anthropic.resources.models.AsyncModels.list", + new_callable=AsyncMock, + return_value=model_list, ): assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index b259b988424a48..cf5a3d17c8282d 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -99,7 +99,7 @@ async def test_template_error( "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) - with patch("anthropic.resources.models.AsyncModels.retrieve"): + with patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -138,7 +138,7 @@ async def test_template_variables( create_content_block(0, ["Okay, let", " me take care of that for you", "."]) ] with ( - patch("anthropic.resources.models.AsyncModels.retrieve"), + patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock), patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), ): await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index a97a3b7a378010..10626d24fc6f17 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -62,7 +62,7 @@ async def test_init_error( ) -> None: """Test initialization errors.""" with patch( - "anthropic.resources.models.AsyncModels.retrieve", + "anthropic.resources.models.AsyncModels.list", side_effect=side_effect, ): assert await async_setup_component(hass, "anthropic", {}) diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 4596971a8b1412..213c6720b154ed 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -168,6 +168,7 @@ title="Test title", total_duration_seconds=123, track=1, + source_internal_id="123", ) TEST_PLAYBACK_ERROR = PlaybackError(error="Test error") TEST_PLAYBACK_PROGRESS = PlaybackProgress(progress=123) diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index 4a1688f6a3cb2d..c7fea881345e9c 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -2,16 +2,16 @@ # name: test_async_beolink_allstandby StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -50,16 +50,16 @@ # name: test_async_beolink_expand[all_discovered-True-None-log_messages0-3] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -71,7 +71,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'media_content_type': , + 'media_content_type': , 'repeat': , 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', @@ -99,16 +99,16 @@ # name: test_async_beolink_expand[all_discovered-True-expand_side_effect1-log_messages1-3] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -120,7 +120,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'media_content_type': , + 'media_content_type': , 'repeat': , 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', @@ -148,16 +148,16 @@ # name: test_async_beolink_expand[beolink_jids-parameter_value2-None-log_messages2-2] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -169,7 +169,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'media_content_type': , + 'media_content_type': , 'repeat': , 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', @@ -197,16 +197,16 @@ # name: test_async_beolink_expand[beolink_jids-parameter_value3-expand_side_effect3-log_messages3-2] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -218,7 +218,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'media_content_type': , + 'media_content_type': , 'repeat': , 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', @@ -246,16 +246,16 @@ # name: test_async_beolink_join[service_parameters0-method_parameters0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -294,16 +294,16 @@ # name: test_async_beolink_join[service_parameters1-method_parameters1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -342,16 +342,16 @@ # name: test_async_beolink_join[service_parameters2-method_parameters2] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -390,16 +390,16 @@ # name: test_async_beolink_join_invalid[service_parameters0-expected_result0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -438,16 +438,16 @@ # name: test_async_beolink_join_invalid[service_parameters1-expected_result1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -486,16 +486,16 @@ # name: test_async_beolink_join_invalid[service_parameters2-expected_result2] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -534,16 +534,16 @@ # name: test_async_beolink_unexpand StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -582,16 +582,16 @@ # name: test_async_join_players[group_members0-1-0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -603,7 +603,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'media_content_type': , + 'media_content_type': , 'repeat': , 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', @@ -631,16 +631,16 @@ # name: test_async_join_players[group_members0-1-0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', }), }), @@ -680,16 +680,16 @@ # name: test_async_join_players[group_members1-0-1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -701,7 +701,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'media_content_type': , + 'media_content_type': , 'repeat': , 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', @@ -729,16 +729,16 @@ # name: test_async_join_players[group_members1-0-1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', }), }), @@ -778,16 +778,16 @@ # name: test_async_join_players_invalid[source0-group_members0-expected_result0-invalid_source] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -828,16 +828,16 @@ # name: test_async_join_players_invalid[source0-group_members0-expected_result0-invalid_source].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', }), }), @@ -877,16 +877,16 @@ # name: test_async_join_players_invalid[source1-group_members1-expected_result1-invalid_grouping_entity] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -898,7 +898,7 @@ 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', ]), - 'media_content_type': , + 'media_content_type': , 'repeat': , 'shuffle': False, 'sound_mode': 'Test Listening Mode (123)', @@ -926,16 +926,16 @@ # name: test_async_join_players_invalid[source1-group_members1-expected_result1-invalid_grouping_entity].1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', }), }), @@ -975,16 +975,16 @@ # name: test_async_unjoin_player StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -1023,15 +1023,15 @@ # name: test_async_update_beolink_listener StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'leader': dict({ + : dict({ + : dict({ 'Laundry room Core': '1111.1111111.22222222@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', }), }), @@ -1069,16 +1069,16 @@ # name: test_async_update_beolink_listener.1 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'beolink': dict({ - 'listeners': dict({ + : dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'peers': dict({ + : dict({ 'Bedroom Premiere': '1111.1111111.33333333@products.bang-olufsen.com', 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', }), - 'self': dict({ + : dict({ 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', }), }), diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index a48f9f358ad452..e2a72f363f10d8 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -24,6 +24,7 @@ BANG_OLUFSEN_REPEAT_FROM_HA, BANG_OLUFSEN_STATES, DOMAIN, + BangOlufsenMediaType, BangOlufsenSource, ) from homeassistant.components.media_player import ( @@ -260,6 +261,7 @@ async def test_async_update_playback_metadata( assert ATTR_MEDIA_ALBUM_ARTIST not in states.attributes assert ATTR_MEDIA_TRACK not in states.attributes assert ATTR_MEDIA_CHANNEL not in states.attributes + assert ATTR_MEDIA_CONTENT_ID not in states.attributes # Send the WebSocket event dispatch playback_metadata_callback(TEST_PLAYBACK_METADATA) @@ -276,6 +278,12 @@ async def test_async_update_playback_metadata( ) assert states.attributes[ATTR_MEDIA_TRACK] == TEST_PLAYBACK_METADATA.track assert states.attributes[ATTR_MEDIA_CHANNEL] == TEST_PLAYBACK_METADATA.organization + assert states.attributes[ATTR_MEDIA_CHANNEL] == TEST_PLAYBACK_METADATA.organization + assert ( + states.attributes[ATTR_MEDIA_CONTENT_ID] + == TEST_PLAYBACK_METADATA.source_internal_id + ) + assert states.attributes[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC async def test_async_update_playback_error( @@ -342,28 +350,47 @@ async def test_async_update_playback_state( @pytest.mark.parametrize( - ("source", "content_type", "progress", "metadata"), + ("source", "content_type", "progress", "metadata", "content_id_available"), [ - # Normal source, music mediatype expected - ( - TEST_SOURCE, - MediaType.MUSIC, - TEST_PLAYBACK_PROGRESS.progress, - PlaybackContentMetadata(), - ), # URI source, url media type expected ( BangOlufsenSource.URI_STREAMER, MediaType.URL, TEST_PLAYBACK_PROGRESS.progress, PlaybackContentMetadata(), + False, ), - # Line-In source,media type expected, progress 0 expected + # Line-In source, music media type expected, progress 0 expected ( BangOlufsenSource.LINE_IN, MediaType.MUSIC, 0, PlaybackContentMetadata(), + False, + ), + # Tidal source, tidal media type expected, media content id expected + ( + BangOlufsenSource.TIDAL, + BangOlufsenMediaType.TIDAL, + TEST_PLAYBACK_PROGRESS.progress, + PlaybackContentMetadata(source_internal_id="123"), + True, + ), + # Deezer source, deezer media type expected, media content id expected + ( + BangOlufsenSource.DEEZER, + BangOlufsenMediaType.DEEZER, + TEST_PLAYBACK_PROGRESS.progress, + PlaybackContentMetadata(source_internal_id="123"), + True, + ), + # Radio source, radio media type expected, media content id expected + ( + BangOlufsenSource.NET_RADIO, + BangOlufsenMediaType.RADIO, + TEST_PLAYBACK_PROGRESS.progress, + PlaybackContentMetadata(source_internal_id="123"), + True, ), ], ) @@ -375,6 +402,7 @@ async def test_async_update_source_change( content_type: MediaType, progress: int, metadata: PlaybackContentMetadata, + content_id_available: bool, ) -> None: """Test _async_update_source_change.""" playback_progress_callback = ( @@ -402,6 +430,7 @@ async def test_async_update_source_change( assert states.attributes[ATTR_INPUT_SOURCE] == source.name assert states.attributes[ATTR_MEDIA_CONTENT_TYPE] == content_type assert states.attributes[ATTR_MEDIA_POSITION] == progress + assert (ATTR_MEDIA_CONTENT_ID in states.attributes) == content_id_available async def test_async_turn_off( diff --git a/tests/components/climate/test_trigger.py b/tests/components/climate/test_trigger.py index 5a066ec731c477..561d262c21f814 100644 --- a/tests/components/climate/test_trigger.py +++ b/tests/components/climate/test_trigger.py @@ -142,11 +142,21 @@ async def test_climate_state_trigger_behavior_any( @pytest.mark.parametrize( ("trigger", "states"), [ + *parametrize_trigger_states( + trigger="climate.started_cooling", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), + *parametrize_trigger_states( + trigger="climate.started_drying", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), *parametrize_trigger_states( trigger="climate.started_heating", - target_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.HEATING})], - other_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.IDLE})], - ) + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), ], ) async def test_climate_state_attribute_trigger_behavior_any( @@ -261,11 +271,21 @@ async def test_climate_state_trigger_behavior_first( @pytest.mark.parametrize( ("trigger", "states"), [ + *parametrize_trigger_states( + trigger="climate.started_cooling", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), + *parametrize_trigger_states( + trigger="climate.started_drying", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), *parametrize_trigger_states( trigger="climate.started_heating", - target_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.HEATING})], - other_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.IDLE})], - ) + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), ], ) async def test_climate_state_attribute_trigger_behavior_first( @@ -378,11 +398,21 @@ async def test_climate_state_trigger_behavior_last( @pytest.mark.parametrize( ("trigger", "states"), [ + *parametrize_trigger_states( + trigger="climate.started_cooling", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), + *parametrize_trigger_states( + trigger="climate.started_drying", + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), *parametrize_trigger_states( trigger="climate.started_heating", - target_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.HEATING})], - other_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.IDLE})], - ) + target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})], + other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})], + ), ], ) async def test_climate_state_attribute_trigger_behavior_last( diff --git a/tests/components/cloud/test_ai_task.py b/tests/components/cloud/test_ai_task.py index ab1f65e6f3ecc3..308971be92ba51 100644 --- a/tests/components/cloud/test_ai_task.py +++ b/tests/components/cloud/test_ai_task.py @@ -21,7 +21,9 @@ from homeassistant.components.cloud.ai_task import ( CloudLLMTaskEntity, async_prepare_image_generation_attachments, + async_setup_entry, ) +from homeassistant.components.cloud.const import DATA_CLOUD from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError @@ -46,6 +48,21 @@ def mock_cloud_ai_task_entity(hass: HomeAssistant) -> CloudLLMTaskEntity: return entity +async def test_setup_entry_skips_when_not_logged_in( + hass: HomeAssistant, +) -> None: + """Test setup_entry exits early when not logged in.""" + cloud = MagicMock() + cloud.is_logged_in = False + entry = MockConfigEntry(domain="cloud") + entry.add_to_hass(hass) + hass.data[DATA_CLOUD] = cloud + + async_add_entities = AsyncMock() + await async_setup_entry(hass, entry, async_add_entities) + async_add_entities.assert_not_called() + + @pytest.fixture(name="mock_handle_chat_log") def mock_handle_chat_log_fixture() -> AsyncMock: """Patch the chat log handler.""" diff --git a/tests/components/cloud/test_conversation.py b/tests/components/cloud/test_conversation.py index f12ac26a5397e7..df1b7e8deb7fca 100644 --- a/tests/components/cloud/test_conversation.py +++ b/tests/components/cloud/test_conversation.py @@ -34,6 +34,21 @@ def cloud_conversation_entity(hass: HomeAssistant) -> CloudConversationEntity: return entity +async def test_setup_entry_skips_when_not_logged_in( + hass: HomeAssistant, +) -> None: + """Test setup_entry exits early when not logged in.""" + cloud = MagicMock() + cloud.is_logged_in = False + entry = MockConfigEntry(domain="cloud") + entry.add_to_hass(hass) + hass.data[DATA_CLOUD] = cloud + + async_add_entities = AsyncMock() + await async_setup_entry(hass, entry, async_add_entities) + async_add_entities.assert_not_called() + + def test_entity_availability( cloud_conversation_entity: CloudConversationEntity, ) -> None: diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index bd4f710b760c4e..3fe398b051fe5a 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -17,6 +17,11 @@ default_agent, get_agent_manager, ) +from homeassistant.components.conversation.chat_log import ( + AssistantContent, + ToolResultContent, + async_get_chat_log, +) from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE from homeassistant.components.conversation.models import ConversationInput from homeassistant.components.conversation.trigger import TriggerDetails @@ -52,6 +57,7 @@ ) from homeassistant.helpers import ( area_registry as ar, + chat_session, device_registry as dr, entity_registry as er, floor_registry as fr, @@ -3424,3 +3430,149 @@ async def test_fuzzy_matching( if slot_name != "preferred_area_id" # context area } assert actual_slots == slots + + +@pytest.mark.usefixtures("init_components") +async def test_intent_tool_call_in_chat_log(hass: HomeAssistant) -> None: + """Test that intent tool calls are stored in the chat log.""" + hass.states.async_set( + "light.test_light", "off", attributes={ATTR_FRIENDLY_NAME: "Test Light"} + ) + async_mock_service(hass, "light", "turn_on") + + result = await conversation.async_converse( + hass, "turn on test light", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + with ( + chat_session.async_get_chat_session(hass, result.conversation_id) as session, + async_get_chat_log(hass, session) as chat_log, + ): + pass + + # Find the tool call in the chat log + tool_call_content: AssistantContent | None = None + tool_result_content: ToolResultContent | None = None + assistant_content: AssistantContent | None = None + + for content in chat_log.content: + if content.role == "assistant" and content.tool_calls: + tool_call_content = content + if content.role == "tool_result": + tool_result_content = content + if content.role == "assistant" and not content.tool_calls: + assistant_content = content + + # Verify tool call was stored + assert tool_call_content is not None and tool_call_content.tool_calls is not None + assert len(tool_call_content.tool_calls) == 1 + assert tool_call_content.tool_calls[0].tool_name == "HassTurnOn" + assert tool_call_content.tool_calls[0].external is True + assert tool_call_content.tool_calls[0].tool_args.get("name") == "Test Light" + + # Verify tool result was stored + assert tool_result_content is not None + assert tool_result_content.tool_name == "HassTurnOn" + assert tool_result_content.tool_result["response_type"] == "action_done" + + # Verify final assistant content with speech + assert assistant_content is not None + assert assistant_content.content is not None + + +@pytest.mark.usefixtures("init_components") +async def test_trigger_tool_call_in_chat_log(hass: HomeAssistant) -> None: + """Test that trigger tool calls are stored in the chat log.""" + trigger_sentence = "test automation trigger" + trigger_response = "Trigger activated!" + + manager = get_agent_manager(hass) + callback = AsyncMock(return_value=trigger_response) + manager.register_trigger(TriggerDetails([trigger_sentence], callback)) + + result = await conversation.async_converse( + hass, trigger_sentence, None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + + with ( + chat_session.async_get_chat_session(hass, result.conversation_id) as session, + async_get_chat_log(hass, session) as chat_log, + ): + pass + + # Find the tool call in the chat log + tool_call_content: AssistantContent | None = None + tool_result_content: ToolResultContent | None = None + + for content in chat_log.content: + if content.role == "assistant" and content.tool_calls: + tool_call_content = content + if content.role == "tool_result": + tool_result_content = content + + # Verify tool call was stored + assert tool_call_content is not None and tool_call_content.tool_calls is not None + assert len(tool_call_content.tool_calls) == 1 + assert tool_call_content.tool_calls[0].tool_name == "trigger_sentence" + assert tool_call_content.tool_calls[0].external is True + assert tool_call_content.tool_calls[0].tool_args == {} + + # Verify tool result was stored + assert tool_result_content is not None + assert tool_result_content.tool_name == "trigger_sentence" + assert tool_result_content.tool_result["response"] == trigger_response + + +@pytest.mark.usefixtures("init_components") +async def test_no_tool_call_on_no_intent_match(hass: HomeAssistant) -> None: + """Test that no tool call is stored when no intent is matched.""" + result = await conversation.async_converse( + hass, "this is a random sentence that should not match", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + + with ( + chat_session.async_get_chat_session(hass, result.conversation_id) as session, + async_get_chat_log(hass, session) as chat_log, + ): + pass + + # Verify no tool call was stored + for content in chat_log.content: + if content.role == "assistant": + assert content.tool_calls is None or len(content.tool_calls) == 0 + break + else: + pytest.fail("No assistant content found in chat log") + + +@pytest.mark.usefixtures("init_components") +async def test_intent_tool_call_with_error_response(hass: HomeAssistant) -> None: + """Test that intent tool calls store error information correctly.""" + # Request to turn on a non-existent device + result = await conversation.async_converse( + hass, "turn on the non existent device", None, Context(), None + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS + + with ( + chat_session.async_get_chat_session(hass, result.conversation_id) as session, + async_get_chat_log(hass, session) as chat_log, + ): + pass + + # Verify no tool call was stored for unmatched entities + tool_call_found = False + for content in chat_log.content: + if content.role == "assistant" and content.tool_calls: + tool_call_found = True + + # No tool call should be stored since the entity could not be matched + assert not tool_call_found diff --git a/tests/components/coolmaster/test_climate.py b/tests/components/coolmaster/test_climate.py index ddc4b5b53d6970..0f5c18ffb38877 100644 --- a/tests/components/coolmaster/test_climate.py +++ b/tests/components/coolmaster/test_climate.py @@ -167,23 +167,28 @@ async def test_set_temperature( assert hass.states.get("climate.l1_100").attributes[ATTR_TEMPERATURE] == 30 +@pytest.mark.parametrize("target_fan_mode", FAN_MODES) async def test_set_fan_mode( hass: HomeAssistant, load_int: ConfigEntry, + target_fan_mode: str, ) -> None: """Test the Coolmaster climate set fan mode.""" assert hass.states.get("climate.l1_100").attributes[ATTR_FAN_MODE] == FAN_LOW + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, { ATTR_ENTITY_ID: "climate.l1_100", - ATTR_FAN_MODE: FAN_HIGH, + ATTR_FAN_MODE: target_fan_mode, }, blocking=True, ) await hass.async_block_till_done() - assert hass.states.get("climate.l1_100").attributes[ATTR_FAN_MODE] == FAN_HIGH + assert ( + hass.states.get("climate.l1_100").attributes[ATTR_FAN_MODE] == target_fan_mode + ) async def test_set_swing_mode( diff --git a/tests/components/essent/snapshots/test_sensor.ambr b/tests/components/essent/snapshots/test_sensor.ambr index 1a52af7810a75e..31c02998f44805 100644 --- a/tests/components/essent/snapshots/test_sensor.ambr +++ b/tests/components/essent/snapshots/test_sensor.ambr @@ -55,62 +55,6 @@ 'state': '0.29591', }) # --- -# name: test_entities[sensor.essent_average_gas_price_today-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.essent_average_gas_price_today', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 3, - }), - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Average gas price today', - 'platform': 'essent', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'gas_average_today', - 'unique_id': 'gas-average_today', - 'unit_of_measurement': '€/m³', - }) -# --- -# name: test_entities[sensor.essent_average_gas_price_today-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by Essent', - 'friendly_name': 'Essent Average gas price today', - 'state_class': , - 'unit_of_measurement': '€/m³', - }), - 'context': , - 'entity_id': 'sensor.essent_average_gas_price_today', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1.13959', - }) -# --- # name: test_entities[sensor.essent_current_electricity_market_price-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index e4549d9c4a0dc7..8c2cc331aefbb7 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock, call, patch -from aioshelly.const import MODEL_BULB, MODEL_BUTTON1 +from aioshelly.const import MODEL_2PM_G3, MODEL_BULB, MODEL_BUTTON1 from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from freezegun.api import FrozenDateTimeFactory import pytest @@ -29,6 +29,8 @@ from homeassistant.const import ATTR_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry from . import ( MOCK_MAC, @@ -40,7 +42,11 @@ register_entity, ) -from tests.common import async_fire_time_changed, mock_restore_cache +from tests.common import ( + async_fire_time_changed, + async_load_json_object_fixture, + mock_restore_cache, +) RELAY_BLOCK_ID = 0 LIGHT_BLOCK_ID = 2 @@ -927,6 +933,7 @@ async def test_rpc_runs_connected_events_when_initialized( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, supports_scripts: bool, zigbee_firmware: bool, result: bool, @@ -950,6 +957,13 @@ async def test_rpc_runs_connected_events_when_initialized( # BLE script list is called during connected events if device supports scripts # and Zigbee is disabled assert bool(call.script_list() in mock_rpc_device.mock_calls) == result + assert "Device Test name already connected" not in caplog.text + + # Mock initialized event after already initialized + caplog.clear() + mock_rpc_device.mock_initialized() + await hass.async_block_till_done() + assert "Device Test name already connected" in caplog.text async def test_rpc_sleeping_device_unload_ignore_ble_scanner( @@ -1139,3 +1153,70 @@ async def test_xmod_model_lookup( ) assert device assert device.model == xmod_model + + +async def test_sub_device_area_from_main_device( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly sub-device area is set to main device area when created.""" + device_fixture = await async_load_json_object_fixture(hass, "2pm_gen3.json", DOMAIN) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + config_entry = await init_integration( + hass, gen=3, model=MODEL_2PM_G3, skip_setup=True + ) + + # create main device and set area + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + name="Test name", + connections={(CONNECTION_NETWORK_MAC, MOCK_MAC)}, + identifiers={(DOMAIN, MOCK_MAC)}, + suggested_area="living_room", + ) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # verify sub-devices have the same area as main device + for relay_index in range(2): + entity_id = f"switch.test_name_switch_{relay_index}" + assert hass.states.get(entity_id) is not None + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.area_id == "living_room" + + +@pytest.mark.parametrize("restart_required", [True, False]) +async def test_rpc_ble_scanner_enable_reboot( + hass: HomeAssistant, + mock_rpc_device, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + restart_required: bool, +) -> None: + """Test RPC BLE scanner enabling requires reboot.""" + monkeypatch.setattr( + mock_rpc_device, + "ble_getconfig", + AsyncMock(return_value={"enable": False}), + ) + monkeypatch.setattr( + mock_rpc_device, + "ble_setconfig", + AsyncMock(return_value={"restart_required": restart_required}), + ) + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + assert bool("BLE enable required a reboot" in caplog.text) == restart_required + assert mock_rpc_device.trigger_reboot.call_count == int(restart_required) diff --git a/tests/components/transmission/__init__.py b/tests/components/transmission/__init__.py index c4abba7b83236e..6cd3328a85fbc8 100644 --- a/tests/components/transmission/__init__.py +++ b/tests/components/transmission/__init__.py @@ -1,5 +1,18 @@ """Tests for Transmission.""" +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + OLD_MOCK_CONFIG_DATA = { "name": "Transmission", "host": "0.0.0.0", diff --git a/tests/components/transmission/conftest.py b/tests/components/transmission/conftest.py new file mode 100644 index 00000000000000..48b4514b50e2b5 --- /dev/null +++ b/tests/components/transmission/conftest.py @@ -0,0 +1,101 @@ +"""Transmission tests configuration.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import pytest +from transmission_rpc.session import Session, SessionStats +from transmission_rpc.torrent import Torrent + +from homeassistant.components.transmission.const import DOMAIN + +from . import MOCK_CONFIG_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.transmission.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Transmission", + data=MOCK_CONFIG_DATA, + entry_id="01J0BC4QM2YBRP6H5G933AETT7", + ) + + +@pytest.fixture +def mock_transmission_client() -> Generator[AsyncMock]: + """Mock a Transmission client.""" + with ( + patch( + "homeassistant.components.transmission.transmission_rpc.Client", + autospec=False, + ) as mock_client_class, + ): + client = mock_client_class.return_value + + session_stats_data = { + "uploadSpeed": 1, + "downloadSpeed": 1, + "activeTorrentCount": 0, + "pausedTorrentCount": 0, + "torrentCount": 0, + } + client.session_stats.return_value = SessionStats(fields=session_stats_data) + + session_data = {"alt-speed-enabled": False} + client.get_session.return_value = Session(fields=session_data) + + client.get_torrents.return_value = [] + + yield mock_client_class + + +@pytest.fixture +def mock_torrent(): + """Fixture that returns a factory function to create mock torrents.""" + + def _create_mock_torrent( + torrent_id: int = 1, + name: str = "Test Torrent", + percent_done: float = 0.5, + status: int = 4, + download_dir: str = "/downloads", + eta: int = 3600, + added_date: datetime | None = None, + ratio: float = 1.5, + ) -> Torrent: + """Create a mock torrent with all required attributes.""" + if added_date is None: + added_date = datetime(2025, 11, 26, 14, 18, 0, tzinfo=UTC) + + torrent_data = { + "id": torrent_id, + "name": name, + "percentDone": percent_done, + "status": status, + "rateDownload": 0, + "rateUpload": 0, + "downloadDir": download_dir, + "eta": eta, + "addedDate": int(added_date.timestamp()), + "uploadRatio": ratio, + "error": 0, + "errorString": "", + } + return Torrent(fields=torrent_data) + + return _create_mock_torrent diff --git a/tests/components/transmission/snapshots/test_sensor.ambr b/tests/components/transmission/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..71d25e4a5201a0 --- /dev/null +++ b/tests/components/transmission/snapshots/test_sensor.ambr @@ -0,0 +1,430 @@ +# serializer version: 1 +# name: test_sensors[sensor.transmission_active_torrents-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_active_torrents', + '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': 'Active torrents', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_torrents', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-active_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_sensors[sensor.transmission_active_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Transmission Active torrents', + 'torrent_info': dict({ + }), + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.transmission_active_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.transmission_completed_torrents-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_completed_torrents', + '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': 'Completed torrents', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'completed_torrents', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-completed_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_sensors[sensor.transmission_completed_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Transmission Completed torrents', + 'torrent_info': dict({ + }), + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.transmission_completed_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.transmission_download_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_download_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Download speed', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'download_speed', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-download', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.transmission_download_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Transmission Download speed', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.transmission_download_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1e-06', + }) +# --- +# name: test_sensors[sensor.transmission_paused_torrents-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_paused_torrents', + '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': 'Paused torrents', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'paused_torrents', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-paused_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_sensors[sensor.transmission_paused_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Transmission Paused torrents', + 'torrent_info': dict({ + }), + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.transmission_paused_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.transmission_started_torrents-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_started_torrents', + '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': 'Started torrents', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'started_torrents', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-started_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_sensors[sensor.transmission_started_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Transmission Started torrents', + 'torrent_info': dict({ + }), + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.transmission_started_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.transmission_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'idle', + 'up_down', + 'seeding', + 'downloading', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'transmission_status', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.transmission_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Transmission Status', + 'options': list([ + 'idle', + 'up_down', + 'seeding', + 'downloading', + ]), + }), + 'context': , + 'entity_id': 'sensor.transmission_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up_down', + }) +# --- +# name: test_sensors[sensor.transmission_total_torrents-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_total_torrents', + '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': 'Total torrents', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_torrents', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-total_torrents', + 'unit_of_measurement': 'torrents', + }) +# --- +# name: test_sensors[sensor.transmission_total_torrents-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Transmission Total torrents', + 'torrent_info': dict({ + }), + 'unit_of_measurement': 'torrents', + }), + 'context': , + 'entity_id': 'sensor.transmission_total_torrents', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.transmission_upload_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.transmission_upload_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upload speed', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'upload_speed', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-upload', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.transmission_upload_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Transmission Upload speed', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.transmission_upload_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1e-06', + }) +# --- diff --git a/tests/components/transmission/snapshots/test_switch.ambr b/tests/components/transmission/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..48c342e5acc6cb --- /dev/null +++ b/tests/components/transmission/snapshots/test_switch.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_switches[switch.transmission_switch-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.transmission_switch', + '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': 'Switch', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'on_off', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-on_off', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.transmission_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Transmission Switch', + }), + 'context': , + 'entity_id': 'switch.transmission_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switches[switch.transmission_turtle_mode-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.transmission_turtle_mode', + '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': 'Turtle mode', + 'platform': 'transmission', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turtle_mode', + 'unique_id': '01J0BC4QM2YBRP6H5G933AETT7-turtle_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.transmission_turtle_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Transmission Turtle mode', + }), + 'context': , + 'entity_id': 'switch.transmission_turtle_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index f18325e7b0a841..1692de2ae84efa 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for Transmission config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from transmission_rpc.error import ( @@ -15,34 +15,26 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import MOCK_CONFIG_DATA +from . import MOCK_CONFIG_DATA, setup_integration from tests.common import MockConfigEntry -@pytest.fixture(autouse=True) -def mock_api(): - """Mock an api.""" - with patch("transmission_rpc.Client") as api: - yield api - - -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_full_flow( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - with patch( - "homeassistant.components.transmission.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_CONFIG_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_CONFIG_DATA, + ) assert len(mock_setup_entry.mock_calls) == 1 assert result["title"] == "Transmission" @@ -52,10 +44,10 @@ async def test_form(hass: HomeAssistant) -> None: async def test_device_already_configured( hass: HomeAssistant, + mock_config_entry: MockConfigEntry, ) -> None: """Test aborting if the device is already configured.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -72,7 +64,10 @@ async def test_device_already_configured( assert result["type"] is FlowResultType.ABORT -async def test_options(hass: HomeAssistant) -> None: +async def test_options( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: """Test updating options.""" entry = MockConfigEntry( domain=transmission.DOMAIN, @@ -103,14 +98,15 @@ async def test_options(hass: HomeAssistant) -> None: async def test_error_on_wrong_credentials( - hass: HomeAssistant, mock_api: MagicMock + hass: HomeAssistant, + mock_transmission_client: AsyncMock, ) -> None: """Test we handle invalid credentials.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_api.side_effect = TransmissionAuthError() + mock_transmission_client.side_effect = TransmissionAuthError() result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG_DATA, @@ -121,7 +117,7 @@ async def test_error_on_wrong_credentials( "password": "invalid_auth", } - mock_api.side_effect = None + mock_transmission_client.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG_DATA, @@ -133,12 +129,13 @@ async def test_error_on_wrong_credentials( ("exception", "error"), [ (TransmissionError, "cannot_connect"), - (TransmissionConnectError, "invalid_auth"), + (TransmissionConnectError, "cannot_connect"), ], ) async def test_flow_errors( hass: HomeAssistant, - mock_api: MagicMock, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, exception: Exception, error: str, ) -> None: @@ -147,15 +144,15 @@ async def test_flow_errors( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_api.side_effect = exception + mock_transmission_client.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG_DATA, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error} - mock_api.side_effect = None + mock_transmission_client.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG_DATA, @@ -163,18 +160,21 @@ async def test_flow_errors( assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_reauth_success(hass: HomeAssistant) -> None: +async def test_reauth_success( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test we can reauth.""" - entry = MockConfigEntry(domain=transmission.DOMAIN, data=MOCK_CONFIG_DATA) - entry.add_to_hass(hass) + await setup_integration(hass, mock_config_entry) - result = await entry.start_reauth_flow(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["description_placeholders"] == { "username": "user", - "name": "Mock Title", + "name": "Transmission", } with patch( @@ -203,7 +203,8 @@ async def test_reauth_success(hass: HomeAssistant) -> None: ) async def test_reauth_flow_errors( hass: HomeAssistant, - mock_api: MagicMock, + mock_config_entry: MockConfigEntry, + mock_transmission_client: AsyncMock, exception: Exception, field: str, error: str, @@ -224,7 +225,7 @@ async def test_reauth_flow_errors( "name": "Mock Title", } - mock_api.side_effect = exception + mock_transmission_client.side_effect = exception result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -235,7 +236,7 @@ async def test_reauth_flow_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {field: error} - mock_api.side_effect = None + mock_transmission_client.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/transmission/test_init.py b/tests/components/transmission/test_init.py index 38d941c3779c69..07698681d1ea0d 100644 --- a/tests/components/transmission/test_init.py +++ b/tests/components/transmission/test_init.py @@ -1,7 +1,8 @@ """Tests for Transmission init.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory import pytest from transmission_rpc.error import ( TransmissionAuthError, @@ -13,6 +14,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.transmission.const import ( DEFAULT_PATH, + DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN, ) @@ -21,30 +23,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MOCK_CONFIG_DATA, MOCK_CONFIG_DATA_VERSION_1_1, OLD_MOCK_CONFIG_DATA +from . import MOCK_CONFIG_DATA_VERSION_1_1, OLD_MOCK_CONFIG_DATA -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.fixture(autouse=True) -def mock_api(): - """Mock an api.""" - with patch("transmission_rpc.Client") as api: - yield api - - -async def test_successful_config_entry(hass: HomeAssistant) -> None: - """Test settings up integration from config entry.""" - - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - - assert entry.state is ConfigEntryState.LOADED - - -async def test_config_flow_entry_migrate_1_1_to_1_2(hass: HomeAssistant) -> None: +async def test_config_flow_entry_migrate_1_1_to_1_2( + hass: HomeAssistant, +) -> None: """Test that config flow entry is migrated correctly from v1.1 to v1.2.""" entry = MockConfigEntry( domain=DOMAIN, @@ -66,59 +52,65 @@ async def test_config_flow_entry_migrate_1_1_to_1_2(hass: HomeAssistant) -> None async def test_setup_failed_connection_error( - hass: HomeAssistant, mock_api: MagicMock + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test integration failed due to connection error.""" + mock_config_entry.add_to_hass(hass) - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) - entry.add_to_hass(hass) - - mock_api.side_effect = TransmissionConnectError() + mock_transmission_client.side_effect = TransmissionConnectError() - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_setup_failed_auth_error( - hass: HomeAssistant, mock_api: MagicMock + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test integration failed due to invalid credentials error.""" + mock_config_entry.add_to_hass(hass) - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) - entry.add_to_hass(hass) - - mock_api.side_effect = TransmissionAuthError() + mock_transmission_client.side_effect = TransmissionAuthError() - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR async def test_setup_failed_unexpected_error( - hass: HomeAssistant, mock_api: MagicMock + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test integration failed due to unexpected error.""" + mock_config_entry.add_to_hass(hass) - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) - entry.add_to_hass(hass) + mock_transmission_client.side_effect = TransmissionError() - mock_api.side_effect = TransmissionError() + await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test removing integration.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert await hass.config_entries.async_unload(entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED @pytest.mark.parametrize( @@ -184,3 +176,28 @@ async def test_migrate_unique_id( assert migrated_entity assert migrated_entity.unique_id == new_unique_id + + +async def test_coordinator_update_error( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensors go unavailable.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Make the coordinator fail on next update + client = mock_transmission_client.return_value + client.session_stats.side_effect = TransmissionError("Connection failed") + + # Trigger an update to make entities unavailable + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Verify entities are unavailable + state = hass.states.get("sensor.transmission_status") + assert state is not None + assert state.state == "unavailable" diff --git a/tests/components/transmission/test_sensor.py b/tests/components/transmission/test_sensor.py new file mode 100644 index 00000000000000..cd7cb9f59c9486 --- /dev/null +++ b/tests/components/transmission/test_sensor.py @@ -0,0 +1,27 @@ +"""Tests for the Transmission sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor entities.""" + with patch("homeassistant.components.transmission.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/transmission/test_services.py b/tests/components/transmission/test_services.py new file mode 100644 index 00000000000000..45061e7b30a9fa --- /dev/null +++ b/tests/components/transmission/test_services.py @@ -0,0 +1,254 @@ +"""Tests for the Transmission services.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.transmission.const import ( + ATTR_DELETE_DATA, + ATTR_DOWNLOAD_PATH, + ATTR_TORRENT, + CONF_ENTRY_ID, + DOMAIN, + SERVICE_ADD_TORRENT, + SERVICE_REMOVE_TORRENT, + SERVICE_START_TORRENT, + SERVICE_STOP_TORRENT, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from tests.common import MockConfigEntry + + +async def test_service_config_entry_not_loaded_state( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service call when config entry is in failed state.""" + mock_config_entry.add_to_hass(hass) + + assert mock_config_entry.state == ConfigEntryState.NOT_LOADED + + with pytest.raises(ServiceValidationError, match="service_not_found"): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_TORRENT, + { + CONF_ENTRY_ID: mock_config_entry.entry_id, + ATTR_TORRENT: "magnet:?xt=urn:btih:test", + }, + blocking=True, + ) + + +async def test_service_integration_not_found( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service call with non-existent config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises( + ServiceValidationError, match='Integration "transmission" not found' + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_TORRENT, + { + CONF_ENTRY_ID: "non_existent_entry_id", + ATTR_TORRENT: "magnet:?xt=urn:btih:test", + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("payload", "expected_torrent", "kwargs"), + [ + ( + {ATTR_TORRENT: "magnet:?xt=urn:btih:test"}, + "magnet:?xt=urn:btih:test", + {}, + ), + ( + { + ATTR_TORRENT: "magnet:?xt=urn:btih:test", + ATTR_DOWNLOAD_PATH: "/custom/path", + }, + "magnet:?xt=urn:btih:test", + {"download_dir": "/custom/path"}, + ), + ( + {ATTR_TORRENT: "http://example.com/test.torrent"}, + "http://example.com/test.torrent", + {}, + ), + ( + {ATTR_TORRENT: "ftp://example.com/test.torrent"}, + "ftp://example.com/test.torrent", + {}, + ), + ( + {ATTR_TORRENT: "/config/test.torrent"}, + "/config/test.torrent", + {}, + ), + ], +) +async def test_add_torrent_service_success( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, + payload: dict[str, str], + expected_torrent: str, + kwargs: dict[str, str | None], +) -> None: + """Test successful torrent addition with url and path sources.""" + client = mock_transmission_client.return_value + client.add_torrent.return_value = MagicMock(id=123, name="test_torrent") + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + full_service_data = {CONF_ENTRY_ID: mock_config_entry.entry_id} | payload + + with patch.object(hass.config, "is_allowed_path", return_value=True): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_TORRENT, + full_service_data, + blocking=True, + ) + + client.add_torrent.assert_called_once_with(expected_torrent, **kwargs) + + +async def test_add_torrent_service_invalid_path( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test torrent addition with invalid path.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError, match="Could not add torrent"): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_TORRENT, + { + CONF_ENTRY_ID: mock_config_entry.entry_id, + ATTR_TORRENT: "/etc/bad.torrent", + }, + blocking=True, + ) + + +async def test_start_torrent_service_success( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful torrent start.""" + client = mock_transmission_client.return_value + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_START_TORRENT, + { + CONF_ENTRY_ID: mock_config_entry.entry_id, + CONF_ID: 123, + }, + blocking=True, + ) + + client.start_torrent.assert_called_once_with(123) + + +async def test_stop_torrent_service_success( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful torrent stop.""" + client = mock_transmission_client.return_value + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_STOP_TORRENT, + { + CONF_ENTRY_ID: mock_config_entry.entry_id, + CONF_ID: 456, + }, + blocking=True, + ) + + client.stop_torrent.assert_called_once_with(456) + + +async def test_remove_torrent_service_success( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful torrent removal without deleting data.""" + client = mock_transmission_client.return_value + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_TORRENT, + { + CONF_ENTRY_ID: mock_config_entry.entry_id, + CONF_ID: 789, + }, + blocking=True, + ) + + client.remove_torrent.assert_called_once_with(789, delete_data=False) + + +async def test_remove_torrent_service_with_delete_data( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful torrent removal with deleting data.""" + client = mock_transmission_client.return_value + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_TORRENT, + { + CONF_ENTRY_ID: mock_config_entry.entry_id, + CONF_ID: 789, + ATTR_DELETE_DATA: True, + }, + blocking=True, + ) + + client.remove_torrent.assert_called_once_with(789, delete_data=True) diff --git a/tests/components/transmission/test_switch.py b/tests/components/transmission/test_switch.py new file mode 100644 index 00000000000000..9fbae8f4e5c060 --- /dev/null +++ b/tests/components/transmission/test_switch.py @@ -0,0 +1,131 @@ +"""Tests for the Transmission switch platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_switches( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the switch entities.""" + with patch("homeassistant.components.transmission.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "api_method"), + [ + (SERVICE_TURN_ON, "start_all"), + (SERVICE_TURN_OFF, "stop_torrent"), + ], +) +async def test_on_off_switch_without_torrents( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_torrent, + service: str, + api_method: str, +) -> None: + """Test on/off switch.""" + client = mock_transmission_client.return_value + client.get_torrents.return_value = [] + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: "switch.transmission_switch"}, + blocking=True, + ) + + getattr(client, api_method).assert_not_called() + + +@pytest.mark.parametrize( + ("service", "api_method"), + [ + (SERVICE_TURN_ON, "start_all"), + (SERVICE_TURN_OFF, "stop_torrent"), + ], +) +async def test_on_off_switch_with_torrents( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_torrent, + service: str, + api_method: str, +) -> None: + """Test on/off switch.""" + client = mock_transmission_client.return_value + client.get_torrents.return_value = [mock_torrent()] + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: "switch.transmission_switch"}, + blocking=True, + ) + + getattr(client, api_method).assert_called_once() + + +@pytest.mark.parametrize( + ("service", "alt_speed_enabled"), + [ + (SERVICE_TURN_ON, True), + (SERVICE_TURN_OFF, False), + ], +) +async def test_turtle_mode_switch( + hass: HomeAssistant, + mock_transmission_client: AsyncMock, + mock_config_entry: MockConfigEntry, + service: str, + alt_speed_enabled: bool, +) -> None: + """Test turtle mode switch.""" + client = mock_transmission_client.return_value + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: "switch.transmission_turtle_mode"}, + blocking=True, + ) + + client.set_session.assert_called_once_with(alt_speed_enabled=alt_speed_enabled) diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 2cd1bab5df10e8..4753fa09335c93 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -5630,7 +5630,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '599.296', + 'state': '0.072', }) # --- # name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_power-entry] @@ -5689,7 +5689,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '18.432', + 'state': '0.008', }) # --- # name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_voltage-entry] @@ -5745,7 +5745,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '52.7', + 'state': '234.1', }) # --- # name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_supply_frequency-entry] diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 830ebf8a0f2adc..d3ed9ffa962213 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -37,6 +37,98 @@ async def test_full_user_flow_implementation(hass: HomeAssistant) -> None: assert result["result"].unique_id == "aabbccddeeff" +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_full_reconfigure_flow_success( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test the full reconfigure flow from start to finish.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + # Assert show form initially + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "10.10.0.10"} + ) + + # Assert show text message and close flow + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + # Assert config entry has been updated. + assert mock_config_entry.data[CONF_HOST] == "10.10.0.10" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_full_reconfigure_flow_unique_id_mismatch( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test reconfiguration failure when the unique ID changes.""" + mock_config_entry.add_to_hass(hass) + + # Change mac address + device = mock_wled.update.return_value + device.info.mac_address = "invalid" + + result = await mock_config_entry.start_reconfigure_flow(hass) + + # Assert show form initially + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + + # Input new host value + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "10.10.0.10"} + ) + + # Assert Show text message and close flow + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "unique_id_mismatch" + + +@pytest.mark.usefixtures("mock_setup_entry", "mock_wled") +async def test_full_reconfigure_flow_connection_error_and_success( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_wled: MagicMock +) -> None: + """Test we show user form on WLED connection error and allows user to change host.""" + mock_config_entry.add_to_hass(hass) + + # Mock connection error + mock_wled.update.side_effect = WLEDConnectionError + + result = await mock_config_entry.start_reconfigure_flow(hass) + + # Assert show form initially + assert result.get("step_id") == "user" + assert result.get("type") is FlowResultType.FORM + + # Input new host value + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "10.10.0.10"} + ) + + # Assert form with errors + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == {"base": "cannot_connect"} + + # Remove mock for connection error + mock_wled.update.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "10.10.0.10"} + ) + + # Assert show text message and close flow + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "reconfigure_successful" + + # Assert config entry has been updated. + assert mock_config_entry.data[CONF_HOST] == "10.10.0.10" + + @pytest.mark.usefixtures("mock_setup_entry", "mock_wled") async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: """Test the full manual user flow from start to finish.""" diff --git a/tests/components/wled/test_coordinator.py b/tests/components/wled/test_coordinator.py index e2935290f03740..2460a887e19165 100644 --- a/tests/components/wled/test_coordinator.py +++ b/tests/components/wled/test_coordinator.py @@ -14,6 +14,7 @@ ) from homeassistant.components.wled.const import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, STATE_OFF, @@ -195,3 +196,25 @@ async def connect(callback: Callable[[WLEDDevice], None]): await hass.async_block_till_done() await hass.async_block_till_done() assert mock_wled.disconnect.call_count == 2 + + +async def test_fail_when_other_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wled: MagicMock, +) -> None: + """Ensure entry fails to setup when mac mismatch.""" + device = mock_wled.update.return_value + device.info.mac_address = "invalid" + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + assert mock_config_entry.state == ConfigEntryState.SETUP_ERROR + assert mock_config_entry.reason + assert ( + "MAC address does not match the configured device." in mock_config_entry.reason + ) diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index 8bd5431cf592f1..5b19967170be1a 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -3,9 +3,11 @@ from datetime import datetime from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.wled.const import SCAN_INTERVAL from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, @@ -21,7 +23,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_wled") @@ -189,3 +191,28 @@ async def test_no_current_measurement( assert hass.states.get("sensor.wled_rgb_light_max_current") is None assert hass.states.get("sensor.wled_rgb_light_estimated_current") is None + + +async def test_fail_when_other_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_wled: MagicMock, +) -> None: + """Ensure no data are updated when mac address mismatch.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.wled_rgb_light_ip")) + assert state.state == "127.0.0.1" + + device = mock_wled.update.return_value + device.info.mac_address = "invalid" + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.wled_rgb_light_ip")) + assert state.state == "unavailable"