diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 86be8cd4da5..2197e8adadb 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: # - No PRs marked as no-stale # - No issues (-1) - name: 60 days stale PRs policy - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 60 @@ -57,7 +57,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -87,7 +87,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000000..02dd134122e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/CODEOWNERS b/CODEOWNERS index 1c01270ff46..5293b1c4388 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1067,6 +1067,8 @@ build.json @home-assistant/supervisor /homeassistant/components/nilu/ @hfurubotten /homeassistant/components/nina/ @DeerMaximum /tests/components/nina/ @DeerMaximum +/homeassistant/components/nintendo_parental/ @pantherale0 +/tests/components/nintendo_parental/ @pantherale0 /homeassistant/components/nissan_leaf/ @filcole /homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 24268f4f4e2..95f4c8e3334 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -635,25 +635,15 @@ async def async_enable_logging( err_log_path = os.path.abspath(log_file) if err_log_path: - err_path_exists = os.path.isfile(err_log_path) - err_dir = os.path.dirname(err_log_path) + err_handler = await hass.async_add_executor_job( + _create_log_file, err_log_path, log_rotate_days + ) - # Check if we can write to the error log if it exists or that - # we can create files in the containing directory if not. - if (err_path_exists and os.access(err_log_path, os.W_OK)) or ( - not err_path_exists and os.access(err_dir, os.W_OK) - ): - err_handler = await hass.async_add_executor_job( - _create_log_file, err_log_path, log_rotate_days - ) + err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) + logger.addHandler(err_handler) - err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) - logger.addHandler(err_handler) - - # Save the log file location for access by other components. - hass.data[DATA_LOGGING] = err_log_path - else: - _LOGGER.error("Unable to set up error log %s (access denied)", err_log_path) + # Save the log file location for access by other components. + hass.data[DATA_LOGGING] = err_log_path async_activate_log_queue_handler(hass) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index a487e95582c..a7083c4ae0f 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -71,4 +71,4 @@ POLLEN_CATEGORY_MAP = { } UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) -UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30) +UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(minutes=30) diff --git a/homeassistant/components/accuweather/icons.json b/homeassistant/components/accuweather/icons.json index 183b4d2731d..2b715774bc8 100644 --- a/homeassistant/components/accuweather/icons.json +++ b/homeassistant/components/accuweather/icons.json @@ -1,6 +1,9 @@ { "entity": { "sensor": { + "air_quality": { + "default": "mdi:air-filter" + }, "cloud_ceiling": { "default": "mdi:weather-fog" }, @@ -34,9 +37,6 @@ "thunderstorm_probability_night": { "default": "mdi:weather-lightning" }, - "translation_key": { - "default": "mdi:air-filter" - }, "tree_pollen": { "default": "mdi:tree-outline" }, diff --git a/homeassistant/components/airgradient/update.py b/homeassistant/components/airgradient/update.py index 97cb8576e79..3f2422078d4 100644 --- a/homeassistant/components/airgradient/update.py +++ b/homeassistant/components/airgradient/update.py @@ -1,7 +1,9 @@ """Airgradient Update platform.""" from datetime import timedelta +import logging +from airgradient import AirGradientConnectionError from propcache.api import cached_property from homeassistant.components.update import UpdateDeviceClass, UpdateEntity @@ -13,6 +15,7 @@ from .entity import AirGradientEntity PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(hours=1) +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -31,6 +34,7 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity): """Representation of Airgradient Update.""" _attr_device_class = UpdateDeviceClass.FIRMWARE + _server_unreachable_logged = False def __init__(self, coordinator: AirGradientCoordinator) -> None: """Initialize the entity.""" @@ -47,10 +51,27 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity): """Return the installed version of the entity.""" return self.coordinator.data.measures.firmware_version + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._attr_available + async def async_update(self) -> None: """Update the entity.""" - self._attr_latest_version = ( - await self.coordinator.client.get_latest_firmware_version( - self.coordinator.serial_number + try: + self._attr_latest_version = ( + await self.coordinator.client.get_latest_firmware_version( + self.coordinator.serial_number + ) ) - ) + except AirGradientConnectionError: + self._attr_latest_version = None + self._attr_available = False + if not self._server_unreachable_logged: + _LOGGER.error( + "Unable to connect to AirGradient server to check for updates" + ) + self._server_unreachable_logged = True + else: + self._server_unreachable_logged = False + self._attr_available = True diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index 6a6857d95b3..57b55cb2bfb 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -6,7 +6,11 @@ import dataclasses import logging from typing import Any -from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from airthings_ble import ( + AirthingsBluetoothDeviceData, + AirthingsDevice, + UnsupportedDeviceError, +) from bleak import BleakError from habluetooth import BluetoothServiceInfoBleak import voluptuous as vol @@ -28,6 +32,7 @@ SERVICE_UUIDS = [ "b42e4a8e-ade7-11e4-89d3-123b93f75cba", "b42e1c08-ade7-11e4-89d3-123b93f75cba", "b42e3882-ade7-11e4-89d3-123b93f75cba", + "b42e90a2-ade7-11e4-89d3-123b93f75cba", ] @@ -38,6 +43,7 @@ class Discovery: name: str discovery_info: BluetoothServiceInfo device: AirthingsDevice + data: AirthingsBluetoothDeviceData def get_name(device: AirthingsDevice) -> str: @@ -63,8 +69,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device: Discovery | None = None self._discovered_devices: dict[str, Discovery] = {} - async def _get_device_data( - self, discovery_info: BluetoothServiceInfo + async def _get_device( + self, data: AirthingsBluetoothDeviceData, discovery_info: BluetoothServiceInfo ) -> AirthingsDevice: ble_device = bluetooth.async_ble_device_from_address( self.hass, discovery_info.address @@ -73,10 +79,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("no ble_device in _get_device_data") raise AirthingsDeviceUpdateError("No ble_device") - airthings = AirthingsBluetoothDeviceData(_LOGGER) - try: - data = await airthings.update_device(ble_device) + device = await data.update_device(ble_device) except BleakError as err: _LOGGER.error( "Error connecting to and getting data from %s: %s", @@ -84,12 +88,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): err, ) raise AirthingsDeviceUpdateError("Failed getting device data") from err + except UnsupportedDeviceError: + _LOGGER.debug("Skipping unsupported device: %s", discovery_info.name) + raise except Exception as err: _LOGGER.error( "Unknown error occurred from %s: %s", discovery_info.address, err ) raise - return data + return device async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo @@ -99,17 +106,21 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(discovery_info.address) self._abort_if_unique_id_configured() + data = AirthingsBluetoothDeviceData(logger=_LOGGER) + try: - device = await self._get_device_data(discovery_info) + device = await self._get_device(data=data, discovery_info=discovery_info) except AirthingsDeviceUpdateError: return self.async_abort(reason="cannot_connect") + except UnsupportedDeviceError: + return self.async_abort(reason="unsupported_device") except Exception: _LOGGER.exception("Unknown error occurred") return self.async_abort(reason="unknown") name = get_name(device) self.context["title_placeholders"] = {"name": name} - self._discovered_device = Discovery(name, discovery_info, device) + self._discovered_device = Discovery(name, discovery_info, device, data=data) return await self.async_step_bluetooth_confirm() @@ -164,16 +175,28 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): if MFCT_ID not in discovery_info.manufacturer_data: continue if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids): + _LOGGER.debug( + "Skipping unsupported device: %s (%s)", discovery_info.name, address + ) continue devices.append(discovery_info) for discovery_info in devices: address = discovery_info.address + data = AirthingsBluetoothDeviceData(logger=_LOGGER) try: - device = await self._get_device_data(discovery_info) + device = await self._get_device(data, discovery_info) except AirthingsDeviceUpdateError: _LOGGER.error( - "Error connecting to and getting data from %s", + "Error connecting to and getting data from %s (%s)", + discovery_info.name, + discovery_info.address, + ) + continue + except UnsupportedDeviceError: + _LOGGER.debug( + "Skipping unsupported device: %s (%s)", + discovery_info.name, discovery_info.address, ) continue @@ -181,7 +204,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unknown error occurred") return self.async_abort(reason="unknown") name = get_name(device) - self._discovered_devices[address] = Discovery(name, discovery_info, device) + _LOGGER.debug("Discovered Airthings device: %s (%s)", name, address) + self._discovered_devices[address] = Discovery( + name, discovery_info, device, data + ) if not self._discovered_devices: return self.async_abort(reason="no_devices_found") diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index 5ac0b27e26f..d7365bb5f1b 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -17,6 +17,10 @@ { "manufacturer_id": 820, "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba" + }, + { + "manufacturer_id": 820, + "service_uuid": "b42e90a2-ade7-11e4-89d3-123b93f75cba" } ], "codeowners": ["@vincegio", "@LaStrada"], diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index ee94052c286..49ca7970ae3 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -16,10 +16,12 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, PERCENTAGE, EntityCategory, Platform, UnitOfPressure, + UnitOfSoundPressure, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback @@ -112,6 +114,21 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), + "lux": SensorEntityDescription( + key="lux", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + "noise": SensorEntityDescription( + key="noise", + translation_key="ambient_noise", + device_class=SensorDeviceClass.SOUND_PRESSURE, + native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), } PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json index f5639e8da8f..2755866cdb6 100644 --- a/homeassistant/components/airthings_ble/strings.json +++ b/homeassistant/components/airthings_ble/strings.json @@ -21,6 +21,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "firmware_upgrade_required": "Your device requires a firmware upgrade. Please use the Airthings app (Android/iOS) to upgrade it.", + "unsupported_device": "Unsupported device", "unknown": "[%key:common::config_flow::error::unknown%]" } }, @@ -40,6 +41,9 @@ }, "illuminance": { "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "ambient_noise": { + "name": "Ambient noise" } } } diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 8347fa34423..dd8be49f521 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -18,7 +18,9 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.helpers.entity_registry as er +from .const import _LOGGER, DOMAIN from .coordinator import AmazonConfigEntry from .entity import AmazonEntity from .utils import async_update_unique_id @@ -58,6 +60,40 @@ BINARY_SENSORS: Final = ( ), ) +DEPRECATED_BINARY_SENSORS: Final = ( + AmazonBinarySensorEntityDescription( + key="bluetooth", + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="bluetooth", + is_on_fn=lambda device, key: False, + ), + AmazonBinarySensorEntityDescription( + key="babyCryDetectionState", + translation_key="baby_cry_detection", + is_on_fn=lambda device, key: False, + ), + AmazonBinarySensorEntityDescription( + key="beepingApplianceDetectionState", + translation_key="beeping_appliance_detection", + is_on_fn=lambda device, key: False, + ), + AmazonBinarySensorEntityDescription( + key="coughDetectionState", + translation_key="cough_detection", + is_on_fn=lambda device, key: False, + ), + AmazonBinarySensorEntityDescription( + key="dogBarkDetectionState", + translation_key="dog_bark_detection", + is_on_fn=lambda device, key: False, + ), + AmazonBinarySensorEntityDescription( + key="waterSoundsDetectionState", + translation_key="water_sounds_detection", + is_on_fn=lambda device, key: False, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -68,6 +104,8 @@ async def async_setup_entry( coordinator = entry.runtime_data + entity_registry = er.async_get(hass) + # Replace unique id for "detectionState" binary sensor await async_update_unique_id( hass, @@ -77,6 +115,16 @@ async def async_setup_entry( "detectionState", ) + # Clean up deprecated sensors + for sensor_desc in DEPRECATED_BINARY_SENSORS: + for serial_num in coordinator.data: + unique_id = f"{serial_num}-{sensor_desc.key}" + if entity_id := entity_registry.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, unique_id + ): + _LOGGER.debug("Removing deprecated entity %s", entity_id) + entity_registry.async_remove(entity_id) + known_devices: set[str] = set() def _check_device() -> None: diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index 003f5762079..5acaa8c2dd0 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -18,7 +18,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity -from .utils import alexa_api_call, async_update_unique_id +from .utils import ( + alexa_api_call, + async_remove_dnd_from_virtual_group, + async_update_unique_id, +) PARALLEL_UPDATES = 1 @@ -60,6 +64,9 @@ async def async_setup_entry( hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd" ) + # Remove DND switch from virtual groups + await async_remove_dnd_from_virtual_group(hass, coordinator) + known_devices: set[str] = set() def _check_device() -> None: diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py index f8898aa5fe4..3fbba539a6a 100644 --- a/homeassistant/components/alexa_devices/utils.py +++ b/homeassistant/components/alexa_devices/utils.py @@ -4,8 +4,10 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate +from aioamazondevices.const import SPEAKER_GROUP_FAMILY from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.entity_registry as er @@ -61,3 +63,21 @@ async def async_update_unique_id( # Update the registry with the new unique_id entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + +async def async_remove_dnd_from_virtual_group( + hass: HomeAssistant, + coordinator: AmazonDevicesCoordinator, +) -> None: + """Remove entity DND from virtual group.""" + entity_registry = er.async_get(hass) + + for serial_num in coordinator.data: + unique_id = f"{serial_num}-do_not_disturb" + entity_id = entity_registry.async_get_entity_id( + DOMAIN, SWITCH_DOMAIN, unique_id + ) + is_group = coordinator.data[serial_num].device_family == SPEAKER_GROUP_FAMILY + if entity_id and is_group: + entity_registry.async_remove(entity_id) + _LOGGER.debug("Removed DND switch from virtual group %s", entity_id) diff --git a/homeassistant/components/altruist/sensor.py b/homeassistant/components/altruist/sensor.py index f02c442e5cd..0635673a0fc 100644 --- a/homeassistant/components/altruist/sensor.py +++ b/homeassistant/components/altruist/sensor.py @@ -65,6 +65,31 @@ SENSOR_DESCRIPTIONS = [ suggested_display_precision=2, translation_placeholders={"sensor_name": "BME280"}, ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key="BME680_humidity", + translation_key="humidity", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BME680"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PRESSURE, + key="BME680_pressure", + translation_key="pressure", + native_unit_of_measurement=UnitOfPressure.PA, + suggested_unit_of_measurement=UnitOfPressure.MMHG, + suggested_display_precision=0, + translation_placeholders={"sensor_name": "BME680"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="BME680_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BME680"}, + ), AltruistSensorEntityDescription( device_class=SensorDeviceClass.PRESSURE, key="BMP_pressure", diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index 68390642c87..915c20473c2 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitVedoSystem +from .utils import DeviceType, new_device_listener # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -29,23 +30,19 @@ async def async_setup_entry( coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) - known_devices: set[int] = set() + def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None: + """Add entities for new monitors.""" + entities = [ + ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data["alarm_zones"].values() + if device in new_devices + ] + if entities: + async_add_entities(entities) - def _check_device() -> None: - current_devices = set(coordinator.data["alarm_zones"]) - new_devices = current_devices - known_devices - if new_devices: - known_devices.update(new_devices) - async_add_entities( - ComelitVedoBinarySensorEntity( - coordinator, device, config_entry.entry_id - ) - for device in coordinator.data["alarm_zones"].values() - if device.index in new_devices - ) - - _check_device() - config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + config_entry.async_on_unload( + new_device_listener(coordinator, _add_new_entities, "alarm_zones") + ) class ComelitVedoBinarySensorEntity( diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 0f47d88fad1..dd94fa62988 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from asyncio.exceptions import TimeoutError from collections.abc import Mapping +import re from typing import Any from aiocomelit import ( @@ -27,25 +28,20 @@ from .utils import async_client_session DEFAULT_HOST = "192.168.1.252" DEFAULT_PIN = "111111" - -pin_regex = r"^[0-9]{4,10}$" - USER_SCHEMA = vol.Schema( { vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex), + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string, vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), } ) -STEP_REAUTH_DATA_SCHEMA = vol.Schema( - {vol.Required(CONF_PIN): cv.matches_regex(pin_regex)} -) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string}) STEP_RECONFIGURE = vol.Schema( { vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port, - vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.matches_regex(pin_regex), + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string, } ) @@ -55,6 +51,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, api: ComelitCommonApi + if not re.fullmatch(r"[0-9]{4,10}", data[CONF_PIN]): + raise InvalidPin + session = await async_client_session(hass) if data.get(CONF_TYPE, BRIDGE) == BRIDGE: api = ComeliteSerialBridgeApi( @@ -105,6 +104,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + except InvalidPin: + errors["base"] = "invalid_pin" except Exception: # noqa: BLE001 _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -146,6 +147,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + except InvalidPin: + errors["base"] = "invalid_pin" except Exception: # noqa: BLE001 _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -189,6 +192,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + except InvalidPin: + errors["base"] = "invalid_pin" except Exception: # noqa: BLE001 _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -210,3 +215,7 @@ class CannotConnect(HomeAssistantError): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class InvalidPin(HomeAssistantError): + """Error to indicate an invalid pin.""" diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 8818e296e03..1351c8258f5 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -161,7 +161,7 @@ class ComelitSerialBridge( entry: ComelitConfigEntry, host: str, port: int, - pin: int, + pin: str, session: ClientSession, ) -> None: """Initialize the scanner.""" @@ -195,7 +195,7 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]): entry: ComelitConfigEntry, host: str, port: int, - pin: int, + pin: str, session: ClientSession, ) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 70525ffe712..b42c34c945d 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -7,14 +7,21 @@ from typing import Any, cast from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON -from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.components.cover import ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + CoverDeviceClass, + CoverEntity, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge from .entity import ComelitBridgeBaseEntity -from .utils import bridge_api_call +from .utils import DeviceType, bridge_api_call, new_device_listener # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -29,21 +36,19 @@ async def async_setup_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - known_devices: set[int] = set() + def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None: + """Add entities for new monitors.""" + entities = [ + ComelitCoverEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[dev_type].values() + if device in new_devices + ] + if entities: + async_add_entities(entities) - def _check_device() -> None: - current_devices = set(coordinator.data[COVER]) - new_devices = current_devices - known_devices - if new_devices: - known_devices.update(new_devices) - async_add_entities( - ComelitCoverEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[COVER].values() - if device.index in new_devices - ) - - _check_device() - config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + config_entry.async_on_unload( + new_device_listener(coordinator, _add_new_entities, COVER) + ) class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): @@ -62,7 +67,6 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): super().__init__(coordinator, device, config_entry_entry_id) # Device doesn't provide a status so we assume UNKNOWN at first startup self._last_action: int | None = None - self._last_state: str | None = None def _current_action(self, action: str) -> bool: """Return the current cover action.""" @@ -98,7 +102,6 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): @bridge_api_call async def _cover_set_state(self, action: int, state: int) -> None: """Set desired cover state.""" - self._last_state = self.state await self.coordinator.api.set_device_status(COVER, self._device.index, action) self.coordinator.data[COVER][self._device.index].status = state self.async_write_ha_state() @@ -124,5 +127,10 @@ class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity): await super().async_added_to_hass() - if last_state := await self.async_get_last_state(): - self._last_state = last_state.state + if (state := await self.async_get_last_state()) is not None: + if state.state == STATE_CLOSED: + self._last_action = STATE_COVER.index(STATE_CLOSING) + if state.state == STATE_OPEN: + self._last_action = STATE_COVER.index(STATE_OPENING) + + self._attr_is_closed = state.state == STATE_CLOSED diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 8ff626ed916..95414ab8d0f 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ComelitConfigEntry, ComelitSerialBridge from .entity import ComelitBridgeBaseEntity -from .utils import bridge_api_call +from .utils import DeviceType, bridge_api_call, new_device_listener # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -27,21 +27,19 @@ async def async_setup_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - known_devices: set[int] = set() + def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None: + """Add entities for new monitors.""" + entities = [ + ComelitLightEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[dev_type].values() + if device in new_devices + ] + if entities: + async_add_entities(entities) - def _check_device() -> None: - current_devices = set(coordinator.data[LIGHT]) - new_devices = current_devices - known_devices - if new_devices: - known_devices.update(new_devices) - async_add_entities( - ComelitLightEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[LIGHT].values() - if device.index in new_devices - ) - - _check_device() - config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + config_entry.async_on_unload( + new_device_listener(coordinator, _add_new_entities, LIGHT) + ) class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity): diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 4e8fee1bba6..e276450f4fc 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiocomelit"], "quality_scale": "platinum", - "requirements": ["aiocomelit==0.12.3"] + "requirements": ["aiocomelit==1.1.1"] } diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index f47a8872368..188ec348ab6 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -20,6 +20,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem from .entity import ComelitBridgeBaseEntity +from .utils import DeviceType, new_device_listener # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -65,24 +66,22 @@ async def async_setup_bridge_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - known_devices: set[int] = set() - - def _check_device() -> None: - current_devices = set(coordinator.data[OTHER]) - new_devices = current_devices - known_devices - if new_devices: - known_devices.update(new_devices) - async_add_entities( - ComelitBridgeSensorEntity( - coordinator, device, config_entry.entry_id, sensor_desc - ) - for sensor_desc in SENSOR_BRIDGE_TYPES - for device in coordinator.data[OTHER].values() - if device.index in new_devices + def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None: + """Add entities for new monitors.""" + entities = [ + ComelitBridgeSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc ) + for sensor_desc in SENSOR_BRIDGE_TYPES + for device in coordinator.data[dev_type].values() + if device in new_devices + ] + if entities: + async_add_entities(entities) - _check_device() - config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + config_entry.async_on_unload( + new_device_listener(coordinator, _add_new_entities, OTHER) + ) async def async_setup_vedo_entry( @@ -94,24 +93,22 @@ async def async_setup_vedo_entry( coordinator = cast(ComelitVedoSystem, config_entry.runtime_data) - known_devices: set[int] = set() - - def _check_device() -> None: - current_devices = set(coordinator.data["alarm_zones"]) - new_devices = current_devices - known_devices - if new_devices: - known_devices.update(new_devices) - async_add_entities( - ComelitVedoSensorEntity( - coordinator, device, config_entry.entry_id, sensor_desc - ) - for sensor_desc in SENSOR_VEDO_TYPES - for device in coordinator.data["alarm_zones"].values() - if device.index in new_devices + def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None: + """Add entities for new monitors.""" + entities = [ + ComelitVedoSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc ) + for sensor_desc in SENSOR_VEDO_TYPES + for device in coordinator.data["alarm_zones"].values() + if device in new_devices + ] + if entities: + async_add_entities(entities) - _check_device() - config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + config_entry.async_on_unload( + new_device_listener(coordinator, _add_new_entities, "alarm_zones") + ) class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity): diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index d63d22f307a..2b051586ac4 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -43,11 +43,13 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_pin": "The provided PIN is invalid. It must be a 4-10 digit number.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_pin": "[%key:component::comelit::config::abort::invalid_pin%]", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 076b6091a3d..2b15f9533a7 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ComelitConfigEntry, ComelitSerialBridge from .entity import ComelitBridgeBaseEntity -from .utils import bridge_api_call +from .utils import DeviceType, bridge_api_call, new_device_listener # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 @@ -28,35 +28,20 @@ async def async_setup_entry( coordinator = cast(ComelitSerialBridge, config_entry.runtime_data) - entities: list[ComelitSwitchEntity] = [] - entities.extend( - ComelitSwitchEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[IRRIGATION].values() - ) - entities.extend( - ComelitSwitchEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[OTHER].values() - ) - async_add_entities(entities) + def _add_new_entities(new_devices: list[DeviceType], dev_type: str) -> None: + """Add entities for new monitors.""" + entities = [ + ComelitSwitchEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[dev_type].values() + if device in new_devices + ] + if entities: + async_add_entities(entities) - known_devices: dict[str, set[int]] = { - dev_type: set() for dev_type in (IRRIGATION, OTHER) - } - - def _check_device() -> None: - for dev_type in (IRRIGATION, OTHER): - current_devices = set(coordinator.data[dev_type]) - new_devices = current_devices - known_devices[dev_type] - if new_devices: - known_devices[dev_type].update(new_devices) - async_add_entities( - ComelitSwitchEntity(coordinator, device, config_entry.entry_id) - for device in coordinator.data[dev_type].values() - if device.index in new_devices - ) - - _check_device() - config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + for dev_type in (IRRIGATION, OTHER): + config_entry.async_on_unload( + new_device_listener(coordinator, _add_new_entities, dev_type) + ) class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity): diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py index d0f0fbbee3f..577aa4e2cf1 100644 --- a/homeassistant/components/comelit/utils.py +++ b/homeassistant/components/comelit/utils.py @@ -4,7 +4,11 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate -from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.api import ( + ComelitSerialBridgeObject, + ComelitVedoAreaObject, + ComelitVedoZoneObject, +) from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData from aiohttp import ClientSession, CookieJar @@ -19,8 +23,11 @@ from homeassistant.helpers import ( ) from .const import _LOGGER, DOMAIN +from .coordinator import ComelitBaseCoordinator from .entity import ComelitBridgeBaseEntity +DeviceType = ComelitSerialBridgeObject | ComelitVedoAreaObject | ComelitVedoZoneObject + async def async_client_session(hass: HomeAssistant) -> ClientSession: """Return a new aiohttp session.""" @@ -113,3 +120,41 @@ def bridge_api_call[_T: ComelitBridgeBaseEntity, **_P]( self.coordinator.config_entry.async_start_reauth(self.hass) return cmd_wrapper + + +def new_device_listener( + coordinator: ComelitBaseCoordinator, + new_devices_callback: Callable[ + [ + list[ + ComelitSerialBridgeObject + | ComelitVedoAreaObject + | ComelitVedoZoneObject + ], + str, + ], + None, + ], + data_type: str, +) -> Callable[[], None]: + """Subscribe to coordinator updates to check for new devices.""" + known_devices: set[int] = set() + + def _check_devices() -> None: + """Check for new devices and call callback with any new monitors.""" + if not coordinator.data: + return + + new_devices: list[DeviceType] = [] + for _id in coordinator.data[data_type]: + if _id not in known_devices: + known_devices.add(_id) + new_devices.append(coordinator.data[data_type][_id]) + + if new_devices: + new_devices_callback(new_devices, data_type) + + # Check for devices immediately + _check_devices() + + return coordinator.async_add_listener(_check_devices) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 6c238ff0c52..8d60528a49f 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -38,22 +38,30 @@ from home_assistant_intents import ( ErrorKey, FuzzyConfig, FuzzyLanguageResponses, + LanguageScores, get_fuzzy_config, get_fuzzy_language, get_intents, + get_language_scores, get_languages, ) import yaml -from homeassistant import core from homeassistant.components.homeassistant.exposed_entities import ( async_listen_entity_updates, async_should_expose, ) from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL -from homeassistant.core import Event, callback +from homeassistant.core import ( + Event, + EventStateChangedData, + HomeAssistant, + State, + callback, +) from homeassistant.helpers import ( area_registry as ar, + config_validation as cv, device_registry as dr, entity_registry as er, floor_registry as fr, @@ -192,7 +200,7 @@ class IntentCache: async def async_setup_default_agent( - hass: core.HomeAssistant, + hass: HomeAssistant, entity_component: EntityComponent[ConversationEntity], config_intents: dict[str, Any], ) -> None: @@ -201,15 +209,13 @@ async def async_setup_default_agent( await entity_component.async_add_entities([agent]) await get_agent_manager(hass).async_setup_default_agent(agent) - @core.callback - def async_entity_state_listener( - event: core.Event[core.EventStateChangedData], - ) -> None: + @callback + def async_entity_state_listener(event: Event[EventStateChangedData]) -> None: """Set expose flag on new entities.""" async_should_expose(hass, DOMAIN, event.data["entity_id"]) - @core.callback - def async_hass_started(hass: core.HomeAssistant) -> None: + @callback + def async_hass_started(hass: HomeAssistant) -> None: """Set expose flag on all entities.""" for state in hass.states.async_all(): async_should_expose(hass, DOMAIN, state.entity_id) @@ -224,9 +230,7 @@ class DefaultAgent(ConversationEntity): _attr_name = "Home Assistant" _attr_supported_features = ConversationEntityFeature.CONTROL - def __init__( - self, hass: core.HomeAssistant, config_intents: dict[str, Any] - ) -> None: + def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None: """Initialize the default agent.""" self.hass = hass self._lang_intents: dict[str, LanguageIntents | object] = {} @@ -259,7 +263,7 @@ class DefaultAgent(ConversationEntity): """Return a list of supported languages.""" return get_languages() - @core.callback + @callback def _filter_entity_registry_changes( self, event_data: er.EventEntityRegistryUpdatedData ) -> bool: @@ -268,12 +272,12 @@ class DefaultAgent(ConversationEntity): field in event_data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS ) - @core.callback - def _filter_state_changes(self, event_data: core.EventStateChangedData) -> bool: + @callback + def _filter_state_changes(self, event_data: EventStateChangedData) -> bool: """Filter state changed events.""" return not event_data["old_state"] or not event_data["new_state"] - @core.callback + @callback def _listen_clear_slot_list(self) -> None: """Listen for changes that can invalidate slot list.""" assert self._unsub_clear_slot_list is None @@ -342,6 +346,81 @@ class DefaultAgent(ConversationEntity): return result + async def async_debug_recognize( + self, user_input: ConversationInput + ) -> dict[str, Any] | None: + """Debug recognize from user input.""" + result_dict: dict[str, Any] | None = None + + if trigger_result := await self.async_recognize_sentence_trigger(user_input): + result_dict = { + # Matched a user-defined sentence trigger. + # We can't provide the response here without executing the + # trigger. + "match": True, + "source": "trigger", + "sentence_template": trigger_result.sentence_template or "", + } + elif intent_result := await self.async_recognize_intent(user_input): + successful_match = not intent_result.unmatched_entities + result_dict = { + # Name of the matching intent (or the closest) + "intent": { + "name": intent_result.intent.name, + }, + # Slot values that would be received by the intent + "slots": { # direct access to values + entity_key: entity.text or entity.value + for entity_key, entity in intent_result.entities.items() + }, + # Extra slot details, such as the originally matched text + "details": { + entity_key: { + "name": entity.name, + "value": entity.value, + "text": entity.text, + } + for entity_key, entity in intent_result.entities.items() + }, + # Entities/areas/etc. that would be targeted + "targets": {}, + # True if match was successful + "match": successful_match, + # Text of the sentence template that matched (or was closest) + "sentence_template": "", + # When match is incomplete, this will contain the best slot guesses + "unmatched_slots": _get_unmatched_slots(intent_result), + # True if match was not exact + "fuzzy_match": False, + } + + if successful_match: + result_dict["targets"] = { + state.entity_id: {"matched": is_matched} + for state, is_matched in _get_debug_targets( + self.hass, intent_result + ) + } + + if intent_result.intent_sentence is not None: + result_dict["sentence_template"] = intent_result.intent_sentence.text + + if intent_result.intent_metadata: + # Inspect metadata to determine if this matched a custom sentence + if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE): + result_dict["source"] = "custom" + result_dict["file"] = intent_result.intent_metadata.get( + METADATA_CUSTOM_FILE + ) + else: + result_dict["source"] = "builtin" + + result_dict["fuzzy_match"] = intent_result.intent_metadata.get( + METADATA_FUZZY_MATCH, False + ) + + return result_dict + async def _async_handle_message( self, user_input: ConversationInput, @@ -890,7 +969,7 @@ class DefaultAgent(ConversationEntity): ) -> str: # Get first matched or unmatched state. # This is available in the response template as "state". - state1: core.State | None = None + state1: State | None = None if intent_response.matched_states: state1 = intent_response.matched_states[0] elif intent_response.unmatched_states: @@ -1528,6 +1607,10 @@ class DefaultAgent(ConversationEntity): return None return response + async def async_get_language_scores(self) -> dict[str, LanguageScores]: + """Get support scores per language.""" + return await self.hass.async_add_executor_job(get_language_scores) + def _make_error_result( language: str, @@ -1589,7 +1672,7 @@ def _get_unmatched_response(result: RecognizeResult) -> tuple[ErrorKey, dict[str def _get_match_error_response( - hass: core.HomeAssistant, + hass: HomeAssistant, match_error: intent.MatchFailedError, ) -> tuple[ErrorKey, dict[str, Any]]: """Return key and template arguments for error when target matching fails.""" @@ -1724,3 +1807,75 @@ def _collect_list_references(expression: Expression, list_names: set[str]) -> No elif isinstance(expression, ListReference): # {list} list_names.add(expression.slot_name) + + +def _get_debug_targets( + hass: HomeAssistant, + result: RecognizeResult, +) -> Iterable[tuple[State, bool]]: + """Yield state/is_matched pairs for a hassil recognition.""" + entities = result.entities + + name: str | None = None + area_name: str | None = None + domains: set[str] | None = None + device_classes: set[str] | None = None + state_names: set[str] | None = None + + if "name" in entities: + name = str(entities["name"].value) + + if "area" in entities: + area_name = str(entities["area"].value) + + if "domain" in entities: + domains = set(cv.ensure_list(entities["domain"].value)) + + if "device_class" in entities: + device_classes = set(cv.ensure_list(entities["device_class"].value)) + + if "state" in entities: + # HassGetState only + state_names = set(cv.ensure_list(entities["state"].value)) + + if ( + (name is None) + and (area_name is None) + and (not domains) + and (not device_classes) + and (not state_names) + ): + # Avoid "matching" all entities when there is no filter + return + + states = intent.async_match_states( + hass, + name=name, + area_name=area_name, + domains=domains, + device_classes=device_classes, + ) + + for state in states: + # For queries, a target is "matched" based on its state + is_matched = (state_names is None) or (state.state in state_names) + yield state, is_matched + + +def _get_unmatched_slots( + result: RecognizeResult, +) -> dict[str, str | int | float]: + """Return a dict of unmatched text/range slot entities.""" + unmatched_slots: dict[str, str | int | float] = {} + for entity in result.unmatched_entities_list: + if isinstance(entity, UnmatchedTextEntity): + if entity.text == MISSING_ENTITY: + # Don't report since these are just missing context + # slots. + continue + + unmatched_slots[entity.name] = entity.text + elif isinstance(entity, UnmatchedRangeEntity): + unmatched_slots[entity.name] = entity.value + + return unmatched_slots diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index c43e6709855..9d3eb35a7e3 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -2,21 +2,16 @@ from __future__ import annotations -from collections.abc import Iterable from dataclasses import asdict from typing import Any from aiohttp import web -from hassil.recognize import MISSING_ENTITY, RecognizeResult -from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity -from home_assistant_intents import get_language_scores import voluptuous as vol from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers import config_validation as cv, intent +from homeassistant.core import HomeAssistant, callback from homeassistant.util import language as language_util from .agent_manager import ( @@ -26,11 +21,6 @@ from .agent_manager import ( get_agent_manager, ) from .const import DATA_COMPONENT -from .default_agent import ( - METADATA_CUSTOM_FILE, - METADATA_CUSTOM_SENTENCE, - METADATA_FUZZY_MATCH, -) from .entity import ConversationEntity from .models import ConversationInput @@ -206,150 +196,12 @@ async def websocket_hass_agent_debug( language=msg.get("language", hass.config.language), agent_id=agent.entity_id, ) - result_dict: dict[str, Any] | None = None - - if trigger_result := await agent.async_recognize_sentence_trigger(user_input): - result_dict = { - # Matched a user-defined sentence trigger. - # We can't provide the response here without executing the - # trigger. - "match": True, - "source": "trigger", - "sentence_template": trigger_result.sentence_template or "", - } - elif intent_result := await agent.async_recognize_intent(user_input): - successful_match = not intent_result.unmatched_entities - result_dict = { - # Name of the matching intent (or the closest) - "intent": { - "name": intent_result.intent.name, - }, - # Slot values that would be received by the intent - "slots": { # direct access to values - entity_key: entity.text or entity.value - for entity_key, entity in intent_result.entities.items() - }, - # Extra slot details, such as the originally matched text - "details": { - entity_key: { - "name": entity.name, - "value": entity.value, - "text": entity.text, - } - for entity_key, entity in intent_result.entities.items() - }, - # Entities/areas/etc. that would be targeted - "targets": {}, - # True if match was successful - "match": successful_match, - # Text of the sentence template that matched (or was closest) - "sentence_template": "", - # When match is incomplete, this will contain the best slot guesses - "unmatched_slots": _get_unmatched_slots(intent_result), - # True if match was not exact - "fuzzy_match": False, - } - - if successful_match: - result_dict["targets"] = { - state.entity_id: {"matched": is_matched} - for state, is_matched in _get_debug_targets(hass, intent_result) - } - - if intent_result.intent_sentence is not None: - result_dict["sentence_template"] = intent_result.intent_sentence.text - - if intent_result.intent_metadata: - # Inspect metadata to determine if this matched a custom sentence - if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE): - result_dict["source"] = "custom" - result_dict["file"] = intent_result.intent_metadata.get( - METADATA_CUSTOM_FILE - ) - else: - result_dict["source"] = "builtin" - - result_dict["fuzzy_match"] = intent_result.intent_metadata.get( - METADATA_FUZZY_MATCH, False - ) - + result_dict = await agent.async_debug_recognize(user_input) result_dicts.append(result_dict) connection.send_result(msg["id"], {"results": result_dicts}) -def _get_debug_targets( - hass: HomeAssistant, - result: RecognizeResult, -) -> Iterable[tuple[State, bool]]: - """Yield state/is_matched pairs for a hassil recognition.""" - entities = result.entities - - name: str | None = None - area_name: str | None = None - domains: set[str] | None = None - device_classes: set[str] | None = None - state_names: set[str] | None = None - - if "name" in entities: - name = str(entities["name"].value) - - if "area" in entities: - area_name = str(entities["area"].value) - - if "domain" in entities: - domains = set(cv.ensure_list(entities["domain"].value)) - - if "device_class" in entities: - device_classes = set(cv.ensure_list(entities["device_class"].value)) - - if "state" in entities: - # HassGetState only - state_names = set(cv.ensure_list(entities["state"].value)) - - if ( - (name is None) - and (area_name is None) - and (not domains) - and (not device_classes) - and (not state_names) - ): - # Avoid "matching" all entities when there is no filter - return - - states = intent.async_match_states( - hass, - name=name, - area_name=area_name, - domains=domains, - device_classes=device_classes, - ) - - for state in states: - # For queries, a target is "matched" based on its state - is_matched = (state_names is None) or (state.state in state_names) - yield state, is_matched - - -def _get_unmatched_slots( - result: RecognizeResult, -) -> dict[str, str | int | float]: - """Return a dict of unmatched text/range slot entities.""" - unmatched_slots: dict[str, str | int | float] = {} - for entity in result.unmatched_entities_list: - if isinstance(entity, UnmatchedTextEntity): - if entity.text == MISSING_ENTITY: - # Don't report since these are just missing context - # slots. - continue - - unmatched_slots[entity.name] = entity.text - elif isinstance(entity, UnmatchedRangeEntity): - unmatched_slots[entity.name] = entity.value - - return unmatched_slots - - @websocket_api.websocket_command( { vol.Required("type"): "conversation/agent/homeassistant/language_scores", @@ -364,10 +216,13 @@ async def websocket_hass_agent_language_scores( msg: dict[str, Any], ) -> None: """Get support scores per language.""" + agent = get_agent_manager(hass).default_agent + assert agent is not None + language = msg.get("language", hass.config.language) country = msg.get("country", hass.config.country) - scores = await hass.async_add_executor_job(get_language_scores) + scores = await agent.async_get_language_scores() matching_langs = language_util.matches(language, scores.keys(), country=country) preferred_lang = matching_langs[0] if matching_langs else language result = { diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index b0e2a0595bf..09f6fcfcca2 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -116,6 +116,9 @@ } }, "select": { + "active_map": { + "default": "mdi:floor-plan" + }, "water_amount": { "default": "mdi:water" }, diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index 440141bbcee..d3b5ca34022 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -2,12 +2,13 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any -from deebot_client.capabilities import CapabilitySetTypes +from deebot_client.capabilities import CapabilityMap, CapabilitySet, CapabilitySetTypes from deebot_client.device import Device from deebot_client.events import WorkModeEvent from deebot_client.events.base import Event +from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent from deebot_client.events.water_info import WaterAmountEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -16,7 +17,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity +from .entity import ( + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EcovacsEntity, +) from .util import get_name_key, get_supported_entities @@ -66,6 +71,12 @@ async def async_setup_entry( entities = get_supported_entities( controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS ) + entities.extend( + EcovacsActiveMapSelectEntity(device, device.capabilities.map) + for device in controller.devices + if (map_cap := device.capabilities.map) + and isinstance(map_cap.major, CapabilitySet) + ) if entities: async_add_entities(entities) @@ -103,3 +114,76 @@ class EcovacsSelectEntity[EventT: Event]( async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self._device.execute_command(self._capability.set(option)) + + +class EcovacsActiveMapSelectEntity( + EcovacsEntity[CapabilityMap], + SelectEntity, +): + """Ecovacs active map select entity.""" + + entity_description = SelectEntityDescription( + key="active_map", + translation_key="active_map", + entity_category=EntityCategory.CONFIG, + ) + + def __init__( + self, + device: Device, + capability: CapabilityMap, + **kwargs: Any, + ) -> None: + """Initialize entity.""" + super().__init__(device, capability, **kwargs) + self._option_to_id: dict[str, str] = {} + self._id_to_option: dict[str, str] = {} + + self._handle_on_cached_map( + device.events.get_last_event(CachedMapInfoEvent) + or CachedMapInfoEvent(set()) + ) + + def _handle_on_cached_map(self, event: CachedMapInfoEvent) -> None: + self._id_to_option.clear() + self._option_to_id.clear() + + for map_info in event.maps: + name = map_info.name if map_info.name else map_info.id + self._id_to_option[map_info.id] = name + self._option_to_id[name] = map_info.id + + if map_info.using: + self._attr_current_option = name + + if self._attr_current_option not in self._option_to_id: + self._attr_current_option = None + + # Sort named maps first, then numeric IDs (unnamed maps during building) in ascending order. + self._attr_options = sorted( + self._option_to_id.keys(), key=lambda x: (x.isdigit(), x.lower()) + ) + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_cached_map(event: CachedMapInfoEvent) -> None: + self._handle_on_cached_map(event) + self.async_write_ha_state() + + self._subscribe(self._capability.cached_info.event, on_cached_map) + + async def on_major_map(event: MajorMapEvent) -> None: + self._attr_current_option = self._id_to_option.get(event.map_id) + self.async_write_ha_state() + + self._subscribe(self._capability.major.event, on_major_map) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if TYPE_CHECKING: + assert isinstance(self._capability.major, CapabilitySet) + await self._device.execute_command( + self._capability.major.set(self._option_to_id[option]) + ) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index e69da61799f..106acf8c8bb 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -178,6 +178,9 @@ } }, "select": { + "active_map": { + "name": "Active map" + }, "water_amount": { "name": "[%key:component::ecovacs::entity::number::water_amount::name%]", "state": { diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index e771233b069..ed3864e6f83 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -396,6 +396,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None, ] on_phase: str | None + cttype: str | None = None CT_NET_CONSUMPTION_SENSORS = ( @@ -409,6 +410,7 @@ CT_NET_CONSUMPTION_SENSORS = ( suggested_display_precision=3, value_fn=attrgetter("energy_delivered"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="lifetime_net_production", @@ -420,6 +422,7 @@ CT_NET_CONSUMPTION_SENSORS = ( suggested_display_precision=3, value_fn=attrgetter("energy_received"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="net_consumption", @@ -431,6 +434,7 @@ CT_NET_CONSUMPTION_SENSORS = ( suggested_display_precision=3, value_fn=attrgetter("active_power"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="frequency", @@ -442,6 +446,7 @@ CT_NET_CONSUMPTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("frequency"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="voltage", @@ -454,6 +459,7 @@ CT_NET_CONSUMPTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("voltage"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="net_ct_current", @@ -466,6 +472,7 @@ CT_NET_CONSUMPTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("current"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="net_ct_powerfactor", @@ -476,6 +483,7 @@ CT_NET_CONSUMPTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("power_factor"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="net_consumption_ct_metering_status", @@ -486,6 +494,7 @@ CT_NET_CONSUMPTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("metering_status"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="net_consumption_ct_status_flags", @@ -495,6 +504,7 @@ CT_NET_CONSUMPTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), ) @@ -525,6 +535,7 @@ CT_PRODUCTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("frequency"), on_phase=None, + cttype=CtType.PRODUCTION, ), EnvoyCTSensorEntityDescription( key="production_ct_voltage", @@ -537,6 +548,7 @@ CT_PRODUCTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("voltage"), on_phase=None, + cttype=CtType.PRODUCTION, ), EnvoyCTSensorEntityDescription( key="production_ct_current", @@ -549,6 +561,7 @@ CT_PRODUCTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("current"), on_phase=None, + cttype=CtType.PRODUCTION, ), EnvoyCTSensorEntityDescription( key="production_ct_powerfactor", @@ -559,6 +572,7 @@ CT_PRODUCTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("power_factor"), on_phase=None, + cttype=CtType.PRODUCTION, ), EnvoyCTSensorEntityDescription( key="production_ct_metering_status", @@ -569,6 +583,7 @@ CT_PRODUCTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("metering_status"), on_phase=None, + cttype=CtType.PRODUCTION, ), EnvoyCTSensorEntityDescription( key="production_ct_status_flags", @@ -578,6 +593,7 @@ CT_PRODUCTION_SENSORS = ( entity_registry_enabled_default=False, value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), on_phase=None, + cttype=CtType.PRODUCTION, ), ) @@ -607,6 +623,7 @@ CT_STORAGE_SENSORS = ( suggested_display_precision=3, value_fn=attrgetter("energy_delivered"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="lifetime_battery_charged", @@ -618,6 +635,7 @@ CT_STORAGE_SENSORS = ( suggested_display_precision=3, value_fn=attrgetter("energy_received"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="battery_discharge", @@ -629,6 +647,7 @@ CT_STORAGE_SENSORS = ( suggested_display_precision=3, value_fn=attrgetter("active_power"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="storage_ct_frequency", @@ -640,6 +659,7 @@ CT_STORAGE_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("frequency"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="storage_voltage", @@ -652,6 +672,7 @@ CT_STORAGE_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("voltage"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="storage_ct_current", @@ -664,6 +685,7 @@ CT_STORAGE_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("current"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="storage_ct_powerfactor", @@ -674,6 +696,7 @@ CT_STORAGE_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("power_factor"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="storage_ct_metering_status", @@ -684,6 +707,7 @@ CT_STORAGE_SENSORS = ( entity_registry_enabled_default=False, value_fn=attrgetter("metering_status"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="storage_ct_status_flags", @@ -693,6 +717,7 @@ CT_STORAGE_SENSORS = ( entity_registry_enabled_default=False, value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), on_phase=None, + cttype=CtType.STORAGE, ), ) @@ -1015,50 +1040,31 @@ async def async_setup_entry( for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase] if phase is not None ) - # Add net consumption CT entities - if ctmeter := envoy_data.ctmeter_consumption: + # Add Current Transformer entities + if envoy_data.ctmeters: entities.extend( - EnvoyConsumptionCTEntity(coordinator, description) - for description in CT_NET_CONSUMPTION_SENSORS - if ctmeter.measurement_type == CtType.NET_CONSUMPTION + EnvoyCTEntity(coordinator, description) + for sensors in ( + CT_NET_CONSUMPTION_SENSORS, + CT_PRODUCTION_SENSORS, + CT_STORAGE_SENSORS, + ) + for description in sensors + if description.cttype in envoy_data.ctmeters ) - # For each net consumption ct phase reported add net consumption entities - if phase_data := envoy_data.ctmeter_consumption_phases: + # Add Current Transformer phase entities + if ctmeters_phases := envoy_data.ctmeters_phases: entities.extend( - EnvoyConsumptionCTPhaseEntity(coordinator, description) - for use_phase, phase in phase_data.items() - for description in CT_NET_CONSUMPTION_PHASE_SENSORS[use_phase] - if phase.measurement_type == CtType.NET_CONSUMPTION - ) - # Add production CT entities - if ctmeter := envoy_data.ctmeter_production: - entities.extend( - EnvoyProductionCTEntity(coordinator, description) - for description in CT_PRODUCTION_SENSORS - if ctmeter.measurement_type == CtType.PRODUCTION - ) - # For each production ct phase reported add production ct entities - if phase_data := envoy_data.ctmeter_production_phases: - entities.extend( - EnvoyProductionCTPhaseEntity(coordinator, description) - for use_phase, phase in phase_data.items() - for description in CT_PRODUCTION_PHASE_SENSORS[use_phase] - if phase.measurement_type == CtType.PRODUCTION - ) - # Add storage CT entities - if ctmeter := envoy_data.ctmeter_storage: - entities.extend( - EnvoyStorageCTEntity(coordinator, description) - for description in CT_STORAGE_SENSORS - if ctmeter.measurement_type == CtType.STORAGE - ) - # For each storage ct phase reported add storage ct entities - if phase_data := envoy_data.ctmeter_storage_phases: - entities.extend( - EnvoyStorageCTPhaseEntity(coordinator, description) - for use_phase, phase in phase_data.items() - for description in CT_STORAGE_PHASE_SENSORS[use_phase] - if phase.measurement_type == CtType.STORAGE + EnvoyCTPhaseEntity(coordinator, description) + for sensors in ( + CT_NET_CONSUMPTION_PHASE_SENSORS, + CT_PRODUCTION_PHASE_SENSORS, + CT_STORAGE_PHASE_SENSORS, + ) + for phase, descriptions in sensors.items() + for description in descriptions + if (cttype := description.cttype) in ctmeters_phases + and phase in ctmeters_phases[cttype] ) if envoy_data.inverters: @@ -1245,8 +1251,8 @@ class EnvoyNetConsumptionPhaseEntity(EnvoySystemSensorEntity): return self.entity_description.value_fn(system_net_consumption) -class EnvoyConsumptionCTEntity(EnvoySystemSensorEntity): - """Envoy net consumption CT entity.""" +class EnvoyCTEntity(EnvoySystemSensorEntity): + """Envoy CT entity.""" entity_description: EnvoyCTSensorEntityDescription @@ -1255,13 +1261,13 @@ class EnvoyConsumptionCTEntity(EnvoySystemSensorEntity): self, ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None: """Return the state of the CT sensor.""" - if (ctmeter := self.data.ctmeter_consumption) is None: + if (cttype := self.entity_description.cttype) not in self.data.ctmeters: return None - return self.entity_description.value_fn(ctmeter) + return self.entity_description.value_fn(self.data.ctmeters[cttype]) -class EnvoyConsumptionCTPhaseEntity(EnvoySystemSensorEntity): - """Envoy net consumption CT phase entity.""" +class EnvoyCTPhaseEntity(EnvoySystemSensorEntity): + """Envoy CT phase entity.""" entity_description: EnvoyCTSensorEntityDescription @@ -1272,78 +1278,14 @@ class EnvoyConsumptionCTPhaseEntity(EnvoySystemSensorEntity): """Return the state of the CT phase sensor.""" if TYPE_CHECKING: assert self.entity_description.on_phase - if (ctmeter := self.data.ctmeter_consumption_phases) is None: + if (cttype := self.entity_description.cttype) not in self.data.ctmeters_phases: + return None + if (phase := self.entity_description.on_phase) not in self.data.ctmeters_phases[ + cttype + ]: return None return self.entity_description.value_fn( - ctmeter[self.entity_description.on_phase] - ) - - -class EnvoyProductionCTEntity(EnvoySystemSensorEntity): - """Envoy net consumption CT entity.""" - - entity_description: EnvoyCTSensorEntityDescription - - @property - def native_value( - self, - ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None: - """Return the state of the CT sensor.""" - if (ctmeter := self.data.ctmeter_production) is None: - return None - return self.entity_description.value_fn(ctmeter) - - -class EnvoyProductionCTPhaseEntity(EnvoySystemSensorEntity): - """Envoy net consumption CT phase entity.""" - - entity_description: EnvoyCTSensorEntityDescription - - @property - def native_value( - self, - ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None: - """Return the state of the CT phase sensor.""" - if TYPE_CHECKING: - assert self.entity_description.on_phase - if (ctmeter := self.data.ctmeter_production_phases) is None: - return None - return self.entity_description.value_fn( - ctmeter[self.entity_description.on_phase] - ) - - -class EnvoyStorageCTEntity(EnvoySystemSensorEntity): - """Envoy net storage CT entity.""" - - entity_description: EnvoyCTSensorEntityDescription - - @property - def native_value( - self, - ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None: - """Return the state of the CT sensor.""" - if (ctmeter := self.data.ctmeter_storage) is None: - return None - return self.entity_description.value_fn(ctmeter) - - -class EnvoyStorageCTPhaseEntity(EnvoySystemSensorEntity): - """Envoy net storage CT phase entity.""" - - entity_description: EnvoyCTSensorEntityDescription - - @property - def native_value( - self, - ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None: - """Return the state of the CT phase sensor.""" - if TYPE_CHECKING: - assert self.entity_description.on_phase - if (ctmeter := self.data.ctmeter_storage_phases) is None: - return None - return self.entity_description.value_fn( - ctmeter[self.entity_description.on_phase] + self.data.ctmeters_phases[cttype][phase] ) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9b38b83f335..adc0ffab70b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==41.12.0", + "aioesphomeapi==41.13.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.4.0" ], diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 8e3ab5d6892..b9690cbe7dc 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -67,7 +67,7 @@ def suitable_nextchange_time(device: FritzhomeDevice) -> bool: def suitable_temperature(device: FritzhomeDevice) -> bool: """Check suitablity for temperature sensor.""" - return device.has_temperature_sensor and not device.has_thermostat + return bool(device.has_temperature_sensor) def entity_category_temperature(device: FritzhomeDevice) -> EntityCategory | None: diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 6f747bfb318..8d98da2fe4e 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -54,7 +54,7 @@ async def async_setup_entry( except aiohttp.ClientResponseError as err: if 400 <= err.status < 500: raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" + translation_domain=DOMAIN, translation_key="reauth_required" ) from err raise ConfigEntryNotReady from err except aiohttp.ClientError as err: @@ -76,10 +76,6 @@ async def async_unload_entry( hass: HomeAssistant, entry: GoogleAssistantSDKConfigEntry ) -> bool: """Unload a config entry.""" - if not hass.config_entries.async_loaded_entries(DOMAIN): - for service_name in hass.services.async_services_for_domain(DOMAIN): - hass.services.async_remove(DOMAIN, service_name) - conversation.async_unset_agent(hass, entry) return True diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index c40c848ff3f..93d29e5edb5 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -26,7 +26,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.event import async_call_later @@ -68,7 +68,13 @@ async def async_send_text_commands( ) -> list[CommandResponse]: """Send text commands to Google Assistant Service.""" # There can only be 1 entry (config_flow has single_instance_allowed) - entry: GoogleAssistantSDKConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] + entries = hass.config_entries.async_loaded_entries(DOMAIN) + if not entries: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + entry: GoogleAssistantSDKConfigEntry = entries[0] session = entry.runtime_data.session try: diff --git a/homeassistant/components/google_assistant_sdk/services.py b/homeassistant/components/google_assistant_sdk/services.py index 981f4d8ba5c..6e3e9212443 100644 --- a/homeassistant/components/google_assistant_sdk/services.py +++ b/homeassistant/components/google_assistant_sdk/services.py @@ -1,4 +1,4 @@ -"""Support for Google Assistant SDK.""" +"""Services for the Google Assistant SDK integration.""" from __future__ import annotations diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 5831db9a0e3..f26fdd4a29c 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -65,8 +65,14 @@ } }, "exceptions": { + "entry_not_loaded": { + "message": "Entry not loaded" + }, "grpc_error": { "message": "Failed to communicate with Google Assistant" + }, + "reauth_required": { + "message": "Credentials are invalid, re-authentication required" } } } diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 82e83275b6b..e0aee9b5c2a 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.81", "babel==2.15.0"] + "requirements": ["holidays==0.82", "babel==2.15.0"] } diff --git a/homeassistant/components/idasen_desk/coordinator.py b/homeassistant/components/idasen_desk/coordinator.py index f7b7edd2cc1..ee15a90c667 100644 --- a/homeassistant/components/idasen_desk/coordinator.py +++ b/homeassistant/components/idasen_desk/coordinator.py @@ -41,16 +41,12 @@ class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): self._expected_connected = False self._height: int | None = None - @callback - def async_update_data() -> None: - self.async_set_updated_data(self._height) - self._debouncer = Debouncer( hass=self.hass, logger=_LOGGER, cooldown=UPDATE_DEBOUNCE_TIME, immediate=True, - function=async_update_data, + function=callback(lambda: self.async_set_updated_data(self._height)), ) async def async_connect(self) -> bool: diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 3bf47df83a4..b8121286310 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.1.1"] + "requirements": ["pylamarzocco==2.1.2"] } diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index 26f7588033c..60695a91e2b 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["letpot"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["letpot==0.6.2"] } diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index f5e88bfc369..3931e7eabed 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -41,7 +41,10 @@ rules: docs-installation-parameters: done entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: + status: done + comment: | + Logging handled by library when (un)available once (push) or coordinator (pull). parallel-updates: done reauthentication-flow: done test-coverage: done diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index f21a7b7a931..92c6e38a610 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -146,6 +146,13 @@ "off": "mdi:lock-off" } }, + "speaker_mute": { + "default": "mdi:volume-high", + "state": { + "on": "mdi:volume-mute", + "off": "mdi:volume-high" + } + }, "evse_charging_switch": { "default": "mdi:ev-station" }, diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index f9783127673..4162d406e7c 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -176,6 +176,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnLevel,), + not_device_type=(device_types.Speaker,), # allow None value to account for 'default' value allow_none_value=True, ), diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 0c95cda9474..b3b81583b19 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -152,6 +152,7 @@ PUMP_CONTROL_MODE_MAP = { clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None, } +HUMIDITY_SCALING_FACTOR = 100 TEMPERATURE_SCALING_FACTOR = 100 @@ -308,7 +309,7 @@ DISCOVERY_SCHEMAS = [ key="TemperatureSensor", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - device_to_ha=lambda x: x / 100, + device_to_ha=lambda x: x / TEMPERATURE_SCALING_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -344,7 +345,7 @@ DISCOVERY_SCHEMAS = [ key="HumiditySensor", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, - device_to_ha=lambda x: x / 100, + device_to_ha=lambda x: x / HUMIDITY_SCALING_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -1136,7 +1137,7 @@ DISCOVERY_SCHEMAS = [ key="ThermostatLocalTemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - device_to_ha=lambda x: x / 100, + device_to_ha=lambda x: x / TEMPERATURE_SCALING_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index a46fbddd612..6766cd57e5e 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -514,6 +514,9 @@ "power": { "name": "Power" }, + "speaker_mute": { + "name": "Mute" + }, "child_lock": { "name": "Child lock" }, diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 2c02522f0a1..682285e9c97 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -203,7 +203,6 @@ DISCOVERY_SCHEMAS = [ device_types.Refrigerator, device_types.RoboticVacuumCleaner, device_types.RoomAirConditioner, - device_types.Speaker, ), ), MatterDiscoverySchema( @@ -242,6 +241,24 @@ DISCOVERY_SCHEMAS = [ device_types.Speaker, ), ), + MatterDiscoverySchema( + platform=Platform.SWITCH, + entity_description=MatterNumericSwitchEntityDescription( + key="MatterMuteToggle", + translation_key="speaker_mute", + device_to_ha={ + True: False, # True means volume is on, so HA should show mute as off + False: True, # False means volume is off (muted), so HA should show mute as on + }.get, + ha_to_device={ + False: True, # HA showing mute as off means volume is on, so send True + True: False, # HA showing mute as on means volume is off (muted), so send False + }.get, + ), + entity_class=MatterNumericSwitch, + required_attributes=(clusters.OnOff.Attributes.OnOff,), + device_type=(device_types.Speaker,), + ), MatterDiscoverySchema( platform=Platform.SWITCH, entity_description=MatterNumericSwitchEntityDescription( diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py index 0f34962f7ee..064fff32b24 100644 --- a/homeassistant/components/mcp/config_flow.py +++ b/homeassistant/components/mcp/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass import logging from typing import Any, cast @@ -23,7 +24,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from . import async_get_config_entry_implementation from .application_credentials import authorization_server_context -from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN +from .const import ( + CONF_ACCESS_TOKEN, + CONF_AUTHORIZATION_URL, + CONF_SCOPE, + CONF_TOKEN_URL, + DOMAIN, +) from .coordinator import TokenManager, mcp_client _LOGGER = logging.getLogger(__name__) @@ -41,9 +48,17 @@ MCP_DISCOVERY_HEADERS = { } +@dataclass +class OAuthConfig: + """Class to hold OAuth configuration.""" + + authorization_server: AuthorizationServer + scopes: list[str] | None = None + + async def async_discover_oauth_config( hass: HomeAssistant, mcp_server_url: str -) -> AuthorizationServer: +) -> OAuthConfig: """Discover the OAuth configuration for the MCP server. This implements the functionality in the MCP spec for discovery. If the MCP server URL @@ -65,9 +80,11 @@ async def async_discover_oauth_config( except httpx.HTTPStatusError as error: if error.response.status_code == 404: _LOGGER.info("Authorization Server Metadata not found, using default paths") - return AuthorizationServer( - authorize_url=str(parsed_url.with_path("/authorize")), - token_url=str(parsed_url.with_path("/token")), + return OAuthConfig( + authorization_server=AuthorizationServer( + authorize_url=str(parsed_url.with_path("/authorize")), + token_url=str(parsed_url.with_path("/token")), + ) ) raise CannotConnect from error except httpx.HTTPError as error: @@ -81,9 +98,15 @@ async def async_discover_oauth_config( authorize_url = str(parsed_url.with_path(authorize_url)) if token_url.startswith("/"): token_url = str(parsed_url.with_path(token_url)) - return AuthorizationServer( - authorize_url=authorize_url, - token_url=token_url, + # We have no way to know the minimum set of scopes needed, so request + # all of them and let the user limit during the authorization step. + scopes = data.get("scopes_supported") + return OAuthConfig( + authorization_server=AuthorizationServer( + authorize_url=authorize_url, + token_url=token_url, + ), + scopes=scopes, ) @@ -130,6 +153,7 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): """Initialize the config flow.""" super().__init__() self.data: dict[str, Any] = {} + self.oauth_config: OAuthConfig | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -170,7 +194,7 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): to find the OAuth medata then run the OAuth authentication flow. """ try: - authorization_server = await async_discover_oauth_config( + oauth_config = await async_discover_oauth_config( self.hass, self.data[CONF_URL] ) except TimeoutConnectError: @@ -181,11 +205,13 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: - _LOGGER.info("OAuth configuration: %s", authorization_server) + _LOGGER.info("OAuth configuration: %s", oauth_config) + self.oauth_config = oauth_config self.data.update( { - CONF_AUTHORIZATION_URL: authorization_server.authorize_url, - CONF_TOKEN_URL: authorization_server.token_url, + CONF_AUTHORIZATION_URL: oauth_config.authorization_server.authorize_url, + CONF_TOKEN_URL: oauth_config.authorization_server.token_url, + CONF_SCOPE: oauth_config.scopes, } ) return await self.async_step_credentials_choice() @@ -197,6 +223,15 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self.data[CONF_TOKEN_URL], ) + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + data = {} + if self.data and (scopes := self.data[CONF_SCOPE]) is not None: + data[CONF_SCOPE] = " ".join(scopes) + data.update(super().extra_authorize_data) + return data + async def async_step_credentials_choice( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py index 13f63b02c73..19fad8f4736 100644 --- a/homeassistant/components/mcp/const.py +++ b/homeassistant/components/mcp/const.py @@ -5,3 +5,4 @@ DOMAIN = "mcp" CONF_ACCESS_TOKEN = "access_token" CONF_AUTHORIZATION_URL = "authorization_url" CONF_TOKEN_URL = "token_url" +CONF_SCOPE = "scope" diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 3746705510b..19ace718564 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -51,7 +51,7 @@ from .types import MCPServerConfigEntry _LOGGER = logging.getLogger(__name__) # Streamable HTTP endpoint -STREAMABLE_API = f"/api/{DOMAIN}" +STREAMABLE_API = "/api/mcp" TIMEOUT = 60 # Seconds # Content types diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 1fdcc4f897f..2fae62f27cd 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/mealie", "integration_type": "service", "iot_class": "local_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["aiomealie==1.0.0"] } diff --git a/homeassistant/components/mealie/quality_scale.yaml b/homeassistant/components/mealie/quality_scale.yaml index 1fccc3add81..2d19772f54f 100644 --- a/homeassistant/components/mealie/quality_scale.yaml +++ b/homeassistant/components/mealie/quality_scale.yaml @@ -49,11 +49,11 @@ rules: The integration will discover a Mealie addon posting a discovery message. docs-data-update: done docs-examples: done - docs-known-limitations: todo + docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo - docs-use-cases: todo + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: done comment: | diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py index 57c10f6f7bd..8cb4db6bbe0 100644 --- a/homeassistant/components/miele/entity.py +++ b/homeassistant/components/miele/entity.py @@ -37,8 +37,8 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, serial_number=device_id, - name=appliance_type or device.tech_type, - translation_key=appliance_type, + name=device.device_name or appliance_type or device.tech_type, + translation_key=None if device.device_name else appliance_type, manufacturer=MANUFACTURER, model=device.tech_type, hw_version=device.xkm_tech_type, diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 60e7fba5969..d66e29d8f46 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -54,6 +54,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_PLATE_COUNT = 4 PLATE_COUNT = { + "KM7575": 6, "KM7678": 6, "KM7697": 6, "KM7878": 6, diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 467ccd6d821..5f376806d7c 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -253,6 +253,7 @@ class ModbusHub: self._client: ( AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None ) = None + self._lock = asyncio.Lock() self.event_connected = asyncio.Event() self.hass = hass self.name = client_config[CONF_NAME] @@ -415,7 +416,9 @@ class ModbusHub: """Convert async to sync pymodbus call.""" if not self._client: return None - result = await self.low_level_pb_call(unit, address, value, use_call) - if self._msg_wait: - await asyncio.sleep(self._msg_wait) - return result + async with self._lock: + result = await self.low_level_pb_call(unit, address, value, use_call) + if self._msg_wait: + # small delay until next request/response + await asyncio.sleep(self._msg_wait) + return result diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 04adc9f2d60..be1f3e9f8f3 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -174,7 +174,7 @@ class MotionBaseDevice(MotionCoordinatorEntity, CoverEntity): _restore_tilt = False - def __init__(self, coordinator, blind, device_class): + def __init__(self, coordinator, blind, device_class) -> None: """Initialize the blind.""" super().__init__(coordinator, blind) @@ -275,7 +275,7 @@ class MotionTiltDevice(MotionPositionDevice): """ if self._blind.angle is None: return None - return self._blind.angle * 100 / 180 + return 100 - (self._blind.angle * 100 / 180) @property def is_closed(self) -> bool | None: @@ -287,14 +287,14 @@ class MotionTiltDevice(MotionPositionDevice): async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" async with self._api_lock: - await self.hass.async_add_executor_job(self._blind.Set_angle, 180) + await self.hass.async_add_executor_job(self._blind.Set_angle, 0) await self.async_request_position_till_stop() async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" async with self._api_lock: - await self.hass.async_add_executor_job(self._blind.Set_angle, 0) + await self.hass.async_add_executor_job(self._blind.Set_angle, 180) await self.async_request_position_till_stop() @@ -302,7 +302,7 @@ class MotionTiltDevice(MotionPositionDevice): """Move the cover tilt to a specific position.""" angle = kwargs[ATTR_TILT_POSITION] * 180 / 100 async with self._api_lock: - await self.hass.async_add_executor_job(self._blind.Set_angle, angle) + await self.hass.async_add_executor_job(self._blind.Set_angle, 180 - angle) await self.async_request_position_till_stop() @@ -347,9 +347,9 @@ class MotionTiltOnlyDevice(MotionTiltDevice): if self._blind.position is None: if self._blind.angle is None: return None - return self._blind.angle * 100 / 180 + return 100 - (self._blind.angle * 100 / 180) - return self._blind.position + return 100 - self._blind.position @property def is_closed(self) -> bool | None: @@ -357,9 +357,9 @@ class MotionTiltOnlyDevice(MotionTiltDevice): if self._blind.position is None: if self._blind.angle is None: return None - return self._blind.angle == 0 + return self._blind.angle == 180 - return self._blind.position == 0 + return self._blind.position == 100 async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" @@ -381,10 +381,14 @@ class MotionTiltOnlyDevice(MotionTiltDevice): if self._blind.position is None: angle = angle * 180 / 100 async with self._api_lock: - await self.hass.async_add_executor_job(self._blind.Set_angle, angle) + await self.hass.async_add_executor_job( + self._blind.Set_angle, 180 - angle + ) else: async with self._api_lock: - await self.hass.async_add_executor_job(self._blind.Set_position, angle) + await self.hass.async_add_executor_job( + self._blind.Set_position, 100 - angle + ) await self.async_request_position_till_stop() @@ -397,10 +401,14 @@ class MotionTiltOnlyDevice(MotionTiltDevice): if self._blind.position is None: angle = angle * 180 / 100 async with self._api_lock: - await self.hass.async_add_executor_job(self._blind.Set_angle, angle) + await self.hass.async_add_executor_job( + self._blind.Set_angle, 180 - angle + ) else: async with self._api_lock: - await self.hass.async_add_executor_job(self._blind.Set_position, angle) + await self.hass.async_add_executor_job( + self._blind.Set_position, 100 - angle + ) await self.async_request_position_till_stop() @@ -408,7 +416,7 @@ class MotionTiltOnlyDevice(MotionTiltDevice): class MotionTDBUDevice(MotionBaseDevice): """Representation of a Motion Top Down Bottom Up blind Device.""" - def __init__(self, coordinator, blind, device_class, motor): + def __init__(self, coordinator, blind, device_class, motor) -> None: """Initialize the blind.""" super().__init__(coordinator, blind, device_class) self._motor = motor diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index d115c13d0e7..26b663f1c11 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -46,6 +46,14 @@ from homeassistant.components.light import ( VALID_COLOR_MODES, valid_supported_color_modes, ) +from homeassistant.components.number import ( + DEFAULT_MAX_VALUE, + DEFAULT_MIN_VALUE, + DEFAULT_STEP, + DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, + NumberDeviceClass, + NumberMode, +) from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_UNITS, @@ -80,6 +88,7 @@ from homeassistant.const import ( CONF_EFFECT, CONF_ENTITY_CATEGORY, CONF_HOST, + CONF_MODE, CONF_NAME, CONF_OPTIMISTIC, CONF_PASSWORD, @@ -212,7 +221,9 @@ from .const import ( CONF_IMAGE_TOPIC, CONF_KEEPALIVE, CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_MAX, CONF_MAX_KELVIN, + CONF_MIN, CONF_MIN_KELVIN, CONF_MODE_COMMAND_TEMPLATE, CONF_MODE_COMMAND_TOPIC, @@ -294,6 +305,7 @@ from .const import ( CONF_STATE_UNLOCKED, CONF_STATE_UNLOCKING, CONF_STATE_VALUE_TEMPLATE, + CONF_STEP, CONF_SUGGESTED_DISPLAY_PRECISION, CONF_SUPPORTED_COLOR_MODES, CONF_SUPPORTED_FEATURES, @@ -445,6 +457,7 @@ SUBENTRY_PLATFORMS = [ Platform.LIGHT, Platform.LOCK, Platform.NOTIFY, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] @@ -680,6 +693,24 @@ LIGHT_SCHEMA_SELECTOR = SelectSelector( translation_key="light_schema", ) ) +MIN_MAX_SELECTOR = NumberSelector(NumberSelectorConfig(step=1e-3)) +NUMBER_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in NumberDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + # The number device classes are all shared with the sensor device classes + translation_key="device_class_sensor", + sort=True, + ) +) +NUMBER_MODE_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[mode.value for mode in NumberMode], + mode=SelectSelectorMode.DROPDOWN, + translation_key="number_mode", + sort=True, + ) +) ON_COMMAND_TYPE_SELECTOR = SelectSelector( SelectSelectorConfig( options=VALUES_ON_COMMAND_TYPE, @@ -727,6 +758,7 @@ SENSOR_STATE_CLASS_SELECTOR = SelectSelector( translation_key=CONF_STATE_CLASS, ) ) +STEP_SELECTOR = NumberSelector(NumberSelectorConfig(min=1e-3, step=1e-3)) SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( SelectSelectorConfig( options=[platform.value for platform in VALID_COLOR_MODES], @@ -883,6 +915,23 @@ def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: ) +@callback +def number_unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: + """Return a context based unit of measurement selector for number entities.""" + + if ( + device_class := user_data.get(CONF_DEVICE_CLASS) + ) is None or device_class not in NUMBER_DEVICE_CLASS_UNITS: + return TEXT_SELECTOR + return SelectSelector( + SelectSelectorConfig( + options=[str(uom) for uom in NUMBER_DEVICE_CLASS_UNITS[device_class]], + sort=True, + custom_value=True, + ) + ) + + @callback def validate(validator: Callable[[Any], Any]) -> Callable[[Any], Any]: """Run validator, then return the unmodified input.""" @@ -1006,6 +1055,29 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]: return errors +@callback +def validate_number_platform_config(config: dict[str, Any]) -> dict[str, str]: + """Validate MQTT number configuration.""" + errors: dict[str, Any] = {} + if ( + CONF_MIN in config + and CONF_MAX in config + and config[CONF_MIN] > config[CONF_MAX] + ): + errors[CONF_MIN] = "max_below_min" + errors[CONF_MAX] = "max_below_min" + + if ( + (device_class := config.get(CONF_DEVICE_CLASS)) is not None + and device_class in NUMBER_DEVICE_CLASS_UNITS + and config.get(CONF_UNIT_OF_MEASUREMENT) + not in NUMBER_DEVICE_CLASS_UNITS[device_class] + ): + errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom" + + return errors + + @callback def validate_sensor_platform_config( config: dict[str, Any], @@ -1068,6 +1140,7 @@ ENTITY_CONFIG_VALIDATOR: dict[ Platform.LIGHT.value: validate_light_platform_config, Platform.LOCK.value: None, Platform.NOTIFY.value: None, + Platform.NUMBER.value: validate_number_platform_config, Platform.SENSOR.value: validate_sensor_platform_config, Platform.SWITCH.value: None, } @@ -1283,6 +1356,17 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { }, Platform.LOCK.value: {}, Platform.NOTIFY.value: {}, + Platform.NUMBER: { + CONF_DEVICE_CLASS: PlatformField( + selector=NUMBER_DEVICE_CLASS_SELECTOR, + required=False, + ), + CONF_UNIT_OF_MEASUREMENT: PlatformField( + selector=number_unit_of_measurement_selector, + required=False, + custom_filtering=True, + ), + }, Platform.SENSOR.value: { CONF_DEVICE_CLASS: PlatformField( selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False @@ -2967,6 +3051,58 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = { ), CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), }, + Platform.NUMBER.value: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=False, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_MIN: PlatformField( + selector=MIN_MAX_SELECTOR, + required=True, + default=DEFAULT_MIN_VALUE, + ), + CONF_MAX: PlatformField( + selector=MIN_MAX_SELECTOR, + required=True, + default=DEFAULT_MAX_VALUE, + ), + CONF_STEP: PlatformField( + selector=STEP_SELECTOR, + required=True, + default=DEFAULT_STEP, + ), + CONF_MODE: PlatformField( + selector=NUMBER_MODE_SELECTOR, + required=True, + default=NumberMode.AUTO.value, + ), + CONF_PAYLOAD_RESET: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_RESET, + ), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + }, Platform.SENSOR.value: { CONF_STATE_TOPIC: PlatformField( selector=TEXT_SELECTOR, diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index d16617ef2a4..f5d370828ad 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -120,8 +120,10 @@ CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" CONF_HUMIDITY_MAX = "max_humidity" CONF_HUMIDITY_MIN = "min_humidity" CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" +CONF_MAX = "max" CONF_MAX_KELVIN = "max_kelvin" CONF_MAX_MIREDS = "max_mireds" +CONF_MIN = "min" CONF_MIN_KELVIN = "min_kelvin" CONF_MIN_MIREDS = "min_mireds" CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" @@ -196,6 +198,7 @@ CONF_STATE_OPENING = "state_opening" CONF_STATE_STOPPED = "state_stopped" CONF_STATE_UNLOCKED = "state_unlocked" CONF_STATE_UNLOCKING = "state_unlocking" +CONF_STEP = "step" CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template" diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 9da68e62d80..cba52bd04ec 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -37,8 +37,12 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_MAX, + CONF_MIN, CONF_PAYLOAD_RESET, CONF_STATE_TOPIC, + CONF_STEP, + DEFAULT_PAYLOAD_RESET, ) from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( @@ -53,12 +57,7 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -CONF_MIN = "min" -CONF_MAX = "max" -CONF_STEP = "step" - DEFAULT_NAME = "MQTT Number" -DEFAULT_PAYLOAD_RESET = "None" MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset( { diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 49449c2f52d..fe848ea43c6 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -300,7 +300,7 @@ "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)", "supported_features": "The features that the entity supports.", "temperature_unit": "This determines the native unit of measurement the MQTT climate device works with.", - "unit_of_measurement": "Defines the unit of measurement of the sensor, if any." + "unit_of_measurement": "Defines the unit of measurement, if any." }, "sections": { "advanced_settings": { @@ -336,6 +336,9 @@ "image_encoding": "Image encoding", "image_topic": "Image topic", "last_reset_value_template": "Last reset value template", + "max": "Maximum", + "min": "Minimum", + "mode": "Mode", "modes": "Supported operation modes", "mode_command_topic": "Operation mode command topic", "mode_command_template": "Operation mode command template", @@ -346,6 +349,7 @@ "payload_off": "Payload \"off\"", "payload_on": "Payload \"on\"", "payload_press": "Payload \"press\"", + "payload_reset": "Payload \"reset\"", "qos": "QoS", "red_template": "Red template", "retain": "Retain", @@ -354,6 +358,7 @@ "state_template": "State template", "state_topic": "State topic", "state_value_template": "State value template", + "step": "Step", "supported_color_modes": "Supported color modes", "url_template": "URL template", "url_topic": "URL topic", @@ -378,6 +383,9 @@ "image_encoding": "Select the encoding of the received image data", "image_topic": "The MQTT topic subscribed to receive messages containing the image data. [Learn more.]({url}#image_topic)", "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", + "max": "Maximum value. [Learn more.]({url}#max)", + "min": "Minimum value. [Learn more.]({url}#min)", + "mode": "Control how the number should be displayed in the UI. [Learn more.]({url}#mode)", "modes": "A list of supported operation modes. [Learn more.]({url}#modes)", "mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)", "mode_command_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to define the operation mode to be sent to the operation mode command topic. [Learn more.]({url}#mode_command_template)", @@ -388,6 +396,7 @@ "payload_off": "The payload that represents the \"off\" state.", "payload_on": "The payload that represents the \"on\" state.", "payload_press": "The payload to send when the button is triggered.", + "payload_reset": "The payload received at the state topic that resets the entity to an unknown state.", "qos": "The QoS value a {platform} entity should use.", "red_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract red color from the state payload value. Expected result of the template is an integer from 0-255 range.", "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", @@ -395,6 +404,7 @@ "state_on": "The incoming payload that represents the \"on\" state. Use only when the value that represents \"on\" state in the state topic is different from value that should be sent to the command topic to turn the device on.", "state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.", "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", + "step": "Step value. Smallest value 0.001.", "supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)", "url_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)", "url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)", @@ -997,6 +1007,7 @@ "invalid_uom_for_state_class": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected state class, please either remove the state class, select a state class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", "invalid_url": "Invalid URL", "last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only", + "max_below_min": "Max value should be greater or equal to min value", "max_below_min_humidity": "Max humidity value should be greater than min humidity value", "max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value", "max_below_min_temperature": "Max temperature value should be greater than min temperature value", @@ -1296,6 +1307,13 @@ "template": "Template" } }, + "number_mode": { + "options": { + "auto": "[%key:component::number::entity_component::_::state_attributes::mode::state::auto%]", + "box": "[%key:component::number::entity_component::_::state_attributes::mode::state::box%]", + "slider": "[%key:component::number::entity_component::_::state_attributes::mode::state::slider%]" + } + }, "on_command_type": { "options": { "brightness": "Brightness", @@ -1315,6 +1333,7 @@ "light": "[%key:component::light::title%]", "lock": "[%key:component::lock::title%]", "notify": "[%key:component::notify::title%]", + "number": "[%key:component::number::title%]", "sensor": "[%key:component::sensor::title%]", "switch": "[%key:component::switch::title%]" } diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 1193d33d435..07f3aed63e2 100644 --- a/homeassistant/components/niko_home_control/manifest.json +++ b/homeassistant/components/niko_home_control/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/niko_home_control", "iot_class": "local_push", "loggers": ["nikohomecontrol"], - "requirements": ["nhc==0.4.12"] + "requirements": ["nhc==0.6.1"] } diff --git a/homeassistant/components/nintendo_parental/__init__.py b/homeassistant/components/nintendo_parental/__init__.py new file mode 100644 index 00000000000..91b4ebee1cb --- /dev/null +++ b/homeassistant/components/nintendo_parental/__init__.py @@ -0,0 +1,51 @@ +"""The Nintendo Switch Parental Controls integration.""" + +from __future__ import annotations + +from pynintendoparental import Authenticator +from pynintendoparental.exceptions import ( + InvalidOAuthConfigurationException, + InvalidSessionTokenException, +) + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SESSION_TOKEN, DOMAIN +from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: NintendoParentalConfigEntry +) -> bool: + """Set up Nintendo Switch Parental Controls from a config entry.""" + try: + nintendo_auth = await Authenticator.complete_login( + auth=None, + response_token=entry.data[CONF_SESSION_TOKEN], + is_session_token=True, + client_session=async_get_clientsession(hass), + ) + except (InvalidSessionTokenException, InvalidOAuthConfigurationException) as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="auth_expired", + ) from err + entry.runtime_data = coordinator = NintendoUpdateCoordinator( + hass, nintendo_auth, entry + ) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: NintendoParentalConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/nintendo_parental/config_flow.py b/homeassistant/components/nintendo_parental/config_flow.py new file mode 100644 index 00000000000..1bb16e6bb11 --- /dev/null +++ b/homeassistant/components/nintendo_parental/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for the Nintendo Switch Parental Controls integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from pynintendoparental import Authenticator +from pynintendoparental.exceptions import HttpException, InvalidSessionTokenException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_SESSION_TOKEN, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NintendoConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nintendo Switch Parental Controls.""" + + def __init__(self) -> None: + """Initialize a new config flow instance.""" + self.auth: Authenticator | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if self.auth is None: + self.auth = Authenticator.generate_login( + client_session=async_get_clientsession(self.hass) + ) + + if user_input is not None: + try: + await self.auth.complete_login( + self.auth, user_input[CONF_API_TOKEN], False + ) + except (ValueError, InvalidSessionTokenException, HttpException): + errors["base"] = "invalid_auth" + else: + if TYPE_CHECKING: + assert self.auth.account_id + await self.async_set_unique_id(self.auth.account_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self.auth.account_id, + data={ + CONF_SESSION_TOKEN: self.auth.get_session_token, + }, + ) + return self.async_show_form( + step_id="user", + description_placeholders={"link": self.auth.login_url}, + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/nintendo_parental/const.py b/homeassistant/components/nintendo_parental/const.py new file mode 100644 index 00000000000..0cea2e56ac8 --- /dev/null +++ b/homeassistant/components/nintendo_parental/const.py @@ -0,0 +1,5 @@ +"""Constants for the Nintendo Switch Parental Controls integration.""" + +DOMAIN = "nintendo_parental" +CONF_UPDATE_INTERVAL = "update_interval" +CONF_SESSION_TOKEN = "session_token" diff --git a/homeassistant/components/nintendo_parental/coordinator.py b/homeassistant/components/nintendo_parental/coordinator.py new file mode 100644 index 00000000000..49b4fae60f3 --- /dev/null +++ b/homeassistant/components/nintendo_parental/coordinator.py @@ -0,0 +1,52 @@ +"""Nintendo Parental Controls data coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pynintendoparental import Authenticator, NintendoParental +from pynintendoparental.exceptions import InvalidOAuthConfigurationException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +type NintendoParentalConfigEntry = ConfigEntry[NintendoUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL = timedelta(seconds=60) + + +class NintendoUpdateCoordinator(DataUpdateCoordinator[None]): + """Nintendo data update coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + authenticator: Authenticator, + config_entry: NintendoParentalConfigEntry, + ) -> None: + """Initialize update coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=config_entry, + ) + self.api = NintendoParental( + authenticator, hass.config.time_zone, hass.config.language + ) + + async def _async_update_data(self) -> None: + """Update data from Nintendo's API.""" + try: + return await self.api.update() + except InvalidOAuthConfigurationException as err: + raise ConfigEntryError( + err, translation_domain=DOMAIN, translation_key="invalid_auth" + ) from err diff --git a/homeassistant/components/nintendo_parental/entity.py b/homeassistant/components/nintendo_parental/entity.py new file mode 100644 index 00000000000..74d3bcae8a7 --- /dev/null +++ b/homeassistant/components/nintendo_parental/entity.py @@ -0,0 +1,41 @@ +"""Base entity definition for Nintendo Parental.""" + +from __future__ import annotations + +from pynintendoparental.device import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NintendoUpdateCoordinator + + +class NintendoDevice(CoordinatorEntity[NintendoUpdateCoordinator]): + """Represent a Nintendo Switch.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: NintendoUpdateCoordinator, device: Device, key: str + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._device = device + self._attr_unique_id = f"{device.device_id}_{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + manufacturer="Nintendo", + name=device.name, + sw_version=device.extra["firmwareVersion"]["displayedVersion"], + ) + + async def async_added_to_hass(self) -> None: + """When entity is loaded.""" + await super().async_added_to_hass() + self._device.add_device_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """When will be removed from HASS.""" + self._device.remove_device_callback(self.async_write_ha_state) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/nintendo_parental/manifest.json b/homeassistant/components/nintendo_parental/manifest.json new file mode 100644 index 00000000000..03daba3356d --- /dev/null +++ b/homeassistant/components/nintendo_parental/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "nintendo_parental", + "name": "Nintendo Switch Parental Controls", + "codeowners": ["@pantherale0"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/nintendo_parental", + "iot_class": "cloud_polling", + "loggers": ["pynintendoparental"], + "quality_scale": "bronze", + "requirements": ["pynintendoparental==1.1.1"] +} diff --git a/homeassistant/components/nintendo_parental/quality_scale.yaml b/homeassistant/components/nintendo_parental/quality_scale.yaml new file mode 100644 index 00000000000..523d4fe68ce --- /dev/null +++ b/homeassistant/components/nintendo_parental/quality_scale.yaml @@ -0,0 +1,81 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions are defined. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No IP discovery. + discovery: + status: exempt + comment: | + No discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: + status: exempt + comment: | + No specific icons defined. + reconfiguration-flow: todo + repair-issues: + comment: | + No issues in integration + status: exempt + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/nintendo_parental/sensor.py b/homeassistant/components/nintendo_parental/sensor.py new file mode 100644 index 00000000000..803fb39bcb4 --- /dev/null +++ b/homeassistant/components/nintendo_parental/sensor.py @@ -0,0 +1,91 @@ +"""Sensor platform for Nintendo Parental.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NintendoParentalConfigEntry, NintendoUpdateCoordinator +from .entity import Device, NintendoDevice + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class NintendoParentalSensor(StrEnum): + """Store keys for Nintendo Parental sensors.""" + + PLAYING_TIME = "playing_time" + TIME_REMAINING = "time_remaining" + + +@dataclass(kw_only=True, frozen=True) +class NintendoParentalSensorEntityDescription(SensorEntityDescription): + """Description for Nintendo Parental sensor entities.""" + + value_fn: Callable[[Device], int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[NintendoParentalSensorEntityDescription, ...] = ( + NintendoParentalSensorEntityDescription( + key=NintendoParentalSensor.PLAYING_TIME, + translation_key=NintendoParentalSensor.PLAYING_TIME, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.today_playing_time, + ), + NintendoParentalSensorEntityDescription( + key=NintendoParentalSensor.TIME_REMAINING, + translation_key=NintendoParentalSensor.TIME_REMAINING, + native_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.today_time_remaining, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NintendoParentalConfigEntry, + async_add_devices: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + async_add_devices( + NintendoParentalSensorEntity(entry.runtime_data, device, sensor) + for device in entry.runtime_data.api.devices.values() + for sensor in SENSOR_DESCRIPTIONS + ) + + +class NintendoParentalSensorEntity(NintendoDevice, SensorEntity): + """Represent a single sensor.""" + + entity_description: NintendoParentalSensorEntityDescription + + def __init__( + self, + coordinator: NintendoUpdateCoordinator, + device: Device, + description: NintendoParentalSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator, device=device, key=description.key) + self.entity_description = description + + @property + def native_value(self) -> int | float | None: + """Return the native value.""" + return self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/nintendo_parental/strings.json b/homeassistant/components/nintendo_parental/strings.json new file mode 100644 index 00000000000..f35746b41f3 --- /dev/null +++ b/homeassistant/components/nintendo_parental/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "user": { + "description": "To obtain your access token, click [Nintendo Login]({link}) to sign in to your Nintendo account. Then, for the account you want to link, right-click on the red **Select this person** button and choose **Copy Link Address**.", + "data": { + "api_token": "Access token" + }, + "data_description": { + "api_token": "The link copied from the Nintendo website" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "sensor": { + "playing_time": { + "name": "Used screen time" + }, + "time_remaining": { + "name": "Screen time remaining" + } + } + }, + "exceptions": { + "auth_expired": { + "message": "Authentication expired. Please remove and re-add the integration to reconnect." + } + } +} diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index e568764871a..f84694d6364 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -157,7 +157,7 @@ def async_setup_services(hass: HomeAssistant) -> None: ) from error except NordPoolEmptyResponseError: return {area: [] for area in areas} - except NordPoolError as error: + except (NordPoolError, TimeoutError) as error: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="connection_error", diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 8c94269f069..b3cc5f7c814 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -22,7 +22,7 @@ "name": "Mode", "state": { "auto": "Automatic", - "box": "Box", + "box": "Input field", "slider": "Slider" } }, diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 7fb27cc7b80..3c740d445d8 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -17,6 +17,7 @@ from homeassistant.util.enum import try_parse_enum from .const import DOMAIN from .device import ONVIFDevice from .entity import ONVIFBaseEntity +from .util import build_event_entity_names async def async_setup_entry( @@ -24,36 +25,45 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up a ONVIF binary sensor.""" + """Set up ONVIF binary sensor platform.""" device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] - entities = { - event.uid: ONVIFBinarySensor(event.uid, device) - for event in device.events.get_platform("binary_sensor") - } + events = device.events.get_platform("binary_sensor") + entity_names = build_event_entity_names(events) + + uids = set() + entities = [] + for event in events: + uids.add(event.uid) + entities.append( + ONVIFBinarySensor(event.uid, device, name=entity_names[event.uid]) + ) ent_reg = er.async_get(hass) for entry in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id): - if entry.domain == "binary_sensor" and entry.unique_id not in entities: - entities[entry.unique_id] = ONVIFBinarySensor( - entry.unique_id, device, entry - ) + if entry.domain == "binary_sensor" and entry.unique_id not in uids: + uids.add(entry.unique_id) + entities.append(ONVIFBinarySensor(entry.unique_id, device, entry=entry)) - async_add_entities(entities.values()) + async_add_entities(entities) uids_by_platform = device.events.get_uids_by_platform("binary_sensor") @callback def async_check_entities() -> None: """Check if we have added an entity for the event.""" nonlocal uids_by_platform - if not (missing := uids_by_platform.difference(entities)): + if not (missing := uids_by_platform.difference(uids)): return - new_entities: dict[str, ONVIFBinarySensor] = { - uid: ONVIFBinarySensor(uid, device) for uid in missing - } + + events = device.events.get_platform("binary_sensor") + entity_names = build_event_entity_names(events) + + new_entities = [ + ONVIFBinarySensor(uid, device, name=entity_names[uid]) for uid in missing + ] if new_entities: - entities.update(new_entities) - async_add_entities(new_entities.values()) + uids.update(missing) + async_add_entities(new_entities) device.events.async_add_listener(async_check_entities) @@ -65,7 +75,11 @@ class ONVIFBinarySensor(ONVIFBaseEntity, RestoreEntity, BinarySensorEntity): _attr_unique_id: str def __init__( - self, uid: str, device: ONVIFDevice, entry: er.RegistryEntry | None = None + self, + uid: str, + device: ONVIFDevice, + name: str | None = None, + entry: er.RegistryEntry | None = None, ) -> None: """Initialize the ONVIF binary sensor.""" self._attr_unique_id = uid @@ -78,12 +92,13 @@ class ONVIFBinarySensor(ONVIFBaseEntity, RestoreEntity, BinarySensorEntity): else: event = device.events.get_uid(uid) assert event + assert name self._attr_device_class = try_parse_enum( BinarySensorDeviceClass, event.device_class ) self._attr_entity_category = event.entity_category self._attr_entity_registry_enabled_default = event.entity_enabled - self._attr_name = f"{device.name} {event.name}" + self._attr_name = f"{device.name} {name}" self._attr_is_on = event.value super().__init__(device) diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index f6387de009c..15e2144b510 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -16,6 +16,7 @@ from homeassistant.util.enum import try_parse_enum from .const import DOMAIN from .device import ONVIFDevice from .entity import ONVIFBaseEntity +from .util import build_event_entity_names async def async_setup_entry( @@ -23,34 +24,43 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up a ONVIF binary sensor.""" + """Set up ONVIF sensor platform.""" device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id] - entities = { - event.uid: ONVIFSensor(event.uid, device) - for event in device.events.get_platform("sensor") - } + events = device.events.get_platform("sensor") + entity_names = build_event_entity_names(events) + + uids = set() + entities = [] + for event in events: + uids.add(event.uid) + entities.append(ONVIFSensor(event.uid, device, name=entity_names[event.uid])) ent_reg = er.async_get(hass) for entry in er.async_entries_for_config_entry(ent_reg, config_entry.entry_id): - if entry.domain == "sensor" and entry.unique_id not in entities: - entities[entry.unique_id] = ONVIFSensor(entry.unique_id, device, entry) + if entry.domain == "sensor" and entry.unique_id not in uids: + uids.add(entry.unique_id) + entities.append(ONVIFSensor(entry.unique_id, device, entry=entry)) - async_add_entities(entities.values()) + async_add_entities(entities) uids_by_platform = device.events.get_uids_by_platform("sensor") @callback def async_check_entities() -> None: """Check if we have added an entity for the event.""" nonlocal uids_by_platform - if not (missing := uids_by_platform.difference(entities)): + if not (missing := uids_by_platform.difference(uids)): return - new_entities: dict[str, ONVIFSensor] = { - uid: ONVIFSensor(uid, device) for uid in missing - } + + events = device.events.get_platform("sensor") + entity_names = build_event_entity_names(events) + + new_entities = [ + ONVIFSensor(uid, device, name=entity_names[uid]) for uid in missing + ] if new_entities: - entities.update(new_entities) - async_add_entities(new_entities.values()) + uids.update(missing) + async_add_entities(new_entities) device.events.async_add_listener(async_check_entities) @@ -61,9 +71,13 @@ class ONVIFSensor(ONVIFBaseEntity, RestoreSensor): _attr_should_poll = False def __init__( - self, uid, device: ONVIFDevice, entry: er.RegistryEntry | None = None + self, + uid, + device: ONVIFDevice, + name: str | None = None, + entry: er.RegistryEntry | None = None, ) -> None: - """Initialize the ONVIF binary sensor.""" + """Initialize the ONVIF sensor.""" self._attr_unique_id = uid if entry is not None: self._attr_device_class = try_parse_enum( @@ -75,12 +89,13 @@ class ONVIFSensor(ONVIFBaseEntity, RestoreSensor): else: event = device.events.get_uid(uid) assert event + assert name self._attr_device_class = try_parse_enum( SensorDeviceClass, event.device_class ) self._attr_entity_category = event.entity_category self._attr_entity_registry_enabled_default = event.entity_enabled - self._attr_name = f"{device.name} {event.name}" + self._attr_name = f"{device.name} {name}" self._attr_native_unit_of_measurement = event.unit_of_measurement self._attr_native_value = event.value diff --git a/homeassistant/components/onvif/util.py b/homeassistant/components/onvif/util.py index 064d9cfad5f..aaa045abb18 100644 --- a/homeassistant/components/onvif/util.py +++ b/homeassistant/components/onvif/util.py @@ -2,10 +2,47 @@ from __future__ import annotations +from collections import defaultdict from typing import Any from zeep.exceptions import Fault +from .models import Event + + +def build_event_entity_names(events: list[Event]) -> dict[str, str]: + """Build entity names for events, with index appended for duplicates. + + When multiple events share the same base name, a sequential index + is appended to distinguish them (sorted by UID). + + Args: + events: List of events to build entity names for. + + Returns: + Dictionary mapping event UIDs to their entity names. + + """ + # Group events by name + events_by_name: dict[str, list[Event]] = defaultdict(list) + for event in events: + events_by_name[event.name].append(event) + + # Build entity names, appending index when there are duplicates + entity_names: dict[str, str] = {} + for name, name_events in events_by_name.items(): + if len(name_events) == 1: + # No duplicates, use name as-is + entity_names[name_events[0].uid] = name + continue + + # Sort by UID and assign sequential indices + sorted_events = sorted(name_events, key=lambda e: e.uid) + for index, event in enumerate(sorted_events, start=1): + entity_names[event.uid] = f"{name} {index}" + + return entity_names + def extract_subcodes_as_strings(subcodes: Any) -> list[str]: """Stringify ONVIF subcodes.""" diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 4a406e06139..5c3334193ab 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==1.99.5", "python-open-router==0.3.1"] + "requirements": ["openai==2.2.0", "python-open-router==0.3.1"] } diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 4d2c62a7a8c..e876c50481d 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -487,7 +487,7 @@ class OpenAIBaseLLMEntity(Entity): if options.get(CONF_WEB_SEARCH): web_search = WebSearchToolParam( - type="web_search_preview", + type="web_search", search_context_size=options.get( CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE ), diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index a96efbf1ce8..f733b7a2af6 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.99.5"] + "requirements": ["openai==2.2.0"] } diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 737e4fb8e4f..8b2bfb17c95 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -8,7 +8,7 @@ import logging from pyopenweathermap import create_owm_client from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE from homeassistant.core import HomeAssistant from .const import CONFIG_FLOW_VERSION, DEFAULT_OWM_MODE, OWM_MODES, PLATFORMS @@ -25,7 +25,6 @@ type OpenweathermapConfigEntry = ConfigEntry[OpenweathermapData] class OpenweathermapData: """Runtime data definition.""" - name: str mode: str coordinator: OWMUpdateCoordinator @@ -34,7 +33,6 @@ async def async_setup_entry( hass: HomeAssistant, entry: OpenweathermapConfigEntry ) -> bool: """Set up OpenWeatherMap as config entry.""" - name = entry.data[CONF_NAME] api_key = entry.data[CONF_API_KEY] language = entry.options[CONF_LANGUAGE] mode = entry.options[CONF_MODE] @@ -51,7 +49,7 @@ async def async_setup_entry( entry.async_on_unload(entry.add_update_listener(async_update_options)) - entry.runtime_data = OpenweathermapData(name, mode, owm_coordinator) + entry.runtime_data = OpenweathermapData(mode, owm_coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 5805b602821..64545726f1e 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -14,12 +14,17 @@ from homeassistant.const import ( CONF_API_KEY, CONF_LANGUAGE, CONF_LATITUDE, + CONF_LOCATION, CONF_LONGITUDE, CONF_MODE, - CONF_NAME, ) from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.selector import ( + LanguageSelector, + LanguageSelectorConfig, + LocationSelector, + LocationSelectorConfig, +) from .const import ( CONFIG_FLOW_VERSION, @@ -34,10 +39,12 @@ from .utils import build_data_and_options, validate_api_key USER_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGES), + vol.Required(CONF_LOCATION): LocationSelector( + LocationSelectorConfig(radius=False) + ), + vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): LanguageSelector( + LanguageSelectorConfig(languages=LANGUAGES, native_name=True) + ), vol.Required(CONF_API_KEY): str, vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES), } @@ -45,7 +52,9 @@ USER_SCHEMA = vol.Schema( OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGES), + vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): LanguageSelector( + LanguageSelectorConfig(languages=LANGUAGES, native_name=True) + ), vol.Optional(CONF_MODE, default=DEFAULT_OWM_MODE): vol.In(OWM_MODES), } ) @@ -70,8 +79,8 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders = {} if user_input is not None: - latitude = user_input[CONF_LATITUDE] - longitude = user_input[CONF_LONGITUDE] + latitude = user_input[CONF_LOCATION][CONF_LATITUDE] + longitude = user_input[CONF_LOCATION][CONF_LONGITUDE] mode = user_input[CONF_MODE] await self.async_set_unique_id(f"{latitude}-{longitude}") @@ -82,15 +91,21 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): ) if not errors: + # Flatten location + location = user_input.pop(CONF_LOCATION) + user_input[CONF_LATITUDE] = location[CONF_LATITUDE] + user_input[CONF_LONGITUDE] = location[CONF_LONGITUDE] data, options = build_data_and_options(user_input) return self.async_create_entry( - title=user_input[CONF_NAME], data=data, options=options + title=DEFAULT_NAME, data=data, options=options ) schema_data = user_input else: schema_data = { - CONF_LATITUDE: self.hass.config.latitude, - CONF_LONGITUDE: self.hass.config.longitude, + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + }, CONF_LANGUAGE: self.hass.config.language, } diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 2860abbe64c..4cb31b749f0 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -121,6 +121,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), SensorEntityDescription( key=ATTR_API_CLOUDS, @@ -158,6 +159,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), SensorEntityDescription( key=ATTR_API_CONDITION, @@ -227,7 +229,6 @@ async def async_setup_entry( ) -> None: """Set up OpenWeatherMap sensor entities based on a config entry.""" domain_data = config_entry.runtime_data - name = domain_data.name unique_id = config_entry.unique_id assert unique_id is not None coordinator = domain_data.coordinator @@ -242,7 +243,6 @@ async def async_setup_entry( elif domain_data.mode == OWM_MODE_AIRPOLLUTION: async_add_entities( OpenWeatherMapSensor( - name, unique_id, description, coordinator, @@ -252,7 +252,6 @@ async def async_setup_entry( else: async_add_entities( OpenWeatherMapSensor( - name, unique_id, description, coordinator, @@ -270,7 +269,6 @@ class AbstractOpenWeatherMapSensor(SensorEntity): def __init__( self, - name: str, unique_id: str, description: SensorEntityDescription, coordinator: OWMUpdateCoordinator, @@ -284,7 +282,6 @@ class AbstractOpenWeatherMapSensor(SensorEntity): entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, unique_id)}, manufacturer=MANUFACTURER, - name=name, ) @property diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 718ce3e6fdd..e3e5781882c 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -12,16 +12,14 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "language": "[%key:common::config_flow::data::language%]", - "latitude": "[%key:common::config_flow::data::latitude%]", - "longitude": "[%key:common::config_flow::data::longitude%]", + "location": "[%key:common::config_flow::data::location%]", "mode": "[%key:common::config_flow::data::mode%]", "name": "[%key:common::config_flow::data::name%]" }, "data_description": { "api_key": "API key for the OpenWeatherMap integration", "language": "Language for the OpenWeatherMap content", - "latitude": "Latitude of the location", - "longitude": "Longitude of the location", + "location": "Location to get the weather data for", "mode": "Mode for the OpenWeatherMap API", "name": "Name for this OpenWeatherMap location" }, diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 56f44fa46fb..37f8e117ee1 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -57,14 +57,13 @@ async def async_setup_entry( ) -> None: """Set up OpenWeatherMap weather entity based on a config entry.""" domain_data = config_entry.runtime_data - name = domain_data.name mode = domain_data.mode if mode != OWM_MODE_AIRPOLLUTION: weather_coordinator = domain_data.coordinator unique_id = f"{config_entry.unique_id}" - owm_weather = OpenWeatherMapWeather(name, unique_id, mode, weather_coordinator) + owm_weather = OpenWeatherMapWeather(unique_id, mode, weather_coordinator) async_add_entities([owm_weather], False) @@ -93,7 +92,6 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[OWMUpdateCoordinator] def __init__( self, - name: str, unique_id: str, mode: str, weather_coordinator: OWMUpdateCoordinator, @@ -105,7 +103,6 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[OWMUpdateCoordinator] entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, unique_id)}, manufacturer=MANUFACTURER, - name=name, ) self.mode = mode diff --git a/homeassistant/components/raspyrfm/manifest.json b/homeassistant/components/raspyrfm/manifest.json index d001e2b1118..568a01cf862 100644 --- a/homeassistant/components/raspyrfm/manifest.json +++ b/homeassistant/components/raspyrfm/manifest.json @@ -6,5 +6,5 @@ "iot_class": "assumed_state", "loggers": ["raspyrfm_client"], "quality_scale": "legacy", - "requirements": ["raspyrfm-client==1.2.8"] + "requirements": ["raspyrfm-client==1.2.9"] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 1c53b528141..708be5eab20 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2490,7 +2490,7 @@ class BaseMigration(ABC): start_schema_version: int, migration_changes: dict[str, int], ) -> None: - """Initialize a new BaseRunTimeMigration. + """Initialize a new BaseMigration. :param initial_schema_version: The schema version the database was created with. :param start_schema_version: The schema version when starting the migration. @@ -2964,7 +2964,12 @@ class EventIDPostMigration(BaseRunTimeMigration): _drop_foreign_key_constraints( session_maker, instance.engine, TABLE_STATES, "event_id" ) - except (InternalError, OperationalError): + except (InternalError, OperationalError) as err: + _LOGGER.debug( + "Could not drop foreign key constraint on states.event_id, " + "will try again later", + exc_info=err, + ) fk_remove_ok = False else: fk_remove_ok = True diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index d100999527f..8692ff40366 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -48,7 +48,7 @@ from .const import ( DEFAULT_OFF_DELAY = 2.0 -CONNECT_TIMEOUT = 30.0 +CONNECT_TIMEOUT = 60.0 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/satel_integra/strings.json b/homeassistant/components/satel_integra/strings.json index 1d6655145b5..70502902de4 100644 --- a/homeassistant/components/satel_integra/strings.json +++ b/homeassistant/components/satel_integra/strings.json @@ -24,6 +24,7 @@ }, "config_subentries": { "partition": { + "entry_type": "Partition", "initiate_flow": { "user": "Add partition" }, @@ -57,6 +58,7 @@ } }, "zone": { + "entry_type": "Zone", "initiate_flow": { "user": "Add zone" }, @@ -91,6 +93,7 @@ } }, "output": { + "entry_type": "Output", "initiate_flow": { "user": "Add output" }, @@ -125,6 +128,7 @@ } }, "switchable_output": { + "entry_type": "Switchable output", "initiate_flow": { "user": "Add switchable output" }, diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index c2df1ed4cb2..fa920e786b0 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from functools import partial from typing import Final from aioshelly.ble.const import BLE_SCRIPT_NAME @@ -63,6 +64,7 @@ from .repairs import ( ) from .utils import ( async_create_issue_unsupported_firmware, + async_migrate_rpc_virtual_components_unique_ids, get_coap_context, get_device_entry_gen, get_http_port, @@ -323,6 +325,12 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) translation_placeholders={"device": entry.title}, ) from err + await er.async_migrate_entries( + hass, + entry.entry_id, + partial(async_migrate_rpc_virtual_components_unique_ids, device.config), + ) + runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device) runtime_data.rpc.async_setup() runtime_data.rpc_poll = ShellyRpcPollingCoordinator(hass, entry, device) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 3cce2f0183f..28d8c2de084 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_SLEEP_PERIOD +from .const import CONF_SLEEP_PERIOD, MODEL_FRANKEVER_WATER_VALVE from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -270,12 +270,21 @@ RPC_SENSORS: Final = { entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), - "boolean": RpcBinarySensorDescription( + "boolean_generic": RpcBinarySensorDescription( key="boolean", sub_key="value", removal_condition=lambda config, _status, key: not is_view_for_platform( config, key, BINARY_SENSOR_PLATFORM ), + role="generic", + ), + "boolean_has_power": RpcBinarySensorDescription( + key="boolean", + sub_key="value", + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + role="has_power", + models={MODEL_FRANKEVER_WATER_VALVE}, ), "calibration": RpcBinarySensorDescription( key="blutrv", diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index fbc46160f1c..4c6d5695f33 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any, Final from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError -from aioshelly.rpc_device import RpcDevice from homeassistant.components.button import ( DOMAIN as BUTTON_PLATFORM, @@ -24,16 +23,24 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS +from .const import DOMAIN, LOGGER, MODEL_FRANKEVER_WATER_VALVE, SHELLY_GAS_MODELS from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator -from .entity import get_entity_block_device_info, get_entity_rpc_device_info +from .entity import ( + RpcEntityDescription, + ShellyRpcAttributeEntity, + async_setup_entry_rpc, + get_entity_block_device_info, + get_entity_rpc_device_info, + rpc_call, +) from .utils import ( async_remove_orphaned_entities, format_ble_addr, get_blu_trv_device_info, get_device_entry_gen, - get_rpc_entity_name, get_rpc_key_ids, + get_rpc_key_instances, + get_rpc_role_by_key, get_virtual_component_ids, ) @@ -51,6 +58,11 @@ class ShellyButtonDescription[ supported: Callable[[_ShellyCoordinatorT], bool] = lambda _: True +@dataclass(frozen=True, kw_only=True) +class RpcButtonDescription(RpcEntityDescription, ButtonEntityDescription): + """Class to describe a RPC button.""" + + BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ ShellyButtonDescription[ShellyBlockCoordinator | ShellyRpcCoordinator]( key="reboot", @@ -96,12 +108,24 @@ BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [ ), ] -VIRTUAL_BUTTONS: Final[list[ShellyButtonDescription]] = [ - ShellyButtonDescription[ShellyRpcCoordinator]( +RPC_VIRTUAL_BUTTONS = { + "button_generic": RpcButtonDescription( key="button", - press_action="single_push", - ) -] + role="generic", + ), + "button_open": RpcButtonDescription( + key="button", + entity_registry_enabled_default=False, + role="open", + models={MODEL_FRANKEVER_WATER_VALVE}, + ), + "button_close": RpcButtonDescription( + key="button", + entity_registry_enabled_default=False, + role="close", + models={MODEL_FRANKEVER_WATER_VALVE}, + ), +} @callback @@ -129,8 +153,10 @@ def async_migrate_unique_ids( ) } + if not isinstance(coordinator, ShellyRpcCoordinator): + return None + if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): - assert isinstance(coordinator.device, RpcDevice) for _id in blutrv_key_ids: key = f"{BLU_TRV_IDENTIFIER}:{_id}" ble_addr: str = coordinator.device.config[key]["addr"] @@ -149,6 +175,26 @@ def async_migrate_unique_ids( ) } + if virtual_button_keys := get_rpc_key_instances( + coordinator.device.config, "button" + ): + for key in virtual_button_keys: + old_unique_id = f"{coordinator.mac}-{key}" + if entity_entry.unique_id == old_unique_id: + role = get_rpc_role_by_key(coordinator.device.config, key) + new_unique_id = f"{coordinator.mac}-{key}-button_{role}" + LOGGER.debug( + "Migrating unique_id for %s entity from [%s] to [%s]", + entity_entry.entity_id, + old_unique_id, + new_unique_id, + ) + return { + "new_unique_id": entity_entry.unique_id.replace( + old_unique_id, new_unique_id + ) + } + return None @@ -172,7 +218,7 @@ async def async_setup_entry( hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) ) - entities: list[ShellyButton | ShellyBluTrvButton | ShellyVirtualButton] = [] + entities: list[ShellyButton | ShellyBluTrvButton] = [] entities.extend( ShellyButton(coordinator, button) @@ -185,12 +231,9 @@ async def async_setup_entry( return # add virtual buttons - if virtual_button_ids := get_rpc_key_ids(coordinator.device.status, "button"): - entities.extend( - ShellyVirtualButton(coordinator, button, id_) - for id_ in virtual_button_ids - for button in VIRTUAL_BUTTONS - ) + async_setup_entry_rpc( + hass, config_entry, async_add_entities, RPC_VIRTUAL_BUTTONS, RpcVirtualButton + ) # add BLU TRV buttons if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER): @@ -332,30 +375,16 @@ class ShellyBluTrvButton(ShellyBaseButton): await method(self._id) -class ShellyVirtualButton(ShellyBaseButton): - """Defines a Shelly virtual component button.""" +class RpcVirtualButton(ShellyRpcAttributeEntity, ButtonEntity): + """Defines a Shelly RPC virtual component button.""" - def __init__( - self, - coordinator: ShellyRpcCoordinator, - description: ShellyButtonDescription, - _id: int, - ) -> None: - """Initialize Shelly virtual component button.""" - super().__init__(coordinator, description) + entity_description: RpcButtonDescription + _id: int - self._attr_unique_id = f"{coordinator.mac}-{description.key}:{_id}" - self._attr_device_info = get_entity_rpc_device_info(coordinator) - self._attr_name = get_rpc_entity_name( - coordinator.device, f"{description.key}:{_id}" - ) - self._id = _id - - async def _press_method(self) -> None: - """Press method.""" + @rpc_call + async def async_press(self) -> None: + """Triggers the Shelly button press service.""" if TYPE_CHECKING: assert isinstance(self.coordinator, ShellyRpcCoordinator) - await self.coordinator.device.button_trigger( - self._id, self.entity_description.press_action - ) + await self.coordinator.device.button_trigger(self._id, "single_push") diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index d99be1b0eb3..98ccb90d4b9 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -308,3 +308,5 @@ MODEL_NEO_WATER_VALVE = "NeoWaterValve" MODEL_FRANKEVER_WATER_VALVE = "WaterValve" MODEL_LINKEDGO_ST802_THERMOSTAT = "ST-802" MODEL_LINKEDGO_ST1820_THERMOSTAT = "ST1820" +MODEL_TOP_EV_CHARGER_EVE01 = "EVE01" +MODEL_FRANKEVER_IRRIGATION_CONTROLLER = "Irrigation" diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 0e4a2b00742..44a75e64b31 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -29,6 +29,7 @@ from .utils import ( get_rpc_device_info, get_rpc_entity_name, get_rpc_key_instances, + get_rpc_role_by_key, ) @@ -189,14 +190,16 @@ def async_setup_rpc_attribute_entities( if description.models and coordinator.model not in description.models: continue - if description.role and description.role != coordinator.device.config[ - key - ].get("role", "generic"): + if description.role and description.role != get_rpc_role_by_key( + coordinator.device.config, key + ): continue - if description.sub_key not in coordinator.device.status[ - key - ] and not description.supported(coordinator.device.status[key]): + if ( + description.sub_key + and description.sub_key not in coordinator.device.status[key] + and not description.supported(coordinator.device.status[key]) + ): continue # Filter and remove entities that according to settings/status @@ -308,7 +311,7 @@ class RpcEntityDescription(EntityDescription): # restrict the type to str. name: str = "" - sub_key: str + sub_key: str | None = None value: Callable[[Any, Any], Any] | None = None available: Callable[[dict], bool] | None = None diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 5f1f767271b..a370b855c45 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "silver", - "requirements": ["aioshelly==13.11.0"], + "requirements": ["aioshelly==13.12.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index f77db143c85..dfb5fb95038 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -24,7 +24,16 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry -from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER, VIRTUAL_NUMBER_MODE_MAP +from .const import ( + CONF_SLEEP_PERIOD, + DOMAIN, + LOGGER, + MODEL_FRANKEVER_WATER_VALVE, + MODEL_LINKEDGO_ST802_THERMOSTAT, + MODEL_LINKEDGO_ST1820_THERMOSTAT, + MODEL_TOP_EV_CHARGER_EVE01, + VIRTUAL_NUMBER_MODE_MAP, +) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -183,7 +192,7 @@ RPC_NUMBERS: Final = { method="blu_trv_set_external_temperature", entity_class=RpcBluTrvExtTempNumber, ), - "number": RpcNumberDescription( + "number_generic": RpcNumberDescription( key="number", sub_key="value", removal_condition=lambda config, _status, key: not is_view_for_platform( @@ -197,6 +206,58 @@ RPC_NUMBERS: Final = { step_fn=lambda config: config["meta"]["ui"].get("step"), unit=get_virtual_component_unit, method="number_set", + role="generic", + ), + "number_current_limit": RpcNumberDescription( + key="number", + sub_key="value", + max_fn=lambda config: config["max"], + min_fn=lambda config: config["min"], + mode_fn=lambda config: NumberMode.SLIDER, + step_fn=lambda config: config["meta"]["ui"].get("step"), + unit=get_virtual_component_unit, + method="number_set", + role="current_limit", + models={MODEL_TOP_EV_CHARGER_EVE01}, + ), + "number_position": RpcNumberDescription( + key="number", + sub_key="value", + entity_registry_enabled_default=False, + max_fn=lambda config: config["max"], + min_fn=lambda config: config["min"], + mode_fn=lambda config: NumberMode.SLIDER, + step_fn=lambda config: config["meta"]["ui"].get("step"), + unit=get_virtual_component_unit, + method="number_set", + role="position", + models={MODEL_FRANKEVER_WATER_VALVE}, + ), + "number_target_humidity": RpcNumberDescription( + key="number", + sub_key="value", + entity_registry_enabled_default=False, + max_fn=lambda config: config["max"], + min_fn=lambda config: config["min"], + mode_fn=lambda config: NumberMode.SLIDER, + step_fn=lambda config: config["meta"]["ui"].get("step"), + unit=get_virtual_component_unit, + method="number_set", + role="target_humidity", + models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT}, + ), + "number_target_temperature": RpcNumberDescription( + key="number", + sub_key="value", + entity_registry_enabled_default=False, + max_fn=lambda config: config["max"], + min_fn=lambda config: config["min"], + mode_fn=lambda config: NumberMode.SLIDER, + step_fn=lambda config: config["meta"]["ui"].get("step"), + unit=get_virtual_component_unit, + method="number_set", + role="target_temperature", + models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT}, ), "valve_position": RpcNumberDescription( key="blutrv", diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py index c0838482b94..617e2d90009 100644 --- a/homeassistant/components/shelly/select.py +++ b/homeassistant/components/shelly/select.py @@ -38,12 +38,13 @@ class RpcSelectDescription(RpcEntityDescription, SelectEntityDescription): RPC_SELECT_ENTITIES: Final = { - "enum": RpcSelectDescription( + "enum_generic": RpcSelectDescription( key="enum", sub_key="value", removal_condition=lambda config, _status, key: not is_view_for_platform( config, key, SELECT_PLATFORM ), + role="generic", ), } diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 6bece8f9565..fb399fd80d4 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -3,8 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from functools import partial -from typing import Any, Final, cast +from typing import Final, cast from aioshelly.block_device import Block from aioshelly.const import RPC_GENERATIONS @@ -37,13 +36,12 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.typing import StateType -from .const import CONF_SLEEP_PERIOD, LOGGER +from .const import CONF_SLEEP_PERIOD from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -551,7 +549,7 @@ RPC_SENSORS: Final = { "a_act_power": RpcSensorDescription( key="em", sub_key="a_act_power", - name="Active power", + name="Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -561,7 +559,7 @@ RPC_SENSORS: Final = { "b_act_power": RpcSensorDescription( key="em", sub_key="b_act_power", - name="Active power", + name="Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -571,7 +569,7 @@ RPC_SENSORS: Final = { "c_act_power": RpcSensorDescription( key="em", sub_key="c_act_power", - name="Active power", + name="Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -581,7 +579,7 @@ RPC_SENSORS: Final = { "total_act_power": RpcSensorDescription( key="em", sub_key="total_act_power", - name="Total active power", + name="Power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -627,7 +625,7 @@ RPC_SENSORS: Final = { "total_aprt_power": RpcSensorDescription( key="em", sub_key="total_aprt_power", - name="Total apparent power", + name="Apparent power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, @@ -882,7 +880,7 @@ RPC_SENSORS: Final = { "n_current": RpcSensorDescription( key="em", sub_key="n_current", - name="Phase N current", + name="Neutral current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -892,7 +890,7 @@ RPC_SENSORS: Final = { "total_current": RpcSensorDescription( key="em", sub_key="total_current", - name="Total current", + name="Current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -1384,7 +1382,7 @@ RPC_SENSORS: Final = { native_unit_of_measurement="pulse", state_class=SensorStateClass.TOTAL, value=lambda status, _: status["total"], - removal_condition=lambda config, _status, key: ( + removal_condition=lambda config, _, key: ( config[key]["type"] != "count" or config[key]["enable"] is False ), ), @@ -1424,7 +1422,7 @@ RPC_SENSORS: Final = { "text_generic": RpcSensorDescription( key="text", sub_key="value", - removal_condition=lambda config, _status, key: not is_view_for_platform( + removal_condition=lambda config, _, key: not is_view_for_platform( config, key, SENSOR_PLATFORM ), role="generic", @@ -1432,7 +1430,7 @@ RPC_SENSORS: Final = { "number_generic": RpcSensorDescription( key="number", sub_key="value", - removal_condition=lambda config, _status, key: not is_view_for_platform( + removal_condition=lambda config, _, key: not is_view_for_platform( config, key, SENSOR_PLATFORM ), unit=get_virtual_component_unit, @@ -1441,7 +1439,7 @@ RPC_SENSORS: Final = { "enum_generic": RpcSensorDescription( key="enum", sub_key="value", - removal_condition=lambda config, _status, key: not is_view_for_platform( + removal_condition=lambda config, _, key: not is_view_for_platform( config, key, SENSOR_PLATFORM ), options_fn=lambda config: config["options"], @@ -1456,7 +1454,7 @@ RPC_SENSORS: Final = { native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - removal_condition=lambda config, _status, key: config[key].get("enable", False) + removal_condition=lambda config, _, key: config[key].get("enable", False) is False, entity_class=RpcBluTrvSensor, ), @@ -1606,7 +1604,7 @@ RPC_SENSORS: Final = { "object_total_act_energy": RpcSensorDescription( key="object", sub_key="value", - name="Total Active Energy", + name="Energy", value=lambda status, _: float(status["total_act_energy"]), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -1618,7 +1616,7 @@ RPC_SENSORS: Final = { "object_total_power": RpcSensorDescription( key="object", sub_key="value", - name="Total Power", + name="Power", value=lambda status, _: float(status["total_power"]), native_unit_of_measurement=UnitOfPower.WATT, suggested_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -1663,39 +1661,6 @@ RPC_SENSORS: Final = { } -@callback -def async_migrate_unique_ids( - coordinator: ShellyRpcCoordinator, - entity_entry: er.RegistryEntry, -) -> dict[str, Any] | None: - """Migrate sensor unique IDs to include role.""" - if not entity_entry.entity_id.startswith("sensor."): - return None - - for sensor_id in ("text", "number", "enum"): - old_unique_id = entity_entry.unique_id - if old_unique_id.endswith(f"-{sensor_id}"): - if entity_entry.original_device_class == SensorDeviceClass.HUMIDITY: - new_unique_id = f"{old_unique_id}_current_humidity" - elif entity_entry.original_device_class == SensorDeviceClass.TEMPERATURE: - new_unique_id = f"{old_unique_id}_current_temperature" - else: - new_unique_id = f"{old_unique_id}_generic" - LOGGER.debug( - "Migrating unique_id for %s entity from [%s] to [%s]", - entity_entry.entity_id, - old_unique_id, - new_unique_id, - ) - return { - "new_unique_id": entity_entry.unique_id.replace( - old_unique_id, new_unique_id - ) - } - - return None - - async def async_setup_entry( hass: HomeAssistant, config_entry: ShellyConfigEntry, @@ -1715,12 +1680,6 @@ async def async_setup_entry( coordinator = config_entry.runtime_data.rpc assert coordinator - await er.async_migrate_entries( - hass, - config_entry.entry_id, - partial(async_migrate_unique_ids, coordinator), - ) - async_setup_entry_rpc( hass, config_entry, async_add_entities, RPC_SENSORS, RpcSensor ) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 0518858868d..ce55f6d98ad 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from aioshelly.block_device import Block from aioshelly.const import RPC_GENERATIONS @@ -21,6 +21,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.restore_state import RestoreEntity +from .const import ( + MODEL_FRANKEVER_IRRIGATION_CONTROLLER, + MODEL_LINKEDGO_ST802_THERMOSTAT, + MODEL_LINKEDGO_ST1820_THERMOSTAT, + MODEL_NEO_WATER_VALVE, + MODEL_TOP_EV_CHARGER_EVE01, +) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -30,6 +37,7 @@ from .entity import ( ShellySleepingBlockAttributeEntity, async_setup_entry_attribute_entities, async_setup_entry_rpc, + rpc_call, ) from .utils import ( async_remove_orphaned_entities, @@ -71,7 +79,7 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): is_on: Callable[[dict[str, Any]], bool] method_on: str method_off: str - method_params_fn: Callable[[int | None, bool], dict] + method_params_fn: Callable[[int | None, bool], tuple] RPC_RELAY_SWITCHES = { @@ -80,31 +88,145 @@ RPC_RELAY_SWITCHES = { sub_key="output", removal_condition=is_rpc_exclude_from_relay, is_on=lambda status: bool(status["output"]), - method_on="Switch.Set", - method_off="Switch.Set", - method_params_fn=lambda id, value: {"id": id, "on": value}, + method_on="switch_set", + method_off="switch_set", + method_params_fn=lambda id, value: (id, value), ), } RPC_SWITCHES = { - "boolean": RpcSwitchDescription( + "boolean_generic": RpcSwitchDescription( key="boolean", sub_key="value", removal_condition=lambda config, _status, key: not is_view_for_platform( config, key, SWITCH_PLATFORM ), is_on=lambda status: bool(status["value"]), - method_on="Boolean.Set", - method_off="Boolean.Set", - method_params_fn=lambda id, value: {"id": id, "value": value}, + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="generic", + ), + "boolean_anti_freeze": RpcSwitchDescription( + key="boolean", + sub_key="value", + entity_registry_enabled_default=False, + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="anti_freeze", + models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT}, + ), + "boolean_child_lock": RpcSwitchDescription( + key="boolean", + sub_key="value", + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="child_lock", + models={MODEL_LINKEDGO_ST1820_THERMOSTAT}, + ), + "boolean_enable": RpcSwitchDescription( + key="boolean", + sub_key="value", + entity_registry_enabled_default=False, + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="enable", + models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT}, + ), + "boolean_start_charging": RpcSwitchDescription( + key="boolean", + sub_key="value", + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="start_charging", + models={MODEL_TOP_EV_CHARGER_EVE01}, + ), + "boolean_state": RpcSwitchDescription( + key="boolean", + sub_key="value", + entity_registry_enabled_default=False, + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="state", + models={MODEL_NEO_WATER_VALVE}, + ), + "boolean_zone0": RpcSwitchDescription( + key="boolean", + sub_key="value", + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="zone0", + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone1": RpcSwitchDescription( + key="boolean", + sub_key="value", + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="zone1", + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone2": RpcSwitchDescription( + key="boolean", + sub_key="value", + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="zone2", + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone3": RpcSwitchDescription( + key="boolean", + sub_key="value", + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="zone3", + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone4": RpcSwitchDescription( + key="boolean", + sub_key="value", + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="zone4", + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone5": RpcSwitchDescription( + key="boolean", + sub_key="value", + is_on=lambda status: bool(status["value"]), + method_on="boolean_set", + method_off="boolean_set", + method_params_fn=lambda id, value: (id, value), + role="zone5", + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, ), "script": RpcSwitchDescription( key="script", sub_key="running", is_on=lambda status: bool(status["running"]), - method_on="Script.Start", - method_off="Script.Stop", - method_params_fn=lambda id, _: {"id": id}, + method_on="script_start", + method_off="script_stop", + method_params_fn=lambda id, _: (id,), entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), @@ -301,19 +423,27 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity): """If switch is on.""" return self.entity_description.is_on(self.status) + @rpc_call async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on relay.""" - await self.call_rpc( - self.entity_description.method_on, - self.entity_description.method_params_fn(self._id, True), - ) + """Turn on switch.""" + method = getattr(self.coordinator.device, self.entity_description.method_on) + if TYPE_CHECKING: + assert method is not None + + params = self.entity_description.method_params_fn(self._id, True) + await method(*params) + + @rpc_call async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off relay.""" - await self.call_rpc( - self.entity_description.method_off, - self.entity_description.method_params_fn(self._id, False), - ) + """Turn off switch.""" + method = getattr(self.coordinator.device, self.entity_description.method_off) + + if TYPE_CHECKING: + assert method is not None + + params = self.entity_description.method_params_fn(self._id, False) + await method(*params) class RpcRelaySwitch(RpcSwitch): diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py index 5a514771a3f..ef30ec310ed 100644 --- a/homeassistant/components/shelly/text.py +++ b/homeassistant/components/shelly/text.py @@ -38,12 +38,13 @@ class RpcTextDescription(RpcEntityDescription, TextEntityDescription): RPC_TEXT_ENTITIES: Final = { - "text": RpcTextDescription( + "text_generic": RpcTextDescription( key="text", sub_key="value", removal_condition=lambda config, _status, key: not is_view_for_platform( config, key, TEXT_PLATFORM ), + role="generic", ), } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 6cd90f1feb9..8024fe64446 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -484,6 +484,11 @@ def get_rpc_key_by_role(keys_dict: dict[str, Any], role: str) -> str | None: return None +def get_rpc_role_by_key(keys_dict: dict[str, Any], key: str) -> str: + """Return role by key for RPC device from a dict.""" + return cast(str, keys_dict[key].get("role", "generic")) + + def id_from_key(key: str) -> int: """Return id from key.""" return int(key.split(":")[-1]) @@ -934,3 +939,35 @@ def remove_empty_sub_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: def format_ble_addr(ble_addr: str) -> str: """Format BLE address to use in unique_id.""" return ble_addr.replace(":", "").upper() + + +@callback +def async_migrate_rpc_virtual_components_unique_ids( + config: dict[str, Any], entity_entry: er.RegistryEntry +) -> dict[str, Any] | None: + """Migrate RPC virtual components unique_ids to include role in the ID. + + This is needed to support multiple components with the same key. + The old unique_id format is: {mac}-{key}-{component} + The new unique_id format is: {mac}-{key}-{component}_{role} + """ + for component in VIRTUAL_COMPONENTS: + if entity_entry.unique_id.endswith(f"-{component!s}"): + key = entity_entry.unique_id.split("-")[-2] + if key not in config: + continue + role = get_rpc_role_by_key(config, key) + new_unique_id = f"{entity_entry.unique_id}_{role}" + LOGGER.debug( + "Migrating unique_id for %s entity from [%s] to [%s]", + entity_entry.entity_id, + entity_entry.unique_id, + new_unique_id, + ) + return { + "new_unique_id": entity_entry.unique_id.replace( + entity_entry.unique_id, new_unique_id + ) + } + + return None diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index aafb05576bf..477ee73a0cd 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -179,6 +179,13 @@ CAPABILITY_TO_SENSORS: dict[ is_on_key="open", ) }, + Capability.GAS_DETECTOR: { + Attribute.GAS: SmartThingsBinarySensorEntityDescription( + key=Attribute.GAS, + device_class=BinarySensorDeviceClass.GAS, + is_on_key="detected", + ) + }, } diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 28c1c9c3782..526c5840881 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -109,6 +109,8 @@ PRESET_MODE_TO_HA = { "quiet": "quiet", "longWind": "long_wind", "smart": "smart", + "motionIndirect": "motion_indirect", + "motionDirect": "motion_direct", } HA_MODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_HA.items()} diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index aad9182576d..123668612ef 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -34,6 +34,17 @@ "climate": { "air_conditioner": { "state_attributes": { + "preset_mode": { + "state": { + "wind_free": "mdi:weather-dust", + "wind_free_sleep": "mdi:sleep", + "quiet": "mdi:volume-off", + "long_wind": "mdi:weather-windy", + "smart": "mdi:leaf", + "motion_direct": "mdi:account-arrow-left", + "motion_indirect": "mdi:account-arrow-right" + } + }, "fan_mode": { "state": { "turbo": "mdi:wind-power" diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 96c6d94da4f..051c675e211 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.3.0"] + "requirements": ["pysmartthings==3.3.1"] } diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 42581a2807e..e7f90c2b225 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -530,7 +530,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # Haven't seen at devices yet Capability.ILLUMINANCE_MEASUREMENT: { Attribute.ILLUMINANCE: [ SmartThingsSensorEntityDescription( @@ -842,7 +841,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # Haven't seen at devices yet Capability.SIGNAL_STRENGTH: { Attribute.LQI: [ SmartThingsSensorEntityDescription( @@ -1001,7 +999,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ], }, - # Haven't seen at devices yet Capability.TVOC_MEASUREMENT: { Attribute.TVOC_LEVEL: [ SmartThingsSensorEntityDescription( @@ -1012,7 +1009,6 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, - # Haven't seen at devices yet Capability.ULTRAVIOLET_INDEX: { Attribute.ULTRAVIOLET_INDEX: [ SmartThingsSensorEntityDescription( diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index fb6b8465186..8e3ecf97bb8 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -87,7 +87,9 @@ "wind_free_sleep": "WindFree sleep", "quiet": "Quiet", "long_wind": "Long wind", - "smart": "Smart" + "smart": "Smart saver", + "motion_direct": "Motion direct", + "motion_indirect": "Motion indirect" } }, "fan_mode": { diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 6e3469970d1..e92a052fa6e 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -143,6 +143,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): self.reauth_conf: Mapping[str, Any] = {} self.reauth_reason: str | None = None self.shares: list[SynoFileSharedFolder] | None = None + self.api: SynologyDSM | None = None def _show_form( self, @@ -156,6 +157,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): description_placeholders = {} data_schema = None + self.api = None if step_id == "link": user_input.update(self.discovered_conf) @@ -194,14 +196,21 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): else: port = DEFAULT_PORT - session = async_get_clientsession(self.hass, verify_ssl) - api = SynologyDSM( - session, host, port, username, password, use_ssl, timeout=DEFAULT_TIMEOUT - ) + if self.api is None: + session = async_get_clientsession(self.hass, verify_ssl) + self.api = SynologyDSM( + session, + host, + port, + username, + password, + use_ssl, + timeout=DEFAULT_TIMEOUT, + ) errors = {} try: - serial = await _login_and_fetch_syno_info(api, otp_code) + serial = await _login_and_fetch_syno_info(self.api, otp_code) except SynologyDSMLogin2SARequiredException: return await self.async_step_2sa(user_input) except SynologyDSMLogin2SAFailedException: @@ -221,10 +230,11 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "missing_data" if errors: + self.api = None return self._show_form(step_id, user_input, errors) with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS): - self.shares = await api.file.get_shared_folders(only_writable=True) + self.shares = await self.api.file.get_shared_folders(only_writable=True) if self.shares and not backup_path: return await self.async_step_backup_share(user_input) @@ -239,14 +249,14 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): CONF_VERIFY_SSL: verify_ssl, CONF_USERNAME: username, CONF_PASSWORD: password, - CONF_MAC: api.network.macs, + CONF_MAC: self.api.network.macs, } config_options = { CONF_BACKUP_PATH: backup_path, CONF_BACKUP_SHARE: backup_share, } if otp_code: - config_data[CONF_DEVICE_TOKEN] = api.device_token + config_data[CONF_DEVICE_TOKEN] = self.api.device_token if user_input.get(CONF_DISKS): config_data[CONF_DISKS] = user_input[CONF_DISKS] if user_input.get(CONF_VOLUMES): diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 9302746aa17..84e696de42e 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.0.0"], + "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.1.0"], "single_config_entry": true } diff --git a/homeassistant/components/telegram_bot/entity.py b/homeassistant/components/telegram_bot/entity.py new file mode 100644 index 00000000000..95adc934781 --- /dev/null +++ b/homeassistant/components/telegram_bot/entity.py @@ -0,0 +1,38 @@ +"""Base entity for Telegram bot integration.""" + +import telegram + +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from . import TelegramBotConfigEntry +from .const import DOMAIN + + +class TelegramBotEntity(Entity): + """Base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + config_entry: TelegramBotConfigEntry, + entity_description: EntityDescription, + ) -> None: + """Initialize the entity.""" + + self.bot_id = config_entry.runtime_data.bot.id + self.config_entry = config_entry + self.entity_description = entity_description + self.service = config_entry.runtime_data + + self._attr_unique_id = f"{self.bot_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + name=config_entry.title, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Telegram", + model=config_entry.data[CONF_PLATFORM].capitalize(), + sw_version=telegram.__version__, + identifiers={(DOMAIN, f"{self.bot_id}")}, + ) diff --git a/homeassistant/components/telegram_bot/notify.py b/homeassistant/components/telegram_bot/notify.py index 822bd7b925d..510b25493d8 100644 --- a/homeassistant/components/telegram_bot/notify.py +++ b/homeassistant/components/telegram_bot/notify.py @@ -2,17 +2,18 @@ from typing import Any -import telegram - -from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature +from homeassistant.components.notify import ( + NotifyEntity, + NotifyEntityDescription, + NotifyEntityFeature, +) from homeassistant.config_entries import ConfigSubentry -from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TelegramBotConfigEntry -from .const import ATTR_TITLE, CONF_CHAT_ID, DOMAIN +from .const import ATTR_TITLE, CONF_CHAT_ID +from .entity import TelegramBotEntity async def async_setup_entry( @@ -29,7 +30,7 @@ async def async_setup_entry( ) -class TelegramBotNotifyEntity(NotifyEntity): +class TelegramBotNotifyEntity(TelegramBotEntity, NotifyEntity): """Representation of a telegram bot notification entity.""" _attr_supported_features = NotifyEntityFeature.TITLE @@ -40,23 +41,13 @@ class TelegramBotNotifyEntity(NotifyEntity): subentry: ConfigSubentry, ) -> None: """Initialize a notification entity.""" - bot_id = config_entry.runtime_data.bot.id - chat_id = subentry.data[CONF_CHAT_ID] - - self._attr_unique_id = f"{bot_id}_{chat_id}" - self.name = subentry.title - - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - manufacturer="Telegram", - model=config_entry.data[CONF_PLATFORM].capitalize(), - sw_version=telegram.__version__, - identifiers={(DOMAIN, f"{bot_id}")}, + super().__init__( + config_entry, NotifyEntityDescription(key=subentry.data[CONF_CHAT_ID]) ) - self._target = chat_id - self._service = config_entry.runtime_data + self.chat_id = subentry.data[CONF_CHAT_ID] + self._attr_name = subentry.title async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message.""" kwargs: dict[str, Any] = {ATTR_TITLE: title} - await self._service.send_message(message, self._target, self._context, **kwargs) + await self.service.send_message(message, self.chat_id, self._context, **kwargs) diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 16fa9f294ea..ac14f7686d0 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -300,9 +300,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): self._current_state is not None and (current_state := self.device.status.get(self._current_state)) is not None + and current_state != "stop" ): return self.entity_description.current_state_inverse is not ( - current_state in (True, "fully_close") + current_state in (True, "close", "fully_close") ) return None diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 23edf1660a0..9f3a7bc9ba8 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -18,7 +18,7 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from .common import is_fan, is_purifier +from .common import is_fan, is_purifier, rgetattr from .const import ( DOMAIN, VS_COORDINATOR, @@ -90,11 +90,26 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): _attr_name = None _attr_translation_key = "vesync" + def __init__( + self, + device: VeSyncBaseDevice, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the fan.""" + super().__init__(device, coordinator) + if rgetattr(device, "state.oscillation_status") is not None: + self._attr_supported_features |= FanEntityFeature.OSCILLATE + @property def is_on(self) -> bool: """Return True if device is on.""" return self.device.state.device_status == "on" + @property + def oscillating(self) -> bool: + """Return True if device is oscillating.""" + return rgetattr(self.device, "state.oscillation_status") == "on" + @property def percentage(self) -> int | None: """Return the currently set speed.""" @@ -212,17 +227,17 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): await self.device.turn_on() if preset_mode == VS_FAN_MODE_AUTO: - success = await self.device.auto_mode() + success = await self.device.set_auto_mode() elif preset_mode == VS_FAN_MODE_SLEEP: - success = await self.device.sleep_mode() + success = await self.device.set_sleep_mode() elif preset_mode == VS_FAN_MODE_ADVANCED_SLEEP: - success = await self.device.advanced_sleep_mode() + success = await self.device.set_advanced_sleep_mode() elif preset_mode == VS_FAN_MODE_PET: - success = await self.device.pet_mode() + success = await self.device.set_pet_mode() elif preset_mode == VS_FAN_MODE_TURBO: - success = await self.device.turbo_mode() + success = await self.device.set_turbo_mode() elif preset_mode == VS_FAN_MODE_NORMAL: - success = await self.device.normal_mode() + success = await self.device.set_normal_mode() if not success: raise HomeAssistantError(self.device.last_response.message) @@ -248,3 +263,10 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): if not success: raise HomeAssistantError(self.device.last_response.message) self.schedule_update_ha_state() + + async def async_oscillate(self, oscillating: bool) -> None: + """Set oscillation.""" + success = await self.device.toggle_oscillation(oscillating) + if not success: + raise HomeAssistantError(self.device.last_response.message) + self.schedule_update_ha_state() diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 864439c746c..7825db7043b 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -952,6 +952,15 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( value_getter=lambda api: api.getFuelNeed(), unit_getter=lambda api: api.getFuelUnit(), ), + ViCareSensorEntityDescription( + key="hydraulic_separator_temperature", + translation_key="hydraulic_separator_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getHydraulicSeparatorTemperature(), + ), ) CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 260b51f56f3..99c78e262a6 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -293,6 +293,9 @@ "energy_summary_dhw_consumption_heating_lastsevendays": { "name": "DHW electricity consumption last seven days" }, + "hydraulic_separator_temperature": { + "name": "Hydraulic separator temperature" + }, "power_consumption_today": { "name": "Electricity consumption today" }, diff --git a/homeassistant/components/victron_remote_monitoring/config_flow.py b/homeassistant/components/victron_remote_monitoring/config_flow.py index 83649e8e5c5..53c33757e3c 100644 --- a/homeassistant/components/victron_remote_monitoring/config_flow.py +++ b/homeassistant/components/victron_remote_monitoring/config_flow.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -69,7 +69,7 @@ class VictronRemoteMonitoringFlowHandler(ConfigFlow, domain=DOMAIN): """ client = VictronVRMClient( token=api_token, - client_session=get_async_client(self.hass), + client_session=async_get_clientsession(self.hass), ) try: sites = await client.users.list_sites() @@ -86,7 +86,7 @@ class VictronRemoteMonitoringFlowHandler(ConfigFlow, domain=DOMAIN): """Validate access to the selected site and return its data.""" client = VictronVRMClient( token=api_token, - client_session=get_async_client(self.hass), + client_session=async_get_clientsession(self.hass), ) try: site_data = await client.users.get_site(site_id) diff --git a/homeassistant/components/victron_remote_monitoring/coordinator.py b/homeassistant/components/victron_remote_monitoring/coordinator.py index 68cae39813d..a7a58fbbe4a 100644 --- a/homeassistant/components/victron_remote_monitoring/coordinator.py +++ b/homeassistant/components/victron_remote_monitoring/coordinator.py @@ -11,7 +11,7 @@ from victron_vrm.utils import dt_now from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN, LOGGER @@ -26,8 +26,8 @@ class VRMForecastStore: """Class to hold the forecast data.""" site_id: int - solar: ForecastAggregations - consumption: ForecastAggregations + solar: ForecastAggregations | None + consumption: ForecastAggregations | None async def get_forecast(client: VictronVRMClient, site_id: int) -> VRMForecastStore: @@ -75,7 +75,7 @@ class VictronRemoteMonitoringDataUpdateCoordinator( """Initialize.""" self.client = VictronVRMClient( token=config_entry.data[CONF_API_TOKEN], - client_session=get_async_client(hass), + client_session=async_get_clientsession(hass), ) self.site_id = config_entry.data[CONF_SITE_ID] super().__init__( diff --git a/homeassistant/components/victron_remote_monitoring/manifest.json b/homeassistant/components/victron_remote_monitoring/manifest.json index 1ce45ad2475..d6a7b2f9586 100644 --- a/homeassistant/components/victron_remote_monitoring/manifest.json +++ b/homeassistant/components/victron_remote_monitoring/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["victron-vrm==0.1.7"] + "requirements": ["victron-vrm==0.1.8"] } diff --git a/homeassistant/components/victron_remote_monitoring/sensor.py b/homeassistant/components/victron_remote_monitoring/sensor.py index 8876f784fa8..6d5e97c92cf 100644 --- a/homeassistant/components/victron_remote_monitoring/sensor.py +++ b/homeassistant/components/victron_remote_monitoring/sensor.py @@ -39,7 +39,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="energy_production_estimate_yesterday", translation_key="energy_production_estimate_yesterday", - value_fn=lambda estimate: estimate.solar.yesterday_total, + value_fn=lambda store: ( + store.solar.yesterday_total if store.solar is not None else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -49,7 +51,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="energy_production_estimate_today", translation_key="energy_production_estimate_today", - value_fn=lambda estimate: estimate.solar.today_total, + value_fn=lambda store: ( + store.solar.today_total if store.solar is not None else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -59,7 +63,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="energy_production_estimate_today_remaining", translation_key="energy_production_estimate_today_remaining", - value_fn=lambda estimate: estimate.solar.today_left_total, + value_fn=lambda store: ( + store.solar.today_left_total if store.solar is not None else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -69,7 +75,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="energy_production_estimate_tomorrow", translation_key="energy_production_estimate_tomorrow", - value_fn=lambda estimate: estimate.solar.tomorrow_total, + value_fn=lambda store: ( + store.solar.tomorrow_total if store.solar is not None else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -79,25 +87,33 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="power_highest_peak_time_yesterday", translation_key="power_highest_peak_time_yesterday", - value_fn=lambda estimate: estimate.solar.yesterday_peak_time, + value_fn=lambda store: ( + store.solar.yesterday_peak_time if store.solar is not None else None + ), device_class=SensorDeviceClass.TIMESTAMP, ), VRMForecastsSensorEntityDescription( key="power_highest_peak_time_today", translation_key="power_highest_peak_time_today", - value_fn=lambda estimate: estimate.solar.today_peak_time, + value_fn=lambda store: ( + store.solar.today_peak_time if store.solar is not None else None + ), device_class=SensorDeviceClass.TIMESTAMP, ), VRMForecastsSensorEntityDescription( key="power_highest_peak_time_tomorrow", translation_key="power_highest_peak_time_tomorrow", - value_fn=lambda estimate: estimate.solar.tomorrow_peak_time, + value_fn=lambda store: ( + store.solar.tomorrow_peak_time if store.solar is not None else None + ), device_class=SensorDeviceClass.TIMESTAMP, ), VRMForecastsSensorEntityDescription( key="energy_production_current_hour", translation_key="energy_production_current_hour", - value_fn=lambda estimate: estimate.solar.current_hour_total, + value_fn=lambda store: ( + store.solar.current_hour_total if store.solar is not None else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -107,7 +123,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="energy_production_next_hour", translation_key="energy_production_next_hour", - value_fn=lambda estimate: estimate.solar.next_hour_total, + value_fn=lambda store: ( + store.solar.next_hour_total if store.solar is not None else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -118,7 +136,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="energy_consumption_estimate_yesterday", translation_key="energy_consumption_estimate_yesterday", - value_fn=lambda estimate: estimate.consumption.yesterday_total, + value_fn=lambda store: ( + store.consumption.yesterday_total if store.consumption is not None else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -128,7 +148,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="energy_consumption_estimate_today", translation_key="energy_consumption_estimate_today", - value_fn=lambda estimate: estimate.consumption.today_total, + value_fn=lambda store: ( + store.consumption.today_total if store.consumption is not None else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -138,7 +160,11 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="energy_consumption_estimate_today_remaining", translation_key="energy_consumption_estimate_today_remaining", - value_fn=lambda estimate: estimate.consumption.today_left_total, + value_fn=lambda store: ( + store.consumption.today_left_total + if store.consumption is not None + else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -148,7 +174,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="energy_consumption_estimate_tomorrow", translation_key="energy_consumption_estimate_tomorrow", - value_fn=lambda estimate: estimate.consumption.tomorrow_total, + value_fn=lambda store: ( + store.consumption.tomorrow_total if store.consumption is not None else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -158,25 +186,39 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="consumption_highest_peak_time_yesterday", translation_key="consumption_highest_peak_time_yesterday", - value_fn=lambda estimate: estimate.consumption.yesterday_peak_time, + value_fn=lambda store: ( + store.consumption.yesterday_peak_time + if store.consumption is not None + else None + ), device_class=SensorDeviceClass.TIMESTAMP, ), VRMForecastsSensorEntityDescription( key="consumption_highest_peak_time_today", translation_key="consumption_highest_peak_time_today", - value_fn=lambda estimate: estimate.consumption.today_peak_time, + value_fn=lambda store: ( + store.consumption.today_peak_time if store.consumption is not None else None + ), device_class=SensorDeviceClass.TIMESTAMP, ), VRMForecastsSensorEntityDescription( key="consumption_highest_peak_time_tomorrow", translation_key="consumption_highest_peak_time_tomorrow", - value_fn=lambda estimate: estimate.consumption.tomorrow_peak_time, + value_fn=lambda store: ( + store.consumption.tomorrow_peak_time + if store.consumption is not None + else None + ), device_class=SensorDeviceClass.TIMESTAMP, ), VRMForecastsSensorEntityDescription( key="energy_consumption_current_hour", translation_key="energy_consumption_current_hour", - value_fn=lambda estimate: estimate.consumption.current_hour_total, + value_fn=lambda store: ( + store.consumption.current_hour_total + if store.consumption is not None + else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -186,7 +228,9 @@ SENSORS: tuple[VRMForecastsSensorEntityDescription, ...] = ( VRMForecastsSensorEntityDescription( key="energy_consumption_next_hour", translation_key="energy_consumption_next_hour", - value_fn=lambda estimate: estimate.consumption.next_hour_total, + value_fn=lambda store: ( + store.consumption.next_hour_total if store.consumption is not None else None + ), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, diff --git a/homeassistant/components/water_heater/services.yaml b/homeassistant/components/water_heater/services.yaml index b60cfdd8c48..3600545175e 100644 --- a/homeassistant/components/water_heater/services.yaml +++ b/homeassistant/components/water_heater/services.yaml @@ -20,8 +20,9 @@ set_temperature: selector: number: min: 0 - max: 100 + max: 250 step: 0.5 + mode: box unit_of_measurement: "°" operation_mode: example: eco diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c7a97ffb392..d0b420fe5c6 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.81"] + "requirements": ["holidays==0.82"] } diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index bece865bef2..59f8d134f27 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -744,8 +744,11 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN): # Without confirmation, discovery can automatically progress into parts of the # config flow logic that interacts with hardware. + # Ignore Zeroconf discoveries during onboarding, as they may be in use already. if user_input is not None or ( - not onboarding.async_is_onboarded(self.hass) and not zha_config_entries + not onboarding.async_is_onboarded(self.hass) + and not zha_config_entries + and self.source != SOURCE_ZEROCONF ): # Probe the radio type if we don't have one yet if self._radio_mgr.radio_type is None: diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 23b906a9d16..aa7ba9fde34 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -134,7 +134,6 @@ ENTITY_DESCRIPTION_KEY_UNIT_MAP: dict[tuple[str, str], SensorEntityDescription] device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, - suggested_display_precision=0, ), ( ENTITY_DESC_KEY_VOLTAGE, diff --git a/homeassistant/const.py b/homeassistant/const.py index 4ae1a73df6b..f5d6dd5b4a9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -8,9 +8,7 @@ from typing import TYPE_CHECKING, Final from .generated.entity_platforms import EntityPlatforms from .helpers.deprecation import ( - DeprecatedConstant, DeprecatedConstantEnum, - EnumWithDeprecatedMembers, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, @@ -316,60 +314,6 @@ STATE_OK: Final = "ok" STATE_PROBLEM: Final = "problem" -# #### ALARM CONTROL PANEL STATES #### -# STATE_ALARM_* below are deprecated as of 2024.11 -# use the AlarmControlPanelState enum instead. -_DEPRECATED_STATE_ALARM_DISARMED: Final = DeprecatedConstant( - "disarmed", - "AlarmControlPanelState.DISARMED", - "2025.11", -) -_DEPRECATED_STATE_ALARM_ARMED_HOME: Final = DeprecatedConstant( - "armed_home", - "AlarmControlPanelState.ARMED_HOME", - "2025.11", -) -_DEPRECATED_STATE_ALARM_ARMED_AWAY: Final = DeprecatedConstant( - "armed_away", - "AlarmControlPanelState.ARMED_AWAY", - "2025.11", -) -_DEPRECATED_STATE_ALARM_ARMED_NIGHT: Final = DeprecatedConstant( - "armed_night", - "AlarmControlPanelState.ARMED_NIGHT", - "2025.11", -) -_DEPRECATED_STATE_ALARM_ARMED_VACATION: Final = DeprecatedConstant( - "armed_vacation", - "AlarmControlPanelState.ARMED_VACATION", - "2025.11", -) -_DEPRECATED_STATE_ALARM_ARMED_CUSTOM_BYPASS: Final = DeprecatedConstant( - "armed_custom_bypass", - "AlarmControlPanelState.ARMED_CUSTOM_BYPASS", - "2025.11", -) -_DEPRECATED_STATE_ALARM_PENDING: Final = DeprecatedConstant( - "pending", - "AlarmControlPanelState.PENDING", - "2025.11", -) -_DEPRECATED_STATE_ALARM_ARMING: Final = DeprecatedConstant( - "arming", - "AlarmControlPanelState.ARMING", - "2025.11", -) -_DEPRECATED_STATE_ALARM_DISARMING: Final = DeprecatedConstant( - "disarming", - "AlarmControlPanelState.DISARMING", - "2025.11", -) -_DEPRECATED_STATE_ALARM_TRIGGERED: Final = DeprecatedConstant( - "triggered", - "AlarmControlPanelState.TRIGGERED", - "2025.11", -) - # #### STATE AND EVENT ATTRIBUTES #### # Attribution ATTR_ATTRIBUTION: Final = "attribution" @@ -759,35 +703,13 @@ class UnitOfMass(StrEnum): STONES = "st" -class UnitOfConductivity( - StrEnum, - metaclass=EnumWithDeprecatedMembers, - deprecated={ - "SIEMENS": ("UnitOfConductivity.SIEMENS_PER_CM", "2025.11.0"), - "MICROSIEMENS": ("UnitOfConductivity.MICROSIEMENS_PER_CM", "2025.11.0"), - "MILLISIEMENS": ("UnitOfConductivity.MILLISIEMENS_PER_CM", "2025.11.0"), - }, -): +class UnitOfConductivity(StrEnum): """Conductivity units.""" SIEMENS_PER_CM = "S/cm" MICROSIEMENS_PER_CM = "μS/cm" MILLISIEMENS_PER_CM = "mS/cm" - # Deprecated aliases - SIEMENS = "S/cm" - """Deprecated: Please use UnitOfConductivity.SIEMENS_PER_CM""" - MICROSIEMENS = "μS/cm" - """Deprecated: Please use UnitOfConductivity.MICROSIEMENS_PER_CM""" - MILLISIEMENS = "mS/cm" - """Deprecated: Please use UnitOfConductivity.MILLISIEMENS_PER_CM""" - - -_DEPRECATED_CONDUCTIVITY: Final = DeprecatedConstantEnum( - UnitOfConductivity.MICROSIEMENS_PER_CM, - "2025.11", -) -"""Deprecated: please use UnitOfConductivity.MICROSIEMENS_PER_CM""" # Light units LIGHT_LUX: Final = "lx" diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index fcaa824ff39..402a159847a 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -48,6 +48,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ "manufacturer_id": 820, "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba", }, + { + "domain": "airthings_ble", + "manufacturer_id": 820, + "service_uuid": "b42e90a2-ade7-11e4-89d3-123b93f75cba", + }, { "connectable": False, "domain": "aranet", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9394d57beb9..8116eeeda82 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -441,6 +441,7 @@ FLOWS = { "nightscout", "niko_home_control", "nina", + "nintendo_parental", "nmap_tracker", "nmbs", "nobo_hub", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 32e48d4aac6..f7e3d4fce39 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4465,6 +4465,12 @@ "iot_class": "cloud_polling", "single_config_entry": true }, + "nintendo_parental": { + "name": "Nintendo Switch Parental Controls", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "nissan_leaf": { "name": "Nissan Leaf", "integration_type": "hub", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c47ff2c605e..4c6d8c2d579 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.5.0 aiohasupervisor==0.3.3 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.15 +aiohttp==3.13.0 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 @@ -15,7 +15,7 @@ astral==2.2 async-interrupt==1.2.2 async-upnp-client==0.45.0 atomicwrites-homeassistant==1.4.1 -attrs==25.3.0 +attrs==25.4.0 audioop-lts==0.2.1 av==13.1.0 awesomeversion==25.5.0 @@ -74,7 +74,7 @@ voluptuous-openapi==0.1.0 voluptuous-serialize==2.7.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.20.1 +yarl==1.22.0 zeroconf==0.148.0 # Constrain pycryptodome to avoid vulnerability @@ -130,7 +130,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.9 +pydantic==2.12.0 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/pyproject.toml b/pyproject.toml index 3ce2b9a4c64..cc62bb15051 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.3", - "aiohttp==3.12.15", + "aiohttp==3.13.0", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", @@ -36,7 +36,7 @@ dependencies = [ "annotatedyaml==1.0.2", "astral==2.2", "async-interrupt==1.2.2", - "attrs==25.3.0", + "attrs==25.4.0", "atomicwrites-homeassistant==1.4.1", "audioop-lts==0.2.1", "awesomeversion==25.5.0", @@ -78,7 +78,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.7.0", "voluptuous-openapi==0.1.0", - "yarl==1.20.1", + "yarl==1.22.0", "webrtc-models==0.3.0", "zeroconf==0.148.0", ] @@ -485,6 +485,8 @@ filterwarnings = [ "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", # -- tracked upstream / open PRs + # https://github.com/kbr/fritzconnection/pull/244 - v1.15.0 - 2025-05-17 + "ignore:.*invalid escape sequence:SyntaxWarning:.*fritzconnection.core.soaper", # https://github.com/hacf-fr/meteofrance-api/pull/688 - v1.4.0 - 2025-03-26 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast", @@ -522,8 +524,8 @@ filterwarnings = [ # https://pypi.org/project/motionblindsble/ - v0.1.3 - 2024-11-12 # https://github.com/LennP/motionblindsble/blob/0.1.3/motionblindsble/device.py#L390 "ignore:Passing additional arguments for BLEDevice is deprecated and has no effect:DeprecationWarning:motionblindsble.device", - # https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15 - # https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38 + # https://pypi.org/project/pyeconet/ - v0.2.0 - 2025-10-05 + # https://github.com/w1ll1am23/pyeconet/blob/v0.2.0/src/pyeconet/api.py#L39 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", # https://github.com/thecynic/pylutron - v0.2.18 - 2025-04-15 "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", @@ -565,7 +567,6 @@ filterwarnings = [ # - SyntaxWarning - is with literal # https://github.com/majuss/lupupy/pull/15 - >0.3.2 # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 - # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 "ignore:\"is.*\" with '.*' literal:SyntaxWarning:importlib._bootstrap", @@ -574,7 +575,6 @@ filterwarnings = [ "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", - "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", # -- Websockets 14.1 @@ -605,8 +605,6 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 "ignore:pkg_resources is deprecated as an API:UserWarning:pilight", - # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 - "ignore:.*invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 "ignore:.*invalid escape sequence:SyntaxWarning:.*ppadb", # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 diff --git a/requirements.txt b/requirements.txt index d10b789c4e3..7412151259b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.5.0 aiohasupervisor==0.3.3 -aiohttp==3.12.15 +aiohttp==3.13.0 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==1.0.2 astral==2.2 async-interrupt==1.2.2 -attrs==25.3.0 +attrs==25.4.0 atomicwrites-homeassistant==1.4.1 audioop-lts==0.2.1 awesomeversion==25.5.0 @@ -50,6 +50,6 @@ uv==0.8.9 voluptuous==0.15.2 voluptuous-serialize==2.7.0 voluptuous-openapi==0.1.0 -yarl==1.20.1 +yarl==1.22.0 webrtc-models==0.3.0 zeroconf==0.148.0 diff --git a/requirements_all.txt b/requirements_all.txt index 44a096e5fbd..ab207408da8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -217,7 +217,7 @@ aiobafi6==0.9.0 aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==0.12.3 +aiocomelit==1.1.1 # homeassistant.components.dhcp aiodhcpwatcher==1.2.1 @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.12.0 +aioesphomeapi==41.13.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -384,7 +384,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.11.0 +aioshelly==13.12.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -1186,7 +1186,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.81 +holidays==0.82 # homeassistant.components.frontend home-assistant-frontend==20251001.0 @@ -1548,7 +1548,7 @@ nextcord==3.1.0 nextdns==4.1.0 # homeassistant.components.niko_home_control -nhc==0.4.12 +nhc==0.6.1 # homeassistant.components.nibe_heatpump nibe==2.19.0 @@ -1631,7 +1631,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==1.99.5 +openai==2.2.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1758,7 +1758,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==7.0.0 +psutil==7.1.0 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 @@ -2138,7 +2138,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.1.1 +pylamarzocco==2.1.2 # homeassistant.components.lastfm pylast==5.1.0 @@ -2212,6 +2212,9 @@ pynetio==0.1.9.1 # homeassistant.components.nina pynina==0.3.6 +# homeassistant.components.nintendo_parental +pynintendoparental==1.1.1 + # homeassistant.components.nobo_hub pynobo==1.8.1 @@ -2390,7 +2393,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.2 # homeassistant.components.smartthings -pysmartthings==3.3.0 +pysmartthings==3.3.1 # homeassistant.components.smarty pysmarty2==0.10.3 @@ -2692,7 +2695,7 @@ raincloudy==0.0.7 rapt-ble==0.1.2 # homeassistant.components.raspyrfm -raspyrfm-client==1.2.8 +raspyrfm-client==1.2.9 # homeassistant.components.refoss refoss-ha==1.2.5 @@ -3095,7 +3098,7 @@ velbus-aio==2025.8.0 venstarcolortouch==0.21 # homeassistant.components.victron_remote_monitoring -victron-vrm==0.1.7 +victron-vrm==0.1.8 # homeassistant.components.vilfo vilfo-api-client==0.5.0 diff --git a/requirements_test.txt b/requirements_test.txt index 78750341109..a7edadc0112 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -15,7 +15,7 @@ license-expression==30.4.3 mock-open==1.4.0 mypy-dev==1.19.0a2 pre-commit==4.2.0 -pydantic==2.11.9 +pydantic==2.12.0 pylint==3.3.8 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af38568cdfb..f2ea5db4adb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -205,7 +205,7 @@ aiobafi6==0.9.0 aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==0.12.3 +aiocomelit==1.1.1 # homeassistant.components.dhcp aiodhcpwatcher==1.2.1 @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.12.0 +aioesphomeapi==41.13.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -366,7 +366,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.11.0 +aioshelly==13.12.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -1035,7 +1035,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.81 +holidays==0.82 # homeassistant.components.frontend home-assistant-frontend==20251001.0 @@ -1331,7 +1331,7 @@ nextcord==3.1.0 nextdns==4.1.0 # homeassistant.components.niko_home_control -nhc==0.4.12 +nhc==0.6.1 # homeassistant.components.nibe_heatpump nibe==2.19.0 @@ -1402,7 +1402,7 @@ open-meteo==0.3.2 # homeassistant.components.open_router # homeassistant.components.openai_conversation -openai==1.99.5 +openai==2.2.0 # homeassistant.components.openerz openerz-api==0.3.0 @@ -1490,7 +1490,7 @@ prowlpy==1.0.2 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==7.0.0 +psutil==7.1.0 # homeassistant.components.pushbullet pushbullet.py==0.11.0 @@ -1786,7 +1786,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.1.1 +pylamarzocco==2.1.2 # homeassistant.components.lastfm pylast==5.1.0 @@ -1848,6 +1848,9 @@ pynetgear==0.10.10 # homeassistant.components.nina pynina==0.3.6 +# homeassistant.components.nintendo_parental +pynintendoparental==1.1.1 + # homeassistant.components.nobo_hub pynobo==1.8.1 @@ -1996,7 +1999,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.2 # homeassistant.components.smartthings -pysmartthings==3.3.0 +pysmartthings==3.3.1 # homeassistant.components.smarty pysmarty2==0.10.3 @@ -2569,7 +2572,7 @@ velbus-aio==2025.8.0 venstarcolortouch==0.21 # homeassistant.components.victron_remote_monitoring -victron-vrm==0.1.7 +victron-vrm==0.1.8 # homeassistant.components.vilfo vilfo-api-client==0.5.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index bdd8ed2cda1..6b96e3b5e53 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -155,7 +155,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.9 +pydantic==2.12.0 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index ddc3cb649e8..93859fa301f 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -336,8 +336,6 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = { "obihai": {"homeassistant": {"pyobihai"}}, # https://github.com/iamkubi/pydactyl "pterodactyl": {"homeassistant": {"py-dactyl"}}, - # https://github.com/markusressel/raspyrfm-client - "raspyrfm": {"homeassistant": {"raspyrfm-client"}}, # https://github.com/sstallion/sensorpush-api "sensorpush_cloud": { "homeassistant": {"sensorpush-api"}, diff --git a/tests/components/airgradient/test_update.py b/tests/components/airgradient/test_update.py index 65614312b46..1ef2122f948 100644 --- a/tests/components/airgradient/test_update.py +++ b/tests/components/airgradient/test_update.py @@ -3,10 +3,12 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch +from airgradient import AirGradientConnectionError from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -67,3 +69,64 @@ async def test_update_mechanism( assert state.state == STATE_ON assert state.attributes["installed_version"] == "3.1.4" assert state.attributes["latest_version"] == "3.1.5" + + +async def test_update_errors( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity errors.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("update.airgradient_firmware") + assert state.state == STATE_ON + mock_airgradient_client.get_latest_firmware_version.side_effect = ( + AirGradientConnectionError("Boom") + ) + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("update.airgradient_firmware") + assert state.state == STATE_UNAVAILABLE + + assert "Unable to connect to AirGradient server to check for updates" in caplog.text + + caplog.clear() + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("update.airgradient_firmware") + assert state.state == STATE_UNAVAILABLE + + assert ( + "Unable to connect to AirGradient server to check for updates" + not in caplog.text + ) + + mock_airgradient_client.get_latest_firmware_version.side_effect = None + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("update.airgradient_firmware") + assert state.state == STATE_ON + mock_airgradient_client.get_latest_firmware_version.side_effect = ( + AirGradientConnectionError("Boom") + ) + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("update.airgradient_firmware") + assert state.state == STATE_UNAVAILABLE + + assert "Unable to connect to AirGradient server to check for updates" in caplog.text diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index add21b1067f..cf91634f71f 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -9,12 +9,17 @@ from airthings_ble import ( AirthingsDevice, AirthingsDeviceType, ) +from bleak.backends.device import BLEDevice from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceRegistry +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceEntry, + DeviceRegistry, +) from tests.common import MockConfigEntry, MockEntity from tests.components.bluetooth import generate_advertisement_data, generate_ble_device @@ -28,7 +33,15 @@ def patch_async_setup_entry(return_value=True): ) -def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None): +def patch_async_discovered_service_info(return_value: list[BluetoothServiceInfoBleak]): + """Patch async_discovered_service_info to return given list.""" + return patch( + "homeassistant.components.bluetooth.async_discovered_service_info", + return_value=return_value, + ) + + +def patch_async_ble_device_from_address(return_value: BLEDevice | None): """Patch async ble device from address to return a given value.""" return patch( "homeassistant.components.bluetooth.async_ble_device_from_address", @@ -101,6 +114,27 @@ WAVE_SERVICE_INFO = BluetoothServiceInfoBleak( tx_power=0, ) +WAVE_ENHANCE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="cc-cc-cc-cc-cc-cc", + address="cc:cc:cc:cc:cc:cc", + device=generate_ble_device( + address="cc:cc:cc:cc:cc:cc", + name="Airthings Wave Enhance", + ), + rssi=-61, + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_data={}, + service_uuids=[], + source="local", + advertisement=generate_advertisement_data( + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_uuids=[], + ), + connectable=True, + time=0, + tx_power=0, +) + VIEW_PLUS_SERVICE_INFO = BluetoothServiceInfoBleak( name="cc-cc-cc-cc-cc-cc", address="cc:cc:cc:cc:cc:cc", @@ -146,6 +180,27 @@ VIEW_PLUS_SERVICE_INFO = BluetoothServiceInfoBleak( tx_power=0, ) +UNKNOWN_AIRTHINGS_SERVICE_INFO = BluetoothServiceInfoBleak( + name="unknown", + address="00:cc:cc:cc:cc:cc", + rssi=-61, + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_data={}, + service_uuids=[], + source="local", + device=generate_ble_device( + "cc:cc:cc:cc:cc:cc", + "unknown", + ), + advertisement=generate_advertisement_data( + manufacturer_data={}, + service_uuids=[], + ), + connectable=True, + time=0, + tx_power=0, +) + UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak( name="unknown", address="00:cc:cc:cc:cc:cc", @@ -190,6 +245,26 @@ WAVE_DEVICE_INFO = AirthingsDevice( address="cc:cc:cc:cc:cc:cc", ) +WAVE_ENHANCE_DEVICE_INFO = AirthingsDevice( + manufacturer="Airthings AS", + hw_version="REV X", + sw_version="T-SUB-2.6.2-master+0", + model=AirthingsDeviceType.WAVE_ENHANCE_EU, + name="Airthings Wave Enhance", + identifier="123456", + sensors={ + "lux": 25, + "battery": 85, + "humidity": 60.0, + "temperature": 21.0, + "co2": 500.0, + "voc": 155.0, + "pressure": 1020, + "noise": 40, + }, + address="cc:cc:cc:cc:cc:cc", +) + TEMPERATURE_V1 = MockEntity( unique_id="Airthings Wave Plus 123456_temperature", name="Airthings Wave Plus 123456 Temperature", @@ -226,23 +301,32 @@ VOC_V3 = MockEntity( ) -def create_entry(hass: HomeAssistant) -> MockConfigEntry: +def create_entry( + hass: HomeAssistant, + service_info: BluetoothServiceInfoBleak, + device_info: AirthingsDevice, +) -> MockConfigEntry: """Create a config entry.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=WAVE_SERVICE_INFO.address, - title="Airthings Wave Plus (123456)", + unique_id=service_info.address, + title=f"{device_info.name} ({device_info.identifier})", ) entry.add_to_hass(hass) return entry -def create_device(entry: ConfigEntry, device_registry: DeviceRegistry): +def create_device( + entry: ConfigEntry, + device_registry: DeviceRegistry, + service_info: BluetoothServiceInfoBleak, + device_info: AirthingsDevice, +) -> DeviceEntry: """Create a device for the given entry.""" return device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(CONNECTION_BLUETOOTH, WAVE_SERVICE_INFO.address)}, + connections={(CONNECTION_BLUETOOTH, service_info.address)}, manufacturer="Airthings AS", - name="Airthings Wave Plus (123456)", - model="Wave Plus", + name=f"{device_info.name} ({device_info.identifier})", + model=device_info.model.product_name, ) diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index a65c51b3fd6..71f2148b56b 100644 --- a/tests/components/airthings_ble/test_config_flow.py +++ b/tests/components/airthings_ble/test_config_flow.py @@ -2,8 +2,9 @@ from unittest.mock import patch -from airthings_ble import AirthingsDevice, AirthingsDeviceType +from airthings_ble import AirthingsDevice, AirthingsDeviceType, UnsupportedDeviceError from bleak import BleakError +from home_assistant_bluetooth import BluetoothServiceInfoBleak import pytest from homeassistant.components.airthings_ble.const import DOMAIN @@ -13,6 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + UNKNOWN_AIRTHINGS_SERVICE_INFO, UNKNOWN_SERVICE_INFO, VIEW_PLUS_SERVICE_INFO, WAVE_DEVICE_INFO, @@ -73,7 +75,12 @@ async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("exc", "reason"), [(Exception(), "unknown"), (BleakError(), "cannot_connect")] + ("exc", "reason"), + [ + (Exception(), "unknown"), + (BleakError(), "cannot_connect"), + (UnsupportedDeviceError(), "unsupported_device"), + ], ) async def test_bluetooth_discovery_airthings_ble_update_failed( hass: HomeAssistant, exc: Exception, reason: str @@ -234,22 +241,34 @@ async def test_user_setup_existing_and_unknown_device(hass: HomeAssistant) -> No assert result["reason"] == "no_devices_found" -async def test_user_setup_unknown_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("exc", "reason", "service_info"), + [ + (Exception(), "unknown", WAVE_SERVICE_INFO), + (UnsupportedDeviceError(), "no_devices_found", UNKNOWN_AIRTHINGS_SERVICE_INFO), + ], +) +async def test_user_setup_unknown_error( + hass: HomeAssistant, + exc: Exception, + reason: str, + service_info: BluetoothServiceInfoBleak, +) -> None: """Test the user initiated form with an unknown error.""" with ( patch( "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", return_value=[WAVE_SERVICE_INFO], ), - patch_async_ble_device_from_address(WAVE_SERVICE_INFO), - patch_airthings_ble(None, Exception()), + patch_async_ble_device_from_address(service_info), + patch_airthings_ble(None, exc), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown" + assert result["reason"] == reason async def test_user_setup_unable_to_connect(hass: HomeAssistant) -> None: @@ -350,3 +369,16 @@ async def test_step_user_firmware_required(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "firmware_upgrade_required" + + +async def test_discovering_unsupported_devices(hass: HomeAssistant) -> None: + """Test discovering unsupported devices.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[UNKNOWN_AIRTHINGS_SERVICE_INFO, UNKNOWN_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py index a8acdf7ec7b..988dc313dab 100644 --- a/tests/components/airthings_ble/test_sensor.py +++ b/tests/components/airthings_ble/test_sensor.py @@ -2,6 +2,8 @@ import logging +import pytest + from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -16,10 +18,15 @@ from . import ( VOC_V2, VOC_V3, WAVE_DEVICE_INFO, + WAVE_ENHANCE_DEVICE_INFO, + WAVE_ENHANCE_SERVICE_INFO, WAVE_SERVICE_INFO, create_device, create_entry, + patch_airthings_ble, patch_airthings_device_update, + patch_async_ble_device_from_address, + patch_async_discovered_service_info, ) from tests.components.bluetooth import inject_bluetooth_service_info @@ -33,8 +40,8 @@ async def test_migration_from_v1_to_v3_unique_id( device_registry: dr.DeviceRegistry, ) -> None: """Verify that we can migrate from v1 (pre 2023.9.0) to the latest unique id format.""" - entry = create_entry(hass) - device = create_device(entry, device_registry) + entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) + device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) assert entry is not None assert device is not None @@ -74,8 +81,8 @@ async def test_migration_from_v2_to_v3_unique_id( device_registry: dr.DeviceRegistry, ) -> None: """Verify that we can migrate from v2 (introduced in 2023.9.0) to the latest unique id format.""" - entry = create_entry(hass) - device = create_device(entry, device_registry) + entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) + device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) assert entry is not None assert device is not None @@ -115,8 +122,8 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id( device_registry: dr.DeviceRegistry, ) -> None: """Test if migration works when we have both v1 (pre 2023.9.0) and v2 (introduced in 2023.9.0) unique ids.""" - entry = create_entry(hass) - device = create_device(entry, device_registry) + entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) + device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) assert entry is not None assert device is not None @@ -165,8 +172,8 @@ async def test_migration_with_all_unique_ids( device_registry: dr.DeviceRegistry, ) -> None: """Test if migration works when we have all unique ids.""" - entry = create_entry(hass) - device = create_device(entry, device_registry) + entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) + device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) assert entry is not None assert device is not None @@ -215,3 +222,48 @@ async def test_migration_with_all_unique_ids( assert entity_registry.async_get(v1.entity_id).unique_id == VOC_V1.unique_id assert entity_registry.async_get(v2.entity_id).unique_id == VOC_V2.unique_id assert entity_registry.async_get(v3.entity_id).unique_id == VOC_V3.unique_id + + +@pytest.mark.parametrize( + ("unique_suffix", "expected_sensor_name"), + [ + ("lux", "Illuminance"), + ("noise", "Ambient noise"), + ], +) +async def test_translation_keys( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + unique_suffix: str, + expected_sensor_name: str, +) -> None: + """Test that translated sensor names are correct.""" + entry = create_entry(hass, WAVE_ENHANCE_SERVICE_INFO, WAVE_DEVICE_INFO) + device = create_device( + entry, device_registry, WAVE_ENHANCE_SERVICE_INFO, WAVE_ENHANCE_DEVICE_INFO + ) + + with ( + patch_async_ble_device_from_address(WAVE_ENHANCE_SERVICE_INFO.device), + patch_async_discovered_service_info([WAVE_ENHANCE_SERVICE_INFO]), + patch_airthings_ble(WAVE_ENHANCE_DEVICE_INFO), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert device is not None + assert device.name == "Airthings Wave Enhance (123456)" + + unique_id = f"{WAVE_ENHANCE_DEVICE_INFO.address}_{unique_suffix}" + entity_id = entity_registry.async_get_entity_id(Platform.SENSOR, DOMAIN, unique_id) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + + expected_value = WAVE_ENHANCE_DEVICE_INFO.sensors[unique_suffix] + assert state.state == str(expected_value) + + expected_name = f"Airthings Wave Enhance (123456) {expected_sensor_name}" + assert state.attributes.get("friendly_name") == expected_name diff --git a/tests/components/alexa_devices/test_binary_sensor.py b/tests/components/alexa_devices/test_binary_sensor.py index 6b55a701b45..a6775ec8fc9 100644 --- a/tests/components/alexa_devices/test_binary_sensor.py +++ b/tests/components/alexa_devices/test_binary_sensor.py @@ -11,10 +11,12 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.alexa_devices.const import DOMAIN from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN @@ -139,3 +141,51 @@ async def test_dynamic_device( assert (state := hass.states.get(entity_id_2)) assert state.state == STATE_ON + + +@pytest.mark.parametrize( + "key", + [ + "bluetooth", + "babyCryDetectionState", + "beepingApplianceDetectionState", + "coughDetectionState", + "dogBarkDetectionState", + "waterSoundsDetectionState", + ], +) +async def test_deprecated_sensor_removal( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + key: str, +) -> None: + """Test deprecated sensors are removed.""" + + mock_config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Amazon", + model="Echo Dot", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + entity = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id=f"{TEST_DEVICE_1_SN}-{key}", + device_id=device.id, + config_entry=mock_config_entry, + has_entity_name=True, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity2 = entity_registry.async_get(entity.entity_id) + assert entity2 is None diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py index 020971d8f76..3424227673c 100644 --- a/tests/components/alexa_devices/test_utils.py +++ b/tests/components/alexa_devices/test_utils.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +from aioamazondevices.const import SPEAKER_GROUP_FAMILY, SPEAKER_GROUP_MODEL from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData import pytest @@ -94,3 +95,42 @@ async def test_alexa_unique_id_migration( assert migrated_entity is not None assert migrated_entity.config_entry_id == mock_config_entry.entry_id assert migrated_entity.unique_id == f"{TEST_DEVICE_1_SN}-dnd" + + +async def test_alexa_dnd_group_removal( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test dnd switch is removed for Speaker Groups.""" + + mock_config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Amazon", + model=SPEAKER_GROUP_MODEL, + entry_type=dr.DeviceEntryType.SERVICE, + ) + + entity = entity_registry.async_get_or_create( + DOMAIN, + SWITCH_DOMAIN, + unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb", + device_id=device.id, + config_entry=mock_config_entry, + has_entity_name=True, + ) + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_DEVICE_1_SN + ].device_family = SPEAKER_GROUP_FAMILY + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.states.get(entity.entity_id) diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index 90622bbe457..68a44b6d055 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -6,11 +6,12 @@ from aiocomelit import CannotAuthenticate, CannotConnect from aiocomelit.const import BRIDGE, VEDO import pytest +from homeassistant.components.comelit.config_flow import InvalidPin from homeassistant.components.comelit.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType, InvalidData +from homeassistant.data_entry_flow import FlowResultType from .const import ( BAD_PIN, @@ -97,6 +98,7 @@ async def test_flow_vedo( (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), (ConnectionResetError, "unknown"), + (InvalidPin, "invalid_pin"), ], ) async def test_exception_connection( @@ -181,6 +183,7 @@ async def test_reauth_successful( (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), (ConnectionResetError, "unknown"), + (InvalidPin, "invalid_pin"), ], ) async def test_reauth_not_successful( @@ -261,6 +264,7 @@ async def test_reconfigure_successful( (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), (ConnectionResetError, "unknown"), + (InvalidPin, "invalid_pin"), ], ) async def test_reconfigure_fails( @@ -326,16 +330,17 @@ async def test_pin_format_serial_bridge( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with pytest.raises(InvalidData): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_HOST: BRIDGE_HOST, - CONF_PORT: BRIDGE_PORT, - CONF_PIN: BAD_PIN, - }, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: BRIDGE_HOST, + CONF_PORT: BRIDGE_PORT, + CONF_PIN: BAD_PIN, + }, + ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_pin"} result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/comelit/test_cover.py b/tests/components/comelit/test_cover.py index 02efff1dd94..1e3b27e9c27 100644 --- a/tests/components/comelit/test_cover.py +++ b/tests/components/comelit/test_cover.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from aiocomelit.api import ComelitSerialBridgeObject from aiocomelit.const import COVER, WATT from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.comelit.const import SCAN_INTERVAL @@ -17,14 +18,20 @@ from homeassistant.components.cover import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + CoverState, ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache, + snapshot_platform, +) ENTITY_ID = "cover.cover0" @@ -162,37 +169,26 @@ async def test_cover_stop_if_stopped( assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + "cover_state", + [ + CoverState.OPEN, + CoverState.CLOSED, + ], +) async def test_cover_restore_state( hass: HomeAssistant, - freezer: FrozenDateTimeFactory, mock_serial_bridge: AsyncMock, mock_serial_bridge_config_entry: MockConfigEntry, + cover_state: CoverState, ) -> None: """Test cover restore state on reload.""" - mock_serial_bridge.reset_mock() + mock_restore_cache(hass, [State(ENTITY_ID, cover_state)]) await setup_integration(hass, mock_serial_bridge_config_entry) assert (state := hass.states.get(ENTITY_ID)) - assert state.state == STATE_UNKNOWN - - # Open cover - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: ENTITY_ID}, - blocking=True, - ) - mock_serial_bridge.set_device_status.assert_called() - - assert (state := hass.states.get(ENTITY_ID)) - assert state.state == STATE_OPENING - - await hass.config_entries.async_reload(mock_serial_bridge_config_entry.entry_id) - await hass.async_block_till_done() - - assert (state := hass.states.get(ENTITY_ID)) - assert state.state == STATE_OPENING + assert state.state == cover_state async def test_cover_dynamic( diff --git a/tests/components/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr index f8e269593d9..be03e609812 100644 --- a/tests/components/ecovacs/snapshots/test_select.ambr +++ b/tests/components/ecovacs/snapshots/test_select.ambr @@ -1,5 +1,62 @@ # serializer version: 1 -# name: test_selects[n0vyif-entity_ids1][select.x8_pro_omni_work_mode:entity-registry] +# name: test_selects[n0vyif-entity_ids2][select.x8_pro_omni_active_map:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.x8_pro_omni_active_map', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active map', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_map', + 'unique_id': 'E1234567890000000009_active_map', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[n0vyif-entity_ids2][select.x8_pro_omni_active_map:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'X8 PRO OMNI Active map', + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.x8_pro_omni_active_map', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Map 2', + }) +# --- +# name: test_selects[n0vyif-entity_ids2][select.x8_pro_omni_work_mode:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -41,7 +98,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_selects[n0vyif-entity_ids1][select.x8_pro_omni_work_mode:state] +# name: test_selects[n0vyif-entity_ids2][select.x8_pro_omni_work_mode:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'X8 PRO OMNI Work mode', @@ -60,6 +117,179 @@ 'state': 'vacuum', }) # --- +# name: test_selects[qhe2o2-entity_ids1][select.dusty_active_map:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dusty_active_map', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active map', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_map', + 'unique_id': '8516fbb1-17f1-4194-0000001_active_map', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[qhe2o2-entity_ids1][select.dusty_active_map:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Active map', + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.dusty_active_map', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Map 2', + }) +# --- +# name: test_selects[qhe2o2-entity_ids1][select.dusty_water_flow_level:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dusty_water_flow_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water flow level', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_amount', + 'unique_id': '8516fbb1-17f1-4194-0000001_water_amount', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[qhe2o2-entity_ids1][select.dusty_water_flow_level:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dusty Water flow level', + 'options': list([ + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.dusty_water_flow_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_active_map:entity-registry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.ozmo_950_active_map', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Active map', + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'active_map', + 'unique_id': 'E1234567890000000001_active_map', + 'unit_of_measurement': None, + }) +# --- +# name: test_selects[yna5x1-entity_ids0][select.ozmo_950_active_map:state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Ozmo 950 Active map', + 'options': list([ + 'Map 2', + '1', + ]), + }), + 'context': , + 'entity_id': 'select.ozmo_950_active_map', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Map 2', + }) +# --- # name: test_selects[yna5x1-entity_ids0][select.ozmo_950_water_flow_level:entity-registry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 5965398bd0c..3f3af62f22b 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -105,7 +105,7 @@ async def test_devices_in_dr( @pytest.mark.parametrize( ("device_fixture", "entities"), [ - ("yna5x1", 26), + ("yna5x1", 27), ("5xu9h3", 25), ("123", 3), ], diff --git a/tests/components/ecovacs/test_select.py b/tests/components/ecovacs/test_select.py index 538ab66bed0..f840e3dfc10 100644 --- a/tests/components/ecovacs/test_select.py +++ b/tests/components/ecovacs/test_select.py @@ -3,6 +3,7 @@ from deebot_client.command import Command from deebot_client.commands.json import SetWaterInfo from deebot_client.event_bus import EventBus +from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent, Map from deebot_client.events.water_info import WaterAmount, WaterAmountEvent from deebot_client.events.work_mode import WorkMode, WorkModeEvent import pytest @@ -36,6 +37,15 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): """Notify events.""" event_bus.notify(WaterAmountEvent(WaterAmount.ULTRAHIGH)) event_bus.notify(WorkModeEvent(WorkMode.VACUUM)) + event_bus.notify( + CachedMapInfoEvent( + { + Map(id="1", name="", using=False, built=False), + Map(id="2", name="Map 2", using=True, built=True), + } + ) + ) + event_bus.notify(MajorMapEvent("2", [], requested=False)) await block_till_done(hass, event_bus) @@ -47,12 +57,21 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus): "yna5x1", [ "select.ozmo_950_water_flow_level", + "select.ozmo_950_active_map", + ], + ), + ( + "qhe2o2", + [ + "select.dusty_water_flow_level", + "select.dusty_active_map", ], ), ( "n0vyif", [ "select.x8_pro_omni_work_mode", + "select.x8_pro_omni_active_map", ], ), ], diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index a9ee1f370a8..99ad9ac93e9 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -5,7 +5,8 @@ import logging from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from pyenphase.const import PHASENAMES +from pyenphase.const import PHASENAMES, PhaseNames +from pyenphase.models.meters import CtType import pytest from syrupy.assertion import SnapshotAssertion @@ -1137,7 +1138,7 @@ async def test_sensor_missing_data( entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, ) -> None: - """Test enphase_envoy sensor platform midding data handling.""" + """Test enphase_envoy sensor platform missing data handling.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, config_entry) @@ -1153,6 +1154,12 @@ async def test_sensor_missing_data( mock_envoy.data.ctmeter_production_phases = None mock_envoy.data.ctmeter_consumption_phases = None mock_envoy.data.ctmeter_storage_phases = None + del mock_envoy.data.ctmeters[CtType.NET_CONSUMPTION] + del mock_envoy.data.ctmeters_phases[CtType.NET_CONSUMPTION][PhaseNames.PHASE_2] + del mock_envoy.data.ctmeters[CtType.PRODUCTION] + del mock_envoy.data.ctmeters_phases[CtType.PRODUCTION][PhaseNames.PHASE_2] + del mock_envoy.data.ctmeters[CtType.STORAGE] + del mock_envoy.data.ctmeters_phases[CtType.STORAGE][PhaseNames.PHASE_2] # use different inverter serial to test 'expected inverter missing' code mock_envoy.data.inverters["2"] = mock_envoy.data.inverters.pop("1") @@ -1183,6 +1190,25 @@ async def test_sensor_missing_data( assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == STATE_UNKNOWN + del mock_envoy.data.ctmeters_phases[CtType.PRODUCTION] + del mock_envoy.data.ctmeters_phases[CtType.STORAGE] + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed again"} + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + for entity in ( + "metering_status_production_ct", + "metering_status_production_ct_l1", + "metering_status_storage_ct", + "metering_status_storage_ct_l1", + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{entity}")) + assert entity_state.state == STATE_UNKNOWN + @pytest.mark.parametrize( ("mock_envoy"), diff --git a/tests/components/fritzbox/snapshots/test_sensor.ambr b/tests/components/fritzbox/snapshots/test_sensor.ambr index bcf27e25fee..061708960d4 100644 --- a/tests/components/fritzbox/snapshots/test_sensor.ambr +++ b/tests/components/fritzbox/snapshots/test_sensor.ambr @@ -409,6 +409,62 @@ 'state': '22.0', }) # --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fake_name_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'fritzbox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345 1234567_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[FritzDeviceClimateMock][sensor.fake_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'fake_name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fake_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.0', + }) +# --- # name: test_setup[FritzDeviceSensorMock][sensor.fake_name_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/google_assistant_sdk/conftest.py b/tests/components/google_assistant_sdk/conftest.py index 742e89cab08..50aa1e9249b 100644 --- a/tests/components/google_assistant_sdk/conftest.py +++ b/tests/components/google_assistant_sdk/conftest.py @@ -66,19 +66,13 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: @pytest.fixture(name="setup_integration") async def mock_setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_credentials: None, ) -> Callable[[], Coroutine[Any, Any, None]]: """Fixture for setting up the component.""" config_entry.add_to_hass(hass) - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential("client-id", "client-secret"), - DOMAIN, - ) - async def func() -> None: assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/google_assistant_sdk/test_config_flow.py b/tests/components/google_assistant_sdk/test_config_flow.py index 332610e74e8..c911a2a084d 100644 --- a/tests/components/google_assistant_sdk/test_config_flow.py +++ b/tests/components/google_assistant_sdk/test_config_flow.py @@ -26,7 +26,7 @@ async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials, + setup_credentials: None, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -87,7 +87,7 @@ async def test_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials, + setup_credentials: None, ) -> None: """Test the reauthentication case updates the existing config entry.""" @@ -162,7 +162,7 @@ async def test_single_instance_allowed( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - setup_credentials, + setup_credentials: None, ) -> None: """Test case where config flow allows a single test.""" config_entry = MockConfigEntry( diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index caddf9ba797..e45037a19bd 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -6,6 +6,7 @@ import time from unittest.mock import call, patch import aiohttp +from freezegun.api import FrozenDateTimeFactory from grpc import RpcError import pytest @@ -14,9 +15,8 @@ from homeassistant.components.google_assistant_sdk import DOMAIN from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUAGE_CODES from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from .conftest import ComponentSetup, ExpectedCredentials @@ -36,20 +36,26 @@ async def fetch_api_url(hass_client, url): async def test_setup_success( hass: HomeAssistant, setup_integration: ComponentSetup, + config_entry: MockConfigEntry, ) -> None: - """Test successful setup and unload.""" + """Test successful setup, unload, and re-setup.""" + # Initial setup await setup_integration() + assert config_entry.state is ConfigEntryState.LOADED + assert hass.services.has_service(DOMAIN, "send_text_command") - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(entries[0].entry_id) + # Unload the entry + await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert not hass.data.get(DOMAIN) - assert entries[0].state is ConfigEntryState.NOT_LOADED - assert not hass.services.async_services().get(DOMAIN, {}) + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert hass.services.has_service(DOMAIN, "send_text_command") + + # Re-setup the entry + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert hass.services.has_service(DOMAIN, "send_text_command") @pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) @@ -57,6 +63,7 @@ async def test_expired_token_refresh_success( hass: HomeAssistant, setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, ) -> None: """Test expired token is refreshed.""" @@ -72,11 +79,9 @@ async def test_expired_token_refresh_success( await setup_integration() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - assert entries[0].data["token"]["access_token"] == "updated-access-token" - assert entries[0].data["token"]["expires_in"] == 3600 + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data["token"]["access_token"] == "updated-access-token" + assert config_entry.data["token"]["expires_in"] == 3600 @pytest.mark.parametrize( @@ -101,6 +106,7 @@ async def test_expired_token_refresh_failure( aioclient_mock: AiohttpClientMocker, status: http.HTTPStatus, expected_state: ConfigEntryState, + config_entry: MockConfigEntry, ) -> None: """Test failure while refreshing token with a transient error.""" @@ -112,8 +118,7 @@ async def test_expired_token_refresh_failure( await setup_integration() # Verify a transient failure has occurred - entries = hass.config_entries.async_entries(DOMAIN) - assert entries[0].state is expected_state + assert config_entry.state is expected_state @pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) @@ -121,6 +126,7 @@ async def test_setup_client_error( hass: HomeAssistant, setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, ) -> None: """Test setup handling aiohttp.ClientError.""" aioclient_mock.post( @@ -130,32 +136,38 @@ async def test_setup_client_error( await setup_integration() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_RETRY + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + DOMAIN, "send_text_command", {"command": "some command"}, blocking=True + ) + assert exc.value.translation_key == "entry_not_loaded" @pytest.mark.parametrize( - ("configured_language_code", "expected_language_code"), - [("", "en-US"), ("en-US", "en-US"), ("es-ES", "es-ES")], + ("options", "expected_language_code"), + [ + ({}, "en-US"), + ({"language_code": "en-US"}, "en-US"), + ({"language_code": "es-ES"}, "es-ES"), + ], ids=["default", "english", "spanish"], ) async def test_send_text_command( hass: HomeAssistant, setup_integration: ComponentSetup, - configured_language_code: str, + options: dict[str, str], expected_language_code: str, + config_entry: MockConfigEntry, ) -> None: """Test service call send_text_command calls TextAssistant.""" await setup_integration() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - if configured_language_code: - hass.config_entries.async_update_entry( - entries[0], options={"language_code": configured_language_code} - ) + assert config_entry.state is ConfigEntryState.LOADED + + hass.config_entries.async_update_entry(config_entry, options=options) + await hass.async_block_till_done() command = "turn on home assistant unsupported device" with patch( @@ -177,13 +189,12 @@ async def test_send_text_command( async def test_send_text_commands( hass: HomeAssistant, setup_integration: ComponentSetup, + config_entry: MockConfigEntry, ) -> None: """Test service call send_text_command calls TextAssistant.""" await setup_integration() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED command1 = "open the garage door" command2 = "1234" @@ -229,17 +240,15 @@ async def test_send_text_command_expired_token_refresh_failure( aioclient_mock: AiohttpClientMocker, status: http.HTTPStatus, requires_reauth: ConfigEntryState, + config_entry: MockConfigEntry, ) -> None: """Test failure refreshing token in send_text_command.""" await async_setup_component(hass, "homeassistant", {}) await setup_integration() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - assert entry.state is ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED - entry.data["token"]["expires_at"] = time.time() - 3600 + config_entry.data["token"]["expires_at"] = time.time() - 3600 aioclient_mock.post( "https://oauth2.googleapis.com/token", status=status, @@ -253,7 +262,7 @@ async def test_send_text_command_expired_token_refresh_failure( blocking=True, ) - assert any(entry.async_get_active_flows(hass, {"reauth"})) == requires_reauth + assert any(config_entry.async_get_active_flows(hass, {"reauth"})) == requires_reauth async def test_send_text_command_grpc_error( @@ -284,6 +293,7 @@ async def test_send_text_command_media_player( hass: HomeAssistant, setup_integration: ComponentSetup, hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test send_text_command with media_player.""" await setup_integration() @@ -348,7 +358,8 @@ async def test_send_text_command_media_player( assert status == http.HTTPStatus.NOT_FOUND # Assert that both audio responses can still be served before the 5 minutes expiration - async_fire_time_changed(hass, utcnow() + timedelta(minutes=4)) + freezer.tick(timedelta(minutes=4, seconds=59)) + async_fire_time_changed(hass) status, response = await fetch_api_url(hass_client, audio_url1) assert status == http.HTTPStatus.OK assert response == audio_response1 @@ -357,10 +368,11 @@ async def test_send_text_command_media_player( assert response == audio_response2 # Assert that they cannot be served after the 5 minutes expiration - async_fire_time_changed(hass, utcnow() + timedelta(minutes=6)) - status, response = await fetch_api_url(hass_client, audio_url1) + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + status, _ = await fetch_api_url(hass_client, audio_url1) assert status == http.HTTPStatus.NOT_FOUND - status, response = await fetch_api_url(hass_client, audio_url2) + status, _ = await fetch_api_url(hass_client, audio_url2) assert status == http.HTTPStatus.NOT_FOUND @@ -375,12 +387,9 @@ async def test_conversation_agent( assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - assert entry.state is ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED - agent = conversation.get_agent_manager(hass).async_get_agent(entry.entry_id) + agent = conversation.get_agent_manager(hass).async_get_agent(config_entry.entry_id) assert agent.supported_languages == SUPPORTED_LANGUAGE_CODES text1 = "tell me a joke" @@ -414,10 +423,7 @@ async def test_conversation_agent_refresh_token( assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - assert entry.state is ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED text1 = "tell me a joke" text2 = "tell me another one" @@ -429,7 +435,7 @@ async def test_conversation_agent_refresh_token( ) # Expire the token between requests - entry.data["token"]["expires_at"] = time.time() - 3600 + config_entry.data["token"]["expires_at"] = time.time() - 3600 updated_access_token = "updated-access-token" aioclient_mock.post( "https://oauth2.googleapis.com/token", @@ -466,10 +472,7 @@ async def test_conversation_agent_language_changed( assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - entry = entries[0] - assert entry.state is ConfigEntryState.LOADED + assert config_entry.state is ConfigEntryState.LOADED text1 = "tell me a joke" text2 = "cuéntame un chiste" diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index 9b82f2ac305..b7f55ec2abd 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -120,6 +120,7 @@ async def integration_fixture( "silabs_water_heater", "smoke_detector", "solar_power", + "speaker", "switch_unit", "tado_smart_radiator_thermostat_x", "temperature_sensor", diff --git a/tests/components/matter/fixtures/nodes/speaker.json b/tests/components/matter/fixtures/nodes/speaker.json new file mode 100644 index 00000000000..f28923b3b3c --- /dev/null +++ b/tests/components/matter/fixtures/nodes/speaker.json @@ -0,0 +1,237 @@ +{ + "node_id": 107, + "date_commissioned": "2025-07-21T13:03:35.743927", + "last_interview": "2025-07-23T12:06:45.342425", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 3 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 3, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 4, 3, 65532, 65533, 65528, 65529, 65531], + "0/40/65532": 0, + "0/40/0": 19, + "0/40/6": "**REDACTED**", + "0/40/1": "Beep Home", + "0/40/2": 65521, + "0/40/3": "Mock speaker", + "0/40/4": 32768, + "0/40/7": 0, + "0/40/8": "1.0", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/18": "A576929DE6D138DC", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/65533": 5, + "0/40/5": "", + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 18, 19, 21, 22, 65533, 5, 65528, + 65529, 65531 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/4": true, + "0/48/65533": 2, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65532, 2, 3, 1, 4, 65533, 0, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "", + "1": true + } + ], + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531], + "0/51/0": [ + { + "0": "ETH_DEF", + "1": true, + "2": null, + "3": null, + "4": "dk29//5j", + "5": ["wKhP9A=="], + "6": ["/oAAAAAAAAB0Tb3//v/+Yw==", "/Qn01rCr1Hl0Tb3//v/+Yw=="], + "7": 2 + } + ], + "0/51/1": 12, + "0/51/2": 105, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65532, 65533, 65528, 65529, 65531], + "0/60/65532": 0, + "0/60/0": 1, + "0/60/1": 1, + "0/60/2": 4937, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [65532, 0, 1, 2, 65533, 65528, 65529, 65531], + "0/62/65532": 0, + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyYUr7XOACYVpq4e6hgmBID2EDAkBQA3BiYVpq4e6iYRHXXVNxgkBwEkCAEwCUEEKNQ0xxl6/rzKRobxdnx+QXGmjCSeFUw6bBTrKgfYVkDVFUuqg9hozYtzj142gJXKYE5a6VZQUrdX12BkVPmm9zcKNQEoARgkAgE2AwQCBAEYMAQU4DNJO9VPf30U7ckieLsZ9Ab89WwwBRQuto07k43Cuvos2+idCKKlR2DjxBgwC0DCJxyTPnVbXleKY4QGudFSpLRwjTOl35z2AqVxPH/EcEM4jRc7dh/8K+x0yzJ6I8+2pm40nwICfzITnm62Am5QGA==", + "2": null, + "254": 1 + }, + { + "1": "FTABAQEkAgE3AycU13xCfZkhuUsmFSYjBIkYJgSH9hAwJAUANwYmFSYjBIkmEVAYUpUYJAcBJAgBMAlBBN7EuwOoVRUJW2qlVIHm4C2zH8hUxwSehRhqrihihxJSS8CVjwB8FsiQ4ChGFVJ/ZM8CzDIxROm0SNN7L199Czw3CjUBKAEYJAIBNgMEAgQBGDAEFNKmpZs7t/7neBldyIiSUc4qtAwrMAUUqSSMvmWet6YT/IEvHDGHhlJUr8QYMAtA/qCoDPaDbjHhVAkr9VBugUQX8QGqUADnjhbWljjl8t7eA9bttntqwZzsB2AGPeYYg7B3E5SIGPpefLFFh9mnSBg=", + "2": null, + "254": 2 + }, + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRaxgkBwEkCAEwCUEEWKrZlA9bVwcwMsGjb+7gW8OpSFz3uosgT4gDydSflnuU+gDEumOBodeEQRR4rA0J1n1xIXLi5SAXEgHNDe/IzTcKNQEoARgkAgE2AwQCBAEYMAQUKcKB0dKsw1C31hygt4xKX2DdjiIwBRRi03D+l+yahKETUhU/VMUJYJK7CBgwC0DUBJ6vh/KWrjHdWWBmTSFs4dRye6+TD4nSALMhT0jgDPzGHI6yhMsrbs0/GlXfMNruVmSVOdYDjzANhzwKTjr/GA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE9ZELz0go1r5np3XYnsCRPOqEWH9H/5Sg3RLTVeN8iq4MthnWplYvLfXFvMVaw8IEXHD/97aRGKUCkvTHW32PZTcKNQEpARgkAmAwBBRi03D+l+yahKETUhU/VMUJYJK7CDAFFJ1roLAG+djI0sZE5SMRIPfcwCOvGDALQDi7JqN8dF7piijkZ/YQddu4yTRfN9gonsLIOQ+AHaxA4WNr0lxlK5Lx/PPDA6T1CcaIbUOZ3p7sMOaSWZQSvnIY", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "1": "BCLmZe5bFElV+dZkFNaQLhuxkAejdcY41G7ZVTob2ezSI3MUZWSM7nFSJP/5hNA1FokKHg5WnX6nAfnX4eMoc98=", + "2": 4937, + "3": 3927879334, + "4": 936736029, + "5": "Home", + "254": 1 + }, + { + "1": "BM19vyMJrIK/NEJUy/J3yfZxzPLt0NSSq/31Uoo1g8Bgby+YKP3Gj3AA5AKTaPppx9aSfO1wYbPVyFaLF6ISaE8=", + "2": 4996, + "3": 2298749734, + "4": 2505185360, + "5": "", + "254": 2 + }, + { + "1": "BE63W9VgU/wxhR2+c0RPC4BFOE9X6bu0nfUuH7SErlaZKcNqSxKy3Qy3k1gjA3nkFT/1VzHzqXGuMJ3kwTRySDs=", + "2": 4939, + "3": 2, + "4": 107, + "5": "", + "254": 3 + } + ], + "0/62/4": [ + "FTABAQAkAgE3AyYUr7XOACYVpq4e6hgmBHdODDAkBQA3BiYUr7XOACYVpq4e6hgkBwEkCAEwCUEEIuZl7lsUSVX51mQU1pAuG7GQB6N1xjjUbtlVOhvZ7NIjcxRlZIzucVIk//mE0DUWiQoeDladfqcB+dfh4yhz3zcKNQEpARgkAmAwBBQuto07k43Cuvos2+idCKKlR2DjxDAFFC62jTuTjcK6+izb6J0IoqVHYOPEGDALQKjJ/1H3GFUojevBS05lU+idNFpSbXbuzkMtixrBKa++dMvTPBX8fqp0ElvMHiSOGoHhBS07bc26vRe7nWcz+FAY", + "FTABAQAkAgE3AycU13xCfZkhuUsmFSYjBIkYJgRw+UstJAUANwYnFNd8Qn2ZIblLJhUmIwSJGCQHASQIATAJQQTNfb8jCayCvzRCVMvyd8n2cczy7dDUkqv99VKKNYPAYG8vmCj9xo9wAOQCk2j6acfWknztcGGz1chWixeiEmhPNwo1ASkBGCQCYDAEFKkkjL5lnremE/yBLxwxh4ZSVK/EMAUUqSSMvmWet6YT/IEvHDGHhlJUr8QYMAtAG4951kJhhdOpU2mr57a1uSmhdp7o1kcFYS88DvQEZXoZVfKXQNSwTAxapobGado5U7FdTlOihLlZRTNqtZZTjhg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEETrdb1WBT/DGFHb5zRE8LgEU4T1fpu7Sd9S4ftISuVpkpw2pLErLdDLeTWCMDeeQVP/VXMfOpca4wneTBNHJIOzcKNQEpARgkAmAwBBSda6CwBvnYyNLGROUjESD33MAjrzAFFJ1roLAG+djI0sZE5SMRIPfcwCOvGDALQNEMd8zHt6yaUCoi+atIEWEVTc7VKCxYNRkxFg64YWOaHEjo4rJ022E3DtQRdXC7K0aEkctOhqVYLn7yq8Vk6tkY" + ], + "0/62/5": 3, + "0/62/65533": 2, + "0/62/65528": [1, 3, 5, 8, 14], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], + "0/62/65531": [65532, 0, 2, 3, 1, 4, 5, 65533, 65528, 65529, 65531], + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65532, 65533, 0, 1, 2, 3, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 34, + "1": 1 + } + ], + "1/29/1": [29, 3, 6, 8, 30], + "1/29/2": [], + "1/29/3": [2, 3], + "1/29/65532": 0, + "1/29/65533": 3, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "1/3/65532": 0, + "1/3/65533": 5, + "1/3/0": 15, + "1/3/1": 0, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [65532, 65533, 0, 1, 65528, 65529, 65531], + "1/6/65532": 0, + "1/6/65533": 6, + "1/6/0": true, + "1/6/65528": [], + "1/6/65529": [0], + "1/6/65531": [65532, 65533, 0, 65528, 65529, 65531], + "1/8/65532": 0, + "1/8/65533": 6, + "1/8/0": 47, + "1/8/17": null, + "1/8/15": 0, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [65532, 65533, 0, 17, 15, 65528, 65529, 65531], + "1/30/65532": 0, + "1/30/0": [], + "1/30/65533": 1, + "1/30/65528": [], + "1/30/65529": [], + "1/30/65531": [65532, 0, 65533, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index d7c2aba92a3..50542fcdddc 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -828,6 +828,54 @@ 'state': 'off', }) # --- +# name: test_switches[speaker][switch.mock_speaker_mute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_speaker_mute', + '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': 'Mute', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'speaker_mute', + 'unique_id': '00000000000004D2-000000000000006B-MatterNodeDevice-1-MatterMuteToggle-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[speaker][switch.mock_speaker_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock speaker Mute', + }), + 'context': , + 'entity_id': 'switch.mock_speaker_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switches[switch_unit][switch.mock_switchunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/mcp/conftest.py b/tests/components/mcp/conftest.py index b6d6958d3d9..c179936f7d6 100644 --- a/tests/components/mcp/conftest.py +++ b/tests/components/mcp/conftest.py @@ -13,6 +13,7 @@ from homeassistant.components.application_credentials import ( from homeassistant.components.mcp.const import ( CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, + CONF_SCOPE, CONF_TOKEN_URL, DOMAIN, ) @@ -100,6 +101,7 @@ def mock_config_entry_with_auth( "refresh_token": "test-refresh-token", "expires_at": config_entry_token_expiration.timestamp(), }, + CONF_SCOPE: ["read", "write"], }, title=TEST_API_NAME, ) diff --git a/tests/components/mcp/test_config_flow.py b/tests/components/mcp/test_config_flow.py index 426b3267195..678447a58ef 100644 --- a/tests/components/mcp/test_config_flow.py +++ b/tests/components/mcp/test_config_flow.py @@ -11,6 +11,7 @@ import respx from homeassistant import config_entries from homeassistant.components.mcp.const import ( CONF_AUTHORIZATION_URL, + CONF_SCOPE, CONF_TOKEN_URL, DOMAIN, ) @@ -42,9 +43,11 @@ OAUTH_SERVER_METADATA_RESPONSE = httpx.Response( { "authorization_endpoint": OAUTH_AUTHORIZE_URL, "token_endpoint": OAUTH_TOKEN_URL, + "scopes_supported": ["read", "write"], } ), ) +SCOPES = ["read", "write"] CALLBACK_PATH = "/auth/external/callback" OAUTH_CALLBACK_URL = f"https://example.com{CALLBACK_PATH}" OAUTH_CODE = "abcd" @@ -53,6 +56,7 @@ OAUTH_TOKEN_PAYLOAD = { "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, + "scope": " ".join(SCOPES), } @@ -295,6 +299,7 @@ async def perform_oauth_flow( result: config_entries.ConfigFlowResult, authorize_url: str = OAUTH_AUTHORIZE_URL, token_url: str = OAUTH_TOKEN_URL, + scopes: list[str] | None = None, ) -> config_entries.ConfigFlowResult: """Perform the common steps of the OAuth flow. @@ -307,10 +312,13 @@ async def perform_oauth_flow( "redirect_uri": OAUTH_CALLBACK_URL, }, ) + scope_param = "" + if scopes: + scope_param = "&scope=" + "+".join(scopes) assert result["url"] == ( f"{authorize_url}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={OAUTH_CALLBACK_URL}" - f"&state={state}" + f"&state={state}{scope_param}" ) client = await hass_client_no_auth() @@ -327,9 +335,14 @@ async def perform_oauth_flow( @pytest.mark.parametrize( - ("oauth_server_metadata_response", "expected_authorize_url", "expected_token_url"), + ( + "oauth_server_metadata_response", + "expected_authorize_url", + "expected_token_url", + "scopes", + ), [ - (OAUTH_SERVER_METADATA_RESPONSE, OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL), + (OAUTH_SERVER_METADATA_RESPONSE, OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL, SCOPES), ( httpx.Response( status_code=200, @@ -342,11 +355,13 @@ async def perform_oauth_flow( ), f"{MCP_SERVER_BASE_URL}/authorize-path", f"{MCP_SERVER_BASE_URL}/token-path", + None, ), ( httpx.Response(status_code=404), f"{MCP_SERVER_BASE_URL}/authorize", f"{MCP_SERVER_BASE_URL}/token", + None, ), ], ids=( @@ -367,6 +382,7 @@ async def test_authentication_flow( oauth_server_metadata_response: httpx.Response, expected_authorize_url: str, expected_token_url: str, + scopes: list[str] | None, ) -> None: """Test for an OAuth authentication flow for an MCP server.""" @@ -405,6 +421,7 @@ async def test_authentication_flow( result, authorize_url=expected_authorize_url, token_url=expected_token_url, + scopes=scopes, ) # Client now accepts credentials @@ -423,6 +440,7 @@ async def test_authentication_flow( CONF_URL: MCP_SERVER_URL, CONF_AUTHORIZATION_URL: expected_authorize_url, CONF_TOKEN_URL: expected_token_url, + CONF_SCOPE: scopes, } assert token token.pop("expires_at") @@ -536,6 +554,7 @@ async def test_authentication_flow_server_failure_abort( aioclient_mock, hass_client_no_auth, result, + scopes=SCOPES, ) # Client fails with an error @@ -591,6 +610,7 @@ async def test_authentication_flow_server_missing_tool_capabilities( aioclient_mock, hass_client_no_auth, result, + scopes=SCOPES, ) # Client can now authenticate @@ -628,7 +648,9 @@ async def test_reauth_flow( result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - result = await perform_oauth_flow(hass, aioclient_mock, hass_client_no_auth, result) + result = await perform_oauth_flow( + hass, aioclient_mock, hass_client_no_auth, result, scopes=SCOPES + ) # Verify we can connect to the server response = Mock() @@ -648,6 +670,7 @@ async def test_reauth_flow( CONF_URL: MCP_SERVER_URL, CONF_AUTHORIZATION_URL: OAUTH_AUTHORIZE_URL, CONF_TOKEN_URL: OAUTH_TOKEN_URL, + CONF_SCOPE: ["read", "write"], } assert token token.pop("expires_at") diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 19807bff487..1223dab940e 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -1854,7 +1854,7 @@ 'state': '-18.0', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1888,7 +1888,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.hob_with_extraction', + 'entity_id': 'sensor.kdma7774_app2_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1910,11 +1910,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Hob with extraction', + 'friendly_name': 'KDMA7774 | APP2-2', 'icon': 'mdi:pot-steam-outline', 'options': list([ 'autocleaning', @@ -1938,14 +1938,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.hob_with_extraction', + 'entity_id': 'sensor.kdma7774_app2_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'in_use', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_1-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2_plate_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1983,7 +1983,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.hob_with_extraction_plate_1', + 'entity_id': 'sensor.kdma7774_app2_2_plate_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2005,11 +2005,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_1-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2_plate_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Hob with extraction Plate 1', + 'friendly_name': 'KDMA7774 | APP2-2 Plate 1', 'options': list([ 'plate_step_0', 'plate_step_1', @@ -2036,14 +2036,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.hob_with_extraction_plate_1', + 'entity_id': 'sensor.kdma7774_app2_2_plate_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'plate_step_0', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_2-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2_plate_2-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2081,7 +2081,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.hob_with_extraction_plate_2', + 'entity_id': 'sensor.kdma7774_app2_2_plate_2', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2103,11 +2103,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_2-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2_plate_2-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Hob with extraction Plate 2', + 'friendly_name': 'KDMA7774 | APP2-2 Plate 2', 'options': list([ 'plate_step_0', 'plate_step_1', @@ -2134,14 +2134,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.hob_with_extraction_plate_2', + 'entity_id': 'sensor.kdma7774_app2_2_plate_2', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'plate_step_warming', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_3-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2_plate_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2179,7 +2179,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.hob_with_extraction_plate_3', + 'entity_id': 'sensor.kdma7774_app2_2_plate_3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2201,11 +2201,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_3-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2_plate_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Hob with extraction Plate 3', + 'friendly_name': 'KDMA7774 | APP2-2 Plate 3', 'options': list([ 'plate_step_0', 'plate_step_1', @@ -2232,14 +2232,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.hob_with_extraction_plate_3', + 'entity_id': 'sensor.kdma7774_app2_2_plate_3', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'plate_step_8', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_4-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2_plate_4-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2277,7 +2277,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.hob_with_extraction_plate_4', + 'entity_id': 'sensor.kdma7774_app2_2_plate_4', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2299,11 +2299,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_4-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2_plate_4-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Hob with extraction Plate 4', + 'friendly_name': 'KDMA7774 | APP2-2 Plate 4', 'options': list([ 'plate_step_0', 'plate_step_1', @@ -2330,14 +2330,14 @@ ]), }), 'context': , - 'entity_id': 'sensor.hob_with_extraction_plate_4', + 'entity_id': 'sensor.kdma7774_app2_2_plate_4', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'plate_step_15', }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_5-entry] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2_plate_5-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2375,7 +2375,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.hob_with_extraction_plate_5', + 'entity_id': 'sensor.kdma7774_app2_2_plate_5', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2397,11 +2397,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_5-state] +# name: test_hob_sensor_states[platforms0-hob.json][sensor.kdma7774_app2_2_plate_5-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', - 'friendly_name': 'Hob with extraction Plate 5', + 'friendly_name': 'KDMA7774 | APP2-2 Plate 5', 'options': list([ 'plate_step_0', 'plate_step_1', @@ -2428,7 +2428,7 @@ ]), }), 'context': , - 'entity_id': 'sensor.hob_with_extraction_plate_5', + 'entity_id': 'sensor.kdma7774_app2_2_plate_5', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index a45ea4c0648..762cb98ad29 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -460,6 +460,63 @@ MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = { "command_topic": "bad#topic", }, } +MOCK_SUBENTRY_NUMBER_COMPONENT_CUSTOM_UNIT = { + "f9261f6feed443e7b7d5f3fbe2a47413": { + "platform": "number", + "name": "Speed", + "entity_category": None, + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "min": 0.0, + "max": 10.0, + "step": 2.0, + "mode": "box", + "unit_of_measurement": "bla", + "value_template": "{{ value_json.value }}", + "payload_reset": "None", + "retain": False, + "entity_picture": "https://example.com/f9261f6feed443e7b7d5f3fbe2a47413", + }, +} +MOCK_SUBENTRY_NUMBER_COMPONENT_DEVICE_CLASS_UNIT = { + "f9261f6feed443e7b7d5f3fbe2a47414": { + "platform": "number", + "name": "Speed", + "entity_category": None, + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "min": 0.0, + "max": 10.0, + "step": 2.0, + "mode": "slider", + "device_class": "carbon_monoxide", + "unit_of_measurement": "ppm", + "value_template": "{{ value_json.value }}", + "payload_reset": "None", + "retain": False, + "entity_picture": "https://example.com/f9261f6feed443e7b7d5f3fbe2a47414", + }, +} +MOCK_SUBENTRY_NUMBER_COMPONENT_NO_UNIT = { + "f9261f6feed443e7b7d5f3fbe2a47414": { + "platform": "number", + "name": "Speed", + "entity_category": None, + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "min": 0.0, + "max": 10.0, + "step": 2.0, + "mode": "auto", + "value_template": "{{ value_json.value }}", + "payload_reset": "None", + "retain": False, + "entity_picture": "https://example.com/f9261f6feed443e7b7d5f3fbe2a47414", + }, +} MOCK_SUBENTRY_SENSOR_COMPONENT = { "e9261f6feed443e7b7d5f3fbe2a47412": { "platform": "sensor", @@ -599,6 +656,18 @@ MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, } +MOCK_NUMBER_SUBENTRY_DATA_CUSTOM_UNIT = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NUMBER_COMPONENT_CUSTOM_UNIT, +} +MOCK_NUMBER_SUBENTRY_DATA_DEVICE_CLASS_UNIT = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NUMBER_COMPONENT_DEVICE_CLASS_UNIT, +} +MOCK_NUMBER_SUBENTRY_DATA_NO_UNIT = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_NUMBER_COMPONENT_NO_UNIT, +} MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, "components": MOCK_SUBENTRY_SENSOR_COMPONENT, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index e94e842b7c3..a0faef9c699 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -50,6 +50,9 @@ from .common import ( MOCK_NOTIFY_SUBENTRY_DATA_MULTI, MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, + MOCK_NUMBER_SUBENTRY_DATA_CUSTOM_UNIT, + MOCK_NUMBER_SUBENTRY_DATA_DEVICE_CLASS_UNIT, + MOCK_NUMBER_SUBENTRY_DATA_NO_UNIT, MOCK_SENSOR_SUBENTRY_DATA_SINGLE, MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, @@ -3455,6 +3458,101 @@ async def test_migrate_of_incompatible_config_entry( "Milk notifier Milkman alert", id="notify_with_entity_name", ), + pytest.param( + MOCK_NUMBER_SUBENTRY_DATA_CUSTOM_UNIT, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Speed"}, + {"unit_of_measurement": "bla"}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "min": 0, + "max": 10, + "step": 2, + "mode": "box", + "value_template": "{{ value_json.value }}", + "retain": False, + }, + ( + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ( + { + "command_topic": "test-topic#invalid", + "state_topic": "test-topic", + }, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic", + "min": "10", + "max": "1", + }, + {"max": "max_below_min", "min": "max_below_min"}, + ), + ), + "Milk notifier Speed", + id="number_custom_unit", + ), + pytest.param( + MOCK_NUMBER_SUBENTRY_DATA_DEVICE_CLASS_UNIT, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Speed"}, + {"device_class": "carbon_monoxide", "unit_of_measurement": "ppm"}, + ( + ( + { + "device_class": "carbon_monoxide", + "unit_of_measurement": "bla", + }, + {"unit_of_measurement": "invalid_uom"}, + ), + ), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "min": 0, + "max": 10, + "step": 2, + "mode": "slider", + "value_template": "{{ value_json.value }}", + "retain": False, + }, + (), + "Milk notifier Speed", + id="number_device_class_unit", + ), + pytest.param( + MOCK_NUMBER_SUBENTRY_DATA_NO_UNIT, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Speed"}, + {}, + (), + { + "command_topic": "test-topic", + "command_template": "{{ value }}", + "state_topic": "test-topic", + "min": 0, + "max": 10, + "step": 2, + "mode": "auto", + "value_template": "{{ value_json.value }}", + "retain": False, + }, + (), + "Milk notifier Speed", + id="number_no_unit", + ), pytest.param( MOCK_SENSOR_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, diff --git a/tests/components/nintendo_parental/__init__.py b/tests/components/nintendo_parental/__init__.py new file mode 100644 index 00000000000..89853538f8e --- /dev/null +++ b/tests/components/nintendo_parental/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nintendo Switch Parental Controls integration.""" diff --git a/tests/components/nintendo_parental/conftest.py b/tests/components/nintendo_parental/conftest.py new file mode 100644 index 00000000000..7b930589b4b --- /dev/null +++ b/tests/components/nintendo_parental/conftest.py @@ -0,0 +1,63 @@ +"""Common fixtures for the Nintendo Switch Parental Controls tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.nintendo_parental.const import DOMAIN + +from .const import ACCOUNT_ID, API_TOKEN, LOGIN_URL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={"session_token": API_TOKEN}, + unique_id=ACCOUNT_ID, + ) + + +@pytest.fixture +def mock_nintendo_authenticator() -> Generator[MagicMock]: + """Mock Nintendo Authenticator.""" + with ( + patch( + "homeassistant.components.nintendo_parental.Authenticator", + autospec=True, + ) as mock_auth_class, + patch( + "homeassistant.components.nintendo_parental.config_flow.Authenticator", + new=mock_auth_class, + ), + patch( + "homeassistant.components.nintendo_parental.coordinator.NintendoParental.update", + return_value=None, + ), + ): + mock_auth = MagicMock() + mock_auth._id_token = API_TOKEN + mock_auth._at_expiry = datetime(2099, 12, 31, 23, 59, 59) + mock_auth.account_id = ACCOUNT_ID + mock_auth.login_url = LOGIN_URL + mock_auth.get_session_token = API_TOKEN + # Patch complete_login as an AsyncMock on both instance and class as this is a class method + mock_auth.complete_login = AsyncMock() + type(mock_auth).complete_login = mock_auth.complete_login + mock_auth_class.generate_login.return_value = mock_auth + yield mock_auth + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nintendo_parental.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/nintendo_parental/const.py b/tests/components/nintendo_parental/const.py new file mode 100644 index 00000000000..5d8e3f7b713 --- /dev/null +++ b/tests/components/nintendo_parental/const.py @@ -0,0 +1,5 @@ +"""Constants for the Nintendo Parental Controls test suite.""" + +ACCOUNT_ID = "aabbccddee112233" +API_TOKEN = "valid_token" +LOGIN_URL = "http://example.com" diff --git a/tests/components/nintendo_parental/test_config_flow.py b/tests/components/nintendo_parental/test_config_flow.py new file mode 100644 index 00000000000..7cccf1bf3da --- /dev/null +++ b/tests/components/nintendo_parental/test_config_flow.py @@ -0,0 +1,101 @@ +"""Test the Nintendo Switch Parental Controls config flow.""" + +from unittest.mock import AsyncMock + +from pynintendoparental.exceptions import InvalidSessionTokenException + +from homeassistant import config_entries +from homeassistant.components.nintendo_parental.const import CONF_SESSION_TOKEN, DOMAIN +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import ACCOUNT_ID, API_TOKEN, LOGIN_URL + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test a full and successful config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result is not None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert "link" in result["description_placeholders"] + assert result["description_placeholders"]["link"] == LOGIN_URL + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ACCOUNT_ID + assert result["data"][CONF_SESSION_TOKEN] == API_TOKEN + assert result["result"].unique_id == ACCOUNT_ID + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test that the flow aborts if the account is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_invalid_auth( + hass: HomeAssistant, + mock_nintendo_authenticator: AsyncMock, +) -> None: + """Test handling of invalid authentication.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result is not None + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert "link" in result["description_placeholders"] + + # Simulate invalid authentication by raising an exception + mock_nintendo_authenticator.complete_login.side_effect = ( + InvalidSessionTokenException(status_code=401, message="Test") + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: "invalid_token"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Now ensure that the flow can be recovered + mock_nintendo_authenticator.complete_login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_TOKEN: API_TOKEN} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ACCOUNT_ID + assert result["data"][CONF_SESSION_TOKEN] == API_TOKEN + assert result["result"].unique_id == ACCOUNT_ID diff --git a/tests/components/nordpool/test_services.py b/tests/components/nordpool/test_services.py index 9d940af4ad7..d4cc3085efd 100644 --- a/tests/components/nordpool/test_services.py +++ b/tests/components/nordpool/test_services.py @@ -94,6 +94,7 @@ async def test_service_call( [ (NordPoolAuthenticationError, "authentication_error"), (NordPoolError, "connection_error"), + (TimeoutError, "connection_error"), ], ) @pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") diff --git a/tests/components/onvif/test_util.py b/tests/components/onvif/test_util.py new file mode 100644 index 00000000000..1ff0793ad45 --- /dev/null +++ b/tests/components/onvif/test_util.py @@ -0,0 +1,116 @@ +"""Test ONVIF util functions.""" + +from homeassistant.components.onvif.models import Event +from homeassistant.components.onvif.util import build_event_entity_names + +# Example device UID that would be used as prefix +TEST_DEVICE_UID = "aa:bb:cc:dd:ee:ff" + + +def test_build_event_entity_names_unique_names() -> None: + """Test build_event_entity_names with unique event names.""" + events = [ + Event( + uid=f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/Motion_00000_00000_00000", + name="Cell Motion Detection", + platform="binary_sensor", + ), + Event( + uid=f"{TEST_DEVICE_UID}_tns1:RuleEngine/PeopleDetector/People_00000_00000_00000", + name="Person Detection", + platform="binary_sensor", + ), + Event( + uid=f"{TEST_DEVICE_UID}_tns1:RuleEngine/MyRuleDetector/VehicleDetect_00000", + name="Vehicle Detection", + platform="binary_sensor", + ), + ] + + result = build_event_entity_names(events) + + assert result == { + f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/Motion_00000_00000_00000": "Cell Motion Detection", + f"{TEST_DEVICE_UID}_tns1:RuleEngine/PeopleDetector/People_00000_00000_00000": "Person Detection", + f"{TEST_DEVICE_UID}_tns1:RuleEngine/MyRuleDetector/VehicleDetect_00000": "Vehicle Detection", + } + + +def test_build_event_entity_names_duplicated() -> None: + """Test with multiple motion detection zones (realistic camera scenario).""" + # Realistic scenario: Camera with motion detection on multiple source tokens + events = [ + Event( + uid=f"{TEST_DEVICE_UID}_tns1:VideoSource/MotionAlarm_00200", + name="Motion Alarm", + platform="binary_sensor", + ), + Event( + uid=f"{TEST_DEVICE_UID}_tns1:VideoSource/MotionAlarm_00100", + name="Motion Alarm", + platform="binary_sensor", + ), + Event( + uid=f"{TEST_DEVICE_UID}_tns1:VideoSource/MotionAlarm_00000", + name="Motion Alarm", + platform="binary_sensor", + ), + ] + + result = build_event_entity_names(events) + + # Should be sorted by UID (source tokens: 00000, 00100, 00200) + assert result == { + f"{TEST_DEVICE_UID}_tns1:VideoSource/MotionAlarm_00000": "Motion Alarm 1", + f"{TEST_DEVICE_UID}_tns1:VideoSource/MotionAlarm_00100": "Motion Alarm 2", + f"{TEST_DEVICE_UID}_tns1:VideoSource/MotionAlarm_00200": "Motion Alarm 3", + } + + +def test_build_event_entity_names_mixed_events() -> None: + """Test realistic mix of unique and duplicate event names.""" + events = [ + # Multiple person detection with different rules + Event( + uid=f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/People_00000_00000_00000", + name="Person Detection", + platform="binary_sensor", + ), + Event( + uid=f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/People_00000_00000_00100", + name="Person Detection", + platform="binary_sensor", + ), + # Unique tamper detection + Event( + uid=f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/Tamper_00000_00000_00000", + name="Tamper Detection", + platform="binary_sensor", + ), + # Multiple line crossings with different rules + Event( + uid=f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/LineCross_00000_00000_00000", + name="Line Detector Crossed", + platform="binary_sensor", + ), + Event( + uid=f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/LineCross_00000_00000_00100", + name="Line Detector Crossed", + platform="binary_sensor", + ), + ] + + result = build_event_entity_names(events) + + assert result == { + f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/People_00000_00000_00000": "Person Detection 1", + f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/People_00000_00000_00100": "Person Detection 2", + f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/Tamper_00000_00000_00000": "Tamper Detection", + f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/LineCross_00000_00000_00000": "Line Detector Crossed 1", + f"{TEST_DEVICE_UID}_tns1:RuleEngine/CellMotionDetector/LineCross_00000_00000_00100": "Line Detector Crossed 2", + } + + +def test_build_event_entity_names_empty() -> None: + """Test build_event_entity_names with empty list.""" + assert build_event_entity_names([]) == {} diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index fb19236034f..ccd41dffbda 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -157,6 +157,7 @@ def create_function_tool_call_item( ResponseFunctionCallArgumentsDoneEvent( arguments="".join(arguments), item_id=id, + name=name, output_index=output_index, sequence_number=0, type="response.function_call_arguments.done", diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 452404f65ac..a53644d6f5b 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -474,7 +474,7 @@ async def test_web_search( assert mock_create_stream.mock_calls[0][2]["tools"] == [ { - "type": "web_search_preview", + "type": "web_search", "search_context_size": "low", "user_location": { "type": "approximate", diff --git a/tests/components/openweathermap/conftest.py b/tests/components/openweathermap/conftest.py index 7c7de776acf..b534d8fd98c 100644 --- a/tests/components/openweathermap/conftest.py +++ b/tests/components/openweathermap/conftest.py @@ -17,14 +17,17 @@ from pyopenweathermap import ( from pyopenweathermap.client.owm_abstract_client import OWMClient import pytest -from homeassistant.components.openweathermap.const import DEFAULT_LANGUAGE, DOMAIN +from homeassistant.components.openweathermap.const import ( + DEFAULT_LANGUAGE, + DEFAULT_NAME, + DOMAIN, +) from homeassistant.const import ( CONF_API_KEY, CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, - CONF_NAME, ) from tests.common import MockConfigEntry, patch @@ -50,7 +53,6 @@ def mock_config_entry(mode: str) -> MockConfigEntry: CONF_API_KEY: API_KEY, CONF_LATITUDE: LATITUDE, CONF_LONGITUDE: LONGITUDE, - CONF_NAME: NAME, }, options={ CONF_MODE: mode, @@ -59,6 +61,7 @@ def mock_config_entry(mode: str) -> MockConfigEntry: entry_id="test", version=5, unique_id=f"{LATITUDE}-{LONGITUDE}", + title=DEFAULT_NAME, ) diff --git a/tests/components/openweathermap/snapshots/test_sensor.ambr b/tests/components/openweathermap/snapshots/test_sensor.ambr index ae80431f33c..b184aac02ba 100644 --- a/tests/components/openweathermap/snapshots/test_sensor.ambr +++ b/tests/components/openweathermap/snapshots/test_sensor.ambr @@ -41,7 +41,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'aqi', - 'friendly_name': 'openweathermap Air quality index', + 'friendly_name': 'OpenWeatherMap Air quality index', 'state_class': , }), 'context': , @@ -94,7 +94,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'carbon_monoxide', - 'friendly_name': 'openweathermap Carbon monoxide', + 'friendly_name': 'OpenWeatherMap Carbon monoxide', 'state_class': , 'unit_of_measurement': 'μg/m³', }), @@ -148,7 +148,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'nitrogen_dioxide', - 'friendly_name': 'openweathermap Nitrogen dioxide', + 'friendly_name': 'OpenWeatherMap Nitrogen dioxide', 'state_class': , 'unit_of_measurement': 'μg/m³', }), @@ -202,7 +202,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'nitrogen_monoxide', - 'friendly_name': 'openweathermap Nitrogen monoxide', + 'friendly_name': 'OpenWeatherMap Nitrogen monoxide', 'state_class': , 'unit_of_measurement': 'μg/m³', }), @@ -256,7 +256,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'ozone', - 'friendly_name': 'openweathermap Ozone', + 'friendly_name': 'OpenWeatherMap Ozone', 'state_class': , 'unit_of_measurement': 'μg/m³', }), @@ -310,7 +310,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'pm10', - 'friendly_name': 'openweathermap PM10', + 'friendly_name': 'OpenWeatherMap PM10', 'state_class': , 'unit_of_measurement': 'μg/m³', }), @@ -364,7 +364,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'pm25', - 'friendly_name': 'openweathermap PM2.5', + 'friendly_name': 'OpenWeatherMap PM2.5', 'state_class': , 'unit_of_measurement': 'μg/m³', }), @@ -418,7 +418,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'sulphur_dioxide', - 'friendly_name': 'openweathermap Sulphur dioxide', + 'friendly_name': 'OpenWeatherMap Sulphur dioxide', 'state_class': , 'unit_of_measurement': 'μg/m³', }), @@ -471,7 +471,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap Cloud coverage', + 'friendly_name': 'OpenWeatherMap Cloud coverage', 'state_class': , 'unit_of_measurement': '%', }), @@ -522,7 +522,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap Condition', + 'friendly_name': 'OpenWeatherMap Condition', }), 'context': , 'entity_id': 'sensor.openweathermap_condition', @@ -577,7 +577,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'temperature', - 'friendly_name': 'openweathermap Dew Point', + 'friendly_name': 'OpenWeatherMap Dew Point', 'state_class': , 'unit_of_measurement': , }), @@ -634,7 +634,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'temperature', - 'friendly_name': 'openweathermap Feels like temperature', + 'friendly_name': 'OpenWeatherMap Feels like temperature', 'state_class': , 'unit_of_measurement': , }), @@ -688,7 +688,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'humidity', - 'friendly_name': 'openweathermap Humidity', + 'friendly_name': 'OpenWeatherMap Humidity', 'state_class': , 'unit_of_measurement': '%', }), @@ -739,7 +739,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap Precipitation kind', + 'friendly_name': 'OpenWeatherMap Precipitation kind', }), 'context': , 'entity_id': 'sensor.openweathermap_precipitation_kind', @@ -774,7 +774,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -794,7 +794,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'pressure', - 'friendly_name': 'openweathermap Pressure', + 'friendly_name': 'OpenWeatherMap Pressure', 'state_class': , 'unit_of_measurement': , }), @@ -851,7 +851,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'precipitation_intensity', - 'friendly_name': 'openweathermap Rain', + 'friendly_name': 'OpenWeatherMap Rain', 'state_class': , 'unit_of_measurement': , }), @@ -908,7 +908,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'precipitation_intensity', - 'friendly_name': 'openweathermap Snow', + 'friendly_name': 'OpenWeatherMap Snow', 'state_class': , 'unit_of_measurement': , }), @@ -965,7 +965,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'temperature', - 'friendly_name': 'openweathermap Temperature', + 'friendly_name': 'OpenWeatherMap Temperature', 'state_class': , 'unit_of_measurement': , }), @@ -1018,7 +1018,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap UV Index', + 'friendly_name': 'OpenWeatherMap UV Index', 'state_class': , 'unit_of_measurement': 'UV index', }), @@ -1055,7 +1055,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1075,7 +1075,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'distance', - 'friendly_name': 'openweathermap Visibility', + 'friendly_name': 'OpenWeatherMap Visibility', 'state_class': , 'unit_of_measurement': , }), @@ -1126,7 +1126,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap Weather', + 'friendly_name': 'OpenWeatherMap Weather', }), 'context': , 'entity_id': 'sensor.openweathermap_weather', @@ -1175,7 +1175,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap Weather Code', + 'friendly_name': 'OpenWeatherMap Weather Code', }), 'context': , 'entity_id': 'sensor.openweathermap_weather_code', @@ -1227,7 +1227,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'wind_direction', - 'friendly_name': 'openweathermap Wind bearing', + 'friendly_name': 'OpenWeatherMap Wind bearing', 'state_class': , 'unit_of_measurement': '°', }), @@ -1287,7 +1287,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'wind_speed', - 'friendly_name': 'openweathermap Wind gust', + 'friendly_name': 'OpenWeatherMap Wind gust', 'state_class': , 'unit_of_measurement': , }), @@ -1347,7 +1347,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'wind_speed', - 'friendly_name': 'openweathermap Wind speed', + 'friendly_name': 'OpenWeatherMap Wind speed', 'state_class': , 'unit_of_measurement': , }), @@ -1400,7 +1400,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap Cloud coverage', + 'friendly_name': 'OpenWeatherMap Cloud coverage', 'state_class': , 'unit_of_measurement': '%', }), @@ -1451,7 +1451,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap Condition', + 'friendly_name': 'OpenWeatherMap Condition', }), 'context': , 'entity_id': 'sensor.openweathermap_condition', @@ -1506,7 +1506,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'temperature', - 'friendly_name': 'openweathermap Dew Point', + 'friendly_name': 'OpenWeatherMap Dew Point', 'state_class': , 'unit_of_measurement': , }), @@ -1563,7 +1563,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'temperature', - 'friendly_name': 'openweathermap Feels like temperature', + 'friendly_name': 'OpenWeatherMap Feels like temperature', 'state_class': , 'unit_of_measurement': , }), @@ -1617,7 +1617,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'humidity', - 'friendly_name': 'openweathermap Humidity', + 'friendly_name': 'OpenWeatherMap Humidity', 'state_class': , 'unit_of_measurement': '%', }), @@ -1668,7 +1668,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap Precipitation kind', + 'friendly_name': 'OpenWeatherMap Precipitation kind', }), 'context': , 'entity_id': 'sensor.openweathermap_precipitation_kind', @@ -1703,7 +1703,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -1723,7 +1723,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'pressure', - 'friendly_name': 'openweathermap Pressure', + 'friendly_name': 'OpenWeatherMap Pressure', 'state_class': , 'unit_of_measurement': , }), @@ -1780,7 +1780,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'precipitation_intensity', - 'friendly_name': 'openweathermap Rain', + 'friendly_name': 'OpenWeatherMap Rain', 'state_class': , 'unit_of_measurement': , }), @@ -1837,7 +1837,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'precipitation_intensity', - 'friendly_name': 'openweathermap Snow', + 'friendly_name': 'OpenWeatherMap Snow', 'state_class': , 'unit_of_measurement': , }), @@ -1894,7 +1894,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'temperature', - 'friendly_name': 'openweathermap Temperature', + 'friendly_name': 'OpenWeatherMap Temperature', 'state_class': , 'unit_of_measurement': , }), @@ -1947,7 +1947,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap UV Index', + 'friendly_name': 'OpenWeatherMap UV Index', 'state_class': , 'unit_of_measurement': 'UV index', }), @@ -1984,7 +1984,7 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), 'original_device_class': , @@ -2004,7 +2004,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'distance', - 'friendly_name': 'openweathermap Visibility', + 'friendly_name': 'OpenWeatherMap Visibility', 'state_class': , 'unit_of_measurement': , }), @@ -2055,7 +2055,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap Weather', + 'friendly_name': 'OpenWeatherMap Weather', }), 'context': , 'entity_id': 'sensor.openweathermap_weather', @@ -2104,7 +2104,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', - 'friendly_name': 'openweathermap Weather Code', + 'friendly_name': 'OpenWeatherMap Weather Code', }), 'context': , 'entity_id': 'sensor.openweathermap_weather_code', @@ -2156,7 +2156,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'wind_direction', - 'friendly_name': 'openweathermap Wind bearing', + 'friendly_name': 'OpenWeatherMap Wind bearing', 'state_class': , 'unit_of_measurement': '°', }), @@ -2216,7 +2216,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'wind_speed', - 'friendly_name': 'openweathermap Wind gust', + 'friendly_name': 'OpenWeatherMap Wind gust', 'state_class': , 'unit_of_measurement': , }), @@ -2276,7 +2276,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by OpenWeatherMap', 'device_class': 'wind_speed', - 'friendly_name': 'openweathermap Wind speed', + 'friendly_name': 'OpenWeatherMap Wind speed', 'state_class': , 'unit_of_measurement': , }), diff --git a/tests/components/openweathermap/snapshots/test_weather.ambr b/tests/components/openweathermap/snapshots/test_weather.ambr index be3db7bc594..733545a3f43 100644 --- a/tests/components/openweathermap/snapshots/test_weather.ambr +++ b/tests/components/openweathermap/snapshots/test_weather.ambr @@ -65,7 +65,7 @@ 'attribution': 'Data provided by OpenWeatherMap', 'cloud_coverage': 75, 'dew_point': 4.0, - 'friendly_name': 'openweathermap', + 'friendly_name': 'OpenWeatherMap', 'humidity': 82, 'precipitation_unit': , 'pressure': 1000.0, @@ -129,7 +129,7 @@ 'attribution': 'Data provided by OpenWeatherMap', 'cloud_coverage': 75, 'dew_point': 4.0, - 'friendly_name': 'openweathermap', + 'friendly_name': 'OpenWeatherMap', 'humidity': 82, 'precipitation_unit': , 'pressure': 1000.0, @@ -194,7 +194,7 @@ 'attribution': 'Data provided by OpenWeatherMap', 'cloud_coverage': 75, 'dew_point': 4.0, - 'friendly_name': 'openweathermap', + 'friendly_name': 'OpenWeatherMap', 'humidity': 82, 'precipitation_unit': , 'pressure': 1000.0, diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index 0315ca91010..039498e5ec3 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.openweathermap.const import ( DEFAULT_LANGUAGE, + DEFAULT_NAME, DEFAULT_OWM_MODE, DOMAIN, OWM_MODE_V30, @@ -16,9 +17,9 @@ from homeassistant.const import ( CONF_API_KEY, CONF_LANGUAGE, CONF_LATITUDE, + CONF_LOCATION, CONF_LONGITUDE, CONF_MODE, - CONF_NAME, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -28,7 +29,6 @@ from .conftest import LATITUDE, LONGITUDE from tests.common import MockConfigEntry CONFIG = { - CONF_NAME: "openweathermap", CONF_API_KEY: "foo", CONF_LATITUDE: LATITUDE, CONF_LONGITUDE: LONGITUDE, @@ -36,6 +36,13 @@ CONFIG = { CONF_MODE: OWM_MODE_V30, } +USER_INPUT = { + CONF_API_KEY: "foo", + CONF_LOCATION: {CONF_LATITUDE: LATITUDE, CONF_LONGITUDE: LONGITUDE}, + CONF_LANGUAGE: DEFAULT_LANGUAGE, + CONF_MODE: OWM_MODE_V30, +} + VALID_YAML_CONFIG = {CONF_API_KEY: "foo"} @@ -47,31 +54,32 @@ async def test_successful_config_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + # create entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"][CONF_LATITUDE] == USER_INPUT[CONF_LOCATION][CONF_LATITUDE] + assert result["data"][CONF_LONGITUDE] == USER_INPUT[CONF_LOCATION][CONF_LONGITUDE] + assert result["data"][CONF_API_KEY] == USER_INPUT[CONF_API_KEY] + # validate entry state conf_entries = hass.config_entries.async_entries(DOMAIN) entry = conf_entries[0] assert entry.state is ConfigEntryState.LOADED + # unload entry await hass.config_entries.async_unload(conf_entries[0].entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == CONFIG[CONF_NAME] - assert result["data"][CONF_LATITUDE] == CONFIG[CONF_LATITUDE] - assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE] - assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY] - @pytest.mark.parametrize("mode", [OWM_MODE_V30], indirect=True) async def test_abort_config_flow( @@ -84,13 +92,14 @@ async def test_abort_config_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - result = await hass.config_entries.flow.async_configure(result["flow_id"], CONFIG) - + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) assert result["type"] is FlowResultType.ABORT @@ -156,19 +165,26 @@ async def test_form_invalid_api_key( owm_client_mock: AsyncMock, ) -> None: """Test that the form is served with no input.""" - owm_client_mock.validate_key.return_value = False result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + # invalid api key + owm_client_mock.validate_key.return_value = False + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, ) - assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_api_key"} - + # valid api key owm_client_mock.validate_key.return_value = True result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=CONFIG + result["flow_id"], + USER_INPUT, ) - assert result["type"] is FlowResultType.CREATE_ENTRY @@ -177,17 +193,23 @@ async def test_form_api_call_error( owm_client_mock: AsyncMock, ) -> None: """Test setting up with api call error.""" - owm_client_mock.validate_key.side_effect = RequestError("oops") result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + # simulate api call error + owm_client_mock.validate_key.side_effect = RequestError("oops") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, ) - assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} - + # simulate successful api call owm_client_mock.validate_key.side_effect = None result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=CONFIG + result["flow_id"], + USER_INPUT, ) - assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/plant/test_init.py b/tests/components/plant/test_init.py index 122ac3b75d1..c4640dbc3de 100644 --- a/tests/components/plant/test_init.py +++ b/tests/components/plant/test_init.py @@ -80,7 +80,9 @@ async def test_low_battery(hass: HomeAssistant) -> None: async def test_initial_states(hass: HomeAssistant) -> None: """Test plant initialises attributes if sensor already exists.""" hass.states.async_set( - MOISTURE_ENTITY, 5, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS} + MOISTURE_ENTITY, + 5, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM}, ) plant_name = "some_plant" assert await async_setup_component( @@ -101,7 +103,9 @@ async def test_update_states(hass: HomeAssistant) -> None: hass, plant.DOMAIN, {plant.DOMAIN: {plant_name: GOOD_CONFIG}} ) hass.states.async_set( - MOISTURE_ENTITY, 5, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS} + MOISTURE_ENTITY, + 5, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM}, ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") @@ -121,7 +125,7 @@ async def test_unavailable_state(hass: HomeAssistant) -> None: hass.states.async_set( MOISTURE_ENTITY, STATE_UNAVAILABLE, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM}, ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") @@ -139,7 +143,9 @@ async def test_state_problem_if_unavailable(hass: HomeAssistant) -> None: hass, plant.DOMAIN, {plant.DOMAIN: {plant_name: GOOD_CONFIG}} ) hass.states.async_set( - MOISTURE_ENTITY, 42, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS} + MOISTURE_ENTITY, + 42, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM}, ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") @@ -148,7 +154,7 @@ async def test_state_problem_if_unavailable(hass: HomeAssistant) -> None: hass.states.async_set( MOISTURE_ENTITY, STATE_UNAVAILABLE, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM}, ) await hass.async_block_till_done() state = hass.states.get(f"plant.{plant_name}") diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 60eda1b9d64..594e0103428 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -1785,7 +1785,7 @@ async def test_unit_conversion_priority_suggested_unit_change_2( UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, 0, ), - (SensorDeviceClass.CONDUCTIVITY, UnitOfConductivity.MICROSIEMENS, 1), + (SensorDeviceClass.CONDUCTIVITY, UnitOfConductivity.MICROSIEMENS_PER_CM, 1), (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.MILLIAMPERE, 0), (SensorDeviceClass.DATA_RATE, UnitOfDataRate.KILOBITS_PER_SECOND, 0), (SensorDeviceClass.DATA_SIZE, UnitOfInformation.KILOBITS, 0), diff --git a/tests/components/shelly/snapshots/test_button.ambr b/tests/components/shelly/snapshots/test_button.ambr index af19860f546..7ec15e7b1db 100644 --- a/tests/components/shelly/snapshots/test_button.ambr +++ b/tests/components/shelly/snapshots/test_button.ambr @@ -127,7 +127,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456789ABC-button:200', + 'unique_id': '123456789ABC-button:200-button_generic', 'unit_of_measurement': None, }) # --- @@ -175,7 +175,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': '123456789ABC-button:200', + 'unique_id': '123456789ABC-button:200-button_generic', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 06b9acedf03..4a8efa560b3 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -3071,7 +3071,7 @@ 'state': 'unknown', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_a_active_power-entry] +# name: test_shelly_pro_3em[sensor.test_name_phase_a_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3086,7 +3086,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_phase_a_active_power', + 'entity_id': 'sensor.test_name_phase_a_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3101,7 +3101,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Active power', + 'original_name': 'Power', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -3111,16 +3111,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_a_active_power-state] +# name: test_shelly_pro_3em[sensor.test_name_phase_a_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Test name Phase A Active power', + 'friendly_name': 'Test name Phase A Power', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_phase_a_active_power', + 'entity_id': 'sensor.test_name_phase_a_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3521,7 +3521,7 @@ 'state': '227.0', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_b_active_power-entry] +# name: test_shelly_pro_3em[sensor.test_name_phase_b_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3536,7 +3536,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_phase_b_active_power', + 'entity_id': 'sensor.test_name_phase_b_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3551,7 +3551,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Active power', + 'original_name': 'Power', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -3561,16 +3561,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_b_active_power-state] +# name: test_shelly_pro_3em[sensor.test_name_phase_b_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Test name Phase B Active power', + 'friendly_name': 'Test name Phase B Power', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_phase_b_active_power', + 'entity_id': 'sensor.test_name_phase_b_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3971,7 +3971,7 @@ 'state': '230.0', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_c_active_power-entry] +# name: test_shelly_pro_3em[sensor.test_name_phase_c_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -3986,7 +3986,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_phase_c_active_power', + 'entity_id': 'sensor.test_name_phase_c_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4001,7 +4001,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Active power', + 'original_name': 'Power', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -4011,16 +4011,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_c_active_power-state] +# name: test_shelly_pro_3em[sensor.test_name_phase_c_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Test name Phase C Active power', + 'friendly_name': 'Test name Phase C Power', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_phase_c_active_power', + 'entity_id': 'sensor.test_name_phase_c_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4421,7 +4421,7 @@ 'state': '230.2', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_n_current-entry] +# name: test_shelly_pro_3em[sensor.test_name_neutral_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4436,7 +4436,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_phase_n_current', + 'entity_id': 'sensor.test_name_neutral_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4451,7 +4451,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Phase N current', + 'original_name': 'Neutral current', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -4461,16 +4461,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_phase_n_current-state] +# name: test_shelly_pro_3em[sensor.test_name_neutral_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Test name Phase N current', + 'friendly_name': 'Test name Neutral current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_phase_n_current', + 'entity_id': 'sensor.test_name_neutral_current', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4645,7 +4645,7 @@ 'state': '5415.41419', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_total_active_power-entry] +# name: test_shelly_pro_3em[sensor.test_name_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4660,7 +4660,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_total_active_power', + 'entity_id': 'sensor.test_name_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4675,7 +4675,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total active power', + 'original_name': 'Power', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -4685,16 +4685,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_total_active_power-state] +# name: test_shelly_pro_3em[sensor.test_name_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Test name Total active power', + 'friendly_name': 'Test name Power', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_total_active_power', + 'entity_id': 'sensor.test_name_power', 'last_changed': , 'last_reported': , 'last_updated': , @@ -4760,7 +4760,7 @@ 'state': '0.0', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_total_apparent_power-entry] +# name: test_shelly_pro_3em[sensor.test_name_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4775,7 +4775,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_total_apparent_power', + 'entity_id': 'sensor.test_name_apparent_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4790,7 +4790,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total apparent power', + 'original_name': 'Apparent power', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -4800,23 +4800,23 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_total_apparent_power-state] +# name: test_shelly_pro_3em[sensor.test_name_apparent_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'apparent_power', - 'friendly_name': 'Test name Total apparent power', + 'friendly_name': 'Test name Apparent power', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_total_apparent_power', + 'entity_id': 'sensor.test_name_apparent_power', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '2525.779', }) # --- -# name: test_shelly_pro_3em[sensor.test_name_total_current-entry] +# name: test_shelly_pro_3em[sensor.test_name_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -4831,7 +4831,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.test_name_total_current', + 'entity_id': 'sensor.test_name_current', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4846,7 +4846,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Total current', + 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, 'suggested_object_id': None, @@ -4856,16 +4856,16 @@ 'unit_of_measurement': , }) # --- -# name: test_shelly_pro_3em[sensor.test_name_total_current-state] +# name: test_shelly_pro_3em[sensor.test_name_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', - 'friendly_name': 'Test name Total current', + 'friendly_name': 'Test name Current', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.test_name_total_current', + 'entity_id': 'sensor.test_name_current', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 090a0b47c3c..0c42a20d822 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -440,7 +440,7 @@ async def test_rpc_device_virtual_binary_sensor( assert state.state == STATE_ON assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-boolean:203-boolean" + assert entry.unique_id == "123456789ABC-boolean:203-boolean_generic" monkeypatch.setitem(mock_rpc_device.status["boolean:203"], "value", False) mock_rpc_device.mock_update() @@ -472,7 +472,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_mode_toggle( hass, BINARY_SENSOR_DOMAIN, "test_name_boolean_200", - "boolean:200-boolean", + "boolean:200-boolean_generic", config_entry, device_id=device_entry.id, ) @@ -498,7 +498,7 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( hass, BINARY_SENSOR_DOMAIN, "test_name_boolean_200", - "boolean:200-boolean", + "boolean:200-boolean_generic", config_entry, device_id=device_entry.id, ) @@ -507,13 +507,13 @@ async def test_rpc_remove_virtual_binary_sensor_when_orphaned( sub_device_entry = register_sub_device( device_registry, config_entry, - "boolean:201-boolean", + "boolean:201-boolean_generic", ) entity_id2 = register_entity( hass, BINARY_SENSOR_DOMAIN, "boolean_201", - "boolean:201-boolean", + "boolean:201-boolean_generic", config_entry, device_id=sub_device_entry.id, ) diff --git a/tests/components/shelly/test_button.py b/tests/components/shelly/test_button.py index f6a3df0bb48..dd1f56872e1 100644 --- a/tests/components/shelly/test_button.py +++ b/tests/components/shelly/test_button.py @@ -9,7 +9,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import DOMAIN, MODEL_FRANKEVER_WATER_VALVE from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant @@ -17,7 +17,13 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, patch_platforms, register_device, register_entity +from . import ( + MOCK_MAC, + init_integration, + patch_platforms, + register_device, + register_entity, +) @pytest.fixture(autouse=True) @@ -417,3 +423,56 @@ async def test_migrate_unique_id_blu_trv( assert entity_entry.unique_id == "F8447725F0DD-blutrv:200-calibrate" assert "Migrating unique_id for button.trv_name_calibrate" in caplog.text + + +@pytest.mark.parametrize( + ("old_id", "new_id", "role"), + [ + ("button", "button_generic", None), + ("button", "button_open", "open"), + ("button", "button_close", "close"), + ], +) +async def test_migrate_unique_id_virtual_components_roles( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + old_id: str, + new_id: str, + role: str | None, +) -> None: + """Test migration of unique_id for virtual components to include role.""" + entry = await init_integration( + hass, 3, model=MODEL_FRANKEVER_WATER_VALVE, skip_setup=True + ) + old_unique_id = f"{MOCK_MAC}-{old_id}:200" + new_unique_id = f"{old_unique_id}-{new_id}" + config = deepcopy(mock_rpc_device.config) + if role: + config[f"{old_id}:200"] = { + "role": role, + } + else: + config[f"{old_id}:200"] = {} + monkeypatch.setattr(mock_rpc_device, "config", config) + + entity = entity_registry.async_get_or_create( + suggested_object_id="test_name_test_button", + disabled_by=None, + domain=BUTTON_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + assert entity.unique_id == old_unique_id + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get("button.test_name_test_button") + assert entity_entry + assert entity_entry.unique_id == new_unique_id + + assert "Migrating unique_id for button.test_name_test_button" in caplog.text diff --git a/tests/components/shelly/test_devices.py b/tests/components/shelly/test_devices.py index 1e2f8088618..bee8cd7c2c5 100644 --- a/tests/components/shelly/test_devices.py +++ b/tests/components/shelly/test_devices.py @@ -347,7 +347,7 @@ async def test_shelly_pro_3em( config_entry = await init_integration(hass, gen=2, model=MODEL_PRO_EM3) # Main device - entity_id = "sensor.test_name_total_active_power" + entity_id = "sensor.test_name_power" state = hass.states.get(entity_id) assert state @@ -360,7 +360,7 @@ async def test_shelly_pro_3em( assert device_entry.name == "Test name" # Phase A sub-device - entity_id = "sensor.test_name_phase_a_active_power" + entity_id = "sensor.test_name_phase_a_power" state = hass.states.get(entity_id) assert state @@ -373,7 +373,7 @@ async def test_shelly_pro_3em( assert device_entry.name == "Test name Phase A" # Phase B sub-device - entity_id = "sensor.test_name_phase_b_active_power" + entity_id = "sensor.test_name_phase_b_power" state = hass.states.get(entity_id) assert state @@ -386,7 +386,7 @@ async def test_shelly_pro_3em( assert device_entry.name == "Test name Phase B" # Phase C sub-device - entity_id = "sensor.test_name_phase_c_active_power" + entity_id = "sensor.test_name_phase_c_power" state = hass.states.get(entity_id) assert state @@ -423,7 +423,7 @@ async def test_shelly_pro_3em_with_emeter_name( await init_integration(hass, gen=2, model=MODEL_PRO_EM3) # Main device - entity_id = "sensor.test_name_total_active_power" + entity_id = "sensor.test_name_power" state = hass.states.get(entity_id) assert state @@ -436,7 +436,7 @@ async def test_shelly_pro_3em_with_emeter_name( assert device_entry.name == "Test name" # Phase A sub-device - entity_id = "sensor.test_name_phase_a_active_power" + entity_id = "sensor.test_name_phase_a_power" state = hass.states.get(entity_id) assert state @@ -449,7 +449,7 @@ async def test_shelly_pro_3em_with_emeter_name( assert device_entry.name == "Test name Phase A" # Phase B sub-device - entity_id = "sensor.test_name_phase_b_active_power" + entity_id = "sensor.test_name_phase_b_power" state = hass.states.get(entity_id) assert state @@ -462,7 +462,7 @@ async def test_shelly_pro_3em_with_emeter_name( assert device_entry.name == "Test name Phase B" # Phase C sub-device - entity_id = "sensor.test_name_phase_c_active_power" + entity_id = "sensor.test_name_phase_c_power" state = hass.states.get(entity_id) assert state diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index c7230821772..5f42f9a131c 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -331,7 +331,7 @@ async def test_rpc_device_virtual_number( assert state.attributes.get(ATTR_MODE) is mode assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-number:203-number" + assert entry.unique_id == "123456789ABC-number:203-number_generic" monkeypatch.setitem(mock_rpc_device.status["number:203"], "value", 78.9) mock_rpc_device.mock_update() @@ -380,7 +380,7 @@ async def test_rpc_remove_virtual_number_when_mode_label( hass, NUMBER_DOMAIN, "test_name_number_200", - "number:200-number", + "number:200-number_generic", config_entry, device_id=device_entry.id, ) @@ -404,7 +404,7 @@ async def test_rpc_remove_virtual_number_when_orphaned( hass, NUMBER_DOMAIN, "test_name_number_200", - "number:200-number", + "number:200-number_generic", config_entry, device_id=device_entry.id, ) diff --git a/tests/components/shelly/test_select.py b/tests/components/shelly/test_select.py index eefd84d40eb..d99fc9bf85c 100644 --- a/tests/components/shelly/test_select.py +++ b/tests/components/shelly/test_select.py @@ -76,7 +76,7 @@ async def test_rpc_device_virtual_enum( ] assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-enum:203-enum" + assert entry.unique_id == "123456789ABC-enum:203-enum_generic" monkeypatch.setitem(mock_rpc_device.status["enum:203"], "value", "option 2") mock_rpc_device.mock_update() @@ -128,7 +128,7 @@ async def test_rpc_remove_virtual_enum_when_mode_label( hass, SELECT_PLATFORM, "test_name_enum_200", - "enum:200-enum", + "enum:200-enum_generic", config_entry, device_id=device_entry.id, ) @@ -152,7 +152,7 @@ async def test_rpc_remove_virtual_enum_when_orphaned( hass, SELECT_PLATFORM, "test_name_enum_200", - "enum:200-enum", + "enum:200-enum_generic", config_entry, device_id=device_entry.id, ) diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index f1f41f5c188..44e13f7c1fb 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1080,12 +1080,12 @@ async def test_rpc_device_virtual_text_sensor( @pytest.mark.parametrize( - ("old_id", "new_id", "device_class"), + ("old_id", "new_id", "role"), [ - ("enum", "enum_generic", SensorDeviceClass.ENUM), + ("enum", "enum_generic", None), ("number", "number_generic", None), - ("number", "number_current_humidity", SensorDeviceClass.HUMIDITY), - ("number", "number_current_temperature", SensorDeviceClass.TEMPERATURE), + ("number", "number_current_humidity", "current_humidity"), + ("number", "number_current_temperature", "current_temperature"), ("text", "text_generic", None), ], ) @@ -1094,15 +1094,24 @@ async def test_migrate_unique_id_virtual_components_roles( mock_rpc_device: Mock, entity_registry: EntityRegistry, caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, old_id: str, new_id: str, - device_class: SensorDeviceClass | None, + role: str | None, ) -> None: """Test migration of unique_id for virtual components to include role.""" entry = await init_integration(hass, 3, skip_setup=True) unique_base = f"{MOCK_MAC}-{old_id}:200" old_unique_id = f"{unique_base}-{old_id}" new_unique_id = f"{unique_base}-{new_id}" + config = deepcopy(mock_rpc_device.config) + if role: + config[f"{old_id}:200"] = { + "role": role, + } + else: + config[f"{old_id}:200"] = {} + monkeypatch.setattr(mock_rpc_device, "config", config) entity = entity_registry.async_get_or_create( suggested_object_id="test_name_test_sensor", @@ -1111,7 +1120,6 @@ async def test_migrate_unique_id_virtual_components_roles( platform=DOMAIN, unique_id=old_unique_id, config_entry=entry, - original_device_class=device_class, ) assert entity.unique_id == old_unique_id diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 39fc001cbed..59245c17c08 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -404,6 +404,7 @@ async def test_rpc_device_services( ) assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON + mock_rpc_device.switch_set.assert_called_once_with(0, True) monkeypatch.setitem(mock_rpc_device.status["switch:0"], "output", False) await hass.services.async_call( @@ -415,6 +416,7 @@ async def test_rpc_device_services( mock_rpc_device.mock_update() assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF + mock_rpc_device.switch_set.assert_called_with(0, False) async def test_rpc_device_unique_ids( @@ -507,7 +509,7 @@ async def test_rpc_set_state_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device set state connection/call errors.""" - monkeypatch.setattr(mock_rpc_device, "call_rpc", AsyncMock(side_effect=exc)) + mock_rpc_device.switch_set.side_effect = exc monkeypatch.delitem(mock_rpc_device.status, "cover:0") monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) await init_integration(hass, 2) @@ -525,11 +527,7 @@ async def test_rpc_auth_error( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test RPC device set state authentication error.""" - monkeypatch.setattr( - mock_rpc_device, - "call_rpc", - AsyncMock(side_effect=InvalidAuthError), - ) + mock_rpc_device.switch_set.side_effect = InvalidAuthError monkeypatch.delitem(mock_rpc_device.status, "cover:0") monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) entry = await init_integration(hass, 2) @@ -645,7 +643,7 @@ async def test_rpc_device_virtual_switch( assert state.state == STATE_ON assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-boolean:200-boolean" + assert entry.unique_id == "123456789ABC-boolean:200-boolean_generic" monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", False) await hass.services.async_call( @@ -657,6 +655,7 @@ async def test_rpc_device_virtual_switch( mock_rpc_device.mock_update() assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF + mock_rpc_device.boolean_set.assert_called_once_with(200, False) monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", True) await hass.services.async_call( @@ -668,6 +667,7 @@ async def test_rpc_device_virtual_switch( mock_rpc_device.mock_update() assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON + mock_rpc_device.boolean_set.assert_called_with(200, True) @pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities") @@ -715,7 +715,7 @@ async def test_rpc_remove_virtual_switch_when_mode_label( hass, SWITCH_DOMAIN, "test_name_boolean_200", - "boolean:200-boolean", + "boolean:200-boolean_generic", config_entry, device_id=device_entry.id, ) @@ -741,7 +741,7 @@ async def test_rpc_remove_virtual_switch_when_orphaned( hass, SWITCH_DOMAIN, "test_name_boolean_200", - "boolean:200-boolean", + "boolean:200-boolean_generic", config_entry, device_id=device_entry.id, ) @@ -750,13 +750,13 @@ async def test_rpc_remove_virtual_switch_when_orphaned( sub_device_entry = register_sub_device( device_registry, config_entry, - "boolean:201-boolean", + "boolean:201-boolean_generic", ) entity_id2 = register_entity( hass, SWITCH_DOMAIN, "boolean_201", - "boolean:201-boolean", + "boolean:201-boolean_generic", config_entry, device_id=sub_device_entry.id, ) @@ -815,6 +815,7 @@ async def test_rpc_device_script_switch( assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF + mock_rpc_device.script_stop.assert_called_once_with(1) monkeypatch.setitem(mock_rpc_device.status[key], "running", True) await hass.services.async_call( @@ -827,3 +828,4 @@ async def test_rpc_device_script_switch( assert (state := hass.states.get(entity_id)) assert state.state == STATE_ON + mock_rpc_device.script_start.assert_called_once_with(1) diff --git a/tests/components/shelly/test_text.py b/tests/components/shelly/test_text.py index 59c434213b1..ad8497a1d03 100644 --- a/tests/components/shelly/test_text.py +++ b/tests/components/shelly/test_text.py @@ -62,7 +62,7 @@ async def test_rpc_device_virtual_text( assert state.state == "lorem ipsum" assert (entry := entity_registry.async_get(entity_id)) - assert entry.unique_id == "123456789ABC-text:203-text" + assert entry.unique_id == "123456789ABC-text:203-text_generic" monkeypatch.setitem(mock_rpc_device.status["text:203"], "value", "dolor sit amet") mock_rpc_device.mock_update() @@ -107,7 +107,7 @@ async def test_rpc_remove_virtual_text_when_mode_label( hass, TEXT_PLATFORM, "test_name_text_200", - "text:200-text", + "text:200-text_generic", config_entry, device_id=device_entry.id, ) @@ -131,7 +131,7 @@ async def test_rpc_remove_virtual_text_when_orphaned( hass, TEXT_PLATFORM, "test_name_text_200", - "text:200-text", + "text:200-text_generic", config_entry, device_id=device_entry.id, ) diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index a68bbba22d2..393b6b4e3d6 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -97,6 +97,7 @@ def mock_smartthings() -> Generator[AsyncMock]: @pytest.fixture( params=[ "aq_sensor_3_ikea", + "aeotec_ms6", "da_ac_airsensor_01001", "da_ac_rac_000001", "da_ac_rac_000003", @@ -156,6 +157,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "heatit_ztrm3_thermostat", "heatit_zpushwall", "generic_ef00_v1", + "gas_detector", "bosch_radiator_thermostat_ii", "im_speaker_ai_0001", "im_smarttag2_ble_uwb", diff --git a/tests/components/smartthings/fixtures/device_status/aeotec_ms6.json b/tests/components/smartthings/fixtures/device_status/aeotec_ms6.json new file mode 100644 index 00000000000..8e80dbae1ef --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/aeotec_ms6.json @@ -0,0 +1,62 @@ +{ + "components": { + "main": { + "ultravioletIndex": { + "ultravioletIndex": { + "value": 0, + "timestamp": "2025-09-30T15:13:46.521Z" + } + }, + "relativeHumidityMeasurement": { + "humidity": { + "value": 60.0, + "unit": "%", + "timestamp": "2025-09-30T15:13:45.441Z" + } + }, + "temperatureMeasurement": { + "temperatureRange": { + "value": null + }, + "temperature": { + "value": 22.2, + "unit": "C", + "timestamp": "2025-09-30T16:13:50.478Z" + } + }, + "refresh": {}, + "motionSensor": { + "motion": { + "value": "inactive", + "timestamp": "2025-09-30T15:33:27.594Z" + } + }, + "illuminanceMeasurement": { + "illuminance": { + "value": 30, + "unit": "lux", + "timestamp": "2025-09-30T15:13:52.607Z" + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 100, + "unit": "%", + "timestamp": "2025-09-30T15:13:46.166Z" + }, + "type": { + "value": null + } + }, + "tamperAlert": { + "tamper": { + "value": "clear", + "timestamp": "2025-09-30T14:06:07.064Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json index 98434aa2c5a..42fd78cd862 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json +++ b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000003.json @@ -3,9 +3,9 @@ "main": { "relativeHumidityMeasurement": { "humidity": { - "value": 48, + "value": 59, "unit": "%", - "timestamp": "2025-03-27T05:12:16.158Z" + "timestamp": "2025-10-04T13:03:19.251Z" } }, "custom.airConditionerOdorController": { @@ -20,12 +20,12 @@ "minimumSetpoint": { "value": 16, "unit": "C", - "timestamp": "2025-03-13T09:29:37.008Z" + "timestamp": "2025-08-20T15:31:02.655Z" }, "maximumSetpoint": { "value": 30, "unit": "C", - "timestamp": "2024-06-21T13:45:16.785Z" + "timestamp": "2025-06-18T03:34:18.116Z" } }, "airConditionerMode": { @@ -33,23 +33,24 @@ "value": null }, "supportedAcModes": { - "value": ["cool", "dry", "wind", "auto"], - "timestamp": "2024-06-21T13:45:16.785Z" + "value": ["cool", "dry", "wind", "auto", "heat"], + "timestamp": "2025-06-18T03:34:18.116Z" }, "airConditionerMode": { - "value": "cool", - "timestamp": "2025-03-13T09:29:36.789Z" + "value": "heat", + "timestamp": "2025-10-04T12:59:27.205Z" } }, "custom.spiMode": { "spiMode": { "value": "off", - "timestamp": "2025-02-08T08:54:15.661Z" + "timestamp": "2025-10-03T18:10:05.905Z" } }, "samsungce.deviceIdentification": { "micomAssayCode": { - "value": null + "value": "10217841", + "timestamp": "2025-06-18T03:34:18.116Z" }, "modelName": { "value": null @@ -61,17 +62,20 @@ "value": null }, "modelClassificationCode": { - "value": null + "value": "60010523001411010200001000000000", + "timestamp": "2025-06-18T03:34:18.116Z" }, "description": { - "value": null + "value": "ARTIK051_PRAC_20K", + "timestamp": "2025-06-18T03:34:18.116Z" }, "releaseYear": { - "value": null + "value": 20, + "timestamp": "2025-06-12T09:25:21.264Z" }, "binaryId": { "value": "ARTIK051_PRAC_20K", - "timestamp": "2025-03-27T05:12:15.284Z" + "timestamp": "2025-10-04T03:34:13.732Z" } }, "airQualitySensor": { @@ -87,26 +91,28 @@ "quiet", "smart", "speed", + "motionIndirect", + "motionDirect", "windFree", "windFreeSleep" ], - "timestamp": "2024-06-21T13:45:16.785Z" + "timestamp": "2025-06-18T03:34:18.116Z" }, "acOptionalMode": { "value": "off", - "timestamp": "2025-03-26T12:20:41.095Z" + "timestamp": "2025-09-28T10:20:26.885Z" } }, "switch": { "switch": { "value": "on", - "timestamp": "2025-03-27T05:41:42.291Z" + "timestamp": "2025-10-04T12:59:27.185Z" } }, "custom.airConditionerTropicalNightMode": { "acTropicalNightModeLevel": { "value": 0, - "timestamp": "2025-02-08T08:54:15.789Z" + "timestamp": "2025-06-17T10:45:00.985Z" } }, "ocf": { @@ -118,65 +124,65 @@ }, "mnfv": { "value": "ARTIK051_PRAC_20K_11230313", - "timestamp": "2024-06-21T13:58:04.085Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "mnhw": { "value": "ARTIK051", - "timestamp": "2024-06-21T13:51:35.294Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "di": { - "value": "c76d6f38-1b7f-13dd-37b5-db18d5272783", - "timestamp": "2024-06-21T13:45:16.329Z" + "value": "1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977", + "timestamp": "2025-10-04T03:34:13.486Z" }, "mnsl": { "value": "http://www.samsung.com", - "timestamp": "2024-06-21T13:51:35.980Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "dmv": { "value": "res.1.1.0,sh.1.1.0", - "timestamp": "2024-06-21T13:58:04.698Z" + "timestamp": "2025-10-04T03:34:13.486Z" }, "n": { "value": "Samsung Room A/C", - "timestamp": "2024-06-21T13:58:04.085Z" + "timestamp": "2025-10-04T03:34:13.486Z" }, "mnmo": { - "value": "ARTIK051_PRAC_20K|10256941|60010534001411014600003200800000", - "timestamp": "2024-06-21T13:45:16.329Z" + "value": "ARTIK051_PRAC_20K|10217841|60010523001411010200001000000000", + "timestamp": "2025-10-04T03:34:13.732Z" }, "vid": { "value": "DA-AC-RAC-000003", - "timestamp": "2024-06-21T13:45:16.329Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "mnmn": { "value": "Samsung Electronics", - "timestamp": "2024-06-21T13:45:16.329Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "mnml": { "value": "http://www.samsung.com", - "timestamp": "2024-06-21T13:45:16.329Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "mnpv": { "value": "DAWIT 2.0", - "timestamp": "2024-06-21T13:51:35.294Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "mnos": { "value": "TizenRT 1.0 + IPv6", - "timestamp": "2024-06-21T13:51:35.294Z" + "timestamp": "2025-10-04T03:34:13.484Z" }, "pi": { - "value": "c76d6f38-1b7f-13dd-37b5-db18d5272783", - "timestamp": "2024-06-21T13:45:16.329Z" + "value": "1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977", + "timestamp": "2025-10-04T03:34:13.484Z" }, "icv": { "value": "core.1.1.0", - "timestamp": "2024-06-21T13:45:16.329Z" + "timestamp": "2025-10-04T03:34:13.486Z" } }, "airConditionerFanMode": { "fanMode": { - "value": "low", - "timestamp": "2025-03-26T12:20:41.393Z" + "value": "auto", + "timestamp": "2025-10-04T12:59:27.323Z" }, "supportedAcFanModes": { "value": ["auto", "low", "medium", "high", "turbo"], @@ -190,12 +196,12 @@ "alarmThreshold": { "value": 500, "unit": "Hour", - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-17T12:15:04.968Z" }, "supportedAlarmThresholds": { "value": [180, 300, 500, 700], "unit": "Hour", - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-18T03:34:18.116Z" } }, "custom.electricHepaFilter": { @@ -231,10 +237,12 @@ "custom.electricHepaFilter", "custom.periodicSensing", "custom.doNotDisturbMode", + "demandResponseLoadControl", "custom.airConditionerOdorController", - "samsungce.individualControlLock" + "samsungce.individualControlLock", + "samsungce.alwaysOnSensing" ], - "timestamp": "2025-02-08T08:54:15.355Z" + "timestamp": "2025-10-04T12:59:27.205Z" } }, "custom.ocfResourceVersion": { @@ -247,8 +255,8 @@ }, "samsungce.driverVersion": { "versionNumber": { - "value": 24040101, - "timestamp": "2024-06-21T13:45:16.348Z" + "value": 25040101, + "timestamp": "2025-06-12T10:13:31.862Z" } }, "fanOscillationMode": { @@ -261,7 +269,7 @@ }, "fanOscillationMode": { "value": "fixed", - "timestamp": "2025-02-25T15:40:11.773Z" + "timestamp": "2025-10-04T15:53:27.427Z" } }, "temperatureMeasurement": { @@ -269,9 +277,9 @@ "value": null }, "temperature": { - "value": 26, + "value": 20, "unit": "C", - "timestamp": "2025-03-26T14:19:08.047Z" + "timestamp": "2025-10-04T13:07:50.163Z" } }, "dustSensor": { @@ -285,7 +293,7 @@ "custom.deviceReportStateConfiguration": { "reportStateRealtimePeriod": { "value": "disabled", - "timestamp": "2025-02-08T08:54:15.726Z" + "timestamp": "2025-06-18T03:34:18.116Z" }, "reportStateRealtime": { "value": { @@ -293,40 +301,48 @@ "duration": 10, "unit": "minute" }, - "timestamp": "2025-03-24T08:28:07.030Z" + "timestamp": "2025-10-04T15:53:14.562Z" }, "reportStatePeriod": { "value": "enabled", - "timestamp": "2025-02-08T08:54:15.726Z" + "timestamp": "2025-06-18T03:34:18.116Z" } }, "custom.periodicSensing": { "automaticExecutionSetting": { - "value": null + "value": "NotSupported", + "timestamp": "2021-05-28T08:57:59.311Z" }, "automaticExecutionMode": { - "value": null + "value": "NotSupported", + "timestamp": "2021-05-28T08:57:59.311Z" }, "supportedAutomaticExecutionSetting": { - "value": null + "value": ["NotSupported"], + "timestamp": "2021-05-28T08:57:59.311Z" }, "supportedAutomaticExecutionMode": { - "value": null + "value": ["NotSupported"], + "timestamp": "2021-05-28T08:57:59.311Z" }, "periodicSensing": { - "value": null + "value": "off", + "timestamp": "2021-12-22T07:01:09.979Z" }, "periodicSensingInterval": { - "value": null + "value": 600, + "timestamp": "2021-05-28T08:57:59.311Z" }, "lastSensingTime": { "value": null }, "lastSensingLevel": { - "value": null + "value": "", + "timestamp": "2021-05-28T08:57:59.311Z" }, "periodicSensingStatus": { - "value": null + "value": "nonprocessing", + "timestamp": "2021-05-28T08:57:59.311Z" } }, "thermostatCoolingSetpoint": { @@ -334,66 +350,72 @@ "value": null }, "coolingSetpoint": { - "value": 24, + "value": 19, "unit": "C", - "timestamp": "2025-03-26T12:20:41.346Z" + "timestamp": "2025-10-04T12:59:27.582Z" } }, "demandResponseLoadControl": { "drlcStatus": { "value": { "drlcType": 1, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", "duration": 0, "override": false }, - "timestamp": "2025-03-24T04:56:36.855Z" + "timestamp": "2025-06-18T03:34:18.116Z" } }, "audioVolume": { "volume": { "value": 100, "unit": "%", - "timestamp": "2025-02-08T08:54:15.789Z" + "timestamp": "2025-06-17T10:45:00.985Z" } }, "powerConsumptionReport": { "powerConsumption": { "value": { - "energy": 602171, - "deltaEnergy": 0, - "power": 0, - "powerEnergy": 0.0, - "persistedEnergy": 602171, + "energy": 6652713, + "deltaEnergy": 2, + "power": 143, + "powerEnergy": 1.747048611111111, + "persistedEnergy": 6652713, "energySaved": 0, - "persistedSavedEnergy": 0, - "start": "2025-03-27T05:29:22Z", - "end": "2025-03-27T05:40:02Z" + "start": "2025-10-04T15:54:24Z", + "end": "2025-10-04T15:55:07Z" }, - "timestamp": "2025-03-27T05:40:02.686Z" + "timestamp": "2025-10-04T15:55:07.378Z" } }, "custom.autoCleaningMode": { "supportedAutoCleaningModes": { - "value": null + "value": ["on", "off"], + "timestamp": "2025-06-18T03:34:18.116Z" }, "timedCleanDuration": { "value": null }, "operatingState": { - "value": null + "value": "ready", + "timestamp": "2025-08-20T17:44:52.796Z" }, "timedCleanDurationRange": { "value": null }, "supportedOperatingStates": { - "value": null + "value": ["autoClean", "ready"], + "timestamp": "2025-06-18T03:34:18.116Z" }, "progress": { - "value": null + "value": 0, + "unit": "%", + "timestamp": "2025-08-20T17:44:52.796Z" }, "autoCleaningMode": { - "value": "off", - "timestamp": "2025-03-15T05:30:11.075Z" + "value": "on", + "timestamp": "2025-08-18T18:16:46.505Z" } }, "samsungce.individualControlLock": { @@ -401,12 +423,45 @@ "value": null } }, + "samsungce.alwaysOnSensing": { + "origins": { + "value": null + }, + "alwaysOn": { + "value": null + } + }, "refresh": {}, "execute": { "data": { "value": null } }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "02181A230313", + "description": "Version" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "20082000,FFFFFFFF", + "description": "Version" + }, + { + "id": "2", + "swType": "Outdoor", + "versionNumber": "19112900,10000400", + "description": "Version" + } + ], + "timestamp": "2025-06-18T03:34:18.116Z" + } + }, "samsungce.selfCheck": { "result": { "value": null @@ -420,36 +475,37 @@ }, "errors": { "value": [], - "timestamp": "2025-02-08T08:54:15.048Z" + "timestamp": "2025-10-03T16:56:38.154Z" }, "status": { - "value": null + "value": "ready", + "timestamp": "2025-10-03T18:10:05.915Z" } }, "custom.dustFilter": { "dustFilterUsageStep": { "value": 1, - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-17T12:15:04.968Z" }, "dustFilterUsage": { - "value": 69, - "timestamp": "2025-03-26T10:57:41.097Z" + "value": 22, + "timestamp": "2025-10-03T15:20:27.649Z" }, "dustFilterLastResetDate": { "value": null }, "dustFilterStatus": { "value": "normal", - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-17T12:15:04.968Z" }, "dustFilterCapacity": { "value": 500, "unit": "Hour", - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-17T12:15:04.968Z" }, "dustFilterResetType": { "value": ["replaceable", "washable"], - "timestamp": "2025-02-08T08:54:15.473Z" + "timestamp": "2025-06-17T12:15:04.968Z" } }, "odorSensor": { @@ -485,16 +541,16 @@ "custom.energyType": { "energyType": { "value": "1.0", - "timestamp": "2024-06-21T13:45:16.785Z" + "timestamp": "2024-12-14T03:34:07.681Z" }, "energySavingSupport": { - "value": true, - "timestamp": "2024-06-21T13:58:08.419Z" + "value": false, + "timestamp": "2021-12-29T01:15:12.093Z" }, "drMaxDuration": { - "value": 99999999, + "value": 1440, "unit": "min", - "timestamp": "2024-06-21T13:51:39.304Z" + "timestamp": "2022-01-01T12:19:18.649Z" }, "energySavingLevel": { "value": null @@ -506,36 +562,35 @@ "value": null }, "energySavingOperation": { - "value": false, - "timestamp": "2025-02-08T08:54:16.767Z" + "value": null }, "notificationTemplateID": { "value": null }, "energySavingOperationSupport": { "value": false, - "timestamp": "2025-03-24T04:56:36.855Z" + "timestamp": "2022-01-01T12:19:18.649Z" } }, "samsungce.softwareUpdate": { "targetModule": { "value": {}, - "timestamp": "2025-02-08T08:54:16.685Z" + "timestamp": "2025-06-19T07:55:10.859Z" }, "otnDUID": { - "value": "MTCPH4AI4MTYO", - "timestamp": "2025-02-08T08:54:15.626Z" + "value": "ZPCNQWBWA22VW", + "timestamp": "2025-06-18T03:34:18.116Z" }, "lastUpdatedDate": { "value": null }, "availableModules": { "value": [], - "timestamp": "2025-02-08T08:54:15.626Z" + "timestamp": "2024-12-13T22:55:32.254Z" }, "newVersionAvailable": { "value": false, - "timestamp": "2025-02-08T08:54:15.626Z" + "timestamp": "2025-06-18T03:34:18.116Z" }, "operatingState": { "value": null @@ -571,13 +626,16 @@ }, "custom.doNotDisturbMode": { "doNotDisturb": { - "value": null + "value": "off", + "timestamp": "2021-05-28T08:57:59.311Z" }, "startTime": { - "value": null + "value": "0000", + "timestamp": "2021-05-28T08:57:59.311Z" }, "endTime": { - "value": null + "value": "0000", + "timestamp": "2021-05-28T08:57:59.311Z" } } } diff --git a/tests/components/smartthings/fixtures/device_status/gas_detector.json b/tests/components/smartthings/fixtures/device_status/gas_detector.json new file mode 100644 index 00000000000..eac3b5b4548 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/gas_detector.json @@ -0,0 +1,25 @@ +{ + "components": { + "main": { + "momentary": {}, + "gasDetector": { + "gas": { + "value": "clear", + "timestamp": "2025-10-02T03:18:27.139Z" + } + }, + "signalStrength": { + "rssi": { + "value": -71, + "unit": "dBm", + "timestamp": "2025-10-07T04:17:08.419Z" + }, + "lqi": { + "value": 148, + "timestamp": "2025-10-07T04:32:08.512Z" + } + }, + "refresh": {} + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/aeotec_ms6.json b/tests/components/smartthings/fixtures/devices/aeotec_ms6.json new file mode 100644 index 00000000000..3ac2ca1a8c2 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/aeotec_ms6.json @@ -0,0 +1,86 @@ +{ + "items": [ + { + "deviceId": "00f9233e-fdaa-4020-99d4-e0073e53996a", + "name": "aeotec-ms6", + "label": "Parent's Bedroom Sensor", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "6d160aa8-7f54-3611-b7de-0b335d162529", + "deviceManufacturerCode": "0086-0102-0064", + "locationId": "3478ae40-8bd4-40b8-b7e6-f25e3cf86409", + "ownerId": "fe7f9079-8e23-8307-fb7e-4d58929391cf", + "roomId": "f1bb7871-3a3d-48da-b23f-0e1297e8acb0", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "motionSensor", + "version": 1 + }, + { + "id": "temperatureMeasurement", + "version": 1 + }, + { + "id": "relativeHumidityMeasurement", + "version": 1 + }, + { + "id": "illuminanceMeasurement", + "version": 1 + }, + { + "id": "ultravioletIndex", + "version": 1 + }, + { + "id": "tamperAlert", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "MotionSensor", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-04-17T05:47:05.803Z", + "parentDeviceId": "9fdfde11-206e-47af-9e47-9c314d8d965f", + "profile": { + "id": "9893d370-2af6-32a0-86c5-f1a6d2b9fea7" + }, + "zwave": { + "networkId": "BE", + "driverId": "42930682-019d-4dbe-8098-760d7afb3c7f", + "executingLocally": true, + "hubId": "9fdfde11-206e-47af-9e47-9c314d8d965f", + "networkSecurityLevel": "ZWAVE_LEGACY_NON_SECURE", + "provisioningState": "PROVISIONED", + "manufacturerId": 134, + "productType": 258, + "productId": 100, + "fingerprintType": "ZWAVE_MANUFACTURER", + "fingerprintId": "Aeotec/MS6/US" + }, + "type": "ZWAVE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json index 44dafc213f0..16374ab2503 100644 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json +++ b/tests/components/smartthings/fixtures/devices/da_ac_rac_000003.json @@ -1,15 +1,15 @@ { "items": [ { - "deviceId": "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "deviceId": "1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977", "name": "Samsung Room A/C", - "label": "Office AirFree", + "label": "Clim Salon", "manufacturerName": "Samsung Electronics", "presentationId": "DA-AC-RAC-000003", "deviceManufacturerCode": "Samsung Electronics", - "locationId": "403cd42e-f692-416c-91fd-1883c00e3262", - "ownerId": "dd474e5c-59c0-4bea-a319-ff5287fd3373", - "roomId": "dffe353e-b3c5-4a97-8a8a-797ccc649fab", + "locationId": "460f8f20-0428-491d-8ead-8d901cc9f7eb", + "ownerId": "88597a89-5117-0b3c-264f-396fbc9072a4", + "roomId": "e7a14810-8688-4d37-bf02-72a2c673c0d7", "deviceTypeName": "Samsung OCF Air Conditioner", "components": [ { @@ -152,6 +152,10 @@ "id": "custom.disabledCapabilities", "version": 1 }, + { + "id": "samsungce.alwaysOnSensing", + "version": 1 + }, { "id": "samsungce.deviceIdentification", "version": 1 @@ -168,6 +172,10 @@ "id": "samsungce.softwareUpdate", "version": 1 }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, { "id": "samsungce.selfCheck", "version": 1 @@ -182,27 +190,28 @@ "name": "AirConditioner", "categoryType": "manufacturer" } - ] + ], + "optional": false } ], - "createTime": "2024-06-21T13:45:16.238Z", + "createTime": "2021-05-28T08:51:37.616Z", "profile": { - "id": "cedae6e3-1ec9-37e3-9aba-f717518156b8" + "id": "bb4a6df4-6e0f-303a-ac35-445ea78a41fe" }, "ocf": { "ocfDeviceType": "oic.d.airconditioner", "name": "Samsung Room A/C", "specVersion": "core.1.1.0", - "verticalDomainSpecVersion": "1.2.1", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", "manufacturerName": "Samsung Electronics", - "modelNumber": "ARTIK051_PRAC_20K|10256941|60010534001411014600003200800000", + "modelNumber": "ARTIK051_PRAC_20K|10217841|60010523001411010200001000000000", "platformVersion": "DAWIT 2.0", "platformOS": "TizenRT 1.0 + IPv6", "hwVersion": "ARTIK051", "firmwareVersion": "ARTIK051_PRAC_20K_11230313", "vendorId": "DA-AC-RAC-000003", "vendorResourceClientServerVersion": "ARTIK051 Release 2.211222.1", - "lastSignupTime": "2024-06-21T13:45:08.592221Z", + "lastSignupTime": "2021-05-28T08:51:30.356392Z", "transferCandidate": false, "additionalAuthCodeRequired": false }, diff --git a/tests/components/smartthings/fixtures/devices/gas_detector.json b/tests/components/smartthings/fixtures/devices/gas_detector.json new file mode 100644 index 00000000000..9ab0574c706 --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/gas_detector.json @@ -0,0 +1,66 @@ +{ + "items": [ + { + "deviceId": "d830b46f-f094-4560-b8c3-7690032fdb4c", + "name": "generic-ef00-v1", + "label": "Gas Detector", + "manufacturerName": "SmartThingsCommunity", + "presentationId": "d4b88195-fd5b-39d3-ac6f-7070655f08ab", + "deviceManufacturerCode": "_TZE284_chbyv06x", + "locationId": "7139bb09-31e3-4fad-bf03-b9ad02e57b41", + "ownerId": "00126705-d35b-27ee-d18b-17620d9929e7", + "roomId": "5adccb3a-8ae7-41c0-bc58-7ba80ff78a18", + "components": [ + { + "id": "main", + "label": "Detector", + "capabilities": [ + { + "id": "gasDetector", + "version": 1 + }, + { + "id": "momentary", + "version": 1 + }, + { + "id": "signalStrength", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + } + ], + "categories": [ + { + "name": "Siren", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-05-25T04:55:42.440Z", + "profile": { + "id": "1d34dd9d-6840-3df6-a6d0-5d9f4a4af2e1" + }, + "zigbee": { + "eui": "A4C138C524A5BC8D", + "networkId": "1575", + "driverId": "bc7fd1bc-eb00-4b7f-8977-172acf823508", + "executingLocally": true, + "hubId": "0afe704f-eabb-4e4d-8333-6c73903e4f84", + "provisioningState": "DRIVER_SWITCH", + "fingerprintType": "ZIGBEE_GENERIC", + "fingerprintId": "GenericEF00" + }, + "type": "ZIGBEE", + "restrictionTier": 0, + "allowed": null, + "executionContext": "LOCAL", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 4637de49efb..4873737f7f5 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -1,4 +1,102 @@ # serializer version: 1 +# name: test_all_entities[aeotec_ms6][binary_sensor.parent_s_bedroom_sensor_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.parent_s_bedroom_sensor_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00f9233e-fdaa-4020-99d4-e0073e53996a_main_motionSensor_motion_motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aeotec_ms6][binary_sensor.parent_s_bedroom_sensor_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'motion', + 'friendly_name': "Parent's Bedroom Sensor Motion", + }), + 'context': , + 'entity_id': 'binary_sensor.parent_s_bedroom_sensor_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[aeotec_ms6][binary_sensor.parent_s_bedroom_sensor_tamper-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.parent_s_bedroom_sensor_tamper', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tamper', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00f9233e-fdaa-4020-99d4-e0073e53996a_main_tamperAlert_tamper_tamper', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aeotec_ms6][binary_sensor.parent_s_bedroom_sensor_tamper-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tamper', + 'friendly_name': "Parent's Bedroom Sensor Tamper", + }), + 'context': , + 'entity_id': 'binary_sensor.parent_s_bedroom_sensor_tamper', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[c2c_arlo_pro_3_switch][binary_sensor.2nd_floor_hallway_motion-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2572,6 +2670,55 @@ 'state': 'off', }) # --- +# name: test_all_entities[gas_detector][binary_sensor.gas_detector_gas-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.gas_detector_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd830b46f-f094-4560-b8c3-7690032fdb4c_main_gasDetector_gas_gas', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[gas_detector][binary_sensor.gas_detector_gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas Detector Gas', + }), + 'context': , + 'entity_id': 'binary_sensor.gas_detector_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[iphone][binary_sensor.iphone_presence-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 293aa961ca7..e8250a6d1a2 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -420,7 +420,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ac_rac_000003][climate.office_airfree-entry] +# name: test_all_entities[da_ac_rac_000003][climate.clim_salon-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -439,6 +439,7 @@ , , , + , ]), 'max_temp': 35, 'min_temp': 7, @@ -448,6 +449,8 @@ 'quiet', 'smart', 'boost', + 'motion_indirect', + 'motion_direct', 'wind_free', 'wind_free_sleep', ]), @@ -465,7 +468,7 @@ 'disabled_by': None, 'domain': 'climate', 'entity_category': None, - 'entity_id': 'climate.office_airfree', + 'entity_id': 'climate.clim_salon', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -483,17 +486,19 @@ 'suggested_object_id': None, 'supported_features': , 'translation_key': 'air_conditioner', - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main', 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ac_rac_000003][climate.office_airfree-state] +# name: test_all_entities[da_ac_rac_000003][climate.clim_salon-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'current_temperature': 26, + 'current_temperature': 20, 'drlc_status_duration': 0, + 'drlc_status_level': -1, 'drlc_status_override': False, - 'fan_mode': 'low', + 'drlc_status_start': '1970-01-01T00:00:00Z', + 'fan_mode': 'auto', 'fan_modes': list([ 'auto', 'low', @@ -501,13 +506,14 @@ 'high', 'turbo', ]), - 'friendly_name': 'Office AirFree', + 'friendly_name': 'Clim Salon', 'hvac_modes': list([ , , , , , + , ]), 'max_temp': 35, 'min_temp': 7, @@ -518,6 +524,8 @@ 'quiet', 'smart', 'boost', + 'motion_indirect', + 'motion_direct', 'wind_free', 'wind_free_sleep', ]), @@ -529,14 +537,14 @@ 'vertical', 'horizontal', ]), - 'temperature': 24, + 'temperature': 19, }), 'context': , - 'entity_id': 'climate.office_airfree', + 'entity_id': 'climate.clim_salon', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'cool', + 'state': 'heat', }) # --- # name: test_all_entities[da_ac_rac_01001][climate.aire_dormitorio_principal-entry] diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 42eaf548b36..3de46994c4c 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -64,6 +64,37 @@ 'via_device_id': None, }) # --- +# name: test_devices[aeotec_ms6] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '00f9233e-fdaa-4020-99d4-e0073e53996a', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': "Parent's Bedroom Sensor", + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[aq_sensor_3_ikea] DeviceRegistryEntrySnapshot({ 'area_id': None, @@ -451,7 +482,7 @@ 'identifiers': set({ tuple( 'smartthings', - 'c76d6f38-1b7f-13dd-37b5-db18d5272783', + '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977', ), }), 'labels': set({ @@ -459,7 +490,7 @@ 'manufacturer': 'Samsung Electronics', 'model': 'ARTIK051_PRAC_20K', 'model_id': None, - 'name': 'Office AirFree', + 'name': 'Clim Salon', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, @@ -1304,6 +1335,37 @@ 'via_device_id': None, }) # --- +# name: test_devices[gas_detector] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + 'd830b46f-f094-4560-b8c3-7690032fdb4c', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'Gas Detector', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_devices[gas_meter] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index c573ccbbc27..bf5760afc3d 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -163,6 +163,269 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[aeotec_ms6][sensor.parent_s_bedroom_sensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.parent_s_bedroom_sensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00f9233e-fdaa-4020-99d4-e0073e53996a_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[aeotec_ms6][sensor.parent_s_bedroom_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': "Parent's Bedroom Sensor Battery", + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.parent_s_bedroom_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[aeotec_ms6][sensor.parent_s_bedroom_sensor_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parent_s_bedroom_sensor_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00f9233e-fdaa-4020-99d4-e0073e53996a_main_relativeHumidityMeasurement_humidity_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[aeotec_ms6][sensor.parent_s_bedroom_sensor_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': "Parent's Bedroom Sensor Humidity", + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.parent_s_bedroom_sensor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_all_entities[aeotec_ms6][sensor.parent_s_bedroom_sensor_illuminance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parent_s_bedroom_sensor_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00f9233e-fdaa-4020-99d4-e0073e53996a_main_illuminanceMeasurement_illuminance_illuminance', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_all_entities[aeotec_ms6][sensor.parent_s_bedroom_sensor_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': "Parent's Bedroom Sensor Illuminance", + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.parent_s_bedroom_sensor_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_all_entities[aeotec_ms6][sensor.parent_s_bedroom_sensor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parent_s_bedroom_sensor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00f9233e-fdaa-4020-99d4-e0073e53996a_main_temperatureMeasurement_temperature_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[aeotec_ms6][sensor.parent_s_bedroom_sensor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': "Parent's Bedroom Sensor Temperature", + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.parent_s_bedroom_sensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_all_entities[aeotec_ms6][sensor.parent_s_bedroom_sensor_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.parent_s_bedroom_sensor_uv_index', + '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': 'UV index', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': '00f9233e-fdaa-4020-99d4-e0073e53996a_main_ultravioletIndex_ultravioletIndex_ultravioletIndex', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[aeotec_ms6][sensor.parent_s_bedroom_sensor_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': "Parent's Bedroom Sensor UV index", + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.parent_s_bedroom_sensor_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[aq_sensor_3_ikea][sensor.aq_sensor_3_ikea_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2509,7 +2772,7 @@ 'state': '100', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2524,7 +2787,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_energy', + 'entity_id': 'sensor.clim_salon_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2545,27 +2808,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_powerConsumptionReport_powerConsumption_energy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Office AirFree Energy', + 'friendly_name': 'Clim Salon Energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_energy', + 'entity_id': 'sensor.clim_salon_energy', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '602.171', + 'state': '6652.713', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_difference-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy_difference-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2580,7 +2843,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_energy_difference', + 'entity_id': 'sensor.clim_salon_energy_difference', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2601,27 +2864,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_difference', - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_difference-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy_difference-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Office AirFree Energy difference', + 'friendly_name': 'Clim Salon Energy difference', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_energy_difference', + 'entity_id': 'sensor.clim_salon_energy_difference', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '0.002', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_saved-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy_saved-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2636,7 +2899,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_energy_saved', + 'entity_id': 'sensor.clim_salon_energy_saved', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2657,27 +2920,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'energy_saved', - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_powerConsumptionReport_powerConsumption_energySaved_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy_saved-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_energy_saved-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Office AirFree Energy saved', + 'friendly_name': 'Clim Salon Energy saved', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_energy_saved', + 'entity_id': 'sensor.clim_salon_energy_saved', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '0.0', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_humidity-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2692,7 +2955,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_humidity', + 'entity_id': 'sensor.clim_salon_humidity', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2710,27 +2973,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_relativeHumidityMeasurement_humidity_humidity', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_relativeHumidityMeasurement_humidity_humidity', 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_humidity-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', - 'friendly_name': 'Office AirFree Humidity', + 'friendly_name': 'Clim Salon Humidity', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.office_airfree_humidity', + 'entity_id': 'sensor.clim_salon_humidity', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '48', + 'state': '59', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2745,7 +3008,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_power', + 'entity_id': 'sensor.clim_salon_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2766,29 +3029,29 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_power_meter', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_powerConsumptionReport_powerConsumption_power_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Office AirFree Power', - 'power_consumption_end': '2025-03-27T05:40:02Z', - 'power_consumption_start': '2025-03-27T05:29:22Z', + 'friendly_name': 'Clim Salon Power', + 'power_consumption_end': '2025-10-04T15:55:07Z', + 'power_consumption_start': '2025-10-04T15:54:24Z', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_power', + 'entity_id': 'sensor.clim_salon_power', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '143', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power_energy-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_power_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2803,7 +3066,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_power_energy', + 'entity_id': 'sensor.clim_salon_power_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2824,27 +3087,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'power_energy', - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_power_energy-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_power_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Office AirFree Power energy', + 'friendly_name': 'Clim Salon Power energy', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_power_energy', + 'entity_id': 'sensor.clim_salon_power_energy', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': '0.00174704861111111', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_temperature-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2859,7 +3122,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_temperature', + 'entity_id': 'sensor.clim_salon_temperature', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2880,27 +3143,27 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_temperatureMeasurement_temperature_temperature', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_temperatureMeasurement_temperature_temperature', 'unit_of_measurement': , }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_temperature-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', - 'friendly_name': 'Office AirFree Temperature', + 'friendly_name': 'Clim Salon Temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.office_airfree_temperature', + 'entity_id': 'sensor.clim_salon_temperature', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '26', + 'state': '20', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_volume-entry] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2913,7 +3176,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.office_airfree_volume', + 'entity_id': 'sensor.clim_salon_volume', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2931,18 +3194,18 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'audio_volume', - 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main_audioVolume_volume_volume', + 'unique_id': '1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977_main_audioVolume_volume_volume', 'unit_of_measurement': '%', }) # --- -# name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_volume-state] +# name: test_all_entities[da_ac_rac_000003][sensor.clim_salon_volume-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Office AirFree Volume', + 'friendly_name': 'Clim Salon Volume', 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.office_airfree_volume', + 'entity_id': 'sensor.clim_salon_volume', 'last_changed': , 'last_reported': , 'last_updated': , @@ -12651,6 +12914,110 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[gas_detector][sensor.gas_detector_link_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gas_detector_link_quality', + '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': 'Link quality', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'link_quality', + 'unique_id': 'd830b46f-f094-4560-b8c3-7690032fdb4c_main_signalStrength_lqi_lqi', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[gas_detector][sensor.gas_detector_link_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas Detector Link quality', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.gas_detector_link_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '148', + }) +# --- +# name: test_all_entities[gas_detector][sensor.gas_detector_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.gas_detector_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'd830b46f-f094-4560-b8c3-7690032fdb4c_main_signalStrength_rssi_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[gas_detector][sensor.gas_detector_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Gas Detector Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.gas_detector_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-71', + }) +# --- # name: test_all_entities[gas_meter][sensor.gas_meter_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index d27bd042b11..a8373eb2870 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -469,11 +469,11 @@ async def test_ac_set_preset_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.office_airfree", ATTR_PRESET_MODE: mode}, + {ATTR_ENTITY_ID: "climate.clim_salon", ATTR_PRESET_MODE: mode}, blocking=True, ) devices.execute_device_command.assert_called_with( - "c76d6f38-1b7f-13dd-37b5-db18d5272783", + "1e3f7ca2-e005-e1a4-f6d7-bc231e3f7977", Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Command.SET_AC_OPTIONAL_MODE, MAIN, diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index f2aa6df802e..faee892e993 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -252,9 +252,7 @@ async def test_user_2sa( assert result["step_id"] == "2sa" # Failed the first time because was too slow to enter the code - service_2sa.return_value.login = Mock( - side_effect=SynologyDSMLogin2SAFailedException - ) + service_2sa.login = AsyncMock(side_effect=SynologyDSMLogin2SAFailedException) result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_OTP_CODE: "000000"} ) diff --git a/tests/components/telegram_bot/test_notify.py b/tests/components/telegram_bot/test_notify.py index d43d5492760..969eef568b9 100644 --- a/tests/components/telegram_bot/test_notify.py +++ b/tests/components/telegram_bot/test_notify.py @@ -43,7 +43,7 @@ async def test_send_message( NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, { - ATTR_ENTITY_ID: "notify.telegram_bot_123456_12345678", + ATTR_ENTITY_ID: "notify.testbot_mock_last_name_mock_title_12345678", ATTR_MESSAGE: "mock message", ATTR_TITLE: "mock title", }, @@ -64,7 +64,7 @@ async def test_send_message( message_thread_id=None, ) - state = hass.states.get("notify.telegram_bot_123456_12345678") + state = hass.states.get("notify.testbot_mock_last_name_mock_title_12345678") assert state assert state.state == "2025-01-09T12:00:00+00:00" diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 13c24046d2f..6091bfc96ed 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -23,6 +23,7 @@ DEVICE_MOCKS = [ "cl_ebt12ypvexnixvtf", # https://github.com/tuya/tuya-home-assistant/issues/754 "cl_g1cp07dsqnbdbbki", # https://github.com/home-assistant/core/issues/139966 "cl_lfkr93x0ukp5gaia", # https://github.com/home-assistant/core/issues/152826 + "cl_n3xgr5pdmpinictg", # https://github.com/home-assistant/core/issues/153537 "cl_qqdxfdht", # https://github.com/orgs/home-assistant/discussions/539 "cl_rD7uqAAgQOpSA2Rx", # https://github.com/home-assistant/core/issues/139966 "cl_zah67ekd", # https://github.com/home-assistant/core/issues/71242 diff --git a/tests/components/tuya/fixtures/cl_n3xgr5pdmpinictg.json b/tests/components/tuya/fixtures/cl_n3xgr5pdmpinictg.json new file mode 100644 index 00000000000..2a75bf1164b --- /dev/null +++ b/tests/components/tuya/fixtures/cl_n3xgr5pdmpinictg.json @@ -0,0 +1,37 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Estore Sala", + "category": "cl", + "product_id": "n3xgr5pdmpinictg", + "product_name": "Curtain Switch", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2023-03-30T19:15:47+00:00", + "create_time": "2023-03-30T19:15:47+00:00", + "update_time": "2023-03-30T19:15:47+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + } + }, + "status": { + "control": "stop" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index e41c7aa1c29..3c8136432f3 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -101,6 +101,56 @@ 'state': 'open', }) # --- +# name: test_platform_setup_and_discovery[cover.estore_sala_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.estore_sala_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.gtcinipmdp5rgx3nlccontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cover.estore_sala_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'curtain', + 'friendly_name': 'Estore Sala Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.estore_sala_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[cover.garage_door_door_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_init.ambr b/tests/components/tuya/snapshots/test_init.ambr index 67ca9ddec1a..86d3359196f 100644 --- a/tests/components/tuya/snapshots/test_init.ambr +++ b/tests/components/tuya/snapshots/test_init.ambr @@ -3781,6 +3781,37 @@ 'via_device_id': None, }) # --- +# name: test_device_registry[gtcinipmdp5rgx3nlc] + 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', + 'gtcinipmdp5rgx3nlc', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Tuya', + 'model': 'Curtain Switch', + 'model_id': 'n3xgr5pdmpinictg', + 'name': 'Estore Sala', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- # name: test_device_registry[gvxxy4jitzltz5xhscm] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index e4d6d98250a..19c723b2254 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -18,7 +18,13 @@ from homeassistant.components.cover import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_CLOSED, + STATE_OPEN, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er @@ -311,3 +317,38 @@ async def test_clkg_wltqkykhni0papzj_action( mock_device.id, expected_commands, ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["cl_n3xgr5pdmpinictg"], +) +@pytest.mark.parametrize( + ("initial_control", "expected_state"), + [ + ("open", STATE_OPEN), + ("stop", STATE_UNKNOWN), + ("close", STATE_CLOSED), + ], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_cl_n3xgr5pdmpinictg_state( + hass: HomeAssistant, + mock_manager: Manager, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + initial_control: str, + expected_state: str, +) -> None: + """Test cover position for n3xgr5pdmpinictg device. + + See https://github.com/home-assistant/core/issues/153537 + """ + entity_id = "cover.estore_sala_curtain" + mock_device.status["control"] = initial_control + + 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" + assert state.state == expected_state diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 3f01ce765b9..4e41d77e5e3 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -250,7 +250,7 @@ 'friendly_name': 'Test Fan', 'preset_modes': list([ ]), - 'supported_features': 57, + 'supported_features': 59, }), 'entity_id': 'fan.test_fan', 'last_changed': str, diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 88b6bc64ebb..daacddb3267 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -656,7 +656,7 @@ 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vesync', 'unique_id': 'smarttowerfan', 'unit_of_measurement': None, @@ -670,6 +670,7 @@ 'display_status': 'off', 'friendly_name': 'SmartTowerFan', 'mode': 'normal', + 'oscillating': True, 'percentage': None, 'percentage_step': 8.333333333333334, 'preset_mode': 'normal', @@ -679,7 +680,7 @@ 'normal', 'turbo', ]), - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'fan.smarttowerfan', diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index 31e0e514dd3..7929d838fbe 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -107,6 +107,9 @@ async def test_async_get_device_diagnostics__single_fan( "home_assistant.entities.6.state.last_changed": (str,), "home_assistant.entities.6.state.last_reported": (str,), "home_assistant.entities.6.state.last_updated": (str,), + "home_assistant.entities.7.state.last_changed": (str,), + "home_assistant.entities.7.state.last_reported": (str,), + "home_assistant.entities.7.state.last_updated": (str,), } ) ) diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index e5c59bef30f..12801d989c0 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -138,19 +138,33 @@ async def test_turn_on_off_raises_error( ("api_response", "expectation"), [(True, NoException), (False, pytest.raises(HomeAssistantError))], ) +@pytest.mark.parametrize( + ("preset_mode", "patch_target"), + [ + ("normal", "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_normal_mode"), + ( + "advancedSleep", + "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_advanced_sleep_mode", + ), + ("turbo", "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_turbo_mode"), + ("auto", "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_auto_mode"), + ], +) async def test_set_preset_mode( hass: HomeAssistant, fan_config_entry: MockConfigEntry, api_response: bool, expectation, + preset_mode: str, + patch_target: str, ) -> None: """Test handling of value in set_preset_mode method. Does this via turn on as it increases test coverage.""" - # If VeSyncTowerFan.normal_mode fails (returns False), then HomeAssistantError is raised + # If VeSyncTowerFan.mode fails (returns False), then HomeAssistantError is raised with ( expectation, patch( - "pyvesync.devices.vesyncfan.VeSyncTowerFan.normal_mode", + patch_target, return_value=api_response, ) as method_mock, ): @@ -160,7 +174,52 @@ async def test_set_preset_mode( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_FAN, ATTR_PRESET_MODE: "normal"}, + {ATTR_ENTITY_ID: ENTITY_FAN, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + await hass.async_block_till_done() + method_mock.assert_called_once() + update_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("action", "command"), + [ + ("true", "pyvesync.devices.vesyncfan.VeSyncTowerFan.toggle_oscillation"), + ("false", "pyvesync.devices.vesyncfan.VeSyncTowerFan.toggle_oscillation"), + ], +) +@pytest.mark.parametrize( + ("api_response", "expectation"), + [(True, NoException), (False, pytest.raises(HomeAssistantError))], +) +async def test_oscillation_success( + hass: HomeAssistant, + fan_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + action: str, + command: str, + api_response: bool, + expectation, +) -> None: + """Test oscillation on and off.""" + + mock_devices_response(aioclient_mock, "SmartTowerFan") + + with ( + expectation, + patch( + command, new_callable=AsyncMock, return_value=api_response + ) as method_mock, + ): + with patch( + "homeassistant.components.vesync.fan.VeSyncFanHA.schedule_update_ha_state" + ) as update_mock: + await hass.services.async_call( + FAN_DOMAIN, + "oscillate", + {ATTR_ENTITY_ID: ENTITY_FAN, "oscillating": action}, blocking=True, ) diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index 8e10d2f1a25..2608e4bbf64 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -39,7 +39,9 @@ class MockPyViCare: f"installation{idx}", f"gateway{idx}", f"device{idx}", fixture ), f"deviceId{idx}", - f"model{idx}", + "Vitovalor" + if fixture.data_file.endswith("VitoValor.json") + else f"model{idx}", "online", ) ) diff --git a/tests/components/vicare/fixtures/VitoValor.json b/tests/components/vicare/fixtures/VitoValor.json new file mode 100644 index 00000000000..07bed9faea5 --- /dev/null +++ b/tests/components/vicare/fixtures/VitoValor.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "heating.sensors.temperature.hydraulicSeparator", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "status": { + "type": "string", + "value": "connected" + }, + "value": { + "type": "number", + "unit": "celsius", + "value": 22.3 + } + }, + "timestamp": "2022-11-18T06:52:46.507Z", + "uri": "https://api.viessmann-climatesolutions.com/iot/v1/equipment/installations/#######/gateways/################/devices/0/features/heating.sensors.temperature.hydraulicSeparator" + } + ] +} diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 22cba704dcf..6c21511a201 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -1,4 +1,60 @@ # serializer version: 1 +# name: test_all_entities[None-vicare/VitoValor.json][sensor.vitovalor_hydraulic_separator_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vitovalor_hydraulic_separator_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydraulic separator temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hydraulic_separator_temperature', + 'unique_id': 'gateway0_deviceId0-hydraulic_separator_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[None-vicare/VitoValor.json][sensor.vitovalor_hydraulic_separator_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Vitovalor Hydraulic separator temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vitovalor_hydraulic_separator_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.3', + }) +# --- # name: test_all_entities[type:boiler-vicare/Vitodens300W.json][sensor.model0_boiler_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/vicare/test_sensor.py b/tests/components/vicare/test_sensor.py index be7418291a8..ce286212093 100644 --- a/tests/components/vicare/test_sensor.py +++ b/tests/components/vicare/test_sensor.py @@ -23,6 +23,7 @@ from tests.common import MockConfigEntry, snapshot_platform ("type:heatpump", "vicare/Vitocal250A.json"), ("type:ventilation", "vicare/ViAir300F.json"), ("type:ess", "vicare/VitoChargeVX3.json"), + (None, "vicare/VitoValor.json"), ], ) async def test_all_entities( diff --git a/tests/components/youtube/snapshots/test_sensor.ambr b/tests/components/youtube/snapshots/test_sensor.ambr index feddd644cee..01720efc978 100644 --- a/tests/components/youtube/snapshots/test_sensor.ambr +++ b/tests/components/youtube/snapshots/test_sensor.ambr @@ -4,7 +4,7 @@ 'attributes': ReadOnlyDict({ 'entity_picture': 'https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg', 'friendly_name': 'Google for Developers Latest upload', - 'published_at': datetime.datetime(2023, 5, 11, 0, 20, 46, tzinfo=TzInfo(UTC)), + 'published_at': datetime.datetime(2023, 5, 11, 0, 20, 46, tzinfo=TzInfo(0)), 'video_id': 'wysukDrMdqU', }), 'context': , diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index aae16dbccfb..341357662f1 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -952,6 +952,33 @@ async def test_zeroconf_discovery_via_socket_already_setup_with_ip_match( assert result["reason"] == "single_instance_allowed" +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_zeroconf_not_onboarded(hass: HomeAssistant) -> None: + """Test zeroconf discovery needing confirmation when not onboarded.""" + service_info = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], + hostname="tube-zigbee-gw.local.", + name="mock_name", + port=6638, + properties={"name": "tube_123456"}, + type="mock_type", + ) + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result_create = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=service_info, + ) + await hass.async_block_till_done() + + # not automatically confirmed + assert result_create["type"] is FlowResultType.FORM + assert result_create["step_id"] == "confirm" + + @patch( "homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type", mock_detect_radio_type(radio_type=RadioType.deconz), diff --git a/tests/test_const.py b/tests/test_const.py index 4413e8efe96..10fc5241b9e 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -1,21 +1,12 @@ """Test const module.""" from enum import Enum -import logging -import sys -from unittest.mock import Mock, patch import pytest from homeassistant import const -from homeassistant.components import alarm_control_panel -from .common import ( - extract_stack_to_frame, - help_test_all, - import_and_test_deprecated_constant, - import_and_test_deprecated_constant_enum, -) +from .common import help_test_all, import_and_test_deprecated_constant def _create_tuples( @@ -50,102 +41,3 @@ def test_deprecated_constant_name_changes( replacement, breaks_in_version, ) - - -def _create_tuples_alarm_states( - enum: type[Enum], constant_prefix: str, remove_in_version: str -) -> list[tuple[Enum, str]]: - return [(enum_field, constant_prefix, remove_in_version) for enum_field in enum] - - -@pytest.mark.parametrize( - ("enum", "constant_prefix", "remove_in_version"), - _create_tuples_alarm_states( - alarm_control_panel.AlarmControlPanelState, "STATE_ALARM_", "2025.11" - ), -) -def test_deprecated_constants_alarm( - caplog: pytest.LogCaptureFixture, - enum: Enum, - constant_prefix: str, - remove_in_version: str, -) -> None: - """Test deprecated constants.""" - import_and_test_deprecated_constant_enum( - caplog, const, enum, constant_prefix, remove_in_version - ) - - -def test_deprecated_unit_of_conductivity_alias() -> None: - """Test UnitOfConductivity deprecation.""" - - # Test the deprecated members are aliases - assert set(const.UnitOfConductivity) == {"S/cm", "μS/cm", "mS/cm"} - - -def test_deprecated_unit_of_conductivity_members( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test UnitOfConductivity deprecation.""" - - module_name = "config.custom_components.hue.light" - filename = f"/home/paulus/{module_name.replace('.', '/')}.py" - - with ( - patch.dict(sys.modules, {module_name: Mock(__file__=filename)}), - patch( - "homeassistant.helpers.frame.linecache.getline", - return_value="await session.close()", - ), - patch( - "homeassistant.helpers.frame.get_current_frame", - return_value=extract_stack_to_frame( - [ - Mock( - filename="/home/paulus/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - Mock( - filename=filename, - lineno="23", - line="await session.close()", - ), - Mock( - filename="/home/paulus/aiohue/lights.py", - lineno="2", - line="something()", - ), - ] - ), - ), - ): - const.UnitOfConductivity.SIEMENS # noqa: B018 - const.UnitOfConductivity.MICROSIEMENS # noqa: B018 - const.UnitOfConductivity.MILLISIEMENS # noqa: B018 - - assert len(caplog.record_tuples) == 3 - - def deprecation_message(member: str, replacement: str) -> str: - return ( - f"The deprecated enum member UnitOfConductivity.{member} was used from hue. " - "It will be removed in HA Core 2025.11.0. Use UnitOfConductivity." - f"{replacement} instead, please report it to the author of the 'hue' custom" - " integration" - ) - - assert ( - const.__name__, - logging.WARNING, - deprecation_message("SIEMENS", "SIEMENS_PER_CM"), - ) in caplog.record_tuples - assert ( - const.__name__, - logging.WARNING, - deprecation_message("MICROSIEMENS", "MICROSIEMENS_PER_CM"), - ) in caplog.record_tuples - assert ( - const.__name__, - logging.WARNING, - deprecation_message("MILLISIEMENS", "MILLISIEMENS_PER_CM"), - ) in caplog.record_tuples diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index d9377779b68..7b7c3752729 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -281,48 +281,6 @@ _CONVERTED_VALUE: dict[ ), ], ConductivityConverter: [ - # Deprecated to deprecated - (5, UnitOfConductivity.SIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS), - (5, UnitOfConductivity.SIEMENS, 5e6, UnitOfConductivity.MICROSIEMENS), - (5, UnitOfConductivity.MILLISIEMENS, 5e3, UnitOfConductivity.MICROSIEMENS), - (5, UnitOfConductivity.MILLISIEMENS, 5e-3, UnitOfConductivity.SIEMENS), - (5e6, UnitOfConductivity.MICROSIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS), - (5e6, UnitOfConductivity.MICROSIEMENS, 5, UnitOfConductivity.SIEMENS), - # Deprecated to new - (5, UnitOfConductivity.SIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS_PER_CM), - (5, UnitOfConductivity.SIEMENS, 5e6, UnitOfConductivity.MICROSIEMENS_PER_CM), - ( - 5, - UnitOfConductivity.MILLISIEMENS, - 5e3, - UnitOfConductivity.MICROSIEMENS_PER_CM, - ), - (5, UnitOfConductivity.MILLISIEMENS, 5e-3, UnitOfConductivity.SIEMENS_PER_CM), - ( - 5e6, - UnitOfConductivity.MICROSIEMENS, - 5e3, - UnitOfConductivity.MILLISIEMENS_PER_CM, - ), - (5e6, UnitOfConductivity.MICROSIEMENS, 5, UnitOfConductivity.SIEMENS_PER_CM), - # New to deprecated - (5, UnitOfConductivity.SIEMENS_PER_CM, 5e3, UnitOfConductivity.MILLISIEMENS), - (5, UnitOfConductivity.SIEMENS_PER_CM, 5e6, UnitOfConductivity.MICROSIEMENS), - ( - 5, - UnitOfConductivity.MILLISIEMENS_PER_CM, - 5e3, - UnitOfConductivity.MICROSIEMENS, - ), - (5, UnitOfConductivity.MILLISIEMENS_PER_CM, 5e-3, UnitOfConductivity.SIEMENS), - ( - 5e6, - UnitOfConductivity.MICROSIEMENS_PER_CM, - 5e3, - UnitOfConductivity.MILLISIEMENS, - ), - (5e6, UnitOfConductivity.MICROSIEMENS_PER_CM, 5, UnitOfConductivity.SIEMENS), - # New to new ( 5, UnitOfConductivity.SIEMENS_PER_CM,