diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json index f1b82177d5beb1..4729cf1e60064b 100644 --- a/homeassistant/components/adguard/manifest.json +++ b/homeassistant/components/adguard/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["adguardhome"], - "requirements": ["adguardhome==0.7.0"] + "requirements": ["adguardhome==0.8.0"] } diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 5e92abb7a7e439..40af3505968c72 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -30,7 +30,10 @@ from .utils import ( async_remove_orphaned_entities, async_remove_shelly_entity, + get_block_channel, + get_block_custom_name, get_device_entry_gen, + get_rpc_component_name, get_rpc_entity_name, get_rpc_key_instances, is_block_momentary_input, @@ -74,7 +77,6 @@ class ShellyRpcEventDescription(EventEntityDescription): SCRIPT_EVENT: Final = ShellyRpcEventDescription( key="script", translation_key="script", - device_class=None, entity_registry_enabled_default=False, ) @@ -195,6 +197,17 @@ def __init__( self._attr_event_types = list(BASIC_INPUTS_EVENTS_TYPES) self.entity_description = description + if ( + hasattr(self, "_attr_name") + and self._attr_name + and not get_block_custom_name(coordinator.device, block) + ): + self._attr_translation_placeholders = { + "input_number": get_block_channel(block) + } + + delattr(self, "_attr_name") + async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() @@ -227,9 +240,20 @@ def __init__( self.event_id = int(key.split(":")[-1]) self._attr_device_info = get_entity_rpc_device_info(coordinator, key) self._attr_unique_id = f"{coordinator.mac}-{key}" - self._attr_name = get_rpc_entity_name(coordinator.device, key) self.entity_description = description + if description.key == "input": + component = key.split(":")[0] + component_id = key.split(":")[-1] + if not get_rpc_component_name(coordinator.device, key) and ( + component.lower() == "input" and component_id.isnumeric() + ): + self._attr_translation_placeholders = {"input_number": component_id} + else: + self._attr_name = get_rpc_entity_name(coordinator.device, key) + elif description.key == "script": + self._attr_name = get_rpc_entity_name(coordinator.device, key) + async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 59fa41bbb2ec57..86af0df88fd988 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -168,6 +168,7 @@ }, "event": { "input": { + "name": "Input {input_number}", "state_attributes": { "event_type": { "state": { diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 24814dcea147fc..4595c623fbc861 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -120,17 +120,35 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int: def get_block_entity_name( device: BlockDevice, block: Block | None, - description: str | UndefinedType | None = None, + name: str | UndefinedType | None = None, ) -> str | None: """Naming for block based switch and sensors.""" channel_name = get_block_channel_name(device, block) - if description is not UNDEFINED and description: - return f"{channel_name} {description.lower()}" if channel_name else description + if name is not UNDEFINED and name: + return f"{channel_name} {name.lower()}" if channel_name else name return channel_name +def get_block_custom_name(device: BlockDevice, block: Block | None) -> str | None: + """Get custom name from device settings.""" + if block and (key := cast(str, block.type) + "s") and key in device.settings: + assert block.channel + + if name := device.settings[key][int(block.channel)].get("name"): + return cast(str, name) + + return None + + +def get_block_channel(block: Block | None, base: str = "1") -> str: + """Get block channel.""" + assert block and block.channel + + return chr(int(block.channel) + ord(base)) + + def get_block_channel_name(device: BlockDevice, block: Block | None) -> str | None: """Get name based on device and channel name.""" if ( @@ -140,19 +158,10 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str | No ): return None - assert block.channel + if custom_name := get_block_custom_name(device, block): + return custom_name - channel_name: str | None = None - mode = cast(str, block.type) + "s" - if mode in device.settings: - channel_name = device.settings[mode][int(block.channel)].get("name") - - if channel_name: - return channel_name - - base = ord("1") - - return f"Channel {chr(int(block.channel) + base)}" + return f"Channel {get_block_channel(block)}" def get_block_sub_device_name(device: BlockDevice, block: Block) -> str: @@ -160,18 +169,13 @@ def get_block_sub_device_name(device: BlockDevice, block: Block) -> str: if TYPE_CHECKING: assert block.channel - mode = cast(str, block.type) + "s" - if mode in device.settings: - if channel_name := device.settings[mode][int(block.channel)].get("name"): - return cast(str, channel_name) + if custom_name := get_block_custom_name(device, block): + return custom_name if device.settings["device"]["type"] == MODEL_EM3: - base = ord("A") - return f"{device.name} Phase {chr(int(block.channel) + base)}" + return f"{device.name} Phase {get_block_channel(block, 'A')}" - base = ord("1") - - return f"{device.name} Channel {chr(int(block.channel) + base)}" + return f"{device.name} Channel {get_block_channel(block)}" def is_block_momentary_input( @@ -387,6 +391,18 @@ def get_shelly_model_name( return cast(str, MODEL_NAMES.get(model)) +def get_rpc_component_name(device: RpcDevice, key: str) -> str | None: + """Get component name from device config.""" + if ( + key in device.config + and key != "em:0" # workaround for Pro 3EM, we don't want to get name for em:0 + and (name := device.config[key].get("name")) + ): + return cast(str, name) + + return None + + def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None: """Get name based on device and channel name.""" if BLU_TRV_IDENTIFIER in key: @@ -398,13 +414,11 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None: component = key.split(":")[0] component_id = key.split(":")[-1] - if key in device.config and key != "em:0": - # workaround for Pro 3EM, we don't want to get name for em:0 - if component_name := device.config[key].get("name"): - if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"): - return cast(str, component_name) + if component_name := get_rpc_component_name(device, key): + if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"): + return component_name - return cast(str, component_name) if instances == 1 else None + return component_name if instances == 1 else None if component in (*VIRTUAL_COMPONENTS, "input"): return f"{component.title()} {component_id}" diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 1ecc21aeae1013..9e250982230186 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.13.1"] + "requirements": ["thermopro-ble==1.1.2"] } diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 274f28153307c3..7e9cd726f9d9e6 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.4.4"] + "requirements": ["tplink-omada-client==1.5.3"] } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index 37c73a9e11fa76..62bc3c2222d58d 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -7,7 +7,12 @@ from functools import partial from typing import Any, Generic, TypeVar -from tplink_omada_client import OmadaSiteClient, SwitchPortOverrides +from tplink_omada_client import ( + GatewayPortSettings, + OmadaSiteClient, + PortProfileOverrides, + SwitchPortSettings, +) from tplink_omada_client.definitions import GatewayPortMode, PoEMode, PortType from tplink_omada_client.devices import ( OmadaDevice, @@ -17,7 +22,6 @@ OmadaSwitch, OmadaSwitchPortDetails, ) -from tplink_omada_client.omadasiteclient import GatewayPortSettings from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -184,7 +188,12 @@ async def _wan_connect_disconnect( ), set_func=( lambda client, device, port, enable: client.update_switch_port( - device, port, overrides=SwitchPortOverrides(enable_poe=enable) + device, + port, + settings=SwitchPortSettings( + profile_override_enabled=True, + profile_overrides=PortProfileOverrides(enable_poe=enable), + ), ) ), update_func=lambda p: p.poe_mode != PoEMode.DISABLED, diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 9f3a31bcfbae9c..41d27e0317258d 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -26,7 +26,7 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity -from .models import IntegerTypeData, find_dpcode +from .models import DPCodeIntegerWrapper, IntegerTypeData, find_dpcode from .util import get_dpcode TUYA_HVAC_TO_HA = { @@ -41,6 +41,16 @@ } +class _RoundedIntegerWrapper(DPCodeIntegerWrapper): + """An integer that always rounds its value.""" + + def read_device_status(self, device: CustomerDevice) -> int | None: + """Read and round the device status.""" + if (value := super().read_device_status(device)) is None: + return None + return round(value) + + @dataclass(frozen=True, kw_only=True) class TuyaClimateEntityDescription(ClimateEntityDescription): """Describe an Tuya climate entity.""" @@ -97,6 +107,12 @@ def async_discover_device(device_ids: list[str]) -> None: manager, CLIMATE_DESCRIPTIONS[device.category], hass.config.units.temperature_unit, + current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode( + device, DPCode.HUMIDITY_CURRENT + ), + target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode( + device, DPCode.HUMIDITY_SET, prefer_function=True + ), ) ) async_add_entities(entities) @@ -111,10 +127,8 @@ def async_discover_device(device_ids: list[str]) -> None: class TuyaClimateEntity(TuyaEntity, ClimateEntity): """Tuya Climate Device.""" - _current_humidity: IntegerTypeData | None = None _current_temperature: IntegerTypeData | None = None _hvac_to_tuya: dict[str, str] - _set_humidity: IntegerTypeData | None = None _set_temperature: IntegerTypeData | None = None entity_description: TuyaClimateEntityDescription _attr_name = None @@ -125,12 +139,17 @@ def __init__( device_manager: Manager, description: TuyaClimateEntityDescription, system_temperature_unit: UnitOfTemperature, + *, + current_humidity_wrapper: _RoundedIntegerWrapper | None = None, + target_humidity_wrapper: _RoundedIntegerWrapper | None = None, ) -> None: """Determine which values to use.""" self._attr_target_temperature_step = 1.0 self.entity_description = description super().__init__(device, device_manager) + self._current_humidity_wrapper = current_humidity_wrapper + self._target_humidity_wrapper = target_humidity_wrapper # If both temperature values for celsius and fahrenheit are present, # use whatever the device is set to, with a fallback to celsius. @@ -227,21 +246,14 @@ def __init__( ] # Determine dpcode to use for setting the humidity - if int_type := find_dpcode( - self.device, - DPCode.HUMIDITY_SET, - dptype=DPType.INTEGER, - prefer_function=True, - ): + if target_humidity_wrapper: self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY - self._set_humidity = int_type - self._attr_min_humidity = int(int_type.min_scaled) - self._attr_max_humidity = int(int_type.max_scaled) - - # Determine dpcode to use for getting the current humidity - self._current_humidity = find_dpcode( - self.device, DPCode.HUMIDITY_CURRENT, dptype=DPType.INTEGER - ) + self._attr_min_humidity = round( + target_humidity_wrapper.type_information.min_scaled + ) + self._attr_max_humidity = round( + target_humidity_wrapper.type_information.max_scaled + ) # Determine fan modes self._fan_mode_dp_code: str | None = None @@ -303,20 +315,9 @@ def set_fan_mode(self, fan_mode: str) -> None: self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}]) - def set_humidity(self, humidity: int) -> None: + async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - if TYPE_CHECKING: - # guarded by ClimateEntityFeature.TARGET_HUMIDITY - assert self._set_humidity is not None - - self._send_command( - [ - { - "code": self._set_humidity.dpcode, - "value": self._set_humidity.scale_value_back(humidity), - } - ] - ) + await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity) def set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" @@ -382,14 +383,7 @@ def current_temperature(self) -> float | None: @property def current_humidity(self) -> int | None: """Return the current humidity.""" - if self._current_humidity is None: - return None - - humidity = self.device.status.get(self._current_humidity.dpcode) - if humidity is None: - return None - - return round(self._current_humidity.scale_value(humidity)) + return self._read_wrapper(self._current_humidity_wrapper) @property def target_temperature(self) -> float | None: @@ -406,14 +400,7 @@ def target_temperature(self) -> float | None: @property def target_humidity(self) -> int | None: """Return the humidity currently set to be reached.""" - if self._set_humidity is None: - return None - - humidity = self.device.status.get(self._set_humidity.dpcode) - if humidity is None: - return None - - return round(self._set_humidity.scale_value(humidity)) + return self._read_wrapper(self._target_humidity_wrapper) @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 4ae6c6dcc807c1..120237df2bb8d0 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import Any from tuya_sharing import CustomerDevice, Manager @@ -22,10 +22,57 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity -from .models import EnumTypeData, IntegerTypeData, find_dpcode +from .models import DPCodeIntegerWrapper, find_dpcode from .util import get_dpcode +class _DPCodePercentageMappingWrapper(DPCodeIntegerWrapper): + """Wrapper for DPCode position values mapping to 0-100 range.""" + + def _position_reversed(self, device: CustomerDevice) -> bool: + """Check if the position and direction should be reversed.""" + return False + + def read_device_status(self, device: CustomerDevice) -> float | None: + if (value := self._read_device_status_raw(device)) is None: + return None + + return round( + self.type_information.remap_value_to( + value, + 0, + 100, + self._position_reversed(device), + ) + ) + + def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: + return round( + self.type_information.remap_value_from( + value, + 0, + 100, + self._position_reversed(device), + ) + ) + + +class _InvertedPercentageMappingWrapper(_DPCodePercentageMappingWrapper): + """Wrapper for DPCode position values mapping to 0-100 range.""" + + def _position_reversed(self, device: CustomerDevice) -> bool: + """Check if the position and direction should be reversed.""" + return True + + +class _ControlBackModePercentageMappingWrapper(_DPCodePercentageMappingWrapper): + """Wrapper for DPCode position values with control_back_mode support.""" + + def _position_reversed(self, device: CustomerDevice) -> bool: + """Check if the position and direction should be reversed.""" + return device.status.get(DPCode.CONTROL_BACK_MODE) != "back" + + @dataclass(frozen=True) class TuyaCoverEntityDescription(CoverEntityDescription): """Describe an Tuya cover entity.""" @@ -33,11 +80,13 @@ class TuyaCoverEntityDescription(CoverEntityDescription): current_state: DPCode | tuple[DPCode, ...] | None = None current_state_inverse: bool = False current_position: DPCode | tuple[DPCode, ...] | None = None + position_wrapper: type[_DPCodePercentageMappingWrapper] = ( + _InvertedPercentageMappingWrapper + ) set_position: DPCode | None = None open_instruction_value: str = "open" close_instruction_value: str = "close" stop_instruction_value: str = "stop" - motor_reverse_mode: DPCode | None = None COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = { @@ -117,8 +166,8 @@ class TuyaCoverEntityDescription(CoverEntityDescription): key=DPCode.CONTROL, translation_key="curtain", current_position=DPCode.PERCENT_CONTROL, + position_wrapper=_ControlBackModePercentageMappingWrapper, set_position=DPCode.PERCENT_CONTROL, - motor_reverse_mode=DPCode.CONTROL_BACK_MODE, device_class=CoverDeviceClass.CURTAIN, ), TuyaCoverEntityDescription( @@ -126,8 +175,8 @@ class TuyaCoverEntityDescription(CoverEntityDescription): translation_key="indexed_curtain", translation_placeholders={"index": "2"}, current_position=DPCode.PERCENT_CONTROL_2, + position_wrapper=_ControlBackModePercentageMappingWrapper, set_position=DPCode.PERCENT_CONTROL_2, - motor_reverse_mode=DPCode.CONTROL_BACK_MODE, device_class=CoverDeviceClass.CURTAIN, ), ), @@ -159,7 +208,22 @@ def async_discover_device(device_ids: list[str]) -> None: device = manager.device_map[device_id] if descriptions := COVERS.get(device.category): entities.extend( - TuyaCoverEntity(device, manager, description) + TuyaCoverEntity( + device, + manager, + description, + current_position=description.position_wrapper.find_dpcode( + device, description.current_position + ), + set_position=description.position_wrapper.find_dpcode( + device, description.set_position, prefer_function=True + ), + tilt_position=description.position_wrapper.find_dpcode( + device, + (DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL), + prefer_function=True, + ), + ) for description in descriptions if ( description.key in device.function @@ -179,11 +243,7 @@ def async_discover_device(device_ids: list[str]) -> None: class TuyaCoverEntity(TuyaEntity, CoverEntity): """Tuya Cover Device.""" - _current_position: IntegerTypeData | None = None _current_state: DPCode | None = None - _set_position: IntegerTypeData | None = None - _tilt: IntegerTypeData | None = None - _motor_reverse_mode_enum: EnumTypeData | None = None entity_description: TuyaCoverEntityDescription def __init__( @@ -191,6 +251,10 @@ def __init__( device: CustomerDevice, device_manager: Manager, description: TuyaCoverEntityDescription, + *, + current_position: _DPCodePercentageMappingWrapper | None = None, + set_position: _DPCodePercentageMappingWrapper | None = None, + tilt_position: _DPCodePercentageMappingWrapper | None = None, ) -> None: """Init Tuya Cover.""" super().__init__(device, device_manager) @@ -198,6 +262,10 @@ def __init__( self._attr_unique_id = f"{super().unique_id}{description.key}" self._attr_supported_features = CoverEntityFeature(0) + self._current_position = current_position or set_position + self._set_position = set_position + self._tilt_position = tilt_position + # Check if this cover is based on a switch or has controls if get_dpcode(self.device, description.key): if device.function[description.key].type == "Boolean": @@ -216,72 +284,15 @@ def __init__( self._current_state = get_dpcode(self.device, description.current_state) - # Determine type to use for setting the position - if int_type := find_dpcode( - self.device, - description.set_position, - dptype=DPType.INTEGER, - prefer_function=True, - ): + if set_position: self._attr_supported_features |= CoverEntityFeature.SET_POSITION - self._set_position = int_type - # Set as default, unless overwritten below - self._current_position = int_type - - # Determine type for getting the position - if int_type := find_dpcode( - self.device, - description.current_position, - dptype=DPType.INTEGER, - prefer_function=True, - ): - self._current_position = int_type - - # Determine type to use for setting the tilt - if int_type := find_dpcode( - self.device, - (DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL), - dptype=DPType.INTEGER, - prefer_function=True, - ): + if tilt_position: self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION - self._tilt = int_type - - # Determine type to use for checking motor reverse mode - if (motor_mode := description.motor_reverse_mode) and ( - enum_type := find_dpcode( - self.device, - motor_mode, - dptype=DPType.ENUM, - prefer_function=True, - ) - ): - self._motor_reverse_mode_enum = enum_type - - @property - def _is_position_reversed(self) -> bool: - """Check if the cover position and direction should be reversed.""" - # The default is True - # Having motor_reverse_mode == "back" cancels the inversion - return not ( - self._motor_reverse_mode_enum - and self.device.status.get(self._motor_reverse_mode_enum.dpcode) == "back" - ) @property def current_cover_position(self) -> int | None: """Return cover current position.""" - if self._current_position is None: - return None - - if (position := self.device.status.get(self._current_position.dpcode)) is None: - return None - - return round( - self._current_position.remap_value_to( - position, 0, 100, reverse=self._is_position_reversed - ) - ) + return self._read_wrapper(self._current_position) @property def current_cover_tilt_position(self) -> int | None: @@ -289,13 +300,7 @@ def current_cover_tilt_position(self) -> int | None: None is unknown, 0 is closed, 100 is fully open. """ - if self._tilt is None: - return None - - if (angle := self.device.status.get(self._tilt.dpcode)) is None: - return None - - return round(self._tilt.remap_value_to(angle, 0, 100)) + return self._read_wrapper(self._tilt_position) @property def is_closed(self) -> bool | None: @@ -332,16 +337,7 @@ def open_cover(self, **kwargs: Any) -> None: ] if self._set_position is not None: - commands.append( - { - "code": self._set_position.dpcode, - "value": round( - self._set_position.remap_value_from( - 100, 0, 100, reverse=self._is_position_reversed - ), - ), - } - ) + commands.append(self._set_position.get_update_command(self.device, 100)) self._send_command(commands) @@ -361,40 +357,13 @@ def close_cover(self, **kwargs: Any) -> None: ] if self._set_position is not None: - commands.append( - { - "code": self._set_position.dpcode, - "value": round( - self._set_position.remap_value_from( - 0, 0, 100, reverse=self._is_position_reversed - ), - ), - } - ) + commands.append(self._set_position.get_update_command(self.device, 0)) self._send_command(commands) - def set_cover_position(self, **kwargs: Any) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - if TYPE_CHECKING: - # guarded by CoverEntityFeature.SET_POSITION - assert self._set_position is not None - - self._send_command( - [ - { - "code": self._set_position.dpcode, - "value": round( - self._set_position.remap_value_from( - kwargs[ATTR_POSITION], - 0, - 100, - reverse=self._is_position_reversed, - ) - ), - } - ] - ) + await self._async_send_dpcode_update(self._set_position, kwargs[ATTR_POSITION]) def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" @@ -407,24 +376,8 @@ def stop_cover(self, **kwargs: Any) -> None: ] ) - def set_cover_tilt_position(self, **kwargs: Any) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - if TYPE_CHECKING: - # guarded by CoverEntityFeature.SET_TILT_POSITION - assert self._tilt is not None - - self._send_command( - [ - { - "code": self._tilt.dpcode, - "value": round( - self._tilt.remap_value_from( - kwargs[ATTR_TILT_POSITION], - 0, - 100, - reverse=self._is_position_reversed, - ) - ), - } - ] + await self._async_send_dpcode_update( + self._tilt_position, kwargs[ATTR_TILT_POSITION] ) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 0996560d0f1452..3e9b1343786769 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -18,12 +18,22 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import IntegerTypeData, find_dpcode +from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper from .util import ActionDPCodeNotFoundError, get_dpcode +class _RoundedIntegerWrapper(DPCodeIntegerWrapper): + """An integer that always rounds its value.""" + + def read_device_status(self, device: CustomerDevice) -> int | None: + """Read and round the device status.""" + if (value := super().read_device_status(device)) is None: + return None + return round(value) + + @dataclass(frozen=True) class TuyaHumidifierEntityDescription(HumidifierEntityDescription): """Describe an Tuya (de)humidifier entity.""" @@ -84,7 +94,27 @@ def async_discover_device(device_ids: list[str]) -> None: if ( description := HUMIDIFIERS.get(device.category) ) and _has_a_valid_dpcode(device, description): - entities.append(TuyaHumidifierEntity(device, manager, description)) + entities.append( + TuyaHumidifierEntity( + device, + manager, + description, + current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode( + device, description.current_humidity + ), + mode_wrapper=DPCodeEnumWrapper.find_dpcode( + device, DPCode.MODE, prefer_function=True + ), + switch_wrapper=DPCodeBooleanWrapper.find_dpcode( + device, + description.dpcode or DPCode(description.key), + prefer_function=True, + ), + target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode( + device, description.humidity, prefer_function=True + ), + ) + ) async_add_entities(entities) async_discover_device([*manager.device_map]) @@ -97,9 +127,6 @@ def async_discover_device(device_ids: list[str]) -> None: class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity): """Tuya (de)humidifier Device.""" - _current_humidity: IntegerTypeData | None = None - _set_humidity: IntegerTypeData | None = None - _switch_dpcode: DPCode | None = None entity_description: TuyaHumidifierEntityDescription _attr_name = None @@ -108,115 +135,83 @@ def __init__( device: CustomerDevice, device_manager: Manager, description: TuyaHumidifierEntityDescription, + *, + current_humidity_wrapper: _RoundedIntegerWrapper | None = None, + mode_wrapper: DPCodeEnumWrapper | None = None, + switch_wrapper: DPCodeBooleanWrapper | None = None, + target_humidity_wrapper: _RoundedIntegerWrapper | None = None, ) -> None: """Init Tuya (de)humidifier.""" super().__init__(device, device_manager) self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" - # Determine main switch DPCode - self._switch_dpcode = get_dpcode( - self.device, description.dpcode or DPCode(description.key) - ) + self._current_humidity_wrapper = current_humidity_wrapper + self._mode_wrapper = mode_wrapper + self._switch_wrapper = switch_wrapper + self._target_humidity_wrapper = target_humidity_wrapper # Determine humidity parameters - if int_type := find_dpcode( - self.device, - description.humidity, - dptype=DPType.INTEGER, - prefer_function=True, - ): - self._set_humidity = int_type - self._attr_min_humidity = int(int_type.min_scaled) - self._attr_max_humidity = int(int_type.max_scaled) - - # Determine current humidity DPCode - if int_type := find_dpcode( - self.device, - description.current_humidity, - dptype=DPType.INTEGER, - ): - self._current_humidity = int_type + if target_humidity_wrapper: + self._attr_min_humidity = round( + target_humidity_wrapper.type_information.min_scaled + ) + self._attr_max_humidity = round( + target_humidity_wrapper.type_information.max_scaled + ) # Determine mode support and provided modes - if enum_type := find_dpcode( - self.device, DPCode.MODE, dptype=DPType.ENUM, prefer_function=True - ): + if mode_wrapper: self._attr_supported_features |= HumidifierEntityFeature.MODES - self._attr_available_modes = enum_type.range + self._attr_available_modes = mode_wrapper.type_information.range @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the device is on or off.""" - if self._switch_dpcode is None: - return False - return self.device.status.get(self._switch_dpcode, False) + return self._read_wrapper(self._switch_wrapper) @property def mode(self) -> str | None: """Return the current mode.""" - return self.device.status.get(DPCode.MODE) + return self._read_wrapper(self._mode_wrapper) @property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" - if self._set_humidity is None: - return None - - humidity = self.device.status.get(self._set_humidity.dpcode) - if humidity is None: - return None - - return round(self._set_humidity.scale_value(humidity)) + return self._read_wrapper(self._target_humidity_wrapper) @property def current_humidity(self) -> int | None: """Return the current humidity.""" - if self._current_humidity is None: - return None + return self._read_wrapper(self._current_humidity_wrapper) - if ( - current_humidity := self.device.status.get(self._current_humidity.dpcode) - ) is None: - return None - - return round(self._current_humidity.scale_value(current_humidity)) - - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - if self._switch_dpcode is None: + if self._switch_wrapper is None: raise ActionDPCodeNotFoundError( self.device, self.entity_description.dpcode or self.entity_description.key, ) - self._send_command([{"code": self._switch_dpcode, "value": True}]) + await self._async_send_dpcode_update(self._switch_wrapper, True) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if self._switch_dpcode is None: + if self._switch_wrapper is None: raise ActionDPCodeNotFoundError( self.device, self.entity_description.dpcode or self.entity_description.key, ) - self._send_command([{"code": self._switch_dpcode, "value": False}]) + await self._async_send_dpcode_update(self._switch_wrapper, False) - def set_humidity(self, humidity: int) -> None: + async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - if self._set_humidity is None: + if self._target_humidity_wrapper is None: raise ActionDPCodeNotFoundError( self.device, self.entity_description.humidity, ) + await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity) - self._send_command( - [ - { - "code": self._set_humidity.dpcode, - "value": self._set_humidity.scale_value_back(humidity), - } - ] - ) - - def set_mode(self, mode: str) -> None: + async def async_set_mode(self, mode: str) -> None: """Set new target preset mode.""" - self._send_command([{"code": DPCode.MODE, "value": mode}]) + await self._async_send_dpcode_update(self._mode_wrapper, mode) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 53d52c790cb11f..3bdcc68523c1f2 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -29,7 +29,12 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode from .entity import TuyaEntity -from .models import DPCodeBooleanWrapper, IntegerTypeData, find_dpcode +from .models import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + IntegerTypeData, + find_dpcode, +) from .util import get_dpcode, get_dptype, remap_value @@ -429,7 +434,13 @@ def async_discover_device(device_ids: list[str]): if descriptions := LIGHTS.get(device.category): entities.extend( TuyaLightEntity( - device, manager, description, switch_wrapper=switch_wrapper + device, + manager, + description, + color_mode_wrapper=DPCodeEnumWrapper.find_dpcode( + device, description.color_mode, prefer_function=True + ), + switch_wrapper=switch_wrapper, ) for description in descriptions if ( @@ -458,7 +469,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity): _brightness: IntegerTypeData | None = None _color_data_dpcode: DPCode | None = None _color_data_type: ColorTypeData | None = None - _color_mode: DPCode | None = None _color_temp: IntegerTypeData | None = None _white_color_mode = ColorMode.COLOR_TEMP _fixed_color_mode: ColorMode | None = None @@ -471,19 +481,18 @@ def __init__( device_manager: Manager, description: TuyaLightEntityDescription, *, + color_mode_wrapper: DPCodeEnumWrapper | None, switch_wrapper: DPCodeBooleanWrapper, ) -> None: """Init TuyaHaLight.""" super().__init__(device, device_manager) self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" + self._color_mode_wrapper = color_mode_wrapper self._switch_wrapper = switch_wrapper color_modes: set[ColorMode] = {ColorMode.ONOFF} - # Determine DPCodes - self._color_mode_dpcode = get_dpcode(self.device, description.color_mode) - if int_type := find_dpcode( self.device, description.brightness, @@ -537,15 +546,8 @@ def __init__( # work_mode "white" elif ( color_supported(color_modes) - and ( - color_mode_enum := find_dpcode( - self.device, - description.color_mode, - dptype=DPType.ENUM, - prefer_function=True, - ) - ) - and WorkMode.WHITE.value in color_mode_enum.range + and color_mode_wrapper is not None + and WorkMode.WHITE in color_mode_wrapper.type_information.range ): color_modes.add(ColorMode.WHITE) self._white_color_mode = ColorMode.WHITE @@ -566,14 +568,13 @@ def turn_on(self, **kwargs: Any) -> None: self._switch_wrapper.get_update_command(self.device, True), ] - if self._color_mode_dpcode and ( + if self._color_mode_wrapper and ( ATTR_WHITE in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs ): commands += [ - { - "code": self._color_mode_dpcode, - "value": WorkMode.WHITE, - }, + self._color_mode_wrapper.get_update_command( + self.device, WorkMode.WHITE + ), ] if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs: @@ -602,12 +603,11 @@ def turn_on(self, **kwargs: Any) -> None: and ATTR_COLOR_TEMP_KELVIN not in kwargs ) ): - if self._color_mode_dpcode: + if self._color_mode_wrapper: commands += [ - { - "code": self._color_mode_dpcode, - "value": WorkMode.COLOUR, - }, + self._color_mode_wrapper.get_update_command( + self.device, WorkMode.COLOUR + ), ] if not (brightness := kwargs.get(ATTR_BRIGHTNESS)): @@ -765,8 +765,8 @@ def color_mode(self) -> ColorMode: # and HS, determine which mode the light is in. We consider it to be in HS color # mode, when work mode is anything else than "white". if ( - self._color_mode_dpcode - and self.device.status.get(self._color_mode_dpcode) != WorkMode.WHITE + self._color_mode_wrapper + and self._read_wrapper(self._color_mode_wrapper) != WorkMode.WHITE ): return ColorMode.HS return self._white_color_mode diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py index 94cf0d1ab8b89b..7953bcf7c8151d 100644 --- a/homeassistant/components/tuya/models.py +++ b/homeassistant/components/tuya/models.py @@ -36,8 +36,8 @@ class IntegerTypeData(TypeInformation): min: int max: int - scale: float - step: float + scale: int + step: int unit: str | None = None @property @@ -55,7 +55,7 @@ def step_scaled(self) -> float: """Return the step scaled.""" return self.step / (10**self.scale) - def scale_value(self, value: float) -> float: + def scale_value(self, value: int) -> float: """Scale a value.""" return value / (10**self.scale) @@ -93,8 +93,8 @@ def from_json(cls, dpcode: DPCode, data: str) -> Self | None: dpcode, min=int(parsed["min"]), max=int(parsed["max"]), - scale=float(parsed["scale"]), - step=max(float(parsed["step"]), 1), + scale=int(parsed["scale"]), + step=int(parsed["step"]), unit=parsed.get("unit"), ) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index f519ec51fb0204..6b0e454abd047d 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -16,9 +16,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -from .models import EnumTypeData, find_dpcode +from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper from .util import get_dpcode TUYA_MODE_RETURN_HOME = "chargego" @@ -64,7 +64,27 @@ def async_discover_device(device_ids: list[str]) -> None: for device_id in device_ids: device = manager.device_map[device_id] if device.category == DeviceCategory.SD: - entities.append(TuyaVacuumEntity(device, manager)) + entities.append( + TuyaVacuumEntity( + device, + manager, + charge_wrapper=DPCodeBooleanWrapper.find_dpcode( + device, DPCode.SWITCH_CHARGE, prefer_function=True + ), + fan_speed_wrapper=DPCodeEnumWrapper.find_dpcode( + device, DPCode.SUCTION, prefer_function=True + ), + locate_wrapper=DPCodeBooleanWrapper.find_dpcode( + device, DPCode.SEEK, prefer_function=True + ), + mode_wrapper=DPCodeEnumWrapper.find_dpcode( + device, DPCode.MODE, prefer_function=True + ), + switch_wrapper=DPCodeBooleanWrapper.find_dpcode( + device, DPCode.POWER_GO, prefer_function=True + ), + ) + ) async_add_entities(entities) async_discover_device([*manager.device_map]) @@ -77,51 +97,56 @@ def async_discover_device(device_ids: list[str]) -> None: class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): """Tuya Vacuum Device.""" - _fan_speed: EnumTypeData | None = None _attr_name = None - def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: + def __init__( + self, + device: CustomerDevice, + device_manager: Manager, + *, + charge_wrapper: DPCodeBooleanWrapper | None, + fan_speed_wrapper: DPCodeEnumWrapper | None, + locate_wrapper: DPCodeBooleanWrapper | None, + mode_wrapper: DPCodeEnumWrapper | None, + switch_wrapper: DPCodeBooleanWrapper | None, + ) -> None: """Init Tuya vacuum.""" super().__init__(device, device_manager) + self._charge_wrapper = charge_wrapper + self._fan_speed_wrapper = fan_speed_wrapper + self._locate_wrapper = locate_wrapper + self._mode_wrapper = mode_wrapper + self._switch_wrapper = switch_wrapper self._attr_fan_speed_list = [] - self._attr_supported_features = ( VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE ) if get_dpcode(self.device, DPCode.PAUSE): self._attr_supported_features |= VacuumEntityFeature.PAUSE - self._return_home_use_switch_charge = False - if get_dpcode(self.device, DPCode.SWITCH_CHARGE): - self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - self._return_home_use_switch_charge = True - elif ( - enum_type := find_dpcode( - self.device, DPCode.MODE, dptype=DPType.ENUM, prefer_function=True - ) - ) and TUYA_MODE_RETURN_HOME in enum_type.range: + if charge_wrapper or ( + mode_wrapper + and TUYA_MODE_RETURN_HOME in mode_wrapper.type_information.range + ): self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - if get_dpcode(self.device, DPCode.SEEK): + if locate_wrapper: self._attr_supported_features |= VacuumEntityFeature.LOCATE - if get_dpcode(self.device, DPCode.POWER_GO): + if switch_wrapper: self._attr_supported_features |= ( VacuumEntityFeature.STOP | VacuumEntityFeature.START ) - if enum_type := find_dpcode( - self.device, DPCode.SUCTION, dptype=DPType.ENUM, prefer_function=True - ): - self._fan_speed = enum_type - self._attr_fan_speed_list = enum_type.range + if fan_speed_wrapper: + self._attr_fan_speed_list = fan_speed_wrapper.type_information.range self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" - return self.device.status.get(DPCode.SUCTION) + return self._read_wrapper(self._fan_speed_wrapper) @property def activity(self) -> VacuumActivity | None: @@ -134,32 +159,34 @@ def activity(self) -> VacuumActivity | None: return None return TUYA_STATUS_TO_HA.get(status) - def start(self, **kwargs: Any) -> None: + async def async_start(self, **kwargs: Any) -> None: """Start the device.""" - self._send_command([{"code": DPCode.POWER_GO, "value": True}]) + await self._async_send_dpcode_update(self._switch_wrapper, True) - def stop(self, **kwargs: Any) -> None: + async def async_stop(self, **kwargs: Any) -> None: """Stop the device.""" - self._send_command([{"code": DPCode.POWER_GO, "value": False}]) + await self._async_send_dpcode_update(self._switch_wrapper, False) def pause(self, **kwargs: Any) -> None: """Pause the device.""" self._send_command([{"code": DPCode.POWER_GO, "value": False}]) - def return_to_base(self, **kwargs: Any) -> None: + async def async_return_to_base(self, **kwargs: Any) -> None: """Return device to dock.""" - if self._return_home_use_switch_charge: - self._send_command([{"code": DPCode.SWITCH_CHARGE, "value": True}]) + if self._charge_wrapper: + await self._async_send_dpcode_update(self._charge_wrapper, True) else: - self._send_command([{"code": DPCode.MODE, "value": TUYA_MODE_RETURN_HOME}]) + await self._async_send_dpcode_update( + self._mode_wrapper, TUYA_MODE_RETURN_HOME + ) - def locate(self, **kwargs: Any) -> None: + async def async_locate(self, **kwargs: Any) -> None: """Locate the device.""" - self._send_command([{"code": DPCode.SEEK, "value": True}]) + await self._async_send_dpcode_update(self._locate_wrapper, True) - def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - self._send_command([{"code": DPCode.SUCTION, "value": fan_speed}]) + await self._async_send_dpcode_update(self._fan_speed_wrapper, fan_speed) def send_command( self, diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 91a7b9d945039f..383e4554905276 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -12,4 +12,3 @@ UOM_BEATS_PER_MINUTE = "bpm" UOM_BREATHS_PER_MINUTE = "br/min" UOM_FREQUENCY = "times" -UOM_MMHG = "mmhg" diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index f20145f8bf966b..95fd43b00fc8b4 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -30,6 +30,7 @@ Platform, UnitOfLength, UnitOfMass, + UnitOfPressure, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -48,7 +49,6 @@ UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, UOM_FREQUENCY, - UOM_MMHG, ) from .coordinator import ( WithingsActivityDataUpdateCoordinator, @@ -162,14 +162,16 @@ class WithingsMeasurementSensorEntityDescription(SensorEntityDescription): key="diastolic_blood_pressure_mmhg", measurement_type=MeasurementType.DIASTOLIC_BLOOD_PRESSURE, translation_key="diastolic_blood_pressure", - native_unit_of_measurement=UOM_MMHG, + native_unit_of_measurement=UnitOfPressure.MMHG, + device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), MeasurementType.SYSTOLIC_BLOOD_PRESSURE: WithingsMeasurementSensorEntityDescription( key="systolic_blood_pressure_mmhg", measurement_type=MeasurementType.SYSTOLIC_BLOOD_PRESSURE, translation_key="systolic_blood_pressure", - native_unit_of_measurement=UOM_MMHG, + native_unit_of_measurement=UnitOfPressure.MMHG, + device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), MeasurementType.HEART_RATE: WithingsMeasurementSensorEntityDescription( diff --git a/requirements_all.txt b/requirements_all.txt index 8940cc62a3a63f..c3383df0890727 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -145,7 +145,7 @@ adb-shell[async]==0.4.4 adext==0.4.4 # homeassistant.components.adguard -adguardhome==0.7.0 +adguardhome==0.8.0 # homeassistant.components.advantage_air advantage-air==0.4.4 @@ -2966,7 +2966,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.13.1 +thermopro-ble==1.1.2 # homeassistant.components.thingspeak thingspeak==1.0.0 @@ -3005,7 +3005,7 @@ total-connect-client==2025.5 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.4 +tplink-omada-client==1.5.3 # homeassistant.components.transmission transmission-rpc==7.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4426d0ddce7234..042a052a238316 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -133,7 +133,7 @@ adb-shell[async]==0.4.4 adext==0.4.4 # homeassistant.components.adguard -adguardhome==0.7.0 +adguardhome==0.8.0 # homeassistant.components.advantage_air advantage-air==0.4.4 @@ -2451,7 +2451,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.13.1 +thermopro-ble==1.1.2 # homeassistant.components.lg_thinq thinqconnect==1.0.8 @@ -2478,7 +2478,7 @@ toonapi==0.3.0 total-connect-client==2025.5 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.4 +tplink-omada-client==1.5.3 # homeassistant.components.transmission transmission-rpc==7.0.3 diff --git a/tests/components/miele/conftest.py b/tests/components/miele/conftest.py index c8a47eb2b59efb..13c467fe54a3b0 100644 --- a/tests/components/miele/conftest.py +++ b/tests/components/miele/conftest.py @@ -2,9 +2,9 @@ from collections.abc import AsyncGenerator, Generator import time +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from pymiele import MieleAction, MieleDevices import pytest from homeassistant.components.application_credentials import ( @@ -14,6 +14,7 @@ from homeassistant.components.miele.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util.json import JsonValueType from . import get_actions_callback, get_data_callback from .const import CLIENT_ID, CLIENT_SECRET @@ -79,7 +80,7 @@ def load_device_file() -> str: @pytest.fixture -async def device_fixture(hass: HomeAssistant, load_device_file: str) -> MieleDevices: +async def device_fixture(hass: HomeAssistant, load_device_file: str) -> dict[str, Any]: """Fixture for device.""" return await async_load_json_object_fixture(hass, load_device_file, DOMAIN) @@ -91,7 +92,7 @@ def load_action_file() -> str: @pytest.fixture -async def action_fixture(hass: HomeAssistant, load_action_file: str) -> MieleAction: +async def action_fixture(hass: HomeAssistant, load_action_file: str) -> dict[str, Any]: """Fixture for action.""" return await async_load_json_object_fixture(hass, load_action_file, DOMAIN) @@ -103,7 +104,9 @@ def load_programs_file() -> str: @pytest.fixture -async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]: +async def programs_fixture( + hass: HomeAssistant, load_programs_file: str +) -> JsonValueType: """Fixture for available programs.""" return load_json_value_fixture(load_programs_file, DOMAIN) @@ -141,7 +144,7 @@ async def setup_platform( hass: HomeAssistant, mock_config_entry: MockConfigEntry, platforms, -) -> AsyncGenerator[None]: +) -> AsyncGenerator[MockConfigEntry]: """Set up one or all platforms.""" with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): @@ -169,7 +172,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: async def push_data_and_actions( hass: HomeAssistant, mock_miele_client: MagicMock, - device_fixture: MieleDevices, + device_fixture: dict[str, Any], ) -> None: """Fixture to push data and actions through mock.""" diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 57fa8bec950318..e68ebf84bc783e 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -36,10 +36,28 @@ "mac": MOCK_MAC, "hostname": "test-host", "type": MODEL_25, + "num_inputs": 3, "num_outputs": 2, }, "coiot": {"update_period": 15}, "fw": "20201124-092159/v1.9.0@57ac4ad8", + "inputs": [ + { + "name": "TV LEDs", + "btn_type": "momentary", + "btn_reverse": 0, + }, + { + "name": "TV Spots", + "btn_type": "momentary", + "btn_reverse": 0, + }, + { + "name": None, + "btn_type": "momentary", + "btn_reverse": 0, + }, + ], "relays": [{"btn_type": "momentary"}, {"btn_type": "toggle"}], "rollers": [{"positioning": True}], "external_power": 0, @@ -348,6 +366,7 @@ def mock_white_light_set_state( "mac": MOCK_MAC, "auth": False, "fw": "20210715-092854/v1.11.0@57ac4ad8", + "num_inputs": 3, "num_outputs": 2, } diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py index c530f30beb9fab..ba92b8508eb4f8 100644 --- a/tests/components/shelly/test_event.py +++ b/tests/components/shelly/test_event.py @@ -1,5 +1,6 @@ """Tests for Shelly button platform.""" +import copy from unittest.mock import Mock from aioshelly.ble.const import BLE_SCRIPT_NAME @@ -24,9 +25,14 @@ patch_platforms, register_entity, ) +from .conftest import MOCK_BLOCKS DEVICE_BLOCK_ID = 4 +UNORDERED_EVENT_TYPES = unordered( + ["double", "long", "long_single", "single", "single_long", "triple"] +) + @pytest.fixture(autouse=True) def fixture_platforms(): @@ -213,15 +219,57 @@ async def test_block_event( assert state.attributes.get(ATTR_EVENT_TYPE) == "long" +async def test_block_event_single_output( + hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test block device event when num_outputs is 1.""" + monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1) + await init_integration(hass, 1) + + assert hass.states.get("event.test_name") + + async def test_block_event_shix3_1( hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test block device event for SHIX3-1.""" - monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1) + blocks = copy.deepcopy(MOCK_BLOCKS) + blocks[0] = Mock( + sensor_ids={ + "inputEvent": "S", + "inputEventCnt": 2, + }, + channel="0", + type="input", + description="input_0", + ) + blocks[1] = Mock( + sensor_ids={ + "inputEvent": "S", + "inputEventCnt": 2, + }, + channel="1", + type="input", + description="input_1", + ) + blocks[2] = Mock( + sensor_ids={ + "inputEvent": "S", + "inputEventCnt": 2, + }, + channel="2", + type="input", + description="input_2", + ) + monkeypatch.setattr(mock_block_device, "blocks", blocks) + monkeypatch.delitem(mock_block_device.settings, "relays") await init_integration(hass, 1, model=MODEL_I3) - entity_id = "event.test_name" - assert (state := hass.states.get(entity_id)) - assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( - ["double", "long", "long_single", "single", "single_long", "triple"] - ) + assert (state := hass.states.get("event.test_name_tv_leds")) + assert state.attributes.get(ATTR_EVENT_TYPES) == UNORDERED_EVENT_TYPES + + assert (state := hass.states.get("event.test_name_tv_spots")) + assert state.attributes.get(ATTR_EVENT_TYPES) == UNORDERED_EVENT_TYPES + + assert (state := hass.states.get("event.test_name_input_3")) + assert state.attributes.get(ATTR_EVENT_TYPES) == UNORDERED_EVENT_TYPES diff --git a/tests/components/tplink_omada/test_switch.py b/tests/components/tplink_omada/test_switch.py index abce87714a9c13..b9d041ddb9a978 100644 --- a/tests/components/tplink_omada/test_switch.py +++ b/tests/components/tplink_omada/test_switch.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from syrupy.assertion import SnapshotAssertion -from tplink_omada_client import SwitchPortOverrides +from tplink_omada_client import SwitchPortSettings from tplink_omada_client.definitions import PoEMode from tplink_omada_client.devices import ( OmadaGateway, @@ -249,14 +249,16 @@ def assert_update_switch_port( device: OmadaSwitch, switch_port_details: OmadaSwitchPortDetails, poe_enabled: bool, - overrides: SwitchPortOverrides = None, + settings: SwitchPortSettings, ) -> None: assert device assert device.mac == network_switch_mac assert switch_port_details assert switch_port_details.port == port_num - assert overrides - assert overrides.enable_poe == poe_enabled + assert settings + assert settings.profile_override_enabled + assert settings.profile_overrides + assert settings.profile_overrides.enable_poe == poe_enabled entity = hass.states.get(entity_id) assert entity == snapshot diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index ce81a504699e19..847a0b29941338 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch import pytest @@ -46,60 +47,47 @@ async def test_platform_setup_and_discovery( "mock_device_code", ["kt_5wnlzekkstwcdsvm"], ) -async def test_set_temperature( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test set temperature service.""" - entity_id = "climate.air_conditioner" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - state = hass.states.get(entity_id) - assert state is not None, f"{entity_id} does not exist" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TEMPERATURE: 22.7, - }, - blocking=True, - ) - mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "temp_set", "value": 23}] - ) - - @pytest.mark.parametrize( - "mock_device_code", - ["kt_5wnlzekkstwcdsvm"], + ("service", "service_data", "expected_command"), + [ + ( + SERVICE_SET_TEMPERATURE, + {ATTR_TEMPERATURE: 22.7}, + {"code": "temp_set", "value": 23}, + ), + ( + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: 2}, + {"code": "windspeed", "value": "2"}, + ), + ], ) -async def test_fan_mode_windspeed( +async def test_action( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + service: str, + service_data: dict[str, Any], + expected_command: dict[str, Any], ) -> None: - """Test fan mode with windspeed.""" + """Test service action.""" entity_id = "climate.air_conditioner" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) state = hass.states.get(entity_id) assert state is not None, f"{entity_id} does not exist" - assert state.attributes[ATTR_FAN_MODE] == 1 await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, + service, { ATTR_ENTITY_ID: entity_id, - ATTR_FAN_MODE: 2, + **service_data, }, blocking=True, ) mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "windspeed", "value": "2"}] + mock_device.id, [expected_command] ) @@ -107,13 +95,28 @@ async def test_fan_mode_windspeed( "mock_device_code", ["kt_5wnlzekkstwcdsvm"], ) -async def test_fan_mode_no_valid_code( +@pytest.mark.parametrize( + ("service", "service_data"), + [ + ( + SERVICE_SET_FAN_MODE, + {ATTR_FAN_MODE: 2}, + ), + ( + SERVICE_SET_HUMIDITY, + {ATTR_HUMIDITY: 50}, + ), + ], +) +async def test_action_not_supported( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + service: str, + service_data: dict[str, Any], ) -> None: - """Test fan mode with no valid code.""" + """Test service action not supported.""" # Remove windspeed DPCode to simulate a device with no valid fan mode mock_device.function.pop("windspeed", None) mock_device.status_range.pop("windspeed", None) @@ -128,38 +131,10 @@ async def test_fan_mode_no_valid_code( with pytest.raises(ServiceNotSupported): await hass.services.async_call( CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_FAN_MODE: 2, - }, - blocking=True, - ) - - -@pytest.mark.parametrize( - "mock_device_code", - ["kt_5wnlzekkstwcdsvm"], -) -async def test_set_humidity_not_supported( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test set humidity service (not available on this device).""" - entity_id = "climate.air_conditioner" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - state = hass.states.get(entity_id) - assert state is not None, f"{entity_id} does not exist" - with pytest.raises(ServiceNotSupported): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HUMIDITY, + service, { ATTR_ENTITY_ID: entity_id, - ATTR_HUMIDITY: 50, + **service_data, }, blocking=True, ) diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index 2cdf5534b08f6a..8baa9b5d185c50 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch import pytest @@ -44,67 +45,28 @@ async def test_platform_setup_and_discovery( "mock_device_code", ["cs_zibqa9dutqyaxym2"], ) -async def test_turn_on( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test turn on service.""" - entity_id = "humidifier.dehumidifier" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - state = hass.states.get(entity_id) - assert state is not None, f"{entity_id} does not exist" - await hass.services.async_call( - HUMIDIFIER_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "switch", "value": True}] - ) - - @pytest.mark.parametrize( - "mock_device_code", - ["cs_zibqa9dutqyaxym2"], -) -async def test_turn_off( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test turn off service.""" - entity_id = "humidifier.dehumidifier" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - state = hass.states.get(entity_id) - assert state is not None, f"{entity_id} does not exist" - await hass.services.async_call( - HUMIDIFIER_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "switch", "value": False}] - ) - - -@pytest.mark.parametrize( - "mock_device_code", - ["cs_zibqa9dutqyaxym2"], + ("service", "service_data", "expected_command"), + [ + (SERVICE_TURN_ON, {}, {"code": "switch", "value": True}), + (SERVICE_TURN_OFF, {}, {"code": "switch", "value": False}), + ( + SERVICE_SET_HUMIDITY, + {ATTR_HUMIDITY: 50}, + {"code": "dehumidify_set_value", "value": 50}, + ), + ], ) -async def test_set_humidity( +async def test_action( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + service: str, + service_data: dict[str, Any], + expected_command: dict[str, Any], ) -> None: - """Test set humidity service.""" + """Test service action.""" entity_id = "humidifier.dehumidifier" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) @@ -112,15 +74,15 @@ async def test_set_humidity( assert state is not None, f"{entity_id} does not exist" await hass.services.async_call( HUMIDIFIER_DOMAIN, - SERVICE_SET_HUMIDITY, + service, { ATTR_ENTITY_ID: entity_id, - ATTR_HUMIDITY: 50, + **service_data, }, blocking=True, ) mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "dehumidify_set_value", "value": 50}] + mock_device.id, [expected_command] ) @@ -128,84 +90,49 @@ async def test_set_humidity( "mock_device_code", ["cs_zibqa9dutqyaxym2"], ) -async def test_turn_on_unsupported( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test turn on service (not supported by this device).""" - # Remove switch control - but keep other functionality - mock_device.status.pop("switch") - mock_device.function.pop("switch") - mock_device.status_range.pop("switch") - - entity_id = "humidifier.dehumidifier" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - state = hass.states.get(entity_id) - assert state is not None, f"{entity_id} does not exist" - with pytest.raises(ServiceValidationError) as err: - await hass.services.async_call( - HUMIDIFIER_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert err.value.translation_key == "action_dpcode_not_found" - assert err.value.translation_placeholders == { - "expected": "['switch', 'switch_spray']", - "available": ("['child_lock', 'countdown_set', 'dehumidify_set_value']"), - } - - @pytest.mark.parametrize( - "mock_device_code", - ["cs_zibqa9dutqyaxym2"], + ("service", "service_data", "translation_placeholders"), + [ + ( + SERVICE_TURN_ON, + {}, + { + "expected": "['switch', 'switch_spray']", + "available": ("['child_lock', 'countdown_set']"), + }, + ), + ( + SERVICE_TURN_OFF, + {}, + { + "expected": "['switch', 'switch_spray']", + "available": ("['child_lock', 'countdown_set']"), + }, + ), + ( + SERVICE_SET_HUMIDITY, + {ATTR_HUMIDITY: 50}, + { + "expected": "['dehumidify_set_value']", + "available": ("['child_lock', 'countdown_set']"), + }, + ), + ], ) -async def test_turn_off_unsupported( +async def test_action_unsupported( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + service: str, + service_data: dict[str, Any], + translation_placeholders: dict[str, Any], ) -> None: - """Test turn off service (not supported by this device).""" - # Remove switch control - but keep other functionality + """Test service actions when not supported by the device.""" + # Remove switch control and dehumidify_set_value - but keep other functionality mock_device.status.pop("switch") mock_device.function.pop("switch") mock_device.status_range.pop("switch") - - entity_id = "humidifier.dehumidifier" - await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - - state = hass.states.get(entity_id) - assert state is not None, f"{entity_id} does not exist" - with pytest.raises(ServiceValidationError) as err: - await hass.services.async_call( - HUMIDIFIER_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - assert err.value.translation_key == "action_dpcode_not_found" - assert err.value.translation_placeholders == { - "expected": "['switch', 'switch_spray']", - "available": ("['child_lock', 'countdown_set', 'dehumidify_set_value']"), - } - - -@pytest.mark.parametrize( - "mock_device_code", - ["cs_zibqa9dutqyaxym2"], -) -async def test_set_humidity_unsupported( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test set humidity service (not supported by this device).""" - # Remove set humidity control - but keep other functionality mock_device.status.pop("dehumidify_set_value") mock_device.function.pop("dehumidify_set_value") mock_device.status_range.pop("dehumidify_set_value") @@ -218,15 +145,12 @@ async def test_set_humidity_unsupported( with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( HUMIDIFIER_DOMAIN, - SERVICE_SET_HUMIDITY, + service, { ATTR_ENTITY_ID: entity_id, - ATTR_HUMIDITY: 50, + **service_data, }, blocking=True, ) assert err.value.translation_key == "action_dpcode_not_found" - assert err.value.translation_placeholders == { - "expected": "['dehumidify_set_value']", - "available": ("['child_lock', 'countdown_set', 'switch']"), - } + assert err.value.translation_placeholders == translation_placeholders diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py index 545a9b2bc8b369..b51eb405b2fc91 100644 --- a/tests/components/tuya/test_vacuum.py +++ b/tests/components/tuya/test_vacuum.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch import pytest @@ -9,8 +10,13 @@ from tuya_sharing import CustomerDevice, Manager from homeassistant.components.vacuum import ( + ATTR_FAN_SPEED, DOMAIN as VACUUM_DOMAIN, + SERVICE_LOCATE, SERVICE_RETURN_TO_BASE, + SERVICE_SET_FAN_SPEED, + SERVICE_START, + SERVICE_STOP, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -37,30 +43,77 @@ async def test_platform_setup_and_discovery( @pytest.mark.parametrize( - "mock_device_code", - ["sd_lr33znaodtyarrrz"], + ("mock_device_code", "entity_id", "service", "service_data", "expected_command"), + [ + ( + "sd_i6hyjg3af7doaswm", + "vacuum.hoover", + SERVICE_RETURN_TO_BASE, + {}, + {"code": "mode", "value": "chargego"}, + ), + ( + # Based on #141278 + "sd_lr33znaodtyarrrz", + "vacuum.v20", + SERVICE_RETURN_TO_BASE, + {}, + {"code": "switch_charge", "value": True}, + ), + ( + "sd_lr33znaodtyarrrz", + "vacuum.v20", + SERVICE_SET_FAN_SPEED, + {ATTR_FAN_SPEED: "gentle"}, + {"code": "suction", "value": "gentle"}, + ), + ( + "sd_i6hyjg3af7doaswm", + "vacuum.hoover", + SERVICE_LOCATE, + {}, + {"code": "seek", "value": True}, + ), + ( + "sd_i6hyjg3af7doaswm", + "vacuum.hoover", + SERVICE_START, + {}, + {"code": "power_go", "value": True}, + ), + ( + "sd_i6hyjg3af7doaswm", + "vacuum.hoover", + SERVICE_STOP, + {}, + {"code": "power_go", "value": False}, + ), + ], ) -async def test_return_home( +async def test_action( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + entity_id: str, + service: str, + service_data: dict[str, Any], + expected_command: dict[str, Any], ) -> None: - """Test return home service.""" - # Based on #141278 - entity_id = "vacuum.v20" + """Test service action.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) state = hass.states.get(entity_id) assert state is not None, f"{entity_id} does not exist" await hass.services.async_call( VACUUM_DOMAIN, - SERVICE_RETURN_TO_BASE, + service, { ATTR_ENTITY_ID: entity_id, + **service_data, }, blocking=True, ) mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "switch_charge", "value": True}] + mock_device.id, [expected_command] ) diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 446956c12a8363..21a799e7bd4e1a 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -577,8 +577,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Diastolic blood pressure', 'platform': 'withings', @@ -587,15 +590,16 @@ 'supported_features': 0, 'translation_key': 'diastolic_blood_pressure', 'unique_id': 'withings_12345_diastolic_blood_pressure_mmhg', - 'unit_of_measurement': 'mmhg', + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_diastolic_blood_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', 'friendly_name': 'henk Diastolic blood pressure', 'state_class': , - 'unit_of_measurement': 'mmhg', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_diastolic_blood_pressure', @@ -3643,8 +3647,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Systolic blood pressure', 'platform': 'withings', @@ -3653,15 +3660,16 @@ 'supported_features': 0, 'translation_key': 'systolic_blood_pressure', 'unique_id': 'withings_12345_systolic_blood_pressure_mmhg', - 'unit_of_measurement': 'mmhg', + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.henk_systolic_blood_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', 'friendly_name': 'henk Systolic blood pressure', 'state_class': , - 'unit_of_measurement': 'mmhg', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.henk_systolic_blood_pressure',