diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 86be8cd4da5008..2197e8adadb673 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 00000000000000..02dd134122ea96 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 24268f4f4e24a3..95f4c8e333463a 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) - - # 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 = 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/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index 6a6857d95b34fb..57b55cb2bfb1da 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -6,7 +6,11 @@ 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 @@ "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 @@ def __init__(self) -> None: 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 @@ async def _get_device_data( _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 @@ async def _get_device_data( 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 @@ async def async_step_bluetooth( 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 @@ async def async_step_user( 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 @@ async def async_step_user( _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 5ac0b27e26f4b4..d7365bb5f1b388 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/strings.json b/homeassistant/components/airthings_ble/strings.json index f5639e8da8f133..6799aa20ba7076 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%]" } }, diff --git a/homeassistant/components/altruist/sensor.py b/homeassistant/components/altruist/sensor.py index f02c442e5cd793..0635673a0fce61 100644 --- a/homeassistant/components/altruist/sensor.py +++ b/homeassistant/components/altruist/sensor.py @@ -65,6 +65,31 @@ class AltruistSensorEntityDescription(SensorEntityDescription): 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/config_flow.py b/homeassistant/components/comelit/config_flow.py index 0f47d88fad1918..dd94fa62988355 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -4,6 +4,7 @@ from asyncio.exceptions import TimeoutError from collections.abc import Mapping +import re from typing import Any from aiocomelit import ( @@ -27,25 +28,20 @@ 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 @@ async def async_step_user( 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 @@ async def async_step_reauth_confirm( 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 @@ async def async_step_reconfigure( 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 8818e296e032b0..1351c8258f5257 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -161,7 +161,7 @@ def __init__( entry: ComelitConfigEntry, host: str, port: int, - pin: int, + pin: str, session: ClientSession, ) -> None: """Initialize the scanner.""" @@ -195,7 +195,7 @@ def __init__( entry: ComelitConfigEntry, host: str, port: int, - pin: int, + pin: str, session: ClientSession, ) -> None: """Initialize the scanner.""" diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 4e8fee1bba6322..e276450f4fc05f 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/strings.json b/homeassistant/components/comelit/strings.json index d63d22f307ad6e..2b051586ac436a 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/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index b0e2a0595bf3d3..09f6fcfcca2d15 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 440141bbceed44..d3b5ca340229e5 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.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 @@ async def on_event(event: EventT) -> None: 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 e69da61799ff8a..106acf8c8bbdf4 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 e771233b0690db..ed3864e6f836db 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): suggested_display_precision=3, value_fn=attrgetter("energy_received"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="net_consumption", @@ -431,6 +434,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): suggested_display_precision=3, value_fn=attrgetter("active_power"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="frequency", @@ -442,6 +446,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, value_fn=attrgetter("frequency"), on_phase=None, + cttype=CtType.NET_CONSUMPTION, ), EnvoyCTSensorEntityDescription( key="voltage", @@ -454,6 +459,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, value_fn=attrgetter("frequency"), on_phase=None, + cttype=CtType.PRODUCTION, ), EnvoyCTSensorEntityDescription( key="production_ct_voltage", @@ -537,6 +548,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, value_fn=attrgetter("voltage"), on_phase=None, + cttype=CtType.PRODUCTION, ), EnvoyCTSensorEntityDescription( key="production_ct_current", @@ -549,6 +561,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, value_fn=attrgetter("current"), on_phase=None, + cttype=CtType.PRODUCTION, ), EnvoyCTSensorEntityDescription( key="production_ct_powerfactor", @@ -559,6 +572,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): suggested_display_precision=3, value_fn=attrgetter("energy_delivered"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="lifetime_battery_charged", @@ -618,6 +635,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): suggested_display_precision=3, value_fn=attrgetter("energy_received"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="battery_discharge", @@ -629,6 +647,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): suggested_display_precision=3, value_fn=attrgetter("active_power"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="storage_ct_frequency", @@ -640,6 +659,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, value_fn=attrgetter("frequency"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="storage_voltage", @@ -652,6 +672,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, value_fn=attrgetter("voltage"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="storage_ct_current", @@ -664,6 +685,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, value_fn=attrgetter("current"), on_phase=None, + cttype=CtType.STORAGE, ), EnvoyCTSensorEntityDescription( key="storage_ct_powerfactor", @@ -674,6 +696,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription): 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 - ) - # For each net consumption ct phase reported add net consumption entities - if phase_data := envoy_data.ctmeter_consumption_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 + 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 storage ct phase reported add storage ct entities - if phase_data := envoy_data.ctmeter_storage_phases: + # Add Current Transformer phase entities + if ctmeters_phases := envoy_data.ctmeters_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 @@ def native_value(self) -> int | None: 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 @@ def native_value( 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 @@ def native_value( """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 - 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: + 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] + self.data.ctmeters_phases[cttype][phase] ) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 8e3ab5d6892a7b..b9690cbe7dc238 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/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 3746705510b3c5..19ace718564f1e 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -51,7 +51,7 @@ _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 1fdcc4f897fa62..2fae62f27cd260 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 1fccc3add81f59..2d19772f54ff92 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 57c10f6f7bd8cf..8cb4db6bbe0be6 100644 --- a/homeassistant/components/miele/entity.py +++ b/homeassistant/components/miele/entity.py @@ -37,8 +37,8 @@ def __init__( 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/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index d115c13d0e77a5..26b663f1c11cca 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -46,6 +46,14 @@ 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 @@ CONF_EFFECT, CONF_ENTITY_CATEGORY, CONF_HOST, + CONF_MODE, CONF_NAME, CONF_OPTIMISTIC, CONF_PASSWORD, @@ -212,7 +221,9 @@ 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 @@ 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 @@ Platform.LIGHT, Platform.LOCK, Platform.NOTIFY, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] @@ -680,6 +693,24 @@ 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 @@ 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 @@ def validate_sensor_platform_config( 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 @@ class 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 @@ class 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 d16617ef2a4e99..f5d370828adde9 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -120,8 +120,10 @@ 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_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 9da68e62d80783..cba52bd04eceb1 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -37,8 +37,12 @@ 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 @@ 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 49449c2f52d375..fe848ea43c6816 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/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 7fb27cc7b8078a..48c595e9905f9f 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -17,6 +17,7 @@ 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,19 +25,22 @@ 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] + events = device.events.get_platform("binary_sensor") + entity_names = build_event_entity_names(events) + entities = { - event.uid: ONVIFBinarySensor(event.uid, device) - for event in device.events.get_platform("binary_sensor") + event.uid: ONVIFBinarySensor(event.uid, device, name=entity_names[event.uid]) + for event in events } 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 + entry.unique_id, device, entry=entry ) async_add_entities(entities.values()) @@ -48,8 +52,13 @@ def async_check_entities() -> None: nonlocal uids_by_platform if not (missing := uids_by_platform.difference(entities)): return + + events = device.events.get_platform("binary_sensor") + entity_names = build_event_entity_names(events) + new_entities: dict[str, ONVIFBinarySensor] = { - uid: ONVIFBinarySensor(uid, device) for uid in missing + uid: ONVIFBinarySensor(uid, device, name=entity_names[uid]) + for uid in missing } if new_entities: entities.update(new_entities) @@ -65,7 +74,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 +91,13 @@ def __init__( 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 f6387de009cfef..228fd1bbdd37e4 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -16,6 +16,7 @@ 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,18 +24,23 @@ 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] + events = device.events.get_platform("sensor") + entity_names = build_event_entity_names(events) + entities = { - event.uid: ONVIFSensor(event.uid, device) - for event in device.events.get_platform("sensor") + event.uid: ONVIFSensor(event.uid, device, name=entity_names[event.uid]) + for event in events } 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) + entities[entry.unique_id] = ONVIFSensor( + entry.unique_id, device, entry=entry + ) async_add_entities(entities.values()) uids_by_platform = device.events.get_uids_by_platform("sensor") @@ -45,8 +51,12 @@ def async_check_entities() -> None: nonlocal uids_by_platform if not (missing := uids_by_platform.difference(entities)): return + + events = device.events.get_platform("sensor") + entity_names = build_event_entity_names(events) + new_entities: dict[str, ONVIFSensor] = { - uid: ONVIFSensor(uid, device) for uid in missing + uid: ONVIFSensor(uid, device, name=entity_names[uid]) for uid in missing } if new_entities: entities.update(new_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 @@ def __init__( 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 064d9cfad5f3d8..aaa045abb18049 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/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 96c6d94da4f901..051c675e211744 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/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 6e3469970d19ff..e92a052fa6e54c 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -143,6 +143,7 @@ def __init__(self) -> None: 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 @@ def _show_form( description_placeholders = {} data_schema = None + self.api = None if step_id == "link": user_input.update(self.discovered_conf) @@ -194,14 +196,21 @@ async def async_validate_input_create_entry( 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 @@ async def async_validate_input_create_entry( 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 @@ async def async_validate_input_create_entry( 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/telegram_bot/entity.py b/homeassistant/components/telegram_bot/entity.py new file mode 100644 index 00000000000000..95adc934781aec --- /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 822bd7b925d5f3..510b25493d8725 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 @@ def __init__( 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/vesync/fan.py b/homeassistant/components/vesync/fan.py index 23edf1660a0cb7..9f3a7bc9ba8af6 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -18,7 +18,7 @@ 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 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 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 @@ async def async_turn_off(self, **kwargs: Any) -> None: 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 864439c746c4bc..7825db7043bab7 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -952,6 +952,15 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM 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 260b51f56f3f38..99c78e262a65a9 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 83649e8e5c5a74..53c33757e3c8a3 100644 --- a/homeassistant/components/victron_remote_monitoring/config_flow.py +++ b/homeassistant/components/victron_remote_monitoring/config_flow.py @@ -13,7 +13,7 @@ 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 @@ async def _async_validate_token_and_fetch_sites(self, api_token: str) -> list[Si """ 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 @@ async def _async_validate_selected_site(self, api_token: str, site_id: int) -> S """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 68cae39813dca5..a7a58fbbe4aeba 100644 --- a/homeassistant/components/victron_remote_monitoring/coordinator.py +++ b/homeassistant/components/victron_remote_monitoring/coordinator.py @@ -11,7 +11,7 @@ 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 @@ def __init__( """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 1ce45ad2475873..d6a7b2f9586776 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 8876f784fa85b0..6d5e97c92cf827 100644 --- a/homeassistant/components/victron_remote_monitoring/sensor.py +++ b/homeassistant/components/victron_remote_monitoring/sensor.py @@ -39,7 +39,9 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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 @@ class VRMForecastsSensorEntityDescription(SensorEntityDescription): 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/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index fcaa824ff39022..402a159847a2d7 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -48,6 +48,11 @@ "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/requirements_all.txt b/requirements_all.txt index 901d16ff3a15bf..7f9fac77716a48 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 @@ -2387,7 +2387,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 @@ -3092,7 +3092,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_all.txt b/requirements_test_all.txt index 996fcca613045d..daf40ae32ded6d 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 @@ -1993,7 +1993,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 @@ -2566,7 +2566,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/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index add21b1067f771..d2dfd6bbf1267b 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -146,6 +146,27 @@ def patch_airthings_device_update(): 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", diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py index a65c51b3fd6fa0..71f2148b56be24 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.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/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index 90622bbe457caf..68a44b6d055c63 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -6,11 +6,12 @@ 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/ecovacs/snapshots/test_select.ambr b/tests/components/ecovacs/snapshots/test_select.ambr index f8e269593d9bbe..be03e609812fbd 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 5965398bd0cfb1..3f3af62f22b030 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 538ab66bed0ef5..f840e3dfc10b59 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 a9ee1f370a8fac..99ad9ac93e9211 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -5,7 +5,8 @@ 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 bcf27e25fee243..061708960d49e6 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/test_init.py b/tests/components/google_assistant_sdk/test_init.py index ede182d8890717..e45037a19bd053 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import call, patch import aiohttp +from freezegun.api import FrozenDateTimeFactory from grpc import RpcError import pytest @@ -16,7 +17,6 @@ from homeassistant.core import Context, HomeAssistant 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,29 +36,25 @@ 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, unload, and re-setup.""" # Initial setup 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 assert hass.services.has_service(DOMAIN, "send_text_command") # Unload the entry - entry_id = entries[0].entry_id - await hass.config_entries.async_unload(entry_id) + 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 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(entry_id) + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - 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 assert hass.services.has_service(DOMAIN, "send_text_command") @@ -67,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.""" @@ -82,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( @@ -111,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.""" @@ -122,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"]) @@ -131,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( @@ -140,9 +136,7 @@ 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( @@ -152,26 +146,28 @@ async def test_setup_client_error( @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( @@ -193,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" @@ -245,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, @@ -269,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( @@ -300,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() @@ -364,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 @@ -373,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 @@ -391,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" @@ -430,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" @@ -445,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", @@ -482,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/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 19807bff487461..1223dab940edc5 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 a45ea4c0648c49..762cb98ad29dd1 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -460,6 +460,63 @@ "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 @@ "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 e94e842b7c3098..a0faef9c6995af 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -50,6 +50,9 @@ 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/onvif/test_util.py b/tests/components/onvif/test_util.py new file mode 100644 index 00000000000000..1ff0793ad45c67 --- /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/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index f2aa6df802ecae..faee892e9932c3 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 d43d54927605cb..969eef568b9d60 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 13c24046d2f0f4..6091bfc96eda05 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -23,6 +23,7 @@ "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 00000000000000..2a75bf1164be57 --- /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 e41c7aa1c29cbd..3c8136432f36ef 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 67ca9ddec1a174..86d3359196fd9f 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 86616ca319be19..19c723b2254b88 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -321,7 +321,7 @@ async def test_clkg_wltqkykhni0papzj_action( @pytest.mark.parametrize( "mock_device_code", - ["cl_rD7uqAAgQOpSA2Rx"], + ["cl_n3xgr5pdmpinictg"], ) @pytest.mark.parametrize( ("initial_control", "expected_state"), @@ -332,7 +332,7 @@ async def test_clkg_wltqkykhni0papzj_action( ], ) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) -async def test_cl_rD7uqAAgQOpSA2Rx_state( +async def test_cl_n3xgr5pdmpinictg_state( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, @@ -344,7 +344,7 @@ async def test_cl_rD7uqAAgQOpSA2Rx_state( See https://github.com/home-assistant/core/issues/153537 """ - entity_id = "cover.kit_blinds_curtain" + entity_id = "cover.estore_sala_curtain" mock_device.status["control"] = initial_control await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 3f01ce765b9334..4e41d77e5e3e28 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 88b6bc64ebb3ef..daacddb32675dd 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 31e0e514dd333a..7929d838fbeba5 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 e5c59bef30f3f0..12801d989c0d36 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 8e10d2f1a259ce..2608e4bbf64c26 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -39,7 +39,9 @@ def __init__(self, fixtures: list[Fixture]) -> None: 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 00000000000000..07bed9faea5076 --- /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 22cba704dcfa2e..6c21511a201af6 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 be7418291a8ae3..ce286212093910 100644 --- a/tests/components/vicare/test_sensor.py +++ b/tests/components/vicare/test_sensor.py @@ -23,6 +23,7 @@ ("type:heatpump", "vicare/Vitocal250A.json"), ("type:ventilation", "vicare/ViAir300F.json"), ("type:ess", "vicare/VitoChargeVX3.json"), + (None, "vicare/VitoValor.json"), ], ) async def test_all_entities(