diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6a67a4b94deeea..8673c5f4b87fd0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.8 + uses: github/codeql-action/init@v3.29.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.8 + uses: github/codeql-action/analyze@v3.29.9 with: category: "/language:python" diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 5bd40eb5b831d5..26fda1a405f9b3 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -6,12 +6,16 @@ from collections.abc import Callable from contextlib import suppress from datetime import datetime, timedelta -from http import HTTPStatus import logging from typing import TYPE_CHECKING, Any import aiohttp -from hass_nabucasa import Cloud, cloud_api +from hass_nabucasa import AlexaApiError, Cloud +from hass_nabucasa.alexa_api import ( + AlexaAccessTokenDetails, + AlexaApiNeedsRelinkError, + AlexaApiNoTokenError, +) from yarl import URL from homeassistant.components import persistent_notification @@ -146,7 +150,7 @@ def __init__( self._cloud_user = cloud_user self._prefs = prefs self._cloud = cloud - self._token = None + self._token: str | None = None self._token_valid: datetime | None = None self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA) self._alexa_sync_unsub: Callable[[], None] | None = None @@ -318,32 +322,31 @@ def async_invalidate_access_token(self) -> None: async def async_get_access_token(self) -> str | None: """Get an access token.""" + details: AlexaAccessTokenDetails | None if self._token_valid is not None and self._token_valid > utcnow(): return self._token - resp = await cloud_api.async_alexa_access_token(self._cloud) - body = await resp.json() - - if resp.status == HTTPStatus.BAD_REQUEST: - if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"): - if self.should_report_state: - persistent_notification.async_create( - self.hass, - ( - "There was an error reporting state to Alexa" - f" ({body['reason']}). Please re-link your Alexa skill via" - " the Alexa app to continue using it." - ), - "Alexa state reporting disabled", - "cloud_alexa_report", - ) - raise alexa_errors.RequireRelink - - raise alexa_errors.NoTokenAvailable + try: + details = await self._cloud.alexa_api.access_token() + except AlexaApiNeedsRelinkError as exception: + if self.should_report_state: + persistent_notification.async_create( + self.hass, + ( + "There was an error reporting state to Alexa" + f" ({exception.reason}). Please re-link your Alexa skill via" + " the Alexa app to continue using it." + ), + "Alexa state reporting disabled", + "cloud_alexa_report", + ) + raise alexa_errors.RequireRelink from exception + except (AlexaApiNoTokenError, AlexaApiError) as exception: + raise alexa_errors.NoTokenAvailable from exception - self._token = body["access_token"] - self._endpoint = body["event_endpoint"] - self._token_valid = utcnow() + timedelta(seconds=body["expires_in"]) + self._token = details["access_token"] + self._endpoint = details["event_endpoint"] + self._token_valid = utcnow() + timedelta(seconds=details["expires_in"]) return self._token async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: diff --git a/homeassistant/components/foscam/config_flow.py b/homeassistant/components/foscam/config_flow.py index 562c3f42f8b210..93ec5f909c4a0e 100644 --- a/homeassistant/components/foscam/config_flow.py +++ b/homeassistant/components/foscam/config_flow.py @@ -26,7 +26,7 @@ STREAMS = ["Main", "Sub"] DEFAULT_PORT = 88 -DEFAULT_RTSP_PORT = 554 +DEFAULT_RTSP_PORT = 88 DATA_SCHEMA = vol.Schema( diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 29a74fdd2b4aed..d73833b1caef4b 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -11,7 +11,12 @@ "stream": "Stream" }, "data_description": { - "host": "The hostname or IP address of your Foscam camera." + "host": "The hostname or IP address of your Foscam camera.", + "port": "The port of your Foscam camera, default is 88.", + "username": "The username to log in to your Foscam camera.", + "password": "The password to log in to your Foscam camera.", + "rtsp_port": "The RTSP protocol port of the camera, used to pull the camera's real-time video stream. New model cameras only support RTSP ports 88 and 554, while old model cameras only support ports 88 and 65534.", + "stream": "Select the video stream type to pull. The main stream offers higher clarity but requires a better network environment." } } }, diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 152e629be2ed20..88f7d9017ab9e3 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -146,6 +146,20 @@ async def light_switch_options_schema( ) +LIGHT_CONFIG_SCHEMA = basic_group_config_schema("light").extend( + { + vol.Required(CONF_ALL, default=False): selector.BooleanSelector(), + } +) + + +SWITCH_CONFIG_SCHEMA = basic_group_config_schema("switch").extend( + { + vol.Required(CONF_ALL, default=False): selector.BooleanSelector(), + } +) + + GROUP_TYPES = [ "binary_sensor", "button", @@ -210,7 +224,7 @@ async def _set_group_type( validate_user_input=set_group_type("fan"), ), "light": SchemaFlowFormStep( - basic_group_config_schema("light"), + LIGHT_CONFIG_SCHEMA, preview="group", validate_user_input=set_group_type("light"), ), @@ -235,7 +249,7 @@ async def _set_group_type( validate_user_input=set_group_type("sensor"), ), "switch": SchemaFlowFormStep( - basic_group_config_schema("switch"), + SWITCH_CONFIG_SCHEMA, preview="group", validate_user_input=set_group_type("switch"), ), diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index bb9ab4b25d8fa3..8a9f4377a62466 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -66,9 +66,13 @@ "light": { "title": "[%key:component::group::config::step::user::title%]", "data": { + "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } }, "lock": { @@ -115,9 +119,13 @@ "switch": { "title": "[%key:component::group::config::step::user::title%]", "data": { + "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", "name": "[%key:common::config_flow::data::name%]" + }, + "data_description": { + "all": "[%key:component::group::config::step::binary_sensor::data_description::all%]" } } } diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 056ace7011cf1c..27a10738f483fc 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -146,7 +146,7 @@ async def async_step_reconfigure_sensor( """Reconfigure a sensor.""" if user_input is not None: title = user_input.pop("name") - return self.async_update_and_abort( + return self.async_update_reload_and_abort( self._get_entry(), self._get_reconfigure_subentry(), data=user_input, diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index be36569457969d..9b714fdf52dbc2 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -1,5 +1,6 @@ """Intents for the media_player integration.""" +import asyncio from collections.abc import Iterable from dataclasses import dataclass, field import logging @@ -14,21 +15,21 @@ SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_SET, + STATE_PLAYING, ) from homeassistant.core import Context, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent +from homeassistant.helpers.entity_component import EntityComponent -from . import ( +from . import MediaPlayerDeviceClass, MediaPlayerEntity +from .browse_media import SearchMedia +from .const import ( + ATTR_MEDIA_FILTER_CLASSES, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN, SERVICE_PLAY_MEDIA, SERVICE_SEARCH_MEDIA, - MediaPlayerDeviceClass, - SearchMedia, -) -from .const import ( - ATTR_MEDIA_FILTER_CLASSES, MediaClass, MediaPlayerEntityFeature, MediaPlayerState, @@ -39,6 +40,7 @@ INTENT_MEDIA_NEXT = "HassMediaNext" INTENT_MEDIA_PREVIOUS = "HassMediaPrevious" INTENT_SET_VOLUME = "HassSetVolume" +INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative" INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay" _LOGGER = logging.getLogger(__name__) @@ -127,6 +129,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: device_classes={MediaPlayerDeviceClass}, ), ) + intent.async_register(hass, MediaSetVolumeRelativeHandler()) intent.async_register(hass, MediaSearchAndPlayHandler()) @@ -354,3 +357,120 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse response.async_set_speech_slots({"media": first_result.as_dict()}) response.response_type = intent.IntentResponseType.ACTION_DONE return response + + +class MediaSetVolumeRelativeHandler(intent.IntentHandler): + """Handler for setting relative volume.""" + + description = "Increases or decreases the volume of a media player" + + intent_type = INTENT_SET_VOLUME_RELATIVE + slot_schema = { + vol.Required("volume_step"): vol.Any( + "up", + "down", + vol.All( + vol.Coerce(int), + vol.Range(min=-100, max=100), + lambda val: val / 100, + ), + ), + # Optional name/area/floor slots handled by intent matcher + vol.Optional("name"): cv.string, + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, + } + platforms = {DOMAIN} + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + hass = intent_obj.hass + component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] + + slots = self.async_validate_slots(intent_obj.slots) + volume_step = slots["volume_step"]["value"] + + # Entity name to match + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + + # Get area/floor info + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + + floor_slot = slots.get("floor", {}) + floor_id = floor_slot.get("value") + + # Find matching entities + match_constraints = intent.MatchTargetsConstraints( + name=entity_name, + area_name=area_id, + floor_name=floor_id, + domains={DOMAIN}, + assistant=intent_obj.assistant, + features=MediaPlayerEntityFeature.VOLUME_SET, + ) + match_preferences = intent.MatchTargetsPreferences( + area_id=slots.get("preferred_area_id", {}).get("value"), + floor_id=slots.get("preferred_floor_id", {}).get("value"), + ) + match_result = intent.async_match_targets( + hass, match_constraints, match_preferences + ) + + if not match_result.is_match: + # No targets + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + + if ( + match_result.is_match + and (len(match_result.states) > 1) + and ("name" not in intent_obj.slots) + ): + # Multiple targets not by name, so we need to check state + match_result.states = [ + s for s in match_result.states if s.state == STATE_PLAYING + ] + if not match_result.states: + # No media players are playing + raise intent.MatchFailedError( + result=intent.MatchTargetsResult( + is_match=False, no_match_reason=intent.MatchFailedReason.STATE + ), + constraints=match_constraints, + preferences=match_preferences, + ) + + target_entity_ids = {s.entity_id for s in match_result.states} + target_entities = [ + e for e in component.entities if e.entity_id in target_entity_ids + ] + + if volume_step == "up": + coros = [e.async_volume_up() for e in target_entities] + elif volume_step == "down": + coros = [e.async_volume_down() for e in target_entities] + else: + coros = [ + e.async_set_volume_level( + max(0.0, min(1.0, e.volume_level + volume_step)) + ) + for e in target_entities + ] + + try: + await asyncio.gather(*coros) + except HomeAssistantError as err: + _LOGGER.error("Error setting relative volume: %s", err) + raise intent.IntentHandleError( + f"Error setting relative volume: {err}" + ) from err + + response = intent_obj.create_response() + response.response_type = intent.IntentResponseType.ACTION_DONE + response.async_set_states(match_result.states) + return response diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index bfb74d621c3948..373814cae9ae92 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -88,7 +88,7 @@ class NumberDeviceClass(StrEnum): APPARENT_POWER = "apparent_power" """Apparent power. - Unit of measurement: `VA` + Unit of measurement: `mVA`, `VA` """ AQI = "aqi" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7326519b14ebd3..20fd1a3ce28f76 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -42,6 +42,7 @@ from homeassistant.util.collection import chunked_or_all from homeassistant.util.enum import try_parse_enum from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -193,6 +194,7 @@ def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]: STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { + **dict.fromkeys(ApparentPowerConverter.VALID_UNITS, ApparentPowerConverter), **dict.fromkeys(AreaConverter.VALID_UNITS, AreaConverter), **dict.fromkeys( BloodGlucoseConcentrationConverter.VALID_UNITS, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index d052631c5f6543..310e2fc85c58ee 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,6 +16,7 @@ from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, @@ -59,6 +60,7 @@ UNIT_SCHEMA = vol.Schema( { + vol.Optional("apparent_power"): vol.In(ApparentPowerConverter.VALID_UNITS), vol.Optional("area"): vol.In(AreaConverter.VALID_UNITS), vol.Optional("blood_glucose_concentration"): vol.In( BloodGlucoseConcentrationConverter.VALID_UNITS diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 5f9d5ec9ca09dc..251a233e1fa6cf 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -46,6 +46,7 @@ UnitOfVolumetricFlux, ) from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -117,7 +118,7 @@ class SensorDeviceClass(StrEnum): APPARENT_POWER = "apparent_power" """Apparent power. - Unit of measurement: `VA` + Unit of measurement: `mVA`, `VA` """ AQI = "aqi" @@ -528,6 +529,7 @@ class SensorStateClass(StrEnum): STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { + SensorDeviceClass.APPARENT_POWER: ApparentPowerConverter, SensorDeviceClass.ABSOLUTE_HUMIDITY: MassVolumeConcentrationConverter, SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index c69bf99eff0c14..a8d06f8c0e927f 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -25,7 +25,7 @@ "is_illuminance": "Current {entity_name} illuminance", "is_irradiance": "Current {entity_name} irradiance", "is_moisture": "Current {entity_name} moisture", - "is_monetary": "Current {entity_name} balance", + "is_monetary": "Current {entity_name} monetary balance", "is_nitrogen_dioxide": "Current {entity_name} nitrogen dioxide concentration level", "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", @@ -81,7 +81,7 @@ "illuminance": "{entity_name} illuminance changes", "irradiance": "{entity_name} irradiance changes", "moisture": "{entity_name} moisture changes", - "monetary": "{entity_name} balance changes", + "monetary": "{entity_name} monetary balance changes", "nitrogen_dioxide": "{entity_name} nitrogen dioxide concentration changes", "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", @@ -223,7 +223,7 @@ "name": "Moisture" }, "monetary": { - "name": "Balance" + "name": "Monetary balance" }, "nitrogen_dioxide": { "name": "Nitrogen dioxide" diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json index b47947ab0e019e..0db11c5b086bef 100644 --- a/homeassistant/components/snoo/manifest.json +++ b/homeassistant/components/snoo/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["snoo"], "quality_scale": "bronze", - "requirements": ["python-snoo==0.7.0"] + "requirements": ["python-snoo==0.8.1"] } diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index fba42ad76cf027..12b6b11a297755 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -26,6 +26,16 @@ from .models import EnumTypeData, IntegerTypeData from .util import get_dpcode +_DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,) +_OSCILLATE_DPCODES = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) +_SPEED_DPCODES = ( + DPCode.FAN_SPEED_PERCENT, + DPCode.FAN_SPEED, + DPCode.SPEED, + DPCode.FAN_SPEED_ENUM, +) +_SWITCH_DPCODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) + TUYA_SUPPORT_TYPE = { # Dehumidifier # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha @@ -47,6 +57,19 @@ } +def _has_a_valid_dpcode(device: CustomerDevice) -> bool: + """Check if the device has at least one valid DP code.""" + properties_to_check: list[DPCode | tuple[DPCode, ...] | None] = [ + # Main control switch + _SWITCH_DPCODES, + # Other properties + _SPEED_DPCODES, + _OSCILLATE_DPCODES, + _DIRECTION_DPCODES, + ] + return any(get_dpcode(device, code) for code in properties_to_check) + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -61,7 +84,7 @@ def async_discover_device(device_ids: list[str]) -> None: entities: list[TuyaFanEntity] = [] for device_id in device_ids: device = hass_data.manager.device_map[device_id] - if device and device.category in TUYA_SUPPORT_TYPE: + if device.category in TUYA_SUPPORT_TYPE and _has_a_valid_dpcode(device): entities.append(TuyaFanEntity(device, hass_data.manager)) async_add_entities(entities) @@ -91,9 +114,7 @@ def __init__( """Init Tuya Fan Device.""" super().__init__(device, device_manager) - self._switch = get_dpcode( - self.device, (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) - ) + self._switch = get_dpcode(self.device, _SWITCH_DPCODES) self._attr_preset_modes = [] if enum_type := self.find_dpcode( @@ -104,31 +125,23 @@ def __init__( self._attr_preset_modes = enum_type.range # Find speed controls, can be either percentage or a set of speeds - dpcodes = ( - DPCode.FAN_SPEED_PERCENT, - DPCode.FAN_SPEED, - DPCode.SPEED, - DPCode.FAN_SPEED_ENUM, - ) if int_type := self.find_dpcode( - dpcodes, dptype=DPType.INTEGER, prefer_function=True + _SPEED_DPCODES, dptype=DPType.INTEGER, prefer_function=True ): self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speed = int_type elif enum_type := self.find_dpcode( - dpcodes, dptype=DPType.ENUM, prefer_function=True + _SPEED_DPCODES, dptype=DPType.ENUM, prefer_function=True ): self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speeds = enum_type - if dpcode := get_dpcode( - self.device, (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) - ): + if dpcode := get_dpcode(self.device, _OSCILLATE_DPCODES): self._oscillate = dpcode self._attr_supported_features |= FanEntityFeature.OSCILLATE if enum_type := self.find_dpcode( - DPCode.FAN_DIRECTION, dptype=DPType.ENUM, prefer_function=True + _DIRECTION_DPCODES, dptype=DPType.ENUM, prefer_function=True ): self._direction = enum_type self._attr_supported_features |= FanEntityFeature.DIRECTION diff --git a/homeassistant/components/volvo/sensor.py b/homeassistant/components/volvo/sensor.py index a067549f0682a1..7f37ac42dc8c3e 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -107,6 +107,7 @@ def _charging_power_status_value(field: VolvoCarsValue) -> str | None: "power_saving_mode", ], value_fn=_availability_status, + entity_category=EntityCategory.DIAGNOSTIC, ), # statistics endpoint VolvoSensorDescription( diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index da8e73d9566a55..f5ccf9c314357e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3385,6 +3385,34 @@ def async_create_entry( return result + @callback + def _async_update( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + ) -> bool: + """Update config subentry and return result. + + Internal to be used by update_and_abort and update_reload_and_abort methods only. + """ + + if data_updates is not UNDEFINED: + if data is not UNDEFINED: + raise ValueError("Cannot set both data and data_updates") + data = subentry.data | data_updates + return self.hass.config_entries.async_update_subentry( + entry=entry, + subentry=subentry, + unique_id=unique_id, + title=title, + data=data, + ) + @callback def async_update_and_abort( self, @@ -3404,19 +3432,52 @@ def async_update_and_abort( :param title: replace the title of the subentry :param unique_id: replace the unique_id of the subentry """ - if data_updates is not UNDEFINED: - if data is not UNDEFINED: - raise ValueError("Cannot set both data and data_updates") - data = subentry.data | data_updates - self.hass.config_entries.async_update_subentry( + self._async_update( entry=entry, subentry=subentry, unique_id=unique_id, title=title, data=data, + data_updates=data_updates, ) return self.async_abort(reason="reconfigure_successful") + @callback + def async_update_reload_and_abort( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + *, + unique_id: str | None | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + data: Mapping[str, Any] | UndefinedType = UNDEFINED, + data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED, + reload_even_if_entry_is_unchanged: bool = True, + ) -> SubentryFlowResult: + """Update config subentry, reload config entry and finish subentry flow. + + :param data: replace the subentry data with new data + :param data_updates: add items from data_updates to subentry data - existing + keys are overridden + :param title: replace the title of the subentry + :param unique_id: replace the unique_id of the subentry + :param reload_even_if_entry_is_unchanged: set this to `False` if the entry + should not be reloaded if it is unchanged + """ + result = self._async_update( + entry=entry, + subentry=subentry, + unique_id=unique_id, + title=title, + data=data, + data_updates=data_updates, + ) + if reload_even_if_entry_is_unchanged or result: + if entry.update_listeners: + raise ValueError("Cannot update and reload entry with update listeners") + self.hass.config_entries.async_schedule_reload(entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + @property def _entry_id(self) -> str: """Return config entry id.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index b678e02569c9b4..8e340d8468bb03 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -588,6 +588,7 @@ class Platform(StrEnum): class UnitOfApparentPower(StrEnum): """Apparent power units.""" + MILLIVOLT_AMPERE = "mVA" VOLT_AMPERE = "VA" diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 176bcfcd7c4ab0..8af9124920006e 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -12,39 +12,7 @@ import orjson from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic -from homeassistant.util.json import ( - JSON_DECODE_EXCEPTIONS as _JSON_DECODE_EXCEPTIONS, - JSON_ENCODE_EXCEPTIONS as _JSON_ENCODE_EXCEPTIONS, - SerializationError, - format_unserializable_data, - json_loads as _json_loads, -) - -from .deprecation import ( - DeprecatedConstant, - all_with_deprecated_constants, - check_if_deprecated_constant, - deprecated_function, - dir_with_deprecated_constants, -) - -_DEPRECATED_JSON_DECODE_EXCEPTIONS = DeprecatedConstant( - _JSON_DECODE_EXCEPTIONS, "homeassistant.util.json.JSON_DECODE_EXCEPTIONS", "2025.8" -) -_DEPRECATED_JSON_ENCODE_EXCEPTIONS = DeprecatedConstant( - _JSON_ENCODE_EXCEPTIONS, "homeassistant.util.json.JSON_ENCODE_EXCEPTIONS", "2025.8" -) -json_loads = deprecated_function( - "homeassistant.util.json.json_loads", breaks_in_ha_version="2025.8" -)(_json_loads) - -# These can be removed if no deprecated constant are in this module anymore -__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) -__dir__ = partial( - dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] -) -__all__ = all_with_deprecated_constants(globals()) - +from homeassistant.util.json import SerializationError, format_unserializable_data _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 5bde108dfc1590..610cf5db7a9a05 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -14,6 +14,7 @@ CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, + UnitOfApparentPower, UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, @@ -382,6 +383,20 @@ class MassConverter(BaseUnitConverter): } +class ApparentPowerConverter(BaseUnitConverter): + """Utility to convert apparent power values.""" + + UNIT_CLASS = "apparent_power" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfApparentPower.MILLIVOLT_AMPERE: 1 * 1000, + UnitOfApparentPower.VOLT_AMPERE: 1, + } + VALID_UNITS = { + UnitOfApparentPower.MILLIVOLT_AMPERE, + UnitOfApparentPower.VOLT_AMPERE, + } + + class PowerConverter(BaseUnitConverter): """Utility to convert power values.""" diff --git a/requirements_all.txt b/requirements_all.txt index e9a3aab5087f04..7bfc45ae5227a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2506,7 +2506,7 @@ python-roborock==2.18.2 python-smarttub==0.0.44 # homeassistant.components.snoo -python-snoo==0.7.0 +python-snoo==0.8.1 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f60bb9b617536..3cc54a40ab890e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2076,7 +2076,7 @@ python-roborock==2.18.2 python-smarttub==0.0.44 # homeassistant.components.snoo -python-snoo==0.7.0 +python-snoo==0.8.1 # homeassistant.components.songpal python-songpal==0.16.2 diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index ef7a99453f08af..7fc8c73785bfc9 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -3,6 +3,11 @@ import contextlib from unittest.mock import AsyncMock, Mock, patch +from hass_nabucasa.alexa_api import ( + AlexaApiError, + AlexaApiNeedsRelinkError, + AlexaApiNoTokenError, +) import pytest from homeassistant.components.alexa import errors @@ -195,30 +200,40 @@ async def test_alexa_config_invalidate_token( servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), + alexa_api=Mock( + access_token=AsyncMock( + return_value={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + } + ) + ), ), ) token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 1 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 1 token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 1 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 1 assert conf._token_valid is not None conf.async_invalidate_access_token() assert conf._token_valid is None token = await conf.async_get_access_token() assert token == "mock-token" - assert len(aioclient_mock.mock_calls) == 2 + assert len(conf._cloud.alexa_api.access_token.mock_calls) == 2 @pytest.mark.parametrize( - ("reject_reason", "expected_exception"), + ("lib_exception", "expected_exception"), [ - ("RefreshTokenNotFound", errors.RequireRelink), - ("UnknownRegion", errors.RequireRelink), - ("OtherReason", errors.NoTokenAvailable), + (AlexaApiNeedsRelinkError("Needs relink"), errors.RequireRelink), + (AlexaApiNeedsRelinkError("UnknownRegion"), errors.RequireRelink), + (AlexaApiNoTokenError("OtherReason"), errors.NoTokenAvailable), + (AlexaApiError("OtherReason"), errors.NoTokenAvailable), ], ) async def test_alexa_config_fail_refresh_token( @@ -226,7 +241,7 @@ async def test_alexa_config_fail_refresh_token( cloud_prefs: CloudPreferences, aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, - reject_reason: str, + lib_exception: Exception, expected_exception: type[Exception], ) -> None: """Test Alexa config failing to refresh token.""" @@ -259,6 +274,15 @@ async def test_alexa_config_fail_refresh_token( servicehandlers_server="example", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), + alexa_api=Mock( + access_token=AsyncMock( + return_value={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + } + ) + ), ), ) await conf.async_initialize() @@ -284,12 +308,7 @@ async def test_alexa_config_fail_refresh_token( # Invalidate the token and try to fetch another conf.async_invalidate_access_token() - aioclient_mock.clear_requests() - aioclient_mock.post( - "https://example/alexa/access_token", - json={"reason": reject_reason}, - status=400, - ) + conf._cloud.alexa_api.access_token.side_effect = lib_exception # Change states to trigger event listener hass.states.async_set(entity_entry.entity_id, "off") @@ -310,15 +329,8 @@ async def test_alexa_config_fail_refresh_token( # Simulate we're again authorized and token update succeeds # State reporting should now be re-enabled for Alexa - aioclient_mock.clear_requests() - aioclient_mock.post( - "https://example/alexa/access_token", - json={ - "access_token": "mock-token", - "event_endpoint": "http://example.com/alexa_endpoint", - "expires_in": 30, - }, - ) + conf._cloud.alexa_api.access_token.side_effect = None + await conf.set_authorized(True) assert cloud_prefs.alexa_report_state is True assert conf.should_report_state is True diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 322e6ebdad0e53..b1bb6e5d7bb4e7 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -44,7 +44,8 @@ {}, ), ("fan", "on", "on", {}, {}, {}, {}), - ("light", "on", "on", {}, {}, {}, {}), + ("light", "on", "on", {}, {}, {"all": False}, {}), + ("light", "on", "on", {}, {"all": True}, {"all": True}, {}), ("lock", "locked", "locked", {}, {}, {}, {}), ("notify", STATE_UNKNOWN, "2021-01-01T23:59:59.123+00:00", {}, {}, {}, {}), ("media_player", "on", "on", {}, {}, {}, {}), @@ -57,7 +58,8 @@ {"type": "sum"}, {}, ), - ("switch", "on", "on", {}, {}, {}, {}), + ("switch", "on", "on", {}, {}, {"all": False}, {}), + ("switch", "on", "on", {}, {"all": True}, {"all": True}, {}), ], ) async def test_config_flow( diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index d1dc03ed12a524..2b5853198261ba 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -1,5 +1,8 @@ """The tests for the media_player platform.""" +import math +from unittest.mock import patch + import pytest from homeassistant.components.media_player import ( @@ -13,12 +16,17 @@ SERVICE_VOLUME_SET, BrowseMedia, MediaClass, + MediaPlayerEntity, MediaType, SearchMedia, intent as media_player_intent, ) -from homeassistant.components.media_player.const import MediaPlayerEntityFeature +from homeassistant.components.media_player.const import ( + MediaPlayerEntityFeature, + MediaPlayerState, +) from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, STATE_IDLE, STATE_PAUSED, @@ -32,8 +40,10 @@ floor_registry as fr, intent, ) +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.setup import async_setup_component -from tests.common import async_mock_service +from tests.common import MockEntityPlatform, async_mock_service async def test_pause_media_player_intent(hass: HomeAssistant) -> None: @@ -873,3 +883,165 @@ async def test_search_and_play_media_player_intent_with_media_class( "media_class": {"value": "invalid_class"}, }, ) + + +@pytest.mark.parametrize( + ("direction", "volume_change", "volume_change_int"), + [("up", 0.1, 20), ("down", -0.1, -20)], +) +async def test_volume_relative_media_player_intent( + hass: HomeAssistant, direction: str, volume_change: float, volume_change_int: int +) -> None: + """Test relative volume intents for media players.""" + assert await async_setup_component(hass, DOMAIN, {}) + await media_player_intent.async_setup_intents(hass) + + component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] + + default_volume = 0.5 + + class VolumeTestMediaPlayer(MediaPlayerEntity): + _attr_supported_features = MediaPlayerEntityFeature.VOLUME_SET + _attr_volume_level = default_volume + _attr_volume_step = 0.1 + _attr_state = MediaPlayerState.IDLE + + async def async_set_volume_level(self, volume): + self._attr_volume_level = volume + + idle_entity = VolumeTestMediaPlayer() + idle_entity.hass = hass + idle_entity.platform = MockEntityPlatform(hass) + idle_entity.entity_id = f"{DOMAIN}.idle_media_player" + await component.async_add_entities([idle_entity]) + + hass.states.async_set( + idle_entity.entity_id, + STATE_IDLE, + attributes={ + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET, + ATTR_FRIENDLY_NAME: "Idle Media Player", + }, + ) + + idle_expected_volume = default_volume + + # Only 1 media player is present, so it's targeted even though its idle + assert idle_entity.volume_level is not None + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + idle_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + + # Multiple media players (playing one should be targeted) + playing_entity = VolumeTestMediaPlayer() + playing_entity.hass = hass + playing_entity.platform = MockEntityPlatform(hass) + playing_entity.entity_id = f"{DOMAIN}.playing_media_player" + await component.async_add_entities([playing_entity]) + + hass.states.async_set( + playing_entity.entity_id, + STATE_PLAYING, + attributes={ + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET, + ATTR_FRIENDLY_NAME: "Playing Media Player", + }, + ) + + playing_expected_volume = default_volume + assert playing_entity.volume_level is not None + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + playing_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # We can still target by name even if the media player is idle + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}, "name": {"value": "Idle media player"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + idle_expected_volume += volume_change + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # Set relative volume by percent + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": volume_change_int}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + playing_expected_volume += volume_change_int / 100 + assert math.isclose(idle_entity.volume_level, idle_expected_volume) + assert math.isclose(playing_entity.volume_level, playing_expected_volume) + + # Test error in method + with ( + patch.object( + playing_entity, "async_volume_up", side_effect=RuntimeError("boom!") + ), + pytest.raises(intent.IntentError), + ): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": "up"}}, + ) + + # Multiple idle media players should not match + hass.states.async_set( + playing_entity.entity_id, + STATE_IDLE, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_SET}, + ) + + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) + + # Test feature not supported + for entity_id in (idle_entity.entity_id, playing_entity.entity_id): + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_SET_VOLUME_RELATIVE, + {"volume_step": {"value": direction}}, + ) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 48b024e0b10688..f8134a515e0cc9 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -43,7 +43,6 @@ TEST_NVR_NAME = "test_reolink_name" TEST_CAM_NAME = "test_reolink_cam" TEST_NVR_NAME2 = "test2_reolink_name" -TEST_CAM_NAME = "test_reolink_cam" TEST_USE_HTTPS = True TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" @@ -117,7 +116,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.supported.return_value = True host_mock.item_number.return_value = TEST_ITEM_NUMBER host_mock.camera_model.return_value = TEST_CAM_MODEL - host_mock.camera_name.return_value = TEST_NVR_NAME + host_mock.camera_name.return_value = TEST_CAM_NAME host_mock.camera_hardware_version.return_value = "IPC_00001" host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.camera_sw_version_update_required.return_value = False diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index e6275a2108ec93..4bbe222fad6d39 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant -from .conftest import TEST_DUO_MODEL, TEST_HOST_MODEL, TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_DUO_MODEL, TEST_HOST_MODEL from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -31,7 +31,7 @@ async def test_motion_sensor( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion_lens_0" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_motion_lens_0" assert hass.states.get(entity_id).state == STATE_ON reolink_host.motion_detected.return_value = False @@ -66,7 +66,7 @@ async def test_smart_ai_sensor( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_crossline_zone1_person" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_crossline_zone1_person" assert hass.states.get(entity_id).state == STATE_ON reolink_host.baichuan.smart_ai_state.return_value = False @@ -106,7 +106,7 @@ def register_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_motion" assert hass.states.get(entity_id).state == STATE_ON # simulate a TCP push callback diff --git a/tests/components/reolink/test_button.py b/tests/components/reolink/test_button.py index ee51d0f0b99494..1e773491938d02 100644 --- a/tests/components/reolink/test_button.py +++ b/tests/components/reolink/test_button.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -29,7 +29,7 @@ async def test_button( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BUTTON}.{TEST_NVR_NAME}_ptz_up" + entity_id = f"{Platform.BUTTON}.{TEST_CAM_NAME}_ptz_up" await hass.services.async_call( BUTTON_DOMAIN, @@ -60,7 +60,7 @@ async def test_ptz_move_service( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BUTTON}.{TEST_NVR_NAME}_ptz_up" + entity_id = f"{Platform.BUTTON}.{TEST_CAM_NAME}_ptz_up" await hass.services.async_call( DOMAIN, diff --git a/tests/components/reolink/test_camera.py b/tests/components/reolink/test_camera.py index 4ab43de225f5b7..99236526070efa 100644 --- a/tests/components/reolink/test_camera.py +++ b/tests/components/reolink/test_camera.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_DUO_MODEL, TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_DUO_MODEL from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -33,7 +33,7 @@ async def test_camera( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.CAMERA}.{TEST_NVR_NAME}_fluent" + entity_id = f"{Platform.CAMERA}.{TEST_CAM_NAME}_fluent" assert hass.states.get(entity_id).state == CameraState.IDLE # check getting a image from the camera @@ -63,5 +63,5 @@ async def test_camera_no_stream_source( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.CAMERA}.{TEST_NVR_NAME}_snapshots_fluent_lens_0" + entity_id = f"{Platform.CAMERA}.{TEST_CAM_NAME}_snapshots_fluent_lens_0" assert hass.states.get(entity_id).state == CameraState.IDLE diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index 6ae7c66704c627..194d038a32a12f 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -28,7 +28,7 @@ from homeassistant.helpers.network import NoURLAvailableError from homeassistant.util.aiohttp import MockRequest -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -92,7 +92,7 @@ async def test_webhook_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion" + entity_id = f"{Platform.BINARY_SENSOR}.{TEST_CAM_NAME}_motion" webhook_id = config_entry.runtime_data.host.webhook_id unique_id = config_entry.runtime_data.host.unique_id diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 10eefccace90c5..662469ebc01685 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -52,6 +52,7 @@ DEFAULT_PROTOCOL, TEST_BC_PORT, TEST_CAM_MODEL, + TEST_CAM_NAME, TEST_HOST, TEST_HOST_MODEL, TEST_MAC, @@ -1034,7 +1035,7 @@ def register_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # simulate a TCP push callback signaling a privacy mode change @@ -1106,7 +1107,7 @@ def register_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_ON reolink_host.sleeping.return_value = False diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index c3655ec00dfdd9..80a0a7abeab664 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -45,7 +45,7 @@ async def test_light_state( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -63,7 +63,7 @@ async def test_light_turn_off( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" await hass.services.async_call( LIGHT_DOMAIN, @@ -94,7 +94,7 @@ async def test_light_turn_on( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" await hass.services.async_call( LIGHT_DOMAIN, @@ -128,7 +128,7 @@ async def test_light_turn_on_errors( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight" reolink_host.set_whiteled.side_effect = exception with pytest.raises(HomeAssistantError): diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 0308639499ce92..c8bc8fd9c70c4e 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -34,10 +34,10 @@ from .conftest import ( TEST_BC_PORT, + TEST_CAM_NAME, TEST_HOST2, TEST_HOST_MODEL, TEST_MAC2, - TEST_NVR_NAME, TEST_NVR_NAME2, TEST_PASSWORD2, TEST_PORT, @@ -61,7 +61,6 @@ TEST_FILE_NAME_MP4 = f"{TEST_START}00.mp4" TEST_STREAM = "main" TEST_CHANNEL = "0" -TEST_CAM_NAME = "Cam new name" TEST_MIME_TYPE = "application/x-mpegURL" TEST_MIME_TYPE_MP4 = "video/mp4" @@ -172,7 +171,7 @@ async def test_browsing( browse_res_AT_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|autotrack_sub" browse_res_AT_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|autotrack_main" assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0" + assert browse.title == f"{TEST_CAM_NAME} lens 0" assert browse.identifier == browse_resolution_id assert browse.children[0].identifier == browse_res_sub_id assert browse.children[1].identifier == browse_res_main_id @@ -188,19 +187,19 @@ async def test_browsing( browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_sub_id}") assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Low res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 Low res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_sub_id}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Telephoto low res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 Telephoto low res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_main_id}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Telephoto high res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 Telephoto high res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_main_id}" @@ -210,7 +209,7 @@ async def test_browsing( browse_day_0_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" browse_day_1_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 High res." + assert browse.title == f"{TEST_CAM_NAME} lens 0 High res." assert browse.identifier == browse_days_id assert browse.children[0].identifier == browse_day_0_id assert browse.children[1].identifier == browse_day_1_id @@ -232,7 +231,7 @@ async def test_browsing( assert browse.domain == DOMAIN assert ( browse.title - == f"{TEST_NVR_NAME} lens 0 High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY}" + == f"{TEST_CAM_NAME} lens 0 High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY}" ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id @@ -261,7 +260,7 @@ async def test_browsing( assert browse.domain == DOMAIN assert ( browse.title - == f"{TEST_NVR_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY} Person" + == f"{TEST_CAM_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY} Person" ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id @@ -306,7 +305,7 @@ async def test_browsing_h265_encoding( browse_res_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|main" assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME}" + assert browse.title == f"{TEST_CAM_NAME}" assert browse.identifier == browse_resolution_id assert browse.children[0].identifier == browse_res_sub_id assert browse.children[1].identifier == browse_res_main_id @@ -321,7 +320,7 @@ async def test_browsing_h265_encoding( f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} Low res." + assert browse.title == f"{TEST_CAM_NAME} Low res." assert browse.identifier == browse_days_id assert browse.children[0].identifier == browse_day_0_id assert browse.children[1].identifier == browse_day_1_id diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index 17fc279747975a..853edeefa5a1f9 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ async def test_number( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" + entity_id = f"{Platform.NUMBER}.{TEST_CAM_NAME}_volume" assert hass.states.get(entity_id).state == "80" @@ -79,7 +79,7 @@ async def test_smart_ai_number( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_AI_crossline_zone1_sensitivity" + entity_id = f"{Platform.NUMBER}.{TEST_CAM_NAME}_AI_crossline_zone1_sensitivity" assert hass.states.get(entity_id).state == "80" diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index fb0f98a6e31128..5dcce74751860b 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -20,7 +20,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -38,7 +38,7 @@ async def test_floodlight_mode_select( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_floodlight_mode" + entity_id = f"{Platform.SELECT}.{TEST_CAM_NAME}_floodlight_mode" assert hass.states.get(entity_id).state == "auto" await hass.services.async_call( @@ -88,7 +88,7 @@ async def test_play_quick_reply_message( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_play_quick_reply_message" + entity_id = f"{Platform.SELECT}.{TEST_CAM_NAME}_play_quick_reply_message" assert hass.states.get(entity_id).state == STATE_UNKNOWN await hass.services.async_call( diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py index 9b32f70a9bd62c..9049d5906fcab7 100644 --- a/tests/components/reolink/test_sensor.py +++ b/tests/components/reolink/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME from tests.common import MockConfigEntry @@ -31,10 +31,10 @@ async def test_sensors( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_ptz_pan_position" + entity_id = f"{Platform.SENSOR}.{TEST_CAM_NAME}_ptz_pan_position" assert hass.states.get(entity_id).state == "1200" - entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_wi_fi_signal" + entity_id = f"{Platform.SENSOR}.{TEST_CAM_NAME}_wi_fi_signal" assert hass.states.get(entity_id).state == "-55" entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_sd_0_storage" diff --git a/tests/components/reolink/test_siren.py b/tests/components/reolink/test_siren.py index 43156626b12a4f..47e0e47e57f2b7 100644 --- a/tests/components/reolink/test_siren.py +++ b/tests/components/reolink/test_siren.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_NAME from tests.common import MockConfigEntry @@ -38,7 +38,7 @@ async def test_siren( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + entity_id = f"{Platform.SIREN}.{TEST_CAM_NAME}_siren" assert hass.states.get(entity_id).state == STATE_UNKNOWN # test siren turn on @@ -98,7 +98,7 @@ async def test_siren_turn_on_errors( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + entity_id = f"{Platform.SIREN}.{TEST_CAM_NAME}_siren" original = getattr(reolink_host, attr) setattr(reolink_host, attr, value) @@ -124,7 +124,7 @@ async def test_siren_turn_off_errors( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + entity_id = f"{Platform.SIREN}.{TEST_CAM_NAME}_siren" reolink_host.set_siren.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index 9c0f2295a20142..c8a38f19d5ca8f 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -36,7 +36,6 @@ async def test_switch( reolink_host: MagicMock, ) -> None: """Test switch entity.""" - reolink_host.camera_name.return_value = TEST_CAM_NAME reolink_host.audio_record.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): @@ -108,7 +107,6 @@ async def test_host_switch( reolink_host: MagicMock, ) -> None: """Test host switch entity.""" - reolink_host.camera_name.return_value = TEST_CAM_NAME reolink_host.email_enabled.return_value = True reolink_host.is_hub = False reolink_host.supported.return_value = True diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index d12b229e932f24..ce24734f9c1c31 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -34,8 +34,6 @@ async def test_no_update( entity_name: str, ) -> None: """Test update state when no update available.""" - 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) await hass.async_block_till_done() @@ -53,7 +51,6 @@ async def test_update_str( entity_name: str, ) -> None: """Test update state when update available with string from API.""" - 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]): @@ -75,7 +72,6 @@ async def test_update_firm( entity_name: str, ) -> None: """Test update state when update available with firmware info from reolink.com.""" - 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( @@ -174,7 +170,6 @@ async def test_update_firm_keeps_available( entity_name: str, ) -> None: """Test update entity keeps being available during update.""" - 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", diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index 8b730bc708b9c3..1ebeaf902c847e 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -31,7 +31,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr -from .conftest import TEST_NVR_NAME, TEST_UID, TEST_UID_CAM +from .conftest import TEST_CAM_NAME, TEST_UID, TEST_UID_CAM from tests.common import MockConfigEntry @@ -115,7 +115,7 @@ async def test_try_function( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" + entity_id = f"{Platform.NUMBER}.{TEST_CAM_NAME}_volume" reolink_host.set_volume.side_effect = side_effect with pytest.raises(expected.__class__) as err: diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 98fb9d6604abd4..ce21f6ea8ab0c1 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2958,7 +2958,6 @@ def test_device_class_units_are_complete() -> None: def test_device_class_converters_are_complete() -> None: """Test that the device class converters enum is complete.""" no_converter_device_classes = { - SensorDeviceClass.APPARENT_POWER, SensorDeviceClass.AQI, SensorDeviceClass.BATTERY, SensorDeviceClass.CO, diff --git a/tests/components/snoo/const.py b/tests/components/snoo/const.py index 2657048afb8df0..cd52679caf956b 100644 --- a/tests/components/snoo/const.py +++ b/tests/components/snoo/const.py @@ -31,7 +31,12 @@ "name": "Test Snoo", "presence": {}, "presenceIoT": {}, - "awsIoT": {}, + "awsIoT": { + "awsRegion": "us-east-1", + "clientEndpoint": "z00023244d7fia4appr4b-ats.iot.us-east-1.amazonaws.com", + "clientReady": True, + "thingName": "676cbbe74529f85038b2e623_5831231335004715141_prod", + }, "lastSSID": {}, "provisionedAt": "random_time", } diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index c6230eb93448a0..6831e4139c2a44 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -167,6 +167,17 @@ async def async_autosetup_sonos(async_setup_sonos): await async_setup_sonos() +def reset_sonos_alarms(alarm_event: SonosMockEvent) -> None: + """Reset the Sonos alarms to a known state.""" + sonos_alarms = Alarms() + sonos_alarms.alarms = {} + sonos_alarms._last_zone_used = None + sonos_alarms._last_alarm_list_version = None + sonos_alarms.last_uid = None + sonos_alarms.last_id = 0 + alarm_event.variables["alarm_list_version"] = "RINCON_test:0" + + @pytest.fixture def async_setup_sonos( hass: HomeAssistant, config_entry: MockConfigEntry, fire_zgs_event, alarm_event @@ -175,9 +186,7 @@ def async_setup_sonos( async def _wrapper(): config_entry.add_to_hass(hass) - sonos_alarms = Alarms() - sonos_alarms.last_alarm_list_version = "RINCON_test:0" - alarm_event.variables["alarm_list_version"] = "RINCON_test:0" + reset_sonos_alarms(alarm_event) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) await fire_zgs_event() @@ -851,11 +860,15 @@ async def _wrapper(): @pytest.fixture(name="sonos_setup_two_speakers") async def sonos_setup_two_speakers( - hass: HomeAssistant, soco_factory: SoCoMockFactory + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + alarm_event: SonosMockEvent, ) -> list[MockSoCo]: """Set up home assistant with two Sonos Speakers.""" soco_lr = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") soco_br = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + reset_sonos_alarms(alarm_event) + await async_setup_component( hass, DOMAIN, diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index f72abc364700aa..f2dd3478a904bc 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -271,8 +271,6 @@ async def test_alarm_create_delete( async def test_alarm_change_device( hass: HomeAssistant, - async_setup_sonos, - soco: MockSoCo, alarm_clock: SonosMockService, alarm_clock_extended: SonosMockService, alarm_event: SonosMockEvent, @@ -294,7 +292,7 @@ async def test_alarm_change_device( alarm_dict["CurrentAlarmList"] = alarm_dict["CurrentAlarmList"].replace( "RINCON_test", f"{soco_lr.uid}" ) - alarm_dict["CurrentAlarmListVersion"] = f"{soco_lr.uid}:900" + alarm_dict["CurrentAlarmListVersion"] = "RINCON_test:900" soco_lr.alarmClock.ListAlarms.return_value = alarm_dict soco_br = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") await async_setup_component( @@ -327,7 +325,7 @@ async def test_alarm_change_device( alarm_clock.ListAlarms.return_value = alarm_update # Update the alarm_list_version so it gets processed. - alarm_event.variables["alarm_list_version"] = f"{soco_br.uid}:1000" + alarm_event.variables["alarm_list_version"] = "RINCON_test:1000" alarm_update["CurrentAlarmListVersion"] = alarm_event.increment_variable( "alarm_list_version" ) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index a9087b92211afb..c48c99da9fad86 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -21,6 +21,7 @@ "clkg_nhyj64w2", # https://github.com/home-assistant/core/issues/136055 "co2bj_yrr3eiyiacm31ski", # https://github.com/home-assistant/core/issues/133173 "cobj_hcdy5zrq3ikzthws", # https://github.com/orgs/home-assistant/discussions/482 + "cs_ipmyy4nigpqcnd8q", # https://github.com/home-assistant/core/pull/148726 "cs_ka2wfrdoogpvgzfi", # https://github.com/home-assistant/core/issues/119865 "cs_qhxmvae667uap4zh", # https://github.com/home-assistant/core/issues/141278 "cs_vmxuxszzjwp5smli", # https://github.com/home-assistant/core/issues/119865 diff --git a/tests/components/tuya/fixtures/cs_ipmyy4nigpqcnd8q.json b/tests/components/tuya/fixtures/cs_ipmyy4nigpqcnd8q.json new file mode 100644 index 00000000000000..816c17e17c7447 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_ipmyy4nigpqcnd8q.json @@ -0,0 +1,40 @@ +{ + "name": "Pro Breeze 30L Compressor Dehumidifier", + "category": "cs", + "product_id": "ipmyy4nigpqcnd8q", + "product_name": "30L Dehumidifier with Max Extraction", + "online": true, + "function": { + "anion": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "anion": { + "type": "Boolean", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["0", "1", "2", "3", "4", "5"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 1440, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "anion": false, + "fault": 0, + "countdown_left": 0 + } +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 1474aa7cccd154..005420c205c921 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -204,126 +204,26 @@ 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'tuya.ilms5pwjzzsxuxmvsc', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[fan.dehumidifier-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Dehumidifier ', - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.dehumidifier', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_platform_setup_and_discovery[fan.dehumidifier_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.dehumidifier_2', - '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.2myxayqtud9aqbizsc', 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[fan.dehumidifier_2-state] +# name: test_platform_setup_and_discovery[fan.dehumidifier-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Dehumidifier', 'supported_features': , }), 'context': , - 'entity_id': 'fan.dehumidifier_2', + 'entity_id': 'fan.dehumidifier', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_platform_setup_and_discovery[fan.dryfix-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.dryfix', - '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': 0, - 'translation_key': None, - 'unique_id': 'tuya.hz4pau766eavmxhqsc', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[fan.dryfix-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'DryFix', - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.dryfix', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- # name: test_platform_setup_and_discovery[fan.hl400-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -556,53 +456,3 @@ 'state': 'off', }) # --- -# name: test_platform_setup_and_discovery[fan.ventilador_cama-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'fan', - 'entity_category': None, - 'entity_id': 'fan.ventilador_cama', - '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': 0, - 'translation_key': None, - 'unique_id': 'tuya.c1tfgunpf6optybisf', - 'unit_of_measurement': None, - }) -# --- -# name: test_platform_setup_and_discovery[fan.ventilador_cama-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Ventilador Cama', - 'supported_features': , - }), - 'context': , - 'entity_id': 'fan.ventilador_cama', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index a6d5b121f498ab..52ff63dac3656a 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -1601,7 +1601,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'Tower bladeless fan ', + 'model': 'Tower bladeless fan (unsupported)', 'model_id': 'ibytpo6fpnugft1c', 'name': 'Ventilador Cama', 'name_by_user': None, @@ -2593,7 +2593,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': '', + 'model': ' (unsupported)', 'model_id': 'qhxmvae667uap4zh', 'name': 'DryFix', 'name_by_user': None, @@ -2779,7 +2779,7 @@ 'labels': set({ }), 'manufacturer': 'Tuya', - 'model': 'the Smart Dry Plus™ Connect Dehumidifier ', + 'model': 'the Smart Dry Plus™ Connect Dehumidifier (unsupported)', 'model_id': 'vmxuxszzjwp5smli', 'name': 'Dehumidifier ', 'name_by_user': None, @@ -4122,6 +4122,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[q8dncqpgin4yympisc] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tuya', + 'q8dncqpgin4yympisc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': '30L Dehumidifier with Max Extraction', + 'model_id': 'ipmyy4nigpqcnd8q', + 'name': 'Pro Breeze 30L Compressor Dehumidifier', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[qi94v9dmdx4fkpncqld] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index d33c91118ef133..96d88a967ffaaa 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -5658,6 +5658,55 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[switch.pro_breeze_30l_compressor_dehumidifier_ionizer-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.pro_breeze_30l_compressor_dehumidifier_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:atom', + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.q8dncqpgin4yympiscanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[switch.pro_breeze_30l_compressor_dehumidifier_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pro Breeze 30L Compressor Dehumidifier Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.pro_breeze_30l_compressor_dehumidifier_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[switch.qt_switch_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/volvo/snapshots/test_sensor.ambr b/tests/components/volvo/snapshots/test_sensor.ambr index 29e7e1e72a5cf5..e218986517ab38 100644 --- a/tests/components/volvo/snapshots/test_sensor.ambr +++ b/tests/components/volvo/snapshots/test_sensor.ambr @@ -128,7 +128,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.volvo_ex30_car_connection', 'has_entity_name': True, 'hidden_by': None, @@ -1163,7 +1163,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.volvo_s90_car_connection', 'has_entity_name': True, 'hidden_by': None, @@ -1951,7 +1951,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.volvo_xc40_car_connection', 'has_entity_name': True, 'hidden_by': None, @@ -3151,7 +3151,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.volvo_xc60_car_connection', 'has_entity_name': True, 'hidden_by': None, @@ -4012,7 +4012,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.volvo_xc90_car_connection', 'has_entity_name': True, 'hidden_by': None, diff --git a/tests/helpers/test_discovery_flow.py b/tests/helpers/test_discovery_flow.py index dde0f209706639..2cb2bb38030afb 100644 --- a/tests/helpers/test_discovery_flow.py +++ b/tests/helpers/test_discovery_flow.py @@ -10,6 +10,7 @@ from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import discovery_flow, json as json_helper from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.util import json as json_util @pytest.fixture @@ -151,6 +152,6 @@ def test_discovery_key_serialize_deserialize(key: str | tuple[str]) -> None: ) serialized = json_helper.json_dumps(discovery_key_1) assert ( - discovery_flow.DiscoveryKey.from_json_dict(json_helper.json_loads(serialized)) + discovery_flow.DiscoveryKey.from_json_dict(json_util.json_loads(serialized)) == discovery_key_1 ) diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 413e7e0dc9d695..26ee4c675bb52c 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -13,7 +13,6 @@ import pytest from homeassistant.core import Event, HomeAssistant, State -from homeassistant.helpers import json as json_helper from homeassistant.helpers.json import ( ExtendedJSONEncoder, JSONEncoder as DefaultHASSJSONEncoder, @@ -27,14 +26,9 @@ ) from homeassistant.util import dt as dt_util from homeassistant.util.color import RGBColor -from homeassistant.util.json import ( - JSON_DECODE_EXCEPTIONS, - JSON_ENCODE_EXCEPTIONS, - SerializationError, - load_json, -) +from homeassistant.util.json import SerializationError, load_json -from tests.common import import_and_test_deprecated_constant, json_round_trip +from tests.common import json_round_trip # Test data that can be saved as JSON TEST_JSON_A = {"a": 1, "B": "two"} @@ -350,50 +344,3 @@ def as_dict(self) -> dict[str, Any]: BadData(), dump=partial(json.dumps, cls=MockJSONEncoder), ) == {"$(BadData).bla": bad_data} - - -def test_deprecated_json_loads(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated json_loads function. - - It was moved from helpers to util in #88099 - """ - json_helper.json_loads("{}") - assert ( - "The deprecated function json_loads was called. It will be removed " - "in HA Core 2025.8. Use homeassistant.util.json.json_loads instead" - ) in caplog.text - - -@pytest.mark.parametrize( - ("constant_name", "replacement_name", "replacement"), - [ - ( - "JSON_DECODE_EXCEPTIONS", - "homeassistant.util.json.JSON_DECODE_EXCEPTIONS", - JSON_DECODE_EXCEPTIONS, - ), - ( - "JSON_ENCODE_EXCEPTIONS", - "homeassistant.util.json.JSON_ENCODE_EXCEPTIONS", - JSON_ENCODE_EXCEPTIONS, - ), - ], -) -def test_deprecated_aliases( - caplog: pytest.LogCaptureFixture, - constant_name: str, - replacement_name: str, - replacement: Any, -) -> None: - """Test deprecated JSON_DECODE_EXCEPTIONS and JSON_ENCODE_EXCEPTIONS constants. - - They were moved from helpers to util in #88099 - """ - import_and_test_deprecated_constant( - caplog, - json_helper, - constant_name, - replacement_name, - replacement, - "2025.8", - ) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 833d28ecdd9561..9a62fd421b7be2 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6519,6 +6519,275 @@ def async_get_supported_subentry_types( assert result["reason"] == "reconfigure_successful" +@pytest.mark.parametrize( + ( + "kwargs", + "expected_title", + "expected_unique_id", + "expected_data", + "raises", + "reload", # True is default + "setup_call_count", + "expected_result", + ), + [ + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + }, + "Updated title", + "5678", + {"vendor": "data2"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "1234", + "title": "Test", + "data": {"vendor": "data"}, + }, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + False, + 1, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + {}, + "Test", + "1234", + {"vendor": "data"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "data": {"buyer": "me"}, + }, + "Test", + "1234", + {"buyer": "me"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + {"data_updates": {"buyer": "me"}}, + "Test", + "1234", + {"vendor": "data", "buyer": "me"}, + does_not_raise(), + True, + 2, + { + "type": FlowResultType.ABORT, + "reason": "reconfigure_successful", + "description_placeholders": None, + }, + ), + ( + { + "unique_id": "5678", + "title": "Updated title", + "data": {"vendor": "data2"}, + "data_updates": {"buyer": "me"}, + }, + "Test", + "1234", + {"vendor": "data"}, + pytest.raises(ValueError), + True, + 1, + {}, + ), + ], + ids=[ + "changed_entry_default", + "unchanged_entry_default", + "unchanged_entry_no_reload", + "no_kwargs", + "replace_data", + "update_data", + "update_and_data_raises", + ], +) +async def test_update_subentry_reload_and_abort( + hass: HomeAssistant, + expected_title: str, + expected_unique_id: str, + expected_data: dict[str, Any], + kwargs: dict[str, Any], + raises: AbstractContextManager, + reload: bool, + setup_call_count: int, + expected_result: dict[str, Any], +) -> None: + """Test updating an entry and reloading.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + subentry = entry.subentries[subentry_id] + + setup_entry = AsyncMock(return_value=True) + + comp = MockModule( + "comp", + async_setup_entry=setup_entry, + async_unload_entry=AsyncMock(return_value=True), + ) + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_reload_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + **kwargs, + reload_even_if_entry_is_unchanged=reload, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with mock_config_flow("comp", TestFlow), raises: + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + await hass.async_block_till_done() + + subentry = entry.subentries[subentry_id] + assert subentry.title == expected_title + assert subentry.unique_id == expected_unique_id + assert subentry.data == expected_data + assert setup_entry.call_count == setup_call_count + for k, v in expected_result.items(): + assert result[k] == v + + +async def test_update_subentry_reload_with_listener(hass: HomeAssistant) -> None: + """Test updating an entry and reloading fails with update listener.""" + subentry_id = "blabla" + entry = MockConfigEntry( + domain="comp", + unique_id="entry_unique_id", + title="entry_title", + data={}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={"vendor": "data"}, + subentry_id=subentry_id, + subentry_type="test", + unique_id="1234", + title="Test", + ) + ], + ) + entry.add_to_hass(hass) + entry.add_update_listener(AsyncMock()) + + setup_entry = AsyncMock(return_value=True) + + comp = MockModule( + "comp", + async_setup_entry=setup_entry, + async_unload_entry=AsyncMock(return_value=True), + ) + mock_integration(hass, comp) + mock_platform(hass, "comp.config_flow", None) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + class TestFlow(config_entries.ConfigFlow): + class SubentryFlowHandler(config_entries.ConfigSubentryFlow): + async def async_step_reconfigure(self, user_input=None): + return self.async_update_reload_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data={}, + reload_even_if_entry_is_unchanged=True, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: config_entries.ConfigEntry + ) -> dict[str, type[config_entries.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + with ( + mock_config_flow("comp", TestFlow), + pytest.raises( + ValueError, match="Cannot update and reload entry with update listeners" + ), + ): + await entry.start_subentry_reconfigure_flow(hass, subentry_id) + + async def test_reconfigure_subentry_create_subentry(hass: HomeAssistant) -> None: """Test it's not allowed to create a subentry from a subentry reconfigure flow.""" subentry_id = "blabla" diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 537cfb33c31428..1ef665849521b7 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -14,6 +14,7 @@ CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + UnitOfApparentPower, UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, @@ -38,6 +39,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( + ApparentPowerConverter, AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, @@ -83,6 +85,7 @@ EnergyConverter, InformationConverter, MassConverter, + ApparentPowerConverter, PowerConverter, PressureConverter, ReactiveEnergyConverter, @@ -138,6 +141,11 @@ CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, 1000, ), + ApparentPowerConverter: ( + UnitOfApparentPower.MILLIVOLT_AMPERE, + UnitOfApparentPower.VOLT_AMPERE, + 1000, + ), PowerConverter: (UnitOfPower.WATT, UnitOfPower.KILO_WATT, 1000), PressureConverter: (UnitOfPressure.HPA, UnitOfPressure.INHG, 33.86389), ReactiveEnergyConverter: ( @@ -615,6 +623,14 @@ (1, UnitOfMass.STONES, 14, UnitOfMass.POUNDS), (1, UnitOfMass.STONES, 224, UnitOfMass.OUNCES), ], + ApparentPowerConverter: [ + ( + 10, + UnitOfApparentPower.MILLIVOLT_AMPERE, + 0.01, + UnitOfApparentPower.VOLT_AMPERE, + ), + ], PowerConverter: [ (10, UnitOfPower.KILO_WATT, 10000, UnitOfPower.WATT), (10, UnitOfPower.MEGA_WATT, 10e6, UnitOfPower.WATT),