diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 6b1d084b842eca..47ff53dd04ea2b 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -51,14 +51,14 @@ rules: docs-known-limitations: todo docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: todo entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: done reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 32bf5d3ba159e8..5997559c3cf9fd 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -4,10 +4,12 @@ from dataclasses import dataclass from deebot_client.capabilities import CapabilityEvent -from deebot_client.events.base import Event +from deebot_client.events import Event from deebot_client.events.water_info import MopAttachedEvent +from sucks import VacBot from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -16,7 +18,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsLegacyEntity, +) from .util import get_supported_entities @@ -47,12 +53,23 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" + controller = config_entry.runtime_data + async_add_entities( get_supported_entities( config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS ) ) + legacy_entities = [] + for device in controller.legacy_devices: + if not controller.legacy_entity_is_added(device, "battery_charging"): + controller.add_legacy_entity(device, "battery_charging") + legacy_entities.append(EcovacsLegacyBatteryChargingSensor(device)) + + if legacy_entities: + async_add_entities(legacy_entities) + class EcovacsBinarySensor[EventT: Event]( EcovacsDescriptionEntity[CapabilityEvent[EventT]], @@ -71,3 +88,33 @@ async def on_event(event: EventT) -> None: self.async_write_ha_state() self._subscribe(self._capability.event, on_event) + + +class EcovacsLegacyBatteryChargingSensor(EcovacsLegacyEntity, BinarySensorEntity): + """Legacy battery charging sensor.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + device: VacBot, + ) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = f"{device.vacuum['did']}_battery_charging" + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + self._event_listeners.append( + self.device.statusEvents.subscribe( + lambda _: self.schedule_update_ha_state() + ) + ) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if self.device.charge_status is None: + return None + return bool(self.device.is_charging) diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index e84485228e470d..b368b92a579cef 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -37,6 +37,7 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import StateType from . import EcovacsConfigEntry @@ -225,7 +226,7 @@ async def async_setup_entry( async_add_entities(entities) - async def _add_legacy_entities() -> None: + async def _add_legacy_lifespan_entities() -> None: entities = [] for device in controller.legacy_devices: for description in LEGACY_LIFESPAN_SENSORS: @@ -242,14 +243,21 @@ async def _add_legacy_entities() -> None: async_add_entities(entities) def _fire_ecovacs_legacy_lifespan_event(_: Any) -> None: - hass.create_task(_add_legacy_entities()) + hass.create_task(_add_legacy_lifespan_entities()) + legacy_entities = [] for device in controller.legacy_devices: config_entry.async_on_unload( device.lifespanEvents.subscribe( _fire_ecovacs_legacy_lifespan_event ).unsubscribe ) + if not controller.legacy_entity_is_added(device, "battery_status"): + controller.add_legacy_entity(device, "battery_status") + legacy_entities.append(EcovacsLegacyBatterySensor(device)) + + if legacy_entities: + async_add_entities(legacy_entities) class EcovacsSensor( @@ -344,6 +352,44 @@ async def on_event(event: ErrorEvent) -> None: self._subscribe(self._capability.event, on_event) +class EcovacsLegacyBatterySensor(EcovacsLegacyEntity, SensorEntity): + """Legacy battery sensor.""" + + _attr_native_unit_of_measurement = PERCENTAGE + _attr_device_class = SensorDeviceClass.BATTERY + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + device: VacBot, + ) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = f"{device.vacuum['did']}_battery_status" + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + self._event_listeners.append( + self.device.batteryEvents.subscribe( + lambda _: self.schedule_update_ha_state() + ) + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + if (status := self.device.battery_status) is not None: + return status * 100 # type: ignore[no-any-return] + return None + + @property + def icon(self) -> str | None: + """Return the icon to use in the frontend, if any.""" + return icon_for_battery_level( + battery_level=self.native_value, charging=self.device.is_charging + ) + + class EcovacsLegacyLifespanSensor(EcovacsLegacyEntity, SensorEntity): """Legacy Lifespan sensor.""" diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 6570b80e920356..d432410c8c5c86 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -22,7 +22,6 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify from . import EcovacsConfigEntry @@ -71,8 +70,7 @@ class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity): _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] _attr_supported_features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.RETURN_HOME + VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.STOP | VacuumEntityFeature.START @@ -89,11 +87,6 @@ async def async_added_to_hass(self) -> None: lambda _: self.schedule_update_ha_state() ) ) - self._event_listeners.append( - self.device.batteryEvents.subscribe( - lambda _: self.schedule_update_ha_state() - ) - ) self._event_listeners.append( self.device.lifespanEvents.subscribe( lambda _: self.schedule_update_ha_state() @@ -137,21 +130,6 @@ def activity(self) -> VacuumActivity | None: return None - @property - def battery_level(self) -> int | None: - """Return the battery level of the vacuum cleaner.""" - if self.device.battery_status is not None: - return self.device.battery_status * 100 # type: ignore[no-any-return] - - return None - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - return icon_for_battery_level( - battery_level=self.battery_level, charging=self.device.is_charging - ) - @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 0ff72271cb99a5..7f2921f17faf8d 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any from aioautomower.model import ( + ExternalReasons, InactiveReasons, MowerAttributes, MowerModes, @@ -190,11 +191,37 @@ RestrictedReasons.PARK_OVERRIDE, RestrictedReasons.SENSOR, RestrictedReasons.WEEK_SCHEDULE, + ExternalReasons.AMAZON_ALEXA, + ExternalReasons.DEVELOPER_PORTAL, + ExternalReasons.GARDENA_SMART_SYSTEM, + ExternalReasons.GOOGLE_ASSISTANT, + ExternalReasons.HOME_ASSISTANT, + ExternalReasons.IFTTT, + ExternalReasons.IFTTT_APPLETS, + ExternalReasons.IFTTT_CALENDAR_CONNECTION, + ExternalReasons.SMART_ROUTINE, + ExternalReasons.SMART_ROUTINE_FROST_GUARD, + ExternalReasons.SMART_ROUTINE_RAIN_GUARD, + ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION, ] STATE_NO_WORK_AREA_ACTIVE = "no_work_area_active" +@callback +def _get_restricted_reason(data: MowerAttributes) -> str: + """Return the restricted reason. + + If there is an external reason, return that instead, if it's available. + """ + if ( + data.planner.restricted_reason == RestrictedReasons.EXTERNAL + and data.planner.external_reason is not None + ): + return data.planner.external_reason + return data.planner.restricted_reason + + @callback def _get_work_area_names(data: MowerAttributes) -> list[str]: """Return a list with all work area names.""" @@ -400,7 +427,7 @@ class AutomowerSensorEntityDescription(SensorEntityDescription): translation_key="restricted_reason", device_class=SensorDeviceClass.ENUM, option_fn=lambda data: RESTRICTED_REASONS, - value_fn=attrgetter("planner.restricted_reason"), + value_fn=_get_restricted_reason, ), AutomowerSensorEntityDescription( key="inactive_reason", diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 62843d67ae2913..226c9ee17f07f0 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -242,16 +242,28 @@ "restricted_reason": { "name": "Restricted reason", "state": { - "none": "No restrictions", - "week_schedule": "Week schedule", - "park_override": "Park override", - "sensor": "Weather timer", + "all_work_areas_completed": "All work areas completed", + "amazon_alexa": "Amazon Alexa", "daily_limit": "Daily limit", + "developer_portal": "Developer Portal", + "external": "External", "fota": "Firmware Over-the-Air update running", "frost": "Frost", - "all_work_areas_completed": "All work areas completed", - "external": "External", - "not_applicable": "Not applicable" + "gardena_smart_system": "Gardena Smart System", + "google_assistant": "Google Assistant", + "home_assistant": "Home Assistant", + "ifttt_applets": "IFTTT applets", + "ifttt_calendar_connection": "IFTTT calendar connection", + "ifttt": "IFTTT", + "none": "No restrictions", + "not_applicable": "Not applicable", + "park_override": "Park override", + "sensor": "Weather timer", + "smart_routine_frost_guard": "Frost guard", + "smart_routine_rain_guard": "Rain guard", + "smart_routine_wildlife_protection": "Wildlife protection", + "smart_routine": "Generic smart routine", + "week_schedule": "Week schedule" } }, "total_charging_time": { diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index caf8264895b5e8..008a807c0d2456 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -5,6 +5,7 @@ from logging import getLogger from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse +from aioimmich.assets.models import ImmichAsset from aioimmich.exceptions import ImmichError from homeassistant.components.http import HomeAssistantView @@ -83,6 +84,10 @@ async def _async_build_immich( self, item: MediaSourceItem, entries: list[ConfigEntry] ) -> list[BrowseMediaSource]: """Handle browsing different immich instances.""" + + # -------------------------------------------------------- + # root level, render immich instances + # -------------------------------------------------------- if not item.identifier: LOGGER.debug("Render all Immich instances") return [ @@ -97,6 +102,10 @@ async def _async_build_immich( ) for entry in entries ] + + # -------------------------------------------------------- + # 1st level, render collections overview + # -------------------------------------------------------- identifier = ImmichMediaSourceIdentifier(item.identifier) entry: ImmichConfigEntry | None = ( self.hass.config_entries.async_entry_for_domain_unique_id( @@ -111,50 +120,127 @@ async def _async_build_immich( return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"{identifier.unique_id}|albums", + identifier=f"{identifier.unique_id}|{collection}", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, - title="albums", + title=collection, can_play=False, can_expand=True, ) + for collection in ("albums", "people", "tags") ] + # -------------------------------------------------------- + # 2nd level, render collection + # -------------------------------------------------------- if identifier.collection_id is None: - LOGGER.debug("Render all albums for %s", entry.title) + if identifier.collection == "albums": + LOGGER.debug("Render all albums for %s", entry.title) + try: + albums = await immich_api.albums.async_get_all_albums() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|albums|{album.album_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=album.album_name, + can_play=False, + can_expand=True, + thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg", + ) + for album in albums + ] + + if identifier.collection == "tags": + LOGGER.debug("Render all tags for %s", entry.title) + try: + tags = await immich_api.tags.async_get_all_tags() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|tags|{tag.tag_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=tag.name, + can_play=False, + can_expand=True, + ) + for tag in tags + ] + + if identifier.collection == "people": + LOGGER.debug("Render all people for %s", entry.title) + try: + people = await immich_api.people.async_get_all_people() + except ImmichError: + return [] + + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}|people|{person.person_id}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=person.name, + can_play=False, + can_expand=True, + thumbnail=f"/immich/{identifier.unique_id}/{person.person_id}/person/image/jpg", + ) + for person in people + ] + + # -------------------------------------------------------- + # final level, render assets + # -------------------------------------------------------- + assert identifier.collection_id is not None + assets: list[ImmichAsset] = [] + if identifier.collection == "albums": + LOGGER.debug( + "Render all assets of album %s for %s", + identifier.collection_id, + entry.title, + ) try: - albums = await immich_api.albums.async_get_all_albums() + album_info = await immich_api.albums.async_get_album_info( + identifier.collection_id + ) + assets = album_info.assets except ImmichError: return [] - return [ - BrowseMediaSource( - domain=DOMAIN, - identifier=f"{identifier.unique_id}|albums|{album.album_id}", - media_class=MediaClass.DIRECTORY, - media_content_type=MediaClass.IMAGE, - title=album.album_name, - can_play=False, - can_expand=True, - thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg", + elif identifier.collection == "tags": + LOGGER.debug( + "Render all assets with tag %s", + identifier.collection_id, + ) + try: + assets = await immich_api.search.async_get_all_by_tag_ids( + [identifier.collection_id] ) - for album in albums - ] + except ImmichError: + return [] - LOGGER.debug( - "Render all assets of album %s for %s", - identifier.collection_id, - entry.title, - ) - try: - album_info = await immich_api.albums.async_get_album_info( - identifier.collection_id + elif identifier.collection == "people": + LOGGER.debug( + "Render all assets for person %s", + identifier.collection_id, ) - except ImmichError: - return [] + try: + assets = await immich_api.search.async_get_all_by_person_ids( + [identifier.collection_id] + ) + except ImmichError: + return [] ret: list[BrowseMediaSource] = [] - for asset in album_info.assets: + for asset in assets: if not (mime_type := asset.original_mime_type) or not mime_type.startswith( ("image/", "video/") ): @@ -173,7 +259,8 @@ async def _async_build_immich( BrowseMediaSource( domain=DOMAIN, identifier=( - f"{identifier.unique_id}|albums|" + f"{identifier.unique_id}|" + f"{identifier.collection}|" f"{identifier.collection_id}|" f"{asset.asset_id}|" f"{asset.original_file_name}|" @@ -257,7 +344,10 @@ async def get( # web response for images try: - image = await immich_api.assets.async_view_asset(asset_id, size) + if size == "person": + image = await immich_api.people.async_get_person_thumbnail(asset_id) + else: + image = await immich_api.assets.async_view_asset(asset_id, size) except ImmichError as exc: raise HTTPNotFound from exc return Response(body=image, content_type=f"{mime_type_base}/{mime_type_format}") diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 1b757a9e113891..4a0eac7da85793 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -105,6 +105,9 @@ } }, "services": { + "get_programs": { + "service": "mdi:stack-overflow" + }, "set_program": { "service": "mdi:arrow-right-circle-outline" } diff --git a/homeassistant/components/miele/services.py b/homeassistant/components/miele/services.py index 70ea20ccc4ae80..6d4dc77dd36dd5 100644 --- a/homeassistant/components/miele/services.py +++ b/homeassistant/components/miele/services.py @@ -7,7 +7,12 @@ import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.service import async_extract_config_entry_ids @@ -27,6 +32,13 @@ }, ) +SERVICE_GET_PROGRAMS = "get_programs" +SERVICE_GET_PROGRAMS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + }, +) + _LOGGER = logging.getLogger(__name__) @@ -47,17 +59,12 @@ async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry: return target_entries[0] -async def set_program(call: ServiceCall) -> None: - """Set a program on a Miele appliance.""" +async def _get_serial_number(call: ServiceCall) -> str: + """Extract the serial number from the device identifier.""" - _LOGGER.debug("Set program call: %s", call) - config_entry = await _extract_config_entry(call) device_reg = dr.async_get(call.hass) - api = config_entry.runtime_data.api device = call.data[ATTR_DEVICE_ID] device_entry = device_reg.async_get(device) - - data = {"programId": call.data[ATTR_PROGRAM_ID]} serial_number = next( ( identifier[1] @@ -71,6 +78,18 @@ async def set_program(call: ServiceCall) -> None: translation_domain=DOMAIN, translation_key="invalid_target", ) + return serial_number + + +async def set_program(call: ServiceCall) -> None: + """Set a program on a Miele appliance.""" + + _LOGGER.debug("Set program call: %s", call) + config_entry = await _extract_config_entry(call) + api = config_entry.runtime_data.api + + serial_number = await _get_serial_number(call) + data = {"programId": call.data[ATTR_PROGRAM_ID]} try: await api.set_program(serial_number, data) except aiohttp.ClientResponseError as ex: @@ -84,9 +103,82 @@ async def set_program(call: ServiceCall) -> None: ) from ex +async def get_programs(call: ServiceCall) -> ServiceResponse: + """Get available programs from appliance.""" + + config_entry = await _extract_config_entry(call) + api = config_entry.runtime_data.api + serial_number = await _get_serial_number(call) + + try: + programs = await api.get_programs(serial_number) + except aiohttp.ClientResponseError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_programs_error", + translation_placeholders={ + "status": str(ex.status), + "message": ex.message, + }, + ) from ex + + return { + "programs": [ + { + "program_id": item["programId"], + "program": item["program"], + "parameters": ( + { + "temperature": ( + { + "min": item["parameters"]["temperature"]["min"], + "max": item["parameters"]["temperature"]["max"], + "step": item["parameters"]["temperature"]["step"], + "mandatory": item["parameters"]["temperature"][ + "mandatory" + ], + } + if "temperature" in item["parameters"] + else {} + ), + "duration": ( + { + "min": { + "hours": item["parameters"]["duration"]["min"][0], + "minutes": item["parameters"]["duration"]["min"][1], + }, + "max": { + "hours": item["parameters"]["duration"]["max"][0], + "minutes": item["parameters"]["duration"]["max"][1], + }, + "mandatory": item["parameters"]["duration"][ + "mandatory" + ], + } + if "duration" in item["parameters"] + else {} + ), + } + if item["parameters"] + else {} + ), + } + for item in programs + ], + } + + async def async_setup_services(hass: HomeAssistant) -> None: """Set up services.""" hass.services.async_register( DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_PROGRAMS, + get_programs, + SERVICE_GET_PROGRAMS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/miele/services.yaml b/homeassistant/components/miele/services.yaml index 486fdf7307b3be..6866e997c4590c 100644 --- a/homeassistant/components/miele/services.yaml +++ b/homeassistant/components/miele/services.yaml @@ -1,5 +1,13 @@ # Services descriptions for Miele integration +get_programs: + fields: + device_id: + selector: + device: + integration: miele + required: true + set_program: fields: device_id: diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 2ae412ed95e4f8..5b5cac16b534f4 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -1062,6 +1062,9 @@ "invalid_target": { "message": "Invalid device targeted." }, + "get_programs_error": { + "message": "'Get programs' action failed {status} / {message}." + }, "set_program_error": { "message": "'Set program' action failed {status} / {message}." }, @@ -1070,12 +1073,22 @@ } }, "services": { + "get_programs": { + "name": "Get programs", + "description": "Returns a list of available programs.", + "fields": { + "device_id": { + "description": "[%key:component::miele::services::set_program::fields::device_id::description%]", + "name": "[%key:component::miele::services::set_program::fields::device_id::name%]" + } + } + }, "set_program": { "name": "Set program", "description": "Sets and starts a program on the appliance.", "fields": { "device_id": { - "description": "The device to set the program on.", + "description": "The target device for this action.", "name": "Device" }, "program_id": { diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index f95fc0dbab7fef..9bcb656e4aacc2 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -49,6 +49,7 @@ ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA, TemplateEntity, make_template_entity_common_modern_schema, ) @@ -113,8 +114,8 @@ class TemplateCodeFormat(Enum): ) ALARM_CONTROL_PANEL_YAML_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( - make_template_entity_common_modern_schema(DEFAULT_NAME).schema -) + TEMPLATE_ENTITY_OPTIMISTIC_SCHEMA +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA = vol.Schema( { @@ -205,13 +206,12 @@ class AbstractTemplateAlarmControlPanel( """Representation of a templated Alarm Control Panel features.""" _entity_id_format = ENTITY_ID_FORMAT + _optimistic_entity = True # 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._template = config.get(CONF_STATE) - self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] self._attr_code_format = config[CONF_CODE_FORMAT].value @@ -273,18 +273,14 @@ def _handle_state(self, result: Any) -> None: async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any): """Arm the panel to specified state with supplied script.""" - optimistic_set = False - - if self._template is None: - self._state = state - optimistic_set = True if script: await self.async_run_script( script, run_variables={ATTR_CODE: code}, context=self._context ) - if optimistic_set: + if self._attr_assumed_state: + self._state = state self.async_write_ha_state() async def async_alarm_arm_away(self, code: str | None = None) -> None: diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 370548d67b09d7..c8071e68397a89 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -252,7 +252,7 @@ def __init__( # Determine fan modes self._fan_mode_dp_code: str | None = None if enum_type := self.find_dpcode( - (DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), + (DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED), dptype=DPType.ENUM, prefer_function=True, ): diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 9556c1bbd82f5f..851b65e1a15479 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -32,6 +32,8 @@ DEV_MODEL_FLEX_FOB_YS3614_EC = "YS3614-EC" DEV_MODEL_PLUG_YS6602_UC = "YS6602-UC" DEV_MODEL_PLUG_YS6602_EC = "YS6602-EC" +DEV_MODEL_PLUG_YS6614_UC = "YS6614-UC" +DEV_MODEL_PLUG_YS6614_EC = "YS6614-EC" DEV_MODEL_PLUG_YS6803_UC = "YS6803-UC" DEV_MODEL_PLUG_YS6803_EC = "YS6803-EC" DEV_MODEL_SWITCH_YS5708_UC = "YS5708-UC" diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 37cd763194da56..5425c242821f1b 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -58,6 +58,8 @@ from .const import ( DEV_MODEL_PLUG_YS6602_EC, DEV_MODEL_PLUG_YS6602_UC, + DEV_MODEL_PLUG_YS6614_EC, + DEV_MODEL_PLUG_YS6614_UC, DEV_MODEL_PLUG_YS6803_EC, DEV_MODEL_PLUG_YS6803_UC, DEV_MODEL_TH_SENSOR_YS8004_EC, @@ -152,6 +154,8 @@ class YoLinkSensorEntityDescription(SensorEntityDescription): POWER_SUPPORT_MODELS = [ DEV_MODEL_PLUG_YS6602_UC, DEV_MODEL_PLUG_YS6602_EC, + DEV_MODEL_PLUG_YS6614_UC, + DEV_MODEL_PLUG_YS6614_EC, DEV_MODEL_PLUG_YS6803_UC, DEV_MODEL_PLUG_YS6803_EC, ] @@ -319,6 +323,15 @@ def cvt_volume(val: int | None) -> str | None: exists_fn=lambda device: device.device_type in [ATTR_DEVICE_SOIL_TH_SENSOR], should_update_entity=lambda value: value is not None, ), + YoLinkSensorEntityDescription( + key="coreTemperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + exists_fn=lambda device: device.device_model_name + in [DEV_MODEL_PLUG_YS6614_EC, DEV_MODEL_PLUG_YS6614_UC], + should_update_entity=lambda value: value is not None, + ), ) diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index fcd043e10fa238..c216c4c9e4a1c2 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_legacy_sensors[123][sensor.e1234567890000000003_battery:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.e1234567890000000003_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Battery', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'E1234567890000000003_battery_status', + 'unit_of_measurement': '%', + }) +# --- +# name: test_legacy_sensors[123][sensor.e1234567890000000003_battery:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'E1234567890000000003 Battery', + 'icon': 'mdi:battery-unknown', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.e1234567890000000003_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_legacy_sensors[123][sensor.e1234567890000000003_filter_lifespan:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -148,6 +199,7 @@ # --- # name: test_legacy_sensors[123][states] list([ + 'sensor.e1234567890000000003_battery', 'sensor.e1234567890000000003_main_brush_lifespan', 'sensor.e1234567890000000003_side_brush_lifespan', 'sensor.e1234567890000000003_filter_lifespan', diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index c0e5ce143c97d1..3115f1b4040d4d 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -107,7 +107,7 @@ async def test_devices_in_dr( [ ("yna5x1", 26), ("5xu9h3", 25), - ("123", 1), + ("123", 2), ], ) async def test_all_entities_loaded( diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 0fe46c24254312..3aa3504cc26273 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -978,6 +978,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'config_entry_id': , @@ -1025,6 +1037,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'context': , @@ -1953,6 +1977,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'config_entry_id': , @@ -2000,6 +2036,18 @@ , , , + , + , + , + , + , + , + , + , + , + , + , + , ]), }), 'context': , diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index d756b1b2ffa146..204fba872c45f5 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -4,7 +4,13 @@ from unittest.mock import AsyncMock, patch import zoneinfo -from aioautomower.model import MowerAttributes, MowerModes, MowerStates +from aioautomower.model import ( + ExternalReasons, + MowerAttributes, + MowerModes, + MowerStates, + RestrictedReasons, +) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -123,6 +129,41 @@ async def test_work_area_sensor( assert state.state == "no_work_area_active" +async def test_restricted_reason_sensor( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], +) -> None: + """Test the work area sensor.""" + sensor = "sensor.test_mower_1_restricted_reason" + await setup_integration(hass, mock_config_entry) + state = hass.states.get(sensor) + assert state is not None + assert state.state == RestrictedReasons.WEEK_SCHEDULE + + values[TEST_MOWER_ID].planner.restricted_reason = RestrictedReasons.EXTERNAL + values[TEST_MOWER_ID].planner.external_reason = None + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(sensor) + assert state.state == RestrictedReasons.EXTERNAL + + values[TEST_MOWER_ID].planner.restricted_reason = RestrictedReasons.EXTERNAL + values[ + TEST_MOWER_ID + ].planner.external_reason = ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(sensor) + assert state.state == ExternalReasons.SMART_ROUTINE_WILDLIFE_PROTECTION + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("sensor_to_test"), diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index 48e36e70386df8..adcbf14d97b22a 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -4,15 +4,25 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch -from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers +from aioimmich import ( + ImmichAlbums, + ImmichAssests, + ImmichPeople, + ImmichSearch, + ImmichServer, + ImmichTags, + ImmichUsers, +) from aioimmich.albums.models import ImmichAddAssetsToAlbumResponse from aioimmich.assets.models import ImmichAssetUploadResponse +from aioimmich.people.models import ImmichPerson from aioimmich.server.models import ( ImmichServerAbout, ImmichServerStatistics, ImmichServerStorage, ImmichServerVersionCheck, ) +from aioimmich.tags.models import ImmichTag from aioimmich.users.models import ImmichUserObject import pytest @@ -29,7 +39,12 @@ from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReaderChunked -from .const import MOCK_ALBUM_WITH_ASSETS, MOCK_ALBUM_WITHOUT_ASSETS +from .const import ( + MOCK_ALBUM_WITH_ASSETS, + MOCK_ALBUM_WITHOUT_ASSETS, + MOCK_PEOPLE_ASSETS, + MOCK_TAGS_ASSETS, +) from tests.common import MockConfigEntry @@ -87,6 +102,58 @@ def mock_immich_assets() -> AsyncMock: return mock +@pytest.fixture +def mock_immich_people() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichPeople) + mock.async_get_all_people.return_value = [ + ImmichPerson.from_dict( + { + "id": "6176838a-ac5a-4d1f-9a35-91c591d962d8", + "name": "Me", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/61/76/6176838a-ac5a-4d1f-9a35-91c591d962d8.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-11T11:07:41.651Z", + } + ), + ImmichPerson.from_dict( + { + "id": "3e66aa4a-a4a8-41a4-86fe-2ae5e490078f", + "name": "I", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/3e/66/3e66aa4a-a4a8-41a4-86fe-2ae5e490078f.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-19T22:10:21.953Z", + } + ), + ImmichPerson.from_dict( + { + "id": "a3c83297-684a-4576-82dc-b07432e8a18f", + "name": "Myself", + "birthDate": None, + "thumbnailPath": "upload/thumbs/e7ef5713-9dab-4bd4-b899-715b0ca4379e/a3/c8/a3c83297-684a-4576-82dc-b07432e8a18f.jpeg", + "isHidden": False, + "isFavorite": False, + "updatedAt": "2025-05-12T21:07:04.044Z", + } + ), + ] + mock.async_get_person_thumbnail.return_value = b"yyyy" + return mock + + +@pytest.fixture +def mock_immich_search() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichSearch) + mock.async_get_all_by_person_ids.return_value = MOCK_PEOPLE_ASSETS + mock.async_get_all_by_tag_ids.return_value = MOCK_TAGS_ASSETS + return mock + + @pytest.fixture def mock_immich_server() -> AsyncMock: """Mock the Immich server.""" @@ -153,6 +220,33 @@ def mock_immich_server() -> AsyncMock: return mock +@pytest.fixture +def mock_immich_tags() -> AsyncMock: + """Mock the Immich server.""" + mock = AsyncMock(spec=ImmichTags) + mock.async_get_all_tags.return_value = [ + ImmichTag.from_dict( + { + "id": "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5", + "name": "Halloween", + "value": "Halloween", + "createdAt": "2025-05-12T20:00:45.220Z", + "updatedAt": "2025-05-12T20:00:47.224Z", + }, + ), + ImmichTag.from_dict( + { + "id": "69bd487f-dc1e-4420-94c6-656f0515773d", + "name": "Holidays", + "value": "Holidays", + "createdAt": "2025-05-12T20:00:49.967Z", + "updatedAt": "2025-05-12T20:00:55.575Z", + }, + ), + ] + return mock + + @pytest.fixture def mock_immich_user() -> AsyncMock: """Mock the Immich server.""" @@ -185,7 +279,10 @@ def mock_immich_user() -> AsyncMock: async def mock_immich( mock_immich_albums: AsyncMock, mock_immich_assets: AsyncMock, + mock_immich_people: AsyncMock, + mock_immich_search: AsyncMock, mock_immich_server: AsyncMock, + mock_immich_tags: AsyncMock, mock_immich_user: AsyncMock, ) -> AsyncGenerator[AsyncMock]: """Mock the Immich API.""" @@ -196,7 +293,10 @@ async def mock_immich( client = mock_immich.return_value client.albums = mock_immich_albums client.assets = mock_immich_assets + client.people = mock_immich_people + client.search = mock_immich_search client.server = mock_immich_server + client.tags = mock_immich_tags client.users = mock_immich_user yield client diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py index 97721bc7dbcf37..af718c4b7549b4 100644 --- a/tests/components/immich/const.py +++ b/tests/components/immich/const.py @@ -1,6 +1,7 @@ """Constants for the Immich integration tests.""" from aioimmich.albums.models import ImmichAlbum +from aioimmich.assets.models import ImmichAsset from homeassistant.const import ( CONF_API_KEY, @@ -113,3 +114,131 @@ ], } ) + +MOCK_PEOPLE_ASSETS = [ + ImmichAsset.from_dict( + { + "id": "2242eda3-94c2-49ee-86d4-e9e071b6fbf4", + "deviceAssetId": "1000092019", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "5933dd9394fc6bf0493a26b4e38acca1076f30ab246442976d2917f1d57d99a1", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/8e/a3/8ea31ee8-49c3-4be9-aa9d-b8ef26ba0abe.jpg", + "originalFileName": "20250714_201122.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "XRgGDILGeMlPaJaMWIeagJcJSA==", + "fileCreatedAt": "2025-07-14T18:11:22.648Z", + "fileModifiedAt": "2025-07-14T18:11:25.000Z", + "localDateTime": "2025-07-14T20:11:22.648Z", + "updatedAt": "2025-07-26T10:16:39.131Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "unassignedFaces": [], + "checksum": "GcBJkDFoXx9d/wyl1xH89R4/NBQ=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + } + ), + ImmichAsset.from_dict( + { + "id": "046ac0d9-8acd-44d8-953f-ecb3c786358a", + "deviceAssetId": "1000092018", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "5933dd9394fc6bf0493a26b4e38acca1076f30ab246442976d2917f1d57d99a1", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/f5/b4/f5b4b200-47dd-45e8-98a4-4128df3f9189.jpg", + "originalFileName": "20250714_201121.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "XRgGDILHeMlPeJaMSJmKgJcIWQ==", + "fileCreatedAt": "2025-07-14T18:11:21.582Z", + "fileModifiedAt": "2025-07-14T18:11:24.000Z", + "localDateTime": "2025-07-14T20:11:21.582Z", + "updatedAt": "2025-07-26T10:16:39.131Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "unassignedFaces": [], + "checksum": "X6kMpPulu/HJQnKmTqCoQYl3Sjc=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), +] + +MOCK_TAGS_ASSETS = [ + ImmichAsset.from_dict( + { + "id": "ae3d82fc-beb5-4abc-ae83-11fcfa5e7629", + "deviceAssetId": "2132393", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "CLI", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/07/d0/07d04d86-7188-4335-95ca-9bd9fd2b399d.JPG", + "originalFileName": "20110306_025024.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "WCgSFYRXaYdQiYineIiHd4SghQUY", + "fileCreatedAt": "2011-03-06T01:50:24.000Z", + "fileModifiedAt": "2011-03-06T01:50:24.000Z", + "localDateTime": "2011-03-06T02:50:24.000Z", + "updatedAt": "2025-07-26T10:16:39.477Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "checksum": "eNwN0AN2hEYZJJkonl7ylGzJzko=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), + ImmichAsset.from_dict( + { + "id": "b71d0d08-6727-44ae-8bba-83c190f95df4", + "deviceAssetId": "2142137", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "CLI", + "libraryId": None, + "type": "IMAGE", + "originalPath": "/usr/src/app/upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/4a/f4/4af42484-86f8-47a0-958a-f32da89ee03a.JPG", + "originalFileName": "20110306_024053.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "4AcKFYZPZnhSmGl5daaYeG859ytT", + "fileCreatedAt": "2011-03-06T01:40:53.000Z", + "fileModifiedAt": "2011-03-06T01:40:52.000Z", + "localDateTime": "2011-03-06T02:40:53.000Z", + "updatedAt": "2025-07-26T10:16:39.474Z", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "visibility": "timeline", + "duration": "0:00:00.00000", + "livePhotoVideoId": None, + "people": [], + "checksum": "VtokCjIwKqnHBFzH3kHakIJiq5I=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ), +] diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 5b396a780cc0c1..6bd23b272ed066 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -26,7 +26,6 @@ from homeassistant.util.aiohttp import MockRequest, MockStreamReaderChunked from . import setup_integration -from .const import MOCK_ALBUM_WITHOUT_ASSETS from tests.common import MockConfigEntry @@ -143,7 +142,8 @@ async def test_browse_media_get_root( result = await source.async_browse_media(item) assert result - assert len(result.children) == 1 + assert len(result.children) == 3 + media_file = result.children[0] assert isinstance(media_file, BrowseMedia) assert media_file.title == "albums" @@ -151,13 +151,59 @@ async def test_browse_media_get_root( "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums" ) + media_file = result.children[1] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == "people" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|people" + ) -async def test_browse_media_get_albums( + media_file = result.children[2] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == "tags" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|tags" + ) + + +@pytest.mark.parametrize( + ("collection", "children"), + [ + ( + "albums", + [{"title": "My Album", "asset_id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6"}], + ), + ( + "people", + [ + {"title": "Me", "asset_id": "6176838a-ac5a-4d1f-9a35-91c591d962d8"}, + {"title": "I", "asset_id": "3e66aa4a-a4a8-41a4-86fe-2ae5e490078f"}, + {"title": "Myself", "asset_id": "a3c83297-684a-4576-82dc-b07432e8a18f"}, + ], + ), + ( + "tags", + [ + { + "title": "Halloween", + "asset_id": "67301cb8-cb73-4e8a-99e9-475cb3f7e7b5", + }, + { + "title": "Holidays", + "asset_id": "69bd487f-dc1e-4420-94c6-656f0515773d", + }, + ], + ), + ], +) +async def test_browse_media_collections( hass: HomeAssistant, mock_immich: Mock, mock_config_entry: MockConfigEntry, + collection: str, + children: list[dict], ) -> None: - """Test browse_media returning albums.""" + """Test browse through collections.""" assert await async_setup_component(hass, "media_source", {}) with patch("homeassistant.components.immich.PLATFORMS", []): @@ -165,35 +211,47 @@ async def test_browse_media_get_albums( source = await async_get_media_source(hass) item = MediaSourceItem( - hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums", None + hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None ) result = await source.async_browse_media(item) assert result - assert len(result.children) == 1 - media_file = result.children[0] - assert isinstance(media_file, BrowseMedia) - assert media_file.title == "My Album" - assert media_file.media_content_id == ( - "media-source://immich/" - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6" - ) + assert len(result.children) == len(children) + for idx, child in enumerate(children): + media_file = result.children[idx] + assert isinstance(media_file, BrowseMedia) + assert media_file.title == child["title"] + assert media_file.media_content_id == ( + "media-source://immich/" + f"{mock_config_entry.unique_id}|{collection}|" + f"{child['asset_id']}" + ) -async def test_browse_media_get_albums_error( +@pytest.mark.parametrize( + ("collection", "mocked_get_fn"), + [ + ("albums", ("albums", "async_get_all_albums")), + ("people", ("people", "async_get_all_people")), + ("tags", ("tags", "async_get_all_tags")), + ], +) +async def test_browse_media_collections_error( hass: HomeAssistant, mock_immich: Mock, mock_config_entry: MockConfigEntry, + collection: str, + mocked_get_fn: tuple[str, str], ) -> None: - """Test browse_media with unknown album.""" + """Test browse_media with unknown collection.""" assert await async_setup_component(hass, "media_source", {}) with patch("homeassistant.components.immich.PLATFORMS", []): await setup_integration(hass, mock_config_entry) - # exception in get_albums() - mock_immich.albums.async_get_all_albums.side_effect = ImmichError( + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichError( { "message": "Not found or no album.read access", "error": "Bad Request", @@ -204,7 +262,9 @@ async def test_browse_media_get_albums_error( source = await async_get_media_source(hass) - item = MediaSourceItem(hass, DOMAIN, f"{mock_config_entry.unique_id}|albums", None) + item = MediaSourceItem( + hass, DOMAIN, f"{mock_config_entry.unique_id}|{collection}", None + ) result = await source.async_browse_media(item) assert result @@ -212,10 +272,20 @@ async def test_browse_media_get_albums_error( assert len(result.children) == 0 -async def test_browse_media_get_album_items_error( +@pytest.mark.parametrize( + ("collection", "mocked_get_fn"), + [ + ("albums", ("albums", "async_get_album_info")), + ("people", ("search", "async_get_all_by_person_ids")), + ("tags", ("search", "async_get_all_by_tag_ids")), + ], +) +async def test_browse_media_collection_items_error( hass: HomeAssistant, mock_immich: Mock, mock_config_entry: MockConfigEntry, + collection: str, + mocked_get_fn: tuple[str, str], ) -> None: """Test browse_media returning albums.""" assert await async_setup_component(hass, "media_source", {}) @@ -225,22 +295,9 @@ async def test_browse_media_get_album_items_error( source = await async_get_media_source(hass) - # unknown album - mock_immich.albums.async_get_album_info.return_value = MOCK_ALBUM_WITHOUT_ASSETS - item = MediaSourceItem( - hass, - DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - None, - ) - result = await source.async_browse_media(item) - - assert result - assert result.identifier is None - assert len(result.children) == 0 - - # exception in async_get_album_info() - mock_immich.albums.async_get_album_info.side_effect = ImmichError( + getattr( + getattr(mock_immich, mocked_get_fn[0]), mocked_get_fn[1] + ).side_effect = ImmichError( { "message": "Not found or no album.read access", "error": "Bad Request", @@ -251,7 +308,7 @@ async def test_browse_media_get_album_items_error( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + f"{mock_config_entry.unique_id}|{collection}|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", None, ) result = await source.async_browse_media(item) @@ -261,10 +318,84 @@ async def test_browse_media_get_album_items_error( assert len(result.children) == 0 -async def test_browse_media_get_album_items( +@pytest.mark.parametrize( + ("collection", "collection_id", "children"), + [ + ( + "albums", + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + [ + { + "original_file_name": "filename.jpg", + "asset_id": "2e94c203-50aa-4ad2-8e29-56dd74e0eff4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "filename.mp4", + "asset_id": "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b", + "media_class": MediaClass.VIDEO, + "media_content_type": "video/mp4", + "thumb_mime_type": "image/jpeg", + "can_play": True, + }, + ], + ), + ( + "people", + "6176838a-ac5a-4d1f-9a35-91c591d962d8", + [ + { + "original_file_name": "20250714_201122.jpg", + "asset_id": "2242eda3-94c2-49ee-86d4-e9e071b6fbf4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "20250714_201121.jpg", + "asset_id": "046ac0d9-8acd-44d8-953f-ecb3c786358a", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + ], + ), + ( + "tags", + "6176838a-ac5a-4d1f-9a35-91c591d962d8", + [ + { + "original_file_name": "20110306_025024.jpg", + "asset_id": "ae3d82fc-beb5-4abc-ae83-11fcfa5e7629", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + { + "original_file_name": "20110306_024053.jpg", + "asset_id": "b71d0d08-6727-44ae-8bba-83c190f95df4", + "media_class": MediaClass.IMAGE, + "media_content_type": "image/jpeg", + "thumb_mime_type": "image/jpeg", + "can_play": False, + }, + ], + ), + ], +) +async def test_browse_media_collection_get_items( hass: HomeAssistant, mock_immich: Mock, mock_config_entry: MockConfigEntry, + collection: str, + collection_id: str, + children: list[dict], ) -> None: """Test browse_media returning albums.""" assert await async_setup_component(hass, "media_source", {}) @@ -277,46 +408,30 @@ async def test_browse_media_get_album_items( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + f"{mock_config_entry.unique_id}|{collection}|{collection_id}", None, ) result = await source.async_browse_media(item) assert result - assert len(result.children) == 2 - media_file = result.children[0] - assert isinstance(media_file, BrowseMedia) - assert media_file.identifier == ( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4|filename.jpg|image/jpeg" - ) - assert media_file.title == "filename.jpg" - assert media_file.media_class == MediaClass.IMAGE - assert media_file.media_content_type == "image/jpeg" - assert media_file.can_play is False - assert not media_file.can_expand - assert media_file.thumbnail == ( - "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg" - ) - - media_file = result.children[1] - assert isinstance(media_file, BrowseMedia) - assert media_file.identifier == ( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b|filename.mp4|video/mp4" - ) - assert media_file.title == "filename.mp4" - assert media_file.media_class == MediaClass.VIDEO - assert media_file.media_content_type == "video/mp4" - assert media_file.can_play is True - assert not media_file.can_expand - assert media_file.thumbnail == ( - "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail/image/jpeg" - ) + assert len(result.children) == len(children) + + for idx, child in enumerate(children): + media_file = result.children[idx] + assert isinstance(media_file, BrowseMedia) + assert media_file.identifier == ( + f"{mock_config_entry.unique_id}|{collection}|{collection_id}|" + f"{child['asset_id']}|{child['original_file_name']}|{child['media_content_type']}" + ) + assert media_file.title == child["original_file_name"] + assert media_file.media_class == child["media_class"] + assert media_file.media_content_type == child["media_content_type"] + assert media_file.can_play is child["can_play"] + assert not media_file.can_expand + assert media_file.thumbnail == ( + f"/immich/{mock_config_entry.unique_id}/" + f"{child['asset_id']}/thumbnail/{child['thumb_mime_type']}" + ) async def test_media_view( @@ -362,6 +477,22 @@ async def test_media_view( "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg", ) + # exception in async_get_person_thumbnail() + mock_immich.people.async_get_person_thumbnail.side_effect = ImmichError( + { + "message": "Not found or no asset.read access", + "error": "Bad Request", + "statusCode": 400, + "correlationId": "e0hlizyl", + } + ) + with pytest.raises(web.HTTPNotFound): + await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/person/image/jpeg", + ) + # exception in async_play_video_stream() mock_immich.assets.async_play_video_stream.side_effect = ImmichError( { @@ -396,6 +527,24 @@ async def test_media_view( ) assert isinstance(result, web.Response) + mock_immich.people.async_get_person_thumbnail.side_effect = None + mock_immich.people.async_get_person_thumbnail.return_value = b"xxxx" + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/person/image/jpeg", + ) + assert isinstance(result, web.Response) + + with patch.object(tempfile, "tempdir", tmp_path): + result = await view.get( + request, + "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/fullsize/image/jpeg", + ) + assert isinstance(result, web.Response) + mock_immich.assets.async_play_video_stream.side_effect = None mock_immich.assets.async_play_video_stream.return_value = MockStreamReaderChunked( b"xxxx" diff --git a/tests/components/miele/conftest.py b/tests/components/miele/conftest.py index 7b3c3f35f7ec00..d91485ffc590e1 100644 --- a/tests/components/miele/conftest.py +++ b/tests/components/miele/conftest.py @@ -20,8 +20,8 @@ from tests.common import ( MockConfigEntry, - async_load_fixture, async_load_json_object_fixture, + load_json_value_fixture, ) @@ -99,13 +99,13 @@ async def action_fixture(hass: HomeAssistant, load_action_file: str) -> MieleAct @pytest.fixture(scope="package") def load_programs_file() -> str: """Fixture for loading programs file.""" - return "programs_washing_machine.json" + return "programs.json" @pytest.fixture async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]: """Fixture for available programs.""" - return await async_load_fixture(hass, load_programs_file, DOMAIN) + return load_json_value_fixture(load_programs_file, DOMAIN) @pytest.fixture diff --git a/tests/components/miele/fixtures/programs.json b/tests/components/miele/fixtures/programs.json new file mode 100644 index 00000000000000..06eddc5fedcbf2 --- /dev/null +++ b/tests/components/miele/fixtures/programs.json @@ -0,0 +1,34 @@ +[ + { + "programId": 1, + "program": "Cottons", + "parameters": {} + }, + { + "programId": 146, + "program": "QuickPowerWash", + "parameters": {} + }, + { + "programId": 123, + "program": "Dark garments / Denim", + "parameters": {} + }, + { + "programId": 13, + "program": "Fan plus", + "parameters": { + "temperature": { + "min": 30, + "max": 250, + "step": 5, + "mandatory": false + }, + "duration": { + "min": [0, 1], + "max": [12, 0], + "mandatory": true + } + } + } +] diff --git a/tests/components/miele/fixtures/programs_washing_machine.json b/tests/components/miele/fixtures/programs_washing_machine.json deleted file mode 100644 index a3c16ece8e6e3a..00000000000000 --- a/tests/components/miele/fixtures/programs_washing_machine.json +++ /dev/null @@ -1,117 +0,0 @@ -[ - { - "programId": 146, - "program": "QuickPowerWash", - "parameters": {} - }, - { - "programId": 123, - "program": "Dark garments / Denim", - "parameters": {} - }, - { - "programId": 190, - "program": "ECO 40-60 ", - "parameters": {} - }, - { - "programId": 27, - "program": "Proofing", - "parameters": {} - }, - { - "programId": 23, - "program": "Shirts", - "parameters": {} - }, - { - "programId": 9, - "program": "Silks ", - "parameters": {} - }, - { - "programId": 8, - "program": "Woollens ", - "parameters": {} - }, - { - "programId": 4, - "program": "Delicates", - "parameters": {} - }, - { - "programId": 3, - "program": "Minimum iron", - "parameters": {} - }, - { - "programId": 1, - "program": "Cottons", - "parameters": {} - }, - { - "programId": 69, - "program": "Cottons hygiene", - "parameters": {} - }, - { - "programId": 37, - "program": "Outerwear", - "parameters": {} - }, - { - "programId": 122, - "program": "Express 20", - "parameters": {} - }, - { - "programId": 29, - "program": "Sportswear", - "parameters": {} - }, - { - "programId": 31, - "program": "Automatic plus", - "parameters": {} - }, - { - "programId": 39, - "program": "Pillows", - "parameters": {} - }, - { - "programId": 22, - "program": "Curtains", - "parameters": {} - }, - { - "programId": 129, - "program": "Down filled items", - "parameters": {} - }, - { - "programId": 53, - "program": "First wash", - "parameters": {} - }, - { - "programId": 95, - "program": "Down duvets", - "parameters": {} - }, - { - "programId": 52, - "program": "Separate rinse / Starch", - "parameters": {} - }, - { - "programId": 21, - "program": "Drain / Spin", - "parameters": {} - }, - { - "programId": 91, - "program": "Clean machine", - "parameters": {} - } -] diff --git a/tests/components/miele/snapshots/test_services.ambr b/tests/components/miele/snapshots/test_services.ambr new file mode 100644 index 00000000000000..3095ec9b6fbea0 --- /dev/null +++ b/tests/components/miele/snapshots/test_services.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_services_with_response + dict({ + 'programs': list([ + dict({ + 'parameters': dict({ + }), + 'program': 'Cottons', + 'program_id': 1, + }), + dict({ + 'parameters': dict({ + }), + 'program': 'QuickPowerWash', + 'program_id': 146, + }), + dict({ + 'parameters': dict({ + }), + 'program': 'Dark garments / Denim', + 'program_id': 123, + }), + dict({ + 'parameters': dict({ + 'duration': dict({ + 'mandatory': True, + 'max': dict({ + 'hours': 12, + 'minutes': 0, + }), + 'min': dict({ + 'hours': 0, + 'minutes': 1, + }), + }), + 'temperature': dict({ + 'mandatory': False, + 'max': 250, + 'min': 30, + 'step': 5, + }), + }), + 'program': 'Fan plus', + 'program_id': 13, + }), + ]), + }) +# --- diff --git a/tests/components/miele/test_services.py b/tests/components/miele/test_services.py index 8b33c17d69f181..2bf0e2deb9c383 100644 --- a/tests/components/miele/test_services.py +++ b/tests/components/miele/test_services.py @@ -4,10 +4,15 @@ from aiohttp import ClientResponseError import pytest +from syrupy.assertion import SnapshotAssertion from voluptuous import MultipleInvalid from homeassistant.components.miele.const import DOMAIN -from homeassistant.components.miele.services import ATTR_PROGRAM_ID, SERVICE_SET_PROGRAM +from homeassistant.components.miele.services import ( + ATTR_PROGRAM_ID, + SERVICE_GET_PROGRAMS, + SERVICE_SET_PROGRAM, +) from homeassistant.const import ATTR_DEVICE_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -44,6 +49,28 @@ async def test_services( ) +async def test_services_with_response( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the custom services that returns a response are correct.""" + + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + assert snapshot == await hass.services.async_call( + DOMAIN, + SERVICE_GET_PROGRAMS, + { + ATTR_DEVICE_ID: device.id, + }, + blocking=True, + return_response=True, + ) + + async def test_service_api_errors( hass: HomeAssistant, device_registry: DeviceRegistry, @@ -60,7 +87,7 @@ async def test_service_api_errors( await hass.services.async_call( DOMAIN, SERVICE_SET_PROGRAM, - {"device_id": device.id, ATTR_PROGRAM_ID: 1}, + {ATTR_DEVICE_ID: device.id, ATTR_PROGRAM_ID: 1}, blocking=True, ) mock_miele_client.set_program.assert_called_once_with( @@ -68,6 +95,29 @@ async def test_service_api_errors( ) +async def test_get_service_api_errors( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_miele_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test service api errors.""" + await setup_integration(hass, mock_config_entry) + device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)}) + + # Test http error + mock_miele_client.get_programs.side_effect = ClientResponseError("TestInfo", "test") + with pytest.raises(HomeAssistantError, match="'Get programs' action failed"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_PROGRAMS, + {ATTR_DEVICE_ID: device.id}, + blocking=True, + return_response=True, + ) + mock_miele_client.get_programs.assert_called_once() + + async def test_service_validation_errors( hass: HomeAssistant, device_registry: DeviceRegistry, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index d699d1b910224b..fa4cac6fff342d 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -1,11 +1,10 @@ """Setup the Reolink tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, create_autospec, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.api import Chime -from reolink_aio.baichuan import Baichuan from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL @@ -91,6 +90,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.expire_session = AsyncMock() host_mock.set_volume = AsyncMock() host_mock.set_hub_audio = AsyncMock() + host_mock.play_quick_reply = AsyncMock() + host_mock.update_firmware = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC @@ -155,6 +156,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.recording_packing_time = "60 Minutes" # Baichuan + host_mock.baichuan = MagicMock() host_mock.baichuan_only = False # Disable tcp push by default for tests host_mock.baichuan.port = TEST_BC_PORT @@ -163,6 +165,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.unsubscribe_events = AsyncMock() host_mock.baichuan.check_subscribe_events = AsyncMock() host_mock.baichuan.get_privacy_mode = AsyncMock() + host_mock.baichuan.set_privacy_mode = AsyncMock() + host_mock.baichuan.set_scene = AsyncMock() host_mock.baichuan.mac_address.return_value = TEST_MAC_CAM host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.day_night_state.return_value = "day" @@ -180,38 +184,20 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.baichuan.smart_ai_name.return_value = "zone1" -@pytest.fixture(scope="module") -def reolink_connect_class() -> Generator[MagicMock]: +@pytest.fixture +def reolink_host_class() -> Generator[MagicMock]: """Mock reolink connection and return both the host_mock and host_mock_class.""" - with ( - patch( - "homeassistant.components.reolink.host.Host", autospec=True - ) as host_mock_class, - ): - host_mock = host_mock_class.return_value - host_mock.baichuan = create_autospec(Baichuan) - _init_host_mock(host_mock) + with patch( + "homeassistant.components.reolink.host.Host", autospec=False + ) as host_mock_class: + _init_host_mock(host_mock_class.return_value) yield host_mock_class @pytest.fixture -def reolink_connect( - reolink_connect_class: MagicMock, -) -> Generator[MagicMock]: - """Mock reolink connection.""" - return reolink_connect_class.return_value - - -@pytest.fixture -def reolink_host() -> Generator[MagicMock]: +def reolink_host(reolink_host_class: MagicMock) -> Generator[MagicMock]: """Mock reolink Host class.""" - with patch( - "homeassistant.components.reolink.host.Host", autospec=False - ) as host_mock_class: - host_mock = host_mock_class.return_value - host_mock.baichuan = MagicMock() - _init_host_mock(host_mock) - yield host_mock + return reolink_host_class.return_value @pytest.fixture @@ -246,29 +232,6 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: return config_entry -@pytest.fixture -def test_chime(reolink_connect: MagicMock) -> None: - """Mock a reolink chime.""" - TEST_CHIME = Chime( - host=reolink_connect, - dev_id=12345678, - channel=0, - ) - TEST_CHIME.name = "Test chime" - TEST_CHIME.volume = 3 - TEST_CHIME.connect_state = 2 - TEST_CHIME.led_state = True - TEST_CHIME.event_info = { - "md": {"switch": 0, "musicId": 0}, - "people": {"switch": 0, "musicId": 1}, - "visitor": {"switch": 1, "musicId": 2}, - } - - reolink_connect.chime_list = [TEST_CHIME] - reolink_connect.chime.return_value = TEST_CHIME - return TEST_CHIME - - @pytest.fixture def reolink_chime(reolink_host: MagicMock) -> None: """Mock a reolink chime.""" diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4b116929ac81c7..0a837a97b20afb 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -58,7 +58,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed -pytestmark = pytest.mark.usefixtures("reolink_connect") +pytestmark = pytest.mark.usefixtures("reolink_host") async def test_config_flow_manual_success( @@ -101,11 +101,11 @@ async def test_config_flow_manual_success( async def test_config_flow_privacy_success( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow when privacy mode is turned on.""" - reolink_connect.baichuan.privacy_mode.return_value = True - reolink_connect.get_host_data.side_effect = LoginPrivacyModeError("Test error") + reolink_host.baichuan.privacy_mode.return_value = True + reolink_host.get_host_data.side_effect = LoginPrivacyModeError("Test error") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -128,13 +128,13 @@ async def test_config_flow_privacy_success( assert result["step_id"] == "privacy" assert result["errors"] is None - assert reolink_connect.baichuan.set_privacy_mode.call_count == 0 - reolink_connect.get_host_data.reset_mock(side_effect=True) + assert reolink_host.baichuan.set_privacy_mode.call_count == 0 + reolink_host.get_host_data.reset_mock(side_effect=True) with patch("homeassistant.components.reolink.config_flow.API_STARTUP_TIME", new=0): result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert reolink_connect.baichuan.set_privacy_mode.call_count == 1 + assert reolink_host.baichuan.set_privacy_mode.call_count == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TEST_NVR_NAME @@ -153,14 +153,12 @@ async def test_config_flow_privacy_success( } assert result["result"].unique_id == TEST_MAC - reolink_connect.baichuan.privacy_mode.return_value = False - async def test_config_flow_baichuan_only( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user for baichuan only device.""" - reolink_connect.baichuan_only = True + reolink_host.baichuan_only = True result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -196,11 +194,9 @@ async def test_config_flow_baichuan_only( } assert result["result"].unique_id == TEST_MAC - reolink_connect.baichuan_only = False - async def test_config_flow_errors( - hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock + hass: HomeAssistant, reolink_host: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( @@ -211,10 +207,10 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {} - reolink_connect.is_admin = False - reolink_connect.user_level = "guest" - reolink_connect.unsubscribe.side_effect = ReolinkError("Test error") - reolink_connect.logout.side_effect = ReolinkError("Test error") + reolink_host.is_admin = False + reolink_host.user_level = "guest" + reolink_host.unsubscribe.side_effect = ReolinkError("Test error") + reolink_host.logout.side_effect = ReolinkError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -228,9 +224,9 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_USERNAME: "not_admin"} - reolink_connect.is_admin = True - reolink_connect.user_level = "admin" - reolink_connect.get_host_data.side_effect = ReolinkError("Test error") + reolink_host.is_admin = True + reolink_host.user_level = "admin" + reolink_host.get_host_data.side_effect = ReolinkError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -244,7 +240,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "cannot_connect"} - reolink_connect.get_host_data.side_effect = ReolinkWebhookException("Test error") + reolink_host.get_host_data.side_effect = ReolinkWebhookException("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -258,7 +254,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {"base": "webhook_exception"} - reolink_connect.get_host_data.side_effect = json.JSONDecodeError( + reolink_host.get_host_data.side_effect = json.JSONDecodeError( "test_error", "test", 1 ) result = await hass.config_entries.flow.async_configure( @@ -274,7 +270,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "unknown"} - reolink_connect.get_host_data.side_effect = CredentialsInvalidError("Test error") + reolink_host.get_host_data.side_effect = CredentialsInvalidError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -288,7 +284,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} - reolink_connect.get_host_data.side_effect = LoginFirmwareError("Test error") + reolink_host.get_host_data.side_effect = LoginFirmwareError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -302,7 +298,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {"base": "update_needed"} - reolink_connect.valid_password.return_value = False + reolink_host.valid_password.return_value = False result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -316,8 +312,8 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_PASSWORD: "password_incompatible"} - reolink_connect.valid_password.return_value = True - reolink_connect.get_host_data.side_effect = ApiError("Test error") + reolink_host.valid_password.return_value = True + reolink_host.get_host_data.side_effect = ApiError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -331,7 +327,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "api_error"} - reolink_connect.get_host_data.reset_mock(side_effect=True) + reolink_host.get_host_data.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -360,9 +356,6 @@ async def test_config_flow_errors( CONF_PROTOCOL: DEFAULT_PROTOCOL, } - reolink_connect.unsubscribe.reset_mock(side_effect=True) - reolink_connect.logout.reset_mock(side_effect=True) - async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test specifying non default settings using options flow.""" @@ -450,7 +443,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: async def test_reauth_abort_unique_id_mismatch( - hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_connect: MagicMock + hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_host: MagicMock ) -> None: """Test a reauth flow.""" config_entry = MockConfigEntry( @@ -475,7 +468,7 @@ async def test_reauth_abort_unique_id_mismatch( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await config_entry.start_reauth_flow(hass) @@ -497,8 +490,6 @@ async def test_reauth_abort_unique_id_mismatch( assert config_entry.data[CONF_USERNAME] == TEST_USERNAME assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD - reolink_connect.mac_address = TEST_MAC - async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" @@ -544,8 +535,8 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No async def test_dhcp_ip_update_aborted_if_wrong_mac( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, ) -> None: """Test dhcp discovery does not update the IP if the mac address does not match.""" config_entry = MockConfigEntry( @@ -572,7 +563,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( assert config_entry.state is ConfigEntryState.LOADED # ensure the last_update_succes is False for the device_coordinator. - reolink_connect.get_states.side_effect = ReolinkError("Test error") + reolink_host.get_states.side_effect = ReolinkError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -583,7 +574,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( macaddress=DHCP_FORMATTED_MAC, ) - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data @@ -602,9 +593,9 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] in [TEST_HOST, TEST_HOST2] get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -616,10 +607,6 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( # Check that IP was not updated assert config_entry.data[CONF_HOST] == TEST_HOST - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - reolink_connect.mac_address = TEST_MAC - @pytest.mark.parametrize( ("attr", "value", "expected", "host_call_list"), @@ -641,8 +628,8 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( async def test_dhcp_ip_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, attr: str, value: Any, expected: str, @@ -673,7 +660,7 @@ async def test_dhcp_ip_update( assert config_entry.state is ConfigEntryState.LOADED # ensure the last_update_succes is False for the device_coordinator. - reolink_connect.get_states.side_effect = ReolinkError("Test error") + reolink_host.get_states.side_effect = ReolinkError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -685,8 +672,7 @@ async def test_dhcp_ip_update( ) if attr is not None: - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + setattr(reolink_host, attr, value) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data @@ -705,9 +691,9 @@ async def test_dhcp_ip_update( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] in host_call_list get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -718,17 +704,12 @@ async def test_dhcp_ip_update( await hass.async_block_till_done() assert config_entry.data[CONF_HOST] == expected - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - if attr is not None: - setattr(reolink_connect, attr, original) - async def test_dhcp_ip_update_ingnored_if_still_connected( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect_class: MagicMock, - reolink_connect: MagicMock, + reolink_host_class: MagicMock, + reolink_host: MagicMock, ) -> None: """Test dhcp discovery is ignored when the camera is still properly connected to HA.""" config_entry = MockConfigEntry( @@ -776,9 +757,9 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( bc_port=TEST_BC_PORT, bc_only=False, ) - assert expected_call in reolink_connect_class.call_args_list + assert expected_call in reolink_host_class.call_args_list - for exc_call in reolink_connect_class.call_args_list: + for exc_call in reolink_host_class.call_args_list: assert exc_call[0][0] == TEST_HOST get_session = exc_call[1]["aiohttp_get_session_callback"] assert isinstance(get_session(), ClientSession) @@ -789,9 +770,6 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( await hass.async_block_till_done() assert config_entry.data[CONF_HOST] == TEST_HOST - reolink_connect.get_states.side_effect = None - reolink_connect_class.reset_mock() - async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reconfiguration flow.""" @@ -840,7 +818,7 @@ async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> Non async def test_reconfig_abort_unique_id_mismatch( - hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_connect: MagicMock + hass: HomeAssistant, mock_setup_entry: MagicMock, reolink_host: MagicMock ) -> None: """Test a reconfiguration flow aborts if the unique id does not match.""" config_entry = MockConfigEntry( @@ -865,7 +843,7 @@ async def test_reconfig_abort_unique_id_mismatch( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.mac_address = "aa:aa:aa:aa:aa:aa" + reolink_host.mac_address = "aa:aa:aa:aa:aa:aa" result = await config_entry.start_reconfigure_flow(hass) @@ -887,5 +865,3 @@ async def test_reconfig_abort_unique_id_mismatch( assert config_entry.data[CONF_HOST] == TEST_HOST assert config_entry.data[CONF_USERNAME] == TEST_USERNAME assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD - - reolink_connect.mac_address = TEST_MAC diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 32bc5e4435ead3..fb0f98a6e31128 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -29,7 +29,7 @@ async def test_floodlight_mode_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test select entity with floodlight_mode.""" @@ -47,9 +47,9 @@ async def test_floodlight_mode_select( {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True, ) - reolink_connect.set_whiteled.assert_called_once() + reolink_host.set_whiteled.assert_called_once() - reolink_connect.set_whiteled.side_effect = ReolinkError("Test error") + reolink_host.set_whiteled.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -58,7 +58,7 @@ async def test_floodlight_mode_select( blocking=True, ) - reolink_connect.set_whiteled.side_effect = InvalidParameterError("Test error") + reolink_host.set_whiteled.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -67,24 +67,22 @@ async def test_floodlight_mode_select( blocking=True, ) - reolink_connect.whiteled_mode.return_value = -99 # invalid value + reolink_host.whiteled_mode.return_value = -99 # invalid value freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - reolink_connect.set_whiteled.reset_mock(side_effect=True) - async def test_play_quick_reply_message( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test select play_quick_reply_message entity.""" - reolink_connect.quick_reply_dict.return_value = {0: "off", 1: "test message"} + reolink_host.quick_reply_dict.return_value = {0: "off", 1: "test message"} with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -99,16 +97,14 @@ async def test_play_quick_reply_message( {ATTR_ENTITY_ID: entity_id, "option": "test message"}, blocking=True, ) - reolink_connect.play_quick_reply.assert_called_once() - - reolink_connect.quick_reply_dict = MagicMock() + reolink_host.play_quick_reply.assert_called_once() async def test_host_scene_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host select entity with scene mode.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]): @@ -125,9 +121,9 @@ async def test_host_scene_select( {ATTR_ENTITY_ID: entity_id, "option": "home"}, blocking=True, ) - reolink_connect.baichuan.set_scene.assert_called_once() + reolink_host.baichuan.set_scene.assert_called_once() - reolink_connect.baichuan.set_scene.side_effect = ReolinkError("Test error") + reolink_host.baichuan.set_scene.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -136,7 +132,7 @@ async def test_host_scene_select( blocking=True, ) - reolink_connect.baichuan.set_scene.side_effect = InvalidParameterError("Test error") + reolink_host.baichuan.set_scene.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -145,23 +141,20 @@ async def test_host_scene_select( blocking=True, ) - reolink_connect.baichuan.active_scene = "Invalid value" + reolink_host.baichuan.active_scene = "Invalid value" freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - reolink_connect.baichuan.set_scene.reset_mock(side_effect=True) - reolink_connect.baichuan.active_scene = "off" - async def test_chime_select( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, entity_registry: er.EntityRegistry, ) -> None: """Test chime select entity.""" @@ -175,16 +168,16 @@ async def test_chime_select( assert hass.states.get(entity_id).state == "pianokey" # Test selecting chime ringtone option - test_chime.set_tone = AsyncMock() + reolink_chime.set_tone = AsyncMock() await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_ENTITY_ID: entity_id, "option": "off"}, blocking=True, ) - test_chime.set_tone.assert_called_once() + reolink_chime.set_tone.assert_called_once() - test_chime.set_tone.side_effect = ReolinkError("Test error") + reolink_chime.set_tone.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SELECT_DOMAIN, @@ -193,7 +186,7 @@ async def test_chime_select( blocking=True, ) - test_chime.set_tone.side_effect = InvalidParameterError("Test error") + reolink_chime.set_tone.side_effect = InvalidParameterError("Test error") with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -203,11 +196,9 @@ async def test_chime_select( ) # Test unavailable - test_chime.event_info = {} + reolink_chime.event_info = {} freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN - - test_chime.set_tone.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index d48362516b8e82..d12b229e932f24 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -30,11 +30,11 @@ async def test_no_update( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_name: str, ) -> None: """Test update state when no update available.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_host.camera_name.return_value = TEST_CAM_NAME with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -49,12 +49,12 @@ async def test_no_update( async def test_update_str( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_name: str, ) -> None: """Test update state when update available with string from API.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.firmware_update_available.return_value = "New firmware available" + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.firmware_update_available.return_value = "New firmware available" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -69,21 +69,21 @@ async def test_update_str( async def test_update_firm( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, entity_name: str, ) -> None: """Test update state when update available with firmware info from reolink.com.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.sw_upload_progress.return_value = 100 - reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.sw_upload_progress.return_value = 100 + reolink_host.camera_sw_version.return_value = "v1.1.0.0.0.0000" new_firmware = NewSoftwareVersion( version_string="v3.3.0.226_23031644", download_url=TEST_DOWNLOAD_URL, release_notes=TEST_RELEASE_NOTES, ) - reolink_connect.firmware_update_available.return_value = new_firmware + reolink_host.firmware_update_available.return_value = new_firmware with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -117,9 +117,9 @@ async def test_update_firm( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.update_firmware.assert_called() + reolink_host.update_firmware.assert_called() - reolink_connect.sw_upload_progress.return_value = 50 + reolink_host.sw_upload_progress.return_value = 50 freezer.tick(POLL_PROGRESS) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -127,7 +127,7 @@ async def test_update_firm( assert hass.states.get(entity_id).attributes["in_progress"] assert hass.states.get(entity_id).attributes["update_percentage"] == 50 - reolink_connect.sw_upload_progress.return_value = 100 + reolink_host.sw_upload_progress.return_value = 100 freezer.tick(POLL_AFTER_INSTALL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -135,7 +135,7 @@ async def test_update_firm( assert not hass.states.get(entity_id).attributes["in_progress"] assert hass.states.get(entity_id).attributes["update_percentage"] is None - reolink_connect.update_firmware.side_effect = ReolinkError("Test error") + reolink_host.update_firmware.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( UPDATE_DOMAIN, @@ -144,7 +144,7 @@ async def test_update_firm( blocking=True, ) - reolink_connect.update_firmware.side_effect = ApiError( + reolink_host.update_firmware.side_effect = ApiError( "Test error", translation_key="firmware_rate_limit" ) with pytest.raises(HomeAssistantError): @@ -156,34 +156,32 @@ async def test_update_firm( ) # test _async_update_future - reolink_connect.camera_sw_version.return_value = "v3.3.0.226_23031644" - reolink_connect.firmware_update_available.return_value = False + reolink_host.camera_sw_version.return_value = "v3.3.0.226_23031644" + reolink_host.firmware_update_available.return_value = False freezer.tick(POLL_AFTER_INSTALL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF - reolink_connect.update_firmware.side_effect = None - @pytest.mark.parametrize("entity_name", [TEST_NVR_NAME, TEST_CAM_NAME]) async def test_update_firm_keeps_available( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, hass_ws_client: WebSocketGenerator, entity_name: str, ) -> None: """Test update entity keeps being available during update.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.camera_sw_version.return_value = "v1.1.0.0.0.0000" new_firmware = NewSoftwareVersion( version_string="v3.3.0.226_23031644", download_url=TEST_DOWNLOAD_URL, release_notes=TEST_RELEASE_NOTES, ) - reolink_connect.firmware_update_available.return_value = new_firmware + reolink_host.firmware_update_available.return_value = new_firmware with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -196,7 +194,7 @@ async def test_update_firm_keeps_available( async def mock_update_firmware(*args, **kwargs) -> None: await asyncio.sleep(0.000005) - reolink_connect.update_firmware = mock_update_firmware + reolink_host.update_firmware = mock_update_firmware # test install with patch("homeassistant.components.reolink.update.POLL_PROGRESS", 0.000001): @@ -207,11 +205,9 @@ async def mock_update_firmware(*args, **kwargs) -> None: blocking=True, ) - reolink_connect.session_active = False + reolink_host.session_active = False async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() # still available assert hass.states.get(entity_id).state == STATE_ON - - reolink_connect.session_active = True diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 06d678edcab065..c1df654e3280d3 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -932,3 +932,44 @@ async def test_flow_preview( ) assert state["state"] == AlarmControlPanelState.DISARMED + + +@pytest.mark.parametrize( + ("count", "panel_config"), + [ + ( + 1, + { + "name": TEST_OBJECT_ID, + "state": "{{ states('alarm_control_panel.test') }}", + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "optimistic": True, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_panel") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with empty script.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, AlarmControlPanelState.DISARMED) + await hass.async_block_till_done() + + await hass.services.async_call( + ALARM_DOMAIN, + "alarm_arm_away", + {"entity_id": TEST_ENTITY_ID, "code": "1234"}, + blocking=True, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == AlarmControlPanelState.ARMED_AWAY + + hass.states.async_set(TEST_STATE_ENTITY_ID, AlarmControlPanelState.ARMED_HOME) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == AlarmControlPanelState.ARMED_HOME diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 632d05ce931403..ab2d28ef645a78 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -148,6 +148,11 @@ Platform.SELECT, Platform.SWITCH, ], + "wk_air_conditioner": [ + # https://github.com/home-assistant/core/issues/146263 + Platform.CLIMATE, + Platform.SWITCH, + ], "ydkt_dolceclima_unsupported": [ # https://github.com/orgs/home-assistant/discussions/288 # unsupported device - no platforms diff --git a/tests/components/tuya/fixtures/wk_air_conditioner.json b/tests/components/tuya/fixtures/wk_air_conditioner.json new file mode 100644 index 00000000000000..2c162a1a51426d --- /dev/null +++ b/tests/components/tuya/fixtures/wk_air_conditioner.json @@ -0,0 +1,102 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1749538552551GHfV17", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf6fc1645146455a2efrex", + "name": "Clima cucina", + "category": "wk", + "product_id": "aqoouq7x", + "product_name": "T7-Air conditioner thermostat\uff08ZIGBEE)", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-04-21T13:39:47+00:00", + "create_time": "2025-04-21T13:39:47+00:00", + "update_time": "2025-04-21T13:39:47+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 5, + "max": 35, + "scale": 0, + "step": 1 + } + }, + "level": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "auto"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["cold", "hot"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 5, + "max": 35, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 50, + "scale": 0, + "step": 1 + } + }, + "level": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "auto"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": false, + "mode": "cold", + "temp_set": 25, + "temp_current": 27, + "level": "auto", + "child_lock": false + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 42fc10fef5489f..6e93a1b263c16d 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -74,6 +74,87 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][climate.clima_cucina-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'low', + 'middle', + 'high', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.clima_cucina', + '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': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'tuya.bf6fc1645146455a2efrex', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][climate.clima_cucina-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27.0, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'low', + 'middle', + 'high', + 'auto', + ]), + 'friendly_name': 'Clima cucina', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 25.0, + }), + 'context': , + 'entity_id': 'climate.clima_cucina', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index dc47486e980484..92243414892fc2 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1161,6 +1161,54 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][switch.clima_cucina_child_lock-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': , + 'entity_id': 'switch.clima_cucina_child_lock', + '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': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bf6fc1645146455a2efrexchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_air_conditioner][switch.clima_cucina_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Clima cucina Child lock', + }), + 'context': , + 'entity_id': 'switch.clima_cucina_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][switch.wifi_smart_gas_boiler_thermostat_child_lock-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index c295a07d83f540..cd1d926ff76cfd 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -8,9 +8,14 @@ from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import DEVICE_MOCKS, initialize_entry @@ -53,3 +58,62 @@ async def test_platform_setup_no_discovery( assert not er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +async def test_select_option( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + entity_id = "select.kitchen_blinds_motor_mode" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + "entity_id": entity_id, + "option": "forward", + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "control_back_mode", "value": "forward"}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_am43_corded_motor_zigbee_cover"], +) +async def test_select_invalid_option( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + entity_id = "select.kitchen_blinds_motor_mode" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + "entity_id": entity_id, + "option": "hello", + }, + blocking=True, + ) + assert exc.value.translation_key == "not_valid_option"