diff --git a/CODEOWNERS b/CODEOWNERS index 8f541414a3c3a..fe7b662822313 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -627,6 +627,8 @@ build.json @home-assistant/supervisor /tests/components/guardian/ @bachya /homeassistant/components/habitica/ @tr4nt0r /tests/components/habitica/ @tr4nt0r +/homeassistant/components/hanna/ @bestycame +/tests/components/hanna/ @bestycame /homeassistant/components/hardkernel/ @home-assistant/core /tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core diff --git a/homeassistant/components/hanna/__init__.py b/homeassistant/components/hanna/__init__.py new file mode 100644 index 0000000000000..4d32cfb394216 --- /dev/null +++ b/homeassistant/components/hanna/__init__.py @@ -0,0 +1,54 @@ +"""The Hanna Instruments integration.""" + +from __future__ import annotations + +from typing import Any + +from hanna_cloud import HannaCloudClient + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import HannaConfigEntry, HannaDataCoordinator + +PLATFORMS = [Platform.SENSOR] + + +def _authenticate_and_get_devices( + api_client: HannaCloudClient, + email: str, + password: str, +) -> list[dict[str, Any]]: + """Authenticate and get devices in a single executor job.""" + api_client.authenticate(email, password) + return api_client.get_devices() + + +async def async_setup_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool: + """Set up Hanna Instruments from a config entry.""" + api_client = HannaCloudClient() + devices = await hass.async_add_executor_job( + _authenticate_and_get_devices, + api_client, + entry.data[CONF_EMAIL], + entry.data[CONF_PASSWORD], + ) + + # Create device coordinators + device_coordinators = {} + for device in devices: + coordinator = HannaDataCoordinator(hass, entry, device, api_client) + await coordinator.async_config_entry_first_refresh() + device_coordinators[coordinator.device_identifier] = coordinator + + # Set runtime data + entry.runtime_data = device_coordinators + + # Forward the setup to the platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HannaConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hanna/config_flow.py b/homeassistant/components/hanna/config_flow.py new file mode 100644 index 0000000000000..d1a54dc42cd30 --- /dev/null +++ b/homeassistant/components/hanna/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Hanna Instruments integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from hanna_cloud import AuthenticationError, HannaCloudClient +from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class HannaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hanna Instruments.""" + + VERSION = 1 + data_schema = vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the setup flow.""" + + errors: dict[str, str] = {} + + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_EMAIL]) + self._abort_if_unique_id_configured() + client = HannaCloudClient() + try: + await self.hass.async_add_executor_job( + client.authenticate, + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + ) + except (Timeout, RequestsConnectionError): + errors["base"] = "cannot_connect" + except AuthenticationError: + errors["base"] = "invalid_auth" + + if not errors: + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + self.data_schema, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/hanna/const.py b/homeassistant/components/hanna/const.py new file mode 100644 index 0000000000000..ed9b5a84831aa --- /dev/null +++ b/homeassistant/components/hanna/const.py @@ -0,0 +1,3 @@ +"""Constants for the Hanna integration.""" + +DOMAIN = "hanna" diff --git a/homeassistant/components/hanna/coordinator.py b/homeassistant/components/hanna/coordinator.py new file mode 100644 index 0000000000000..c6915610d3f1a --- /dev/null +++ b/homeassistant/components/hanna/coordinator.py @@ -0,0 +1,72 @@ +"""Hanna Instruments data coordinator for Home Assistant. + +This module provides the data coordinator for fetching and managing Hanna Instruments +sensor data. +""" + +from datetime import timedelta +import logging +from typing import Any + +from hanna_cloud import HannaCloudClient +from requests.exceptions import RequestException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +type HannaConfigEntry = ConfigEntry[dict[str, HannaDataCoordinator]] + +_LOGGER = logging.getLogger(__name__) + + +class HannaDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for fetching Hanna sensor data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: HannaConfigEntry, + device: dict[str, Any], + api_client: HannaCloudClient, + ) -> None: + """Initialize the Hanna data coordinator.""" + self.api_client = api_client + self.device_data = device + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{self.device_identifier}", + config_entry=config_entry, + update_interval=timedelta(seconds=30), + ) + + @property + def device_identifier(self) -> str: + """Return the device identifier.""" + return self.device_data["DID"] + + def get_parameters(self) -> list[dict[str, Any]]: + """Get all parameters from the sensor data.""" + return self.api_client.parameters + + def get_parameter_value(self, key: str) -> Any: + """Get the value for a specific parameter.""" + for parameter in self.get_parameters(): + if parameter["name"] == key: + return parameter["value"] + return None + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch latest sensor data from the Hanna API.""" + try: + readings = await self.hass.async_add_executor_job( + self.api_client.get_last_device_reading, self.device_identifier + ) + except RequestException as e: + raise UpdateFailed(f"Error communicating with Hanna API: {e}") from e + except (KeyError, IndexError) as e: + raise UpdateFailed(f"Error parsing Hanna API response: {e}") from e + return readings diff --git a/homeassistant/components/hanna/entity.py b/homeassistant/components/hanna/entity.py new file mode 100644 index 0000000000000..3de5723583a81 --- /dev/null +++ b/homeassistant/components/hanna/entity.py @@ -0,0 +1,28 @@ +"""Hanna Instruments entity base class for Home Assistant. + +This module provides the base entity class for Hanna Instruments entities. +""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HannaDataCoordinator + + +class HannaEntity(CoordinatorEntity[HannaDataCoordinator]): + """Base class for Hanna entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HannaDataCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_identifier)}, + manufacturer=coordinator.device_data.get("manufacturer"), + model=coordinator.device_data.get("DM"), + name=coordinator.device_data.get("name"), + serial_number=coordinator.device_data.get("serial_number"), + sw_version=coordinator.device_data.get("sw_version"), + ) diff --git a/homeassistant/components/hanna/manifest.json b/homeassistant/components/hanna/manifest.json new file mode 100644 index 0000000000000..b1e503e5e28f6 --- /dev/null +++ b/homeassistant/components/hanna/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "hanna", + "name": "Hanna", + "codeowners": ["@bestycame"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hanna", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["hanna-cloud==0.0.6"] +} diff --git a/homeassistant/components/hanna/quality_scale.yaml b/homeassistant/components/hanna/quality_scale.yaml new file mode 100644 index 0000000000000..f4eb96842e6d6 --- /dev/null +++ b/homeassistant/components/hanna/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration doesn't add actions. + appropriate-polling: + status: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not have any configuration parameters. + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/hanna/sensor.py b/homeassistant/components/hanna/sensor.py new file mode 100644 index 0000000000000..6845f1a7c1038 --- /dev/null +++ b/homeassistant/components/hanna/sensor.py @@ -0,0 +1,106 @@ +"""Hanna Instruments sensor integration for Home Assistant. + +This module provides sensor entities for various Hanna Instruments devices, +including pH, ORP, temperature, and chemical sensors. It uses the Hanna API +to fetch readings and updates them periodically. +""" + +from __future__ import annotations + +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import HannaConfigEntry, HannaDataCoordinator +from .entity import HannaEntity + +_LOGGER = logging.getLogger(__name__) + +SENSOR_DESCRIPTIONS = [ + SensorEntityDescription( + key="ph", + translation_key="ph_value", + device_class=SensorDeviceClass.PH, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="orp", + translation_key="chlorine_orp_value", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="temp", + translation_key="water_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="airTemp", + translation_key="air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="acidBase", + translation_key="ph_acid_base_flow_rate", + icon="mdi:chemical-weapon", + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.MILLILITERS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="cl", + translation_key="chlorine_flow_rate", + icon="mdi:chemical-weapon", + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.MILLILITERS, + state_class=SensorStateClass.MEASUREMENT, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HannaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Hanna sensors from a config entry.""" + device_coordinators = entry.runtime_data + + async_add_entities( + HannaSensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + for coordinator in device_coordinators.values() + ) + + +class HannaSensor(HannaEntity, SensorEntity): + """Representation of a Hanna sensor.""" + + def __init__( + self, + coordinator: HannaDataCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize a Hanna sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.device_identifier}_{description.key}" + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.get_parameter_value(self.entity_description.key) diff --git a/homeassistant/components/hanna/strings.json b/homeassistant/components/hanna/strings.json new file mode 100644 index 0000000000000..c94a284bde934 --- /dev/null +++ b/homeassistant/components/hanna/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "email": "Email address for your Hanna Cloud account", + "password": "Password for your Hanna Cloud account" + }, + "description": "Enter your Hanna Cloud credentials" + } + } + }, + "entity": { + "sensor": { + "air_temperature": { + "name": "Air temperature" + }, + "chlorine_flow_rate": { + "name": "Chlorine flow rate" + }, + "chlorine_orp_value": { + "name": "Chlorine ORP value" + }, + "ph_acid_base_flow_rate": { + "name": "pH Acid/Base flow rate" + }, + "water_temperature": { + "name": "Water temperature" + } + } + } +} diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 477ee73a0cd74..7266205549fae 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -8,20 +8,17 @@ from pysmartthings import Attribute, Capability, Category, SmartThings, Status from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullDevice, SmartThingsConfigEntry from .const import INVALID_SWITCH_CATEGORIES, MAIN from .entity import SmartThingsEntity -from .util import deprecate_entity @dataclass(frozen=True, kw_only=True) @@ -31,11 +28,14 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): is_on_key: str category_device_class: dict[Category | str, BinarySensorDeviceClass] | None = None category: set[Category] | None = None - exists_fn: Callable[[str], bool] | None = None + exists_fn: ( + Callable[ + [str, dict[str, dict[Capability | str, dict[Attribute | str, Status]]]], + bool, + ] + | None + ) = None component_translation_key: dict[str, str] | None = None - deprecated_fn: Callable[ - [dict[str, dict[Capability | str, dict[Attribute | str, Status]]]], str | None - ] = lambda _: None CAPABILITY_TO_SENSORS: dict[ @@ -59,17 +59,16 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): Category.DOOR: BinarySensorDeviceClass.DOOR, Category.WINDOW: BinarySensorDeviceClass.WINDOW, }, - exists_fn=lambda key: key in {"freezer", "cooler", "cvroom"}, + exists_fn=lambda component, status: ( + not ("freezer" in status and "cooler" in status) + if component == MAIN + else True + ), component_translation_key={ "freezer": "freezer_door", "cooler": "cooler_door", "cvroom": "cool_select_plus_door", }, - deprecated_fn=( - lambda status: "fridge_door" - if "freezer" in status and "cooler" in status - else None - ), ) }, Capability.CUSTOM_DRYER_WRINKLE_PREVENT: { @@ -155,15 +154,6 @@ class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, ) }, - Capability.VALVE: { - Attribute.VALVE: SmartThingsBinarySensorEntityDescription( - key=Attribute.VALVE, - translation_key="valve", - device_class=BinarySensorDeviceClass.OPENING, - is_on_key="open", - deprecated_fn=lambda _: "valve", - ) - }, Capability.WATER_SENSOR: { Attribute.WATER: SmartThingsBinarySensorEntityDescription( key=Attribute.WATER, @@ -204,64 +194,39 @@ async def async_setup_entry( ) -> None: """Add binary sensors for a config entry.""" entry_data = entry.runtime_data - entities = [] - - entity_registry = er.async_get(hass) - for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks - for capability, attribute_map in CAPABILITY_TO_SENSORS.items(): - for attribute, description in attribute_map.items(): - for component in device.status: - if ( - capability in device.status[component] - and ( - component == MAIN - or ( - description.exists_fn is not None - and description.exists_fn(component) - ) - ) - and ( - not description.category - or get_main_component_category(device) - in description.category - ) - ): - if ( - component == MAIN - and (issue := description.deprecated_fn(device.status)) - is not None - ): - if deprecate_entity( - hass, - entity_registry, - BINARY_SENSOR_DOMAIN, - f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}", - f"deprecated_binary_{issue}", - ): - entities.append( - SmartThingsBinarySensor( - entry_data.client, - device, - description, - capability, - attribute, - component, - ) - ) - continue - entities.append( - SmartThingsBinarySensor( - entry_data.client, - device, - description, - capability, - attribute, - component, - ) - ) - - async_add_entities(entities) + async_add_entities( + SmartThingsBinarySensor( + entry_data.client, + device, + description, + capability, + attribute, + component, + ) + for device in entry_data.devices.values() + for capability, attribute_map in CAPABILITY_TO_SENSORS.items() + for attribute, description in attribute_map.items() + for component in device.status + if ( + capability in device.status[component] + and ( + component == MAIN + or ( + description.component_translation_key is not None + and component in description.component_translation_key + ) + ) + and ( + description.exists_fn is None + or description.exists_fn(component, device.status) + ) + and ( + not description.category + or get_main_component_category(device) in description.category + ) + ) + ) class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity): diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 9ca1c5fe6e198..ab5cae43cd626 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -667,22 +667,6 @@ } }, "issues": { - "deprecated_binary_fridge_door": { - "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. Separate entities for cooler and freezer door are available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue.", - "title": "Refrigerator door binary sensor deprecated" - }, - "deprecated_binary_fridge_door_scripts": { - "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue.", - "title": "[%key:component::smartthings::issues::deprecated_binary_fridge_door::title%]" - }, - "deprecated_binary_valve": { - "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. A valve entity with controls is available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue.", - "title": "Valve binary sensor deprecated" - }, - "deprecated_binary_valve_scripts": { - "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts and disable the entity to fix this issue.", - "title": "[%key:component::smartthings::issues::deprecated_binary_valve::title%]" - }, "deprecated_dhw": { "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a water heater entity.\n\nPlease update your dashboards and templates to use the new water heater entity and disable the sensor to fix this issue.", "title": "Water heater sensors deprecated" diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 41d27e0317258..a82866c696795 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -26,7 +26,13 @@ from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity -from .models import DPCodeIntegerWrapper, IntegerTypeData, find_dpcode +from .models import ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + DPCodeIntegerWrapper, + IntegerTypeData, + find_dpcode, +) from .util import get_dpcode TUYA_HVAC_TO_HA = { @@ -110,6 +116,17 @@ def async_discover_device(device_ids: list[str]) -> None: current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode( device, DPCode.HUMIDITY_CURRENT ), + fan_mode_wrapper=DPCodeEnumWrapper.find_dpcode( + device, + (DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED), + prefer_function=True, + ), + hvac_mode_wrapper=DPCodeEnumWrapper.find_dpcode( + device, DPCode.MODE, prefer_function=True + ), + switch_wrapper=DPCodeBooleanWrapper.find_dpcode( + device, DPCode.SWITCH, prefer_function=True + ), target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode( device, DPCode.HUMIDITY_SET, prefer_function=True ), @@ -140,8 +157,11 @@ def __init__( description: TuyaClimateEntityDescription, system_temperature_unit: UnitOfTemperature, *, - current_humidity_wrapper: _RoundedIntegerWrapper | None = None, - target_humidity_wrapper: _RoundedIntegerWrapper | None = None, + current_humidity_wrapper: _RoundedIntegerWrapper | None, + fan_mode_wrapper: DPCodeEnumWrapper | None, + hvac_mode_wrapper: DPCodeEnumWrapper | None, + switch_wrapper: DPCodeBooleanWrapper | None, + target_humidity_wrapper: _RoundedIntegerWrapper | None, ) -> None: """Determine which values to use.""" self._attr_target_temperature_step = 1.0 @@ -149,6 +169,9 @@ def __init__( super().__init__(device, device_manager) self._current_humidity_wrapper = current_humidity_wrapper + self._fan_mode_wrapper = fan_mode_wrapper + self._hvac_mode_wrapper = hvac_mode_wrapper + self._switch_wrapper = switch_wrapper self._target_humidity_wrapper = target_humidity_wrapper # If both temperature values for celsius and fahrenheit are present, @@ -222,12 +245,10 @@ def __init__( # Determine HVAC modes self._attr_hvac_modes: list[HVACMode] = [] self._hvac_to_tuya = {} - if enum_type := find_dpcode( - self.device, DPCode.MODE, dptype=DPType.ENUM, prefer_function=True - ): + if hvac_mode_wrapper: self._attr_hvac_modes = [HVACMode.OFF] unknown_hvac_modes: list[str] = [] - for tuya_mode in enum_type.range: + for tuya_mode in hvac_mode_wrapper.type_information.range: if tuya_mode in TUYA_HVAC_TO_HA: ha_mode = TUYA_HVAC_TO_HA[tuya_mode] self._hvac_to_tuya[ha_mode] = tuya_mode @@ -239,7 +260,7 @@ def __init__( self._attr_hvac_modes.append(description.switch_only_hvac_mode) self._attr_preset_modes = unknown_hvac_modes self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE - elif get_dpcode(self.device, DPCode.SWITCH): + elif switch_wrapper: self._attr_hvac_modes = [ HVACMode.OFF, description.switch_only_hvac_mode, @@ -256,16 +277,9 @@ def __init__( ) # Determine fan modes - self._fan_mode_dp_code: str | None = None - if enum_type := find_dpcode( - self.device, - (DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED), - dptype=DPType.ENUM, - prefer_function=True, - ): + if fan_mode_wrapper: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE - self._attr_fan_modes = enum_type.range - self._fan_mode_dp_code = enum_type.dpcode + self._attr_fan_modes = fan_mode_wrapper.type_information.range # Determine swing modes if get_dpcode( @@ -288,32 +302,35 @@ def __init__( if get_dpcode(self.device, DPCode.SWITCH_VERTICAL): self._attr_swing_modes.append(SWING_VERTICAL) - if DPCode.SWITCH in self.device.function: + if switch_wrapper: self._attr_supported_features |= ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - commands = [{"code": DPCode.SWITCH, "value": hvac_mode != HVACMode.OFF}] - if hvac_mode in self._hvac_to_tuya: + commands = [] + if self._switch_wrapper: + commands.append( + self._switch_wrapper.get_update_command( + self.device, hvac_mode != HVACMode.OFF + ) + ) + if self._hvac_mode_wrapper and hvac_mode in self._hvac_to_tuya: commands.append( - {"code": DPCode.MODE, "value": self._hvac_to_tuya[hvac_mode]} + self._hvac_mode_wrapper.get_update_command( + self.device, self._hvac_to_tuya[hvac_mode] + ) ) - self._send_command(commands) + await self._async_send_commands(commands) - def set_preset_mode(self, preset_mode: str) -> None: + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" - commands = [{"code": DPCode.MODE, "value": preset_mode}] - self._send_command(commands) + await self._async_send_dpcode_update(self._hvac_mode_wrapper, preset_mode) - def set_fan_mode(self, fan_mode: str) -> None: + async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - if TYPE_CHECKING: - # guarded by ClimateEntityFeature.FAN_MODE - assert self._fan_mode_dp_code is not None - - self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}]) + await self._async_send_dpcode_update(self._fan_mode_wrapper, fan_mode) async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" @@ -405,34 +422,29 @@ def target_humidity(self) -> int | None: @property def hvac_mode(self) -> HVACMode: """Return hvac mode.""" - # If the switch off, hvac mode is off as well. Unless the switch - # the switch is on or doesn't exists of course... - if not self.device.status.get(DPCode.SWITCH, True): + # If the switch is off, hvac mode is off as well. + # Unless the switch doesn't exists of course... + if (switch_status := self._read_wrapper(self._switch_wrapper)) is False: return HVACMode.OFF - if DPCode.MODE not in self.device.function: - if self.device.status.get(DPCode.SWITCH, False): - return self.entity_description.switch_only_hvac_mode - return HVACMode.OFF - - if ( - mode := self.device.status.get(DPCode.MODE) - ) is not None and mode in TUYA_HVAC_TO_HA: - return TUYA_HVAC_TO_HA[mode] + # If the mode is known and maps to an HVAC mode, return it. + if (mode := self._read_wrapper(self._hvac_mode_wrapper)) and ( + hvac_mode := TUYA_HVAC_TO_HA.get(mode) + ): + return hvac_mode - # If the switch is on, and the mode does not match any hvac mode. - if self.device.status.get(DPCode.SWITCH, False): + # If hvac_mode is unknown, return the switch only mode. + if switch_status: return self.entity_description.switch_only_hvac_mode - return HVACMode.OFF @property def preset_mode(self) -> str | None: """Return preset mode.""" - if DPCode.MODE not in self.device.function: + if self._hvac_mode_wrapper is None: return None - mode = self.device.status.get(DPCode.MODE) + mode = self._read_wrapper(self._hvac_mode_wrapper) if mode in TUYA_HVAC_TO_HA: return None @@ -441,11 +453,7 @@ def preset_mode(self) -> str | None: @property def fan_mode(self) -> str | None: """Return fan mode.""" - return ( - self.device.status.get(self._fan_mode_dp_code) - if self._fan_mode_dp_code - else None - ) + return self._read_wrapper(self._fan_mode_wrapper) @property def swing_mode(self) -> str: @@ -466,10 +474,10 @@ def swing_mode(self) -> str: return SWING_OFF - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn the device on, retaining current HVAC (if supported).""" - self._send_command([{"code": DPCode.SWITCH, "value": True}]) + await self._async_send_dpcode_update(self._switch_wrapper, True) - def turn_off(self) -> None: + async def async_turn_off(self) -> None: """Turn the device on, retaining current HVAC (if supported).""" - self._send_command([{"code": DPCode.SWITCH, "value": False}]) + await self._async_send_dpcode_update(self._switch_wrapper, False) diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 120237df2bb8d..ed488fbbe39c0 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -20,9 +20,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 DPCodeIntegerWrapper, find_dpcode +from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper from .util import get_dpcode @@ -73,6 +73,60 @@ def _position_reversed(self, device: CustomerDevice) -> bool: return device.status.get(DPCode.CONTROL_BACK_MODE) != "back" +class _InstructionWrapper: + """Default wrapper for sending open/close/stop instructions.""" + + def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None: + return None + + def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None: + return None + + def get_stop_command(self, device: CustomerDevice) -> dict[str, Any] | None: + return None + + +class _InstructionBooleanWrapper(DPCodeBooleanWrapper, _InstructionWrapper): + """Wrapper for boolean-based open/close instructions.""" + + def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None: + return {"code": self.dpcode, "value": True} + + def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None: + return {"code": self.dpcode, "value": False} + + +class _InstructionEnumWrapper(DPCodeEnumWrapper, _InstructionWrapper): + """Wrapper for enum-based open/close/stop instructions.""" + + open_instruction = "open" + close_instruction = "close" + stop_instruction = "stop" + + def get_open_command(self, device: CustomerDevice) -> dict[str, Any] | None: + if self.open_instruction in self.type_information.range: + return {"code": self.dpcode, "value": self.open_instruction} + return None + + def get_close_command(self, device: CustomerDevice) -> dict[str, Any] | None: + if self.close_instruction in self.type_information.range: + return {"code": self.dpcode, "value": self.close_instruction} + return None + + def get_stop_command(self, device: CustomerDevice) -> dict[str, Any] | None: + if self.stop_instruction in self.type_information.range: + return {"code": self.dpcode, "value": self.stop_instruction} + return None + + +class _SpecialInstructionEnumWrapper(_InstructionEnumWrapper): + """Wrapper for enum-based instructions with special values (FZ/ZZ/STOP).""" + + open_instruction = "FZ" + close_instruction = "ZZ" + stop_instruction = "STOP" + + @dataclass(frozen=True) class TuyaCoverEntityDescription(CoverEntityDescription): """Describe an Tuya cover entity.""" @@ -80,13 +134,11 @@ class TuyaCoverEntityDescription(CoverEntityDescription): current_state: DPCode | tuple[DPCode, ...] | None = None current_state_inverse: bool = False current_position: DPCode | tuple[DPCode, ...] | None = None + instruction_wrapper: type[_InstructionEnumWrapper] = _InstructionEnumWrapper 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" COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = { @@ -147,9 +199,7 @@ class TuyaCoverEntityDescription(CoverEntityDescription): current_position=DPCode.POSITION, set_position=DPCode.POSITION, device_class=CoverDeviceClass.CURTAIN, - open_instruction_value="FZ", - close_instruction_value="ZZ", - stop_instruction_value="STOP", + instruction_wrapper=_SpecialInstructionEnumWrapper, ), # switch_1 is an undocumented code that behaves identically to control # It is used by the Kogan Smart Blinds Driver @@ -192,6 +242,21 @@ class TuyaCoverEntityDescription(CoverEntityDescription): } +def _get_instruction_wrapper( + device: CustomerDevice, description: TuyaCoverEntityDescription +) -> _InstructionWrapper | None: + """Get the instruction wrapper for the cover entity.""" + if enum_wrapper := description.instruction_wrapper.find_dpcode( + device, description.key, prefer_function=True + ): + return enum_wrapper + + # Fallback to a boolean wrapper if available + return _InstructionBooleanWrapper.find_dpcode( + device, description.key, prefer_function=True + ) + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -215,6 +280,9 @@ def async_discover_device(device_ids: list[str]) -> None: current_position=description.position_wrapper.find_dpcode( device, description.current_position ), + instruction_wrapper=_get_instruction_wrapper( + device, description + ), set_position=description.position_wrapper.find_dpcode( device, description.set_position, prefer_function=True ), @@ -253,6 +321,7 @@ def __init__( description: TuyaCoverEntityDescription, *, current_position: _DPCodePercentageMappingWrapper | None = None, + instruction_wrapper: _InstructionWrapper | None = None, set_position: _DPCodePercentageMappingWrapper | None = None, tilt_position: _DPCodePercentageMappingWrapper | None = None, ) -> None: @@ -263,24 +332,17 @@ def __init__( self._attr_supported_features = CoverEntityFeature(0) self._current_position = current_position or set_position + self._instruction_wrapper = instruction_wrapper 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": - self._attr_supported_features |= ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) - elif enum_type := find_dpcode( - self.device, description.key, dptype=DPType.ENUM, prefer_function=True - ): - if description.open_instruction_value in enum_type.range: - self._attr_supported_features |= CoverEntityFeature.OPEN - if description.close_instruction_value in enum_type.range: - self._attr_supported_features |= CoverEntityFeature.CLOSE - if description.stop_instruction_value in enum_type.range: - self._attr_supported_features |= CoverEntityFeature.STOP + if instruction_wrapper: + if instruction_wrapper.get_open_command(device) is not None: + self._attr_supported_features |= CoverEntityFeature.OPEN + if instruction_wrapper.get_close_command(device) is not None: + self._attr_supported_features |= CoverEntityFeature.CLOSE + if instruction_wrapper.get_stop_command(device) is not None: + self._attr_supported_features |= CoverEntityFeature.STOP self._current_state = get_dpcode(self.device, description.current_state) @@ -321,60 +383,42 @@ def is_closed(self) -> bool | None: return None - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - value: bool | str = True - if find_dpcode( - self.device, - self.entity_description.key, - dptype=DPType.ENUM, - prefer_function=True, + if self._instruction_wrapper and ( + command := self._instruction_wrapper.get_open_command(self.device) ): - value = self.entity_description.open_instruction_value - - commands: list[dict[str, str | int]] = [ - {"code": self.entity_description.key, "value": value} - ] + await self._async_send_commands([command]) + return if self._set_position is not None: - commands.append(self._set_position.get_update_command(self.device, 100)) - - self._send_command(commands) + await self._async_send_commands( + [self._set_position.get_update_command(self.device, 100)] + ) - def close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - value: bool | str = False - if find_dpcode( - self.device, - self.entity_description.key, - dptype=DPType.ENUM, - prefer_function=True, + if self._instruction_wrapper and ( + command := self._instruction_wrapper.get_close_command(self.device) ): - value = self.entity_description.close_instruction_value - - commands: list[dict[str, str | int]] = [ - {"code": self.entity_description.key, "value": value} - ] + await self._async_send_commands([command]) + return if self._set_position is not None: - commands.append(self._set_position.get_update_command(self.device, 0)) - - self._send_command(commands) + await self._async_send_commands( + [self._set_position.get_update_command(self.device, 0)] + ) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self._async_send_dpcode_update(self._set_position, kwargs[ATTR_POSITION]) - def stop_cover(self, **kwargs: Any) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._send_command( - [ - { - "code": self.entity_description.key, - "value": self.entity_description.stop_instruction_value, - } - ] - ) + if self._instruction_wrapper and ( + command := self._instruction_wrapper.get_stop_command(self.device) + ): + await self._async_send_commands([command]) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 59eb37b8db0e4..c1c3c4716b28f 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -66,6 +66,10 @@ def _send_command(self, commands: list[dict[str, Any]]) -> None: LOGGER.debug("Sending commands for device %s: %s", self.device.id, commands) self.device_manager.send_commands(self.device.id, commands) + async def _async_send_commands(self, commands: list[dict[str, Any]]) -> None: + """Send a list of commands to the device.""" + await self.hass.async_add_executor_job(self._send_command, commands) + def _read_wrapper(self, dpcode_wrapper: DPCodeWrapper | None) -> Any | None: """Read the wrapper device status.""" if dpcode_wrapper is None: diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 7f5bef640d1ea..e826402a97b97 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -23,10 +23,17 @@ 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 ( + DPCodeBooleanWrapper, + DPCodeEnumWrapper, + EnumTypeData, + IntegerTypeData, + find_dpcode, +) from .util import get_dpcode _DIRECTION_DPCODES = (DPCode.FAN_DIRECTION,) +_MODE_DPCODES = (DPCode.FAN_MODE, DPCode.MODE) _OSCILLATE_DPCODES = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) _SPEED_DPCODES = ( DPCode.FAN_SPEED_PERCENT, @@ -74,7 +81,18 @@ def async_discover_device(device_ids: list[str]) -> None: for device_id in device_ids: device = manager.device_map[device_id] if device.category in TUYA_SUPPORT_TYPE and _has_a_valid_dpcode(device): - entities.append(TuyaFanEntity(device, manager)) + entities.append( + TuyaFanEntity( + device, + manager, + mode_wrapper=DPCodeEnumWrapper.find_dpcode( + device, _MODE_DPCODES, prefer_function=True + ), + switch_wrapper=DPCodeBooleanWrapper.find_dpcode( + device, _SWITCH_DPCODES, prefer_function=True + ), + ) + ) async_add_entities(entities) async_discover_device([*manager.device_map]) @@ -89,32 +107,25 @@ class TuyaFanEntity(TuyaEntity, FanEntity): _direction: EnumTypeData | None = None _oscillate: DPCode | None = None - _presets: EnumTypeData | None = None _speed: IntegerTypeData | None = None _speeds: EnumTypeData | None = None - _switch: DPCode | None = None _attr_name = None def __init__( self, device: CustomerDevice, device_manager: Manager, + *, + mode_wrapper: DPCodeEnumWrapper | None, + switch_wrapper: DPCodeBooleanWrapper | None, ) -> None: """Init Tuya Fan Device.""" super().__init__(device, device_manager) - - self._switch = get_dpcode(self.device, _SWITCH_DPCODES) - - self._attr_preset_modes = [] - if enum_type := find_dpcode( - self.device, - (DPCode.FAN_MODE, DPCode.MODE), - dptype=DPType.ENUM, - prefer_function=True, - ): - self._presets = enum_type + self._mode_wrapper = mode_wrapper + self._switch_wrapper = switch_wrapper + if mode_wrapper: self._attr_supported_features |= FanEntityFeature.PRESET_MODE - self._attr_preset_modes = enum_type.range + self._attr_preset_modes = mode_wrapper.type_information.range # Find speed controls, can be either percentage or a set of speeds if int_type := find_dpcode( @@ -137,16 +148,14 @@ def __init__( ): self._direction = enum_type self._attr_supported_features |= FanEntityFeature.DIRECTION - if self._switch is not None: + if switch_wrapper: self._attr_supported_features |= ( FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF ) - def set_preset_mode(self, preset_mode: str) -> None: + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - if self._presets is None: - return - self._send_command([{"code": self._presets.dpcode, "value": preset_mode}]) + await self._async_send_dpcode_update(self._mode_wrapper, preset_mode) def set_direction(self, direction: str) -> None: """Set the direction of the fan.""" @@ -179,22 +188,22 @@ def set_percentage(self, percentage: int) -> None: ] ) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" - self._send_command([{"code": self._switch, "value": False}]) + await self._async_send_dpcode_update(self._switch_wrapper, False) - def turn_on( + async def async_turn_on( self, percentage: int | None = None, preset_mode: str | None = None, **kwargs: Any, ) -> None: """Turn on the fan.""" - if self._switch is None: + if self._switch_wrapper is None: return commands: list[dict[str, str | bool | int]] = [ - {"code": self._switch, "value": True} + self._switch_wrapper.get_update_command(self.device, True) ] if percentage is not None and self._speed is not None: @@ -215,10 +224,11 @@ def turn_on( } ) - if preset_mode is not None and self._presets is not None: - commands.append({"code": self._presets.dpcode, "value": preset_mode}) - - self._send_command(commands) + if preset_mode is not None and self._mode_wrapper: + commands.append( + self._mode_wrapper.get_update_command(self.device, preset_mode) + ) + await self._async_send_commands(commands) def oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" @@ -229,9 +239,7 @@ def oscillate(self, oscillating: bool) -> None: @property def is_on(self) -> bool | None: """Return true if fan is on.""" - if self._switch is None: - return None - return self.device.status.get(self._switch) + return self._read_wrapper(self._switch_wrapper) @property def current_direction(self) -> str | None: @@ -260,9 +268,7 @@ def oscillating(self) -> bool | None: @property def preset_mode(self) -> str | None: """Return the current preset_mode.""" - if self._presets is None: - return None - return self.device.status.get(self._presets.dpcode) + return self._read_wrapper(self._mode_wrapper) @property def percentage(self) -> int | None: diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 6b0e454abd047..5f0e1da796a16 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -80,6 +80,9 @@ def async_discover_device(device_ids: list[str]) -> None: mode_wrapper=DPCodeEnumWrapper.find_dpcode( device, DPCode.MODE, prefer_function=True ), + status_wrapper=DPCodeEnumWrapper.find_dpcode( + device, DPCode.STATUS + ), switch_wrapper=DPCodeBooleanWrapper.find_dpcode( device, DPCode.POWER_GO, prefer_function=True ), @@ -108,6 +111,7 @@ def __init__( fan_speed_wrapper: DPCodeEnumWrapper | None, locate_wrapper: DPCodeBooleanWrapper | None, mode_wrapper: DPCodeEnumWrapper | None, + status_wrapper: DPCodeEnumWrapper | None, switch_wrapper: DPCodeBooleanWrapper | None, ) -> None: """Init Tuya vacuum.""" @@ -116,6 +120,7 @@ def __init__( self._fan_speed_wrapper = fan_speed_wrapper self._locate_wrapper = locate_wrapper self._mode_wrapper = mode_wrapper + self._status_wrapper = status_wrapper self._switch_wrapper = switch_wrapper self._attr_fan_speed_list = [] @@ -151,13 +156,12 @@ def fan_speed(self) -> str | None: @property def activity(self) -> VacuumActivity | None: """Return Tuya vacuum device state.""" - if self.device.status.get(DPCode.PAUSE) and not ( - self.device.status.get(DPCode.STATUS) - ): + if (status := self._read_wrapper(self._status_wrapper)) is not None: + return TUYA_STATUS_TO_HA.get(status) + + if self.device.status.get(DPCode.PAUSE): return VacuumActivity.PAUSED - if not (status := self.device.status.get(DPCode.STATUS)): - return None - return TUYA_STATUS_TO_HA.get(status) + return None async def async_start(self, **kwargs: Any) -> None: """Start the device.""" diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 43b5d3ef91fac..c6fe991be5e88 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -6,15 +6,15 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed - -from .const import UPDATE_INTERVAL -from .coordinator import ( - InvalidAuth, - WallboxConfigEntry, - WallboxCoordinator, - async_validate_input, + +from .const import ( + CHARGER_JWT_REFRESH_TOKEN, + CHARGER_JWT_REFRESH_TTL, + CHARGER_JWT_TOKEN, + CHARGER_JWT_TTL, + UPDATE_INTERVAL, ) +from .coordinator import WallboxConfigEntry, WallboxCoordinator, check_token_validity PLATFORMS = [ Platform.LOCK, @@ -32,10 +32,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> b entry.data[CONF_PASSWORD], jwtTokenDrift=UPDATE_INTERVAL, ) - try: - await async_validate_input(hass, wallbox) - except InvalidAuth as ex: - raise ConfigEntryAuthFailed from ex + + if CHARGER_JWT_TOKEN in entry.data and check_token_validity( + jwt_token_ttl=entry.data.get(CHARGER_JWT_TTL, 0), + jwt_token_drift=UPDATE_INTERVAL, + ): + wallbox.jwtToken = entry.data.get(CHARGER_JWT_TOKEN) + wallbox.jwtRefreshToken = entry.data.get(CHARGER_JWT_REFRESH_TOKEN) + wallbox.jwtTokenTtl = entry.data.get(CHARGER_JWT_TTL) + wallbox.jwtRefreshTokenTtl = entry.data.get(CHARGER_JWT_REFRESH_TTL) + wallbox.headers["Authorization"] = f"Bearer {entry.data.get(CHARGER_JWT_TOKEN)}" wallbox_coordinator = WallboxCoordinator(hass, entry, wallbox) await wallbox_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/wallbox/config_flow.py b/homeassistant/components/wallbox/config_flow.py index bdc51eef9633d..46de061a33c2a 100644 --- a/homeassistant/components/wallbox/config_flow.py +++ b/homeassistant/components/wallbox/config_flow.py @@ -12,7 +12,15 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import CONF_STATION, DOMAIN +from .const import ( + CHARGER_JWT_REFRESH_TOKEN, + CHARGER_JWT_REFRESH_TTL, + CHARGER_JWT_TOKEN, + CHARGER_JWT_TTL, + CONF_STATION, + DOMAIN, + UPDATE_INTERVAL, +) from .coordinator import InvalidAuth, async_validate_input COMPONENT_DOMAIN = DOMAIN @@ -26,17 +34,22 @@ ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - wallbox = Wallbox(data["username"], data["password"]) + wallbox = Wallbox(data[CONF_USERNAME], data[CONF_PASSWORD], UPDATE_INTERVAL) await async_validate_input(hass, wallbox) + data[CHARGER_JWT_TOKEN] = wallbox.jwtToken + data[CHARGER_JWT_REFRESH_TOKEN] = wallbox.jwtRefreshToken + data[CHARGER_JWT_TTL] = wallbox.jwtTokenTtl + data[CHARGER_JWT_REFRESH_TTL] = wallbox.jwtRefreshTokenTtl + # Return info that you want to store in the config entry. - return {"title": "Wallbox Portal"} + return {"title": "Wallbox Portal", "data": data} class WallboxConfigFlow(ConfigFlow, domain=COMPONENT_DOMAIN): @@ -64,8 +77,11 @@ async def async_step_user( await self.async_set_unique_id(user_input["station"]) if self.source != SOURCE_REAUTH: self._abort_if_unique_id_configured() - info = await validate_input(self.hass, user_input) - return self.async_create_entry(title=info["title"], data=user_input) + validation_data = await validate_input(self.hass, user_input) + return self.async_create_entry( + title=validation_data["title"], + data=validation_data["data"], + ) reauth_entry = self._get_reauth_entry() if user_input["station"] == reauth_entry.data[CONF_STATION]: return self.async_update_reload_and_abort(reauth_entry, data=user_input) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index cbe1aaa912a9d..e0289b57ad727 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -47,6 +47,12 @@ CHARGER_ECO_SMART_KEY = "ecosmart" CHARGER_ECO_SMART_STATUS_KEY = "enabled" CHARGER_ECO_SMART_MODE_KEY = "mode" +CHARGER_WALLBOX_OBJECT_KEY = "wallbox" + +CHARGER_JWT_TOKEN = "jwtToken" +CHARGER_JWT_REFRESH_TOKEN = "jwtRefreshToken" +CHARGER_JWT_TTL = "jwtTokenTtl" +CHARGER_JWT_REFRESH_TTL = "jwtRefreshTokenTtl" class ChargerStatus(StrEnum): diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 36785ee362a93..7558ddecc9864 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable -from datetime import timedelta +from datetime import datetime, timedelta from http import HTTPStatus import logging from typing import Any, Concatenate @@ -27,6 +27,10 @@ CHARGER_ECO_SMART_STATUS_KEY, CHARGER_ENERGY_PRICE_KEY, CHARGER_FEATURES_KEY, + CHARGER_JWT_REFRESH_TOKEN, + CHARGER_JWT_REFRESH_TTL, + CHARGER_JWT_TOKEN, + CHARGER_JWT_TTL, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, CHARGER_MAX_CHARGING_CURRENT_POST_KEY, @@ -86,27 +90,25 @@ def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P]( ) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]: """Authenticate with decorator using Wallbox API.""" - def require_authentication( + async def require_authentication( self: _WallboxCoordinatorT, *args: _P.args, **kwargs: _P.kwargs ) -> Any: """Authenticate using Wallbox API.""" - try: - self.authenticate() - return func(self, *args, **kwargs) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="invalid_auth" - ) from wallbox_connection_error - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="api_failed" - ) from wallbox_connection_error + await self.async_authenticate() + return await func(self, *args, **kwargs) return require_authentication +def check_token_validity(jwt_token_ttl: int, jwt_token_drift: int) -> bool: + """Check if the jwtToken is still valid in order to reuse if possible.""" + return round((jwt_token_ttl / 1000) - jwt_token_drift, 0) > datetime.timestamp( + datetime.now() + ) + + def _validate(wallbox: Wallbox) -> None: - """Authenticate using Wallbox API.""" + """Authenticate using Wallbox API to check if the used credentials are valid.""" try: wallbox.authenticate() except requests.exceptions.HTTPError as wallbox_connection_error: @@ -142,11 +144,38 @@ def __init__( update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - def authenticate(self) -> None: + def _authenticate(self) -> dict[str, str]: + """Authenticate using Wallbox API. First check token validity.""" + data = dict(self.config_entry.data) + if not check_token_validity( + jwt_token_ttl=data.get(CHARGER_JWT_TTL, 0), + jwt_token_drift=UPDATE_INTERVAL, + ): + try: + self._wallbox.authenticate() + except requests.exceptions.HTTPError as wallbox_connection_error: + if ( + wallbox_connection_error.response.status_code + == HTTPStatus.FORBIDDEN + ): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error + else: + data[CHARGER_JWT_TOKEN] = self._wallbox.jwtToken + data[CHARGER_JWT_REFRESH_TOKEN] = self._wallbox.jwtRefreshToken + data[CHARGER_JWT_TTL] = self._wallbox.jwtTokenTtl + data[CHARGER_JWT_REFRESH_TTL] = self._wallbox.jwtRefreshTokenTtl + return data + + async def async_authenticate(self) -> None: """Authenticate using Wallbox API.""" - self._wallbox.authenticate() + data = await self.hass.async_add_executor_job(self._authenticate) + self.hass.config_entries.async_update_entry(self.config_entry, data=data) - @_require_authentication def _get_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" try: @@ -208,6 +237,7 @@ def _get_data(self) -> dict[str, Any]: translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error + @_require_authentication async def _async_update_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component. Set update interval to be UPDATE_INTERVAL * #wallbox chargers configured, this is necessary due to rate limitations.""" @@ -217,7 +247,6 @@ async def _async_update_data(self) -> dict[str, Any]: ) return await self.hass.async_add_executor_job(self._get_data) - @_require_authentication def _set_charging_current( self, charging_current: float ) -> dict[str, dict[str, dict[str, Any]]]: @@ -246,6 +275,7 @@ def _set_charging_current( translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error + @_require_authentication async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" data = await self.hass.async_add_executor_job( @@ -253,7 +283,6 @@ async def async_set_charging_current(self, charging_current: float) -> None: ) self.async_set_updated_data(data) - @_require_authentication def _set_icp_current(self, icp_current: float) -> dict[str, Any]: """Set maximum icp current for Wallbox.""" try: @@ -276,6 +305,7 @@ def _set_icp_current(self, icp_current: float) -> dict[str, Any]: translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error + @_require_authentication async def async_set_icp_current(self, icp_current: float) -> None: """Set maximum icp current for Wallbox.""" data = await self.hass.async_add_executor_job( @@ -283,7 +313,6 @@ async def async_set_icp_current(self, icp_current: float) -> None: ) self.async_set_updated_data(data) - @_require_authentication def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]: """Set energy cost for Wallbox.""" try: @@ -300,6 +329,7 @@ def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]: translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error + @_require_authentication async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" data = await self.hass.async_add_executor_job( @@ -307,7 +337,6 @@ async def async_set_energy_cost(self, energy_cost: float) -> None: ) self.async_set_updated_data(data) - @_require_authentication def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]: """Set wallbox to locked or unlocked.""" try: @@ -335,12 +364,12 @@ def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]: translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error + @_require_authentication async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock) self.async_set_updated_data(data) - @_require_authentication def _pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" try: @@ -357,12 +386,12 @@ def _pause_charger(self, pause: bool) -> None: translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error + @_require_authentication async def async_pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" await self.hass.async_add_executor_job(self._pause_charger, pause) await self.async_request_refresh() - @_require_authentication def _set_eco_smart(self, option: str) -> None: """Set wallbox solar charging mode.""" try: @@ -381,6 +410,7 @@ def _set_eco_smart(self, option: str) -> None: translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error + @_require_authentication async def async_set_eco_smart(self, option: str) -> None: """Set wallbox solar charging mode.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0d697fd37636e..e46f92a50a79f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -264,6 +264,7 @@ "growatt_server", "guardian", "habitica", + "hanna", "harmony", "heos", "here_travel_time", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3256a7019eb52..0f5961e0f3da8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2576,6 +2576,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "hanna": { + "name": "Hanna", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "hardkernel": { "name": "Hardkernel", "integration_type": "hardware", diff --git a/requirements_all.txt b/requirements_all.txt index 39b54de9e1f90..ec8d88827bcba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,6 +1143,9 @@ habiticalib==0.4.6 # homeassistant.components.bluetooth habluetooth==5.7.0 +# homeassistant.components.hanna +hanna-cloud==0.0.6 + # homeassistant.components.cloud hass-nabucasa==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e6eedb417676b..26f247f366fa9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1004,6 +1004,9 @@ habiticalib==0.4.6 # homeassistant.components.bluetooth habluetooth==5.7.0 +# homeassistant.components.hanna +hanna-cloud==0.0.6 + # homeassistant.components.cloud hass-nabucasa==1.5.1 diff --git a/tests/components/hanna/__init__.py b/tests/components/hanna/__init__.py new file mode 100644 index 0000000000000..47a9b1d5aab7d --- /dev/null +++ b/tests/components/hanna/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hanna integration.""" diff --git a/tests/components/hanna/conftest.py b/tests/components/hanna/conftest.py new file mode 100644 index 0000000000000..4585325ba8bd2 --- /dev/null +++ b/tests/components/hanna/conftest.py @@ -0,0 +1,41 @@ +"""Fixtures for Hanna Instruments integration tests.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.hanna.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch("homeassistant.components.hanna.async_setup_entry", return_value=True): + yield + + +@pytest.fixture +def mock_hanna_client(): + """Mock HannaCloudClient.""" + with ( + patch( + "homeassistant.components.hanna.config_flow.HannaCloudClient", autospec=True + ) as mock_client, + patch("homeassistant.components.hanna.HannaCloudClient", new=mock_client), + ): + client = mock_client.return_value + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"}, + title="test@example.com", + unique_id="test@example.com", + ) diff --git a/tests/components/hanna/test_config_flow.py b/tests/components/hanna/test_config_flow.py new file mode 100644 index 0000000000000..ed3c682b49e3d --- /dev/null +++ b/tests/components/hanna/test_config_flow.py @@ -0,0 +1,124 @@ +"""Tests for the Hanna Instruments integration config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from hanna_cloud import AuthenticationError +import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError, Timeout + +from homeassistant.components.hanna.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hanna_client: MagicMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + } + assert result["result"].unique_id == "test@example.com" + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + ( + AuthenticationError("Authentication failed"), + "invalid_auth", + ), + ( + Timeout("Connection timeout"), + "cannot_connect", + ), + ( + RequestsConnectionError("Connection failed"), + "cannot_connect", + ), + ], +) +async def test_error_scenarios( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hanna_client: MagicMock, + exception: Exception, + expected_error: str, +) -> None: + """Test various error scenarios in the config flow.""" + mock_hanna_client.authenticate.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + # Repatch to succeed and complete the flow + mock_hanna_client.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + } + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_hanna_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that duplicate entries are aborted.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_EMAIL: "test@example.com", CONF_PASSWORD: "test-password"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index ab9531bbef6cd..0ce1673a8a542 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -7,15 +7,9 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.script import scripts_with_entity -from homeassistant.components.smartthings import DOMAIN, MAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as er from . import ( setup_integration, @@ -105,172 +99,3 @@ async def test_availability_at_start( hass.states.get("binary_sensor.refrigerator_fridge_door").state == STATE_UNAVAILABLE ) - - -@pytest.mark.parametrize( - ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), - [ - ( - "virtual_valve", - f"612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_{MAIN}_{Capability.VALVE}_{Attribute.VALVE}_{Attribute.VALVE}", - "volvo_valve", - "valve", - "binary_sensor.volvo_valve", - ), - ( - "da_ref_normal_000001", - f"7db87911-7dce-1cf2-7119-b953432a2f09_{MAIN}_{Capability.CONTACT_SENSOR}_{Attribute.CONTACT}_{Attribute.CONTACT}", - "refrigerator_door", - "fridge_door", - "binary_sensor.refrigerator_door", - ), - ], -) -async def test_create_issue_with_items( - hass: HomeAssistant, - devices: AsyncMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - unique_id: str, - suggested_object_id: str, - issue_string: str, - entity_id: str, -) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" - issue_id = f"deprecated_binary_{issue_string}_{entity_id}" - - entity_entry = entity_registry.async_get_or_create( - BINARY_SENSOR_DOMAIN, - DOMAIN, - unique_id, - suggested_object_id=suggested_object_id, - original_name=suggested_object_id, - ) - - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "id": "test", - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": { - "action": "automation.turn_on", - "target": { - "entity_id": "automation.test", - }, - }, - } - }, - ) - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": [ - { - "condition": "state", - "entity_id": entity_id, - "state": "on", - }, - ], - } - } - }, - ) - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get(entity_id).state == STATE_OFF - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue is not None - assert issue.translation_key == f"deprecated_binary_{issue_string}_scripts" - assert issue.translation_placeholders == { - "entity_id": entity_id, - "entity_name": suggested_object_id, - "items": "- [test](/config/automation/edit/test)\n- [test](/config/script/edit/test)", - } - - entity_registry.async_update_entity( - entity_entry.entity_id, - disabled_by=er.RegistryEntryDisabler.USER, - ) - - await hass.config_entries.async_reload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - - -@pytest.mark.parametrize( - ("device_fixture", "unique_id", "suggested_object_id", "issue_string", "entity_id"), - [ - ( - "virtual_valve", - f"612ab3c2-3bb0-48f7-b2c0-15b169cb2fc3_{MAIN}_{Capability.VALVE}_{Attribute.VALVE}_{Attribute.VALVE}", - "volvo_valve", - "valve", - "binary_sensor.volvo_valve", - ), - ( - "da_ref_normal_000001", - f"7db87911-7dce-1cf2-7119-b953432a2f09_{MAIN}_{Capability.CONTACT_SENSOR}_{Attribute.CONTACT}_{Attribute.CONTACT}", - "refrigerator_door", - "fridge_door", - "binary_sensor.refrigerator_door", - ), - ], -) -async def test_create_issue( - hass: HomeAssistant, - devices: AsyncMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, - unique_id: str, - suggested_object_id: str, - issue_string: str, - entity_id: str, -) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" - issue_id = f"deprecated_binary_{issue_string}_{entity_id}" - - entity_entry = entity_registry.async_get_or_create( - BINARY_SENSOR_DOMAIN, - DOMAIN, - unique_id, - suggested_object_id=suggested_object_id, - original_name=suggested_object_id, - ) - - await setup_integration(hass, mock_config_entry) - - assert hass.states.get(entity_id).state == STATE_OFF - - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue is not None - assert issue.translation_key == f"deprecated_binary_{issue_string}" - assert issue.translation_placeholders == { - "entity_id": entity_id, - "entity_name": suggested_object_id, - } - - entity_registry.async_update_entity( - entity_entry.entity_id, - disabled_by=er.RegistryEntryDisabler.USER, - ) - - await hass.config_entries.async_reload(mock_config_entry.entry_id) - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue(DOMAIN, issue_id) diff --git a/tests/components/tuya/snapshots/test_climate.ambr b/tests/components/tuya/snapshots/test_climate.ambr index 1c225bf9fec7f..f7df76f5fc628 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -50,7 +50,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 22.0, - 'fan_mode': 1, + 'fan_mode': None, 'fan_modes': list([ '1', '2', @@ -922,7 +922,7 @@ ]), 'max_temp': 40.0, 'min_temp': 5.0, - 'preset_mode': 'hold', + 'preset_mode': None, 'preset_modes': list([ 'holiday', ]), @@ -1134,7 +1134,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 24.0, - 'fan_mode': 2, + 'fan_mode': None, 'fan_modes': list([ '1', '2', diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 310a3ad12f9eb..17925ffca4bcc 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -92,7 +92,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Bree', - 'preset_mode': 'normal', + 'preset_mode': None, 'preset_modes': list([ 'sleep', ]), @@ -112,8 +112,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'preset_modes': list([ - ]), + 'preset_modes': None, }), 'config_entry_id': , 'config_subentry_id': , @@ -152,8 +151,7 @@ 'percentage': 20, 'percentage_step': 1.0, 'preset_mode': None, - 'preset_modes': list([ - ]), + 'preset_modes': None, 'supported_features': , }), 'context': , @@ -234,8 +232,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'preset_modes': list([ - ]), + 'preset_modes': None, }), 'config_entry_id': , 'config_subentry_id': , @@ -270,8 +267,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Dehumidifer', - 'preset_modes': list([ - ]), + 'preset_modes': None, 'supported_features': , }), 'context': , @@ -338,8 +334,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'preset_modes': list([ - ]), + 'preset_modes': None, }), 'config_entry_id': , 'config_subentry_id': , @@ -377,8 +372,7 @@ 'percentage': 50, 'percentage_step': 50.0, 'preset_mode': None, - 'preset_modes': list([ - ]), + 'preset_modes': None, 'supported_features': , }), 'context': , @@ -395,8 +389,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'preset_modes': list([ - ]), + 'preset_modes': None, }), 'config_entry_id': , 'config_subentry_id': , @@ -434,8 +427,7 @@ 'percentage': None, 'percentage_step': 33.333333333333336, 'preset_mode': None, - 'preset_modes': list([ - ]), + 'preset_modes': None, 'supported_features': , }), 'context': , @@ -563,8 +555,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'preset_modes': list([ - ]), + 'preset_modes': None, }), 'config_entry_id': , 'config_subentry_id': , @@ -602,8 +593,7 @@ 'percentage': 100, 'percentage_step': 50.0, 'preset_mode': None, - 'preset_modes': list([ - ]), + 'preset_modes': None, 'supported_features': , }), 'context': , diff --git a/tests/components/tuya/test_button.py b/tests/components/tuya/test_button.py index 2413f774fc1a6..391691ff931bd 100644 --- a/tests/components/tuya/test_button.py +++ b/tests/components/tuya/test_button.py @@ -39,13 +39,13 @@ async def test_platform_setup_and_discovery( "mock_device_code", ["sd_lr33znaodtyarrrz"], ) -async def test_button_press( +async def test_action( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, ) -> None: - """Test pressing a button.""" + """Test button action.""" entity_id = "button.v20_reset_duster_cloth" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) diff --git a/tests/components/tuya/test_camera.py b/tests/components/tuya/test_camera.py index 35c0cd12a4a11..186c411e0ce4d 100644 --- a/tests/components/tuya/test_camera.py +++ b/tests/components/tuya/test_camera.py @@ -72,7 +72,7 @@ async def test_platform_setup_and_discovery( ), ], ) -async def test_motion_detection( +async def test_action( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, @@ -80,7 +80,7 @@ async def test_motion_detection( service: str, expected_command: dict[str, Any], ) -> None: - """Test turning off a switch.""" + """Test camera action.""" entity_id = "camera.burocam" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index 847a0b2994133..5cfa7dfb5b124 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -12,11 +12,18 @@ from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HUMIDITY, + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, ATTR_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + HVACMode, ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -44,21 +51,49 @@ async def test_platform_setup_and_discovery( @pytest.mark.parametrize( - "mock_device_code", - ["kt_5wnlzekkstwcdsvm"], -) -@pytest.mark.parametrize( - ("service", "service_data", "expected_command"), + ("mock_device_code", "entity_id", "service", "service_data", "expected_commands"), [ ( + "kt_5wnlzekkstwcdsvm", + "climate.air_conditioner", SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: 22.7}, - {"code": "temp_set", "value": 23}, + [{"code": "temp_set", "value": 23}], ), ( + "kt_5wnlzekkstwcdsvm", + "climate.air_conditioner", SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: 2}, - {"code": "windspeed", "value": "2"}, + [{"code": "windspeed", "value": "2"}], + ), + ( + "kt_5wnlzekkstwcdsvm", + "climate.air_conditioner", + SERVICE_TURN_ON, + {}, + [{"code": "switch", "value": True}], + ), + ( + "kt_5wnlzekkstwcdsvm", + "climate.air_conditioner", + SERVICE_TURN_OFF, + {}, + [{"code": "switch", "value": False}], + ), + ( + "kt_ibmmirhhq62mmf1g", + "climate.master_bedroom_ac", + SERVICE_SET_HVAC_MODE, + {ATTR_HVAC_MODE: HVACMode.COOL}, + [{"code": "switch", "value": True}, {"code": "mode", "value": "cold"}], + ), + ( + "wk_gc1bxoq2hafxpa35", + "climate.polotentsosushitel", + SERVICE_SET_PRESET_MODE, + {ATTR_PRESET_MODE: "holiday"}, + [{"code": "mode", "value": "holiday"}], ), ], ) @@ -67,12 +102,12 @@ async def test_action( 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], + expected_commands: list[dict[str, Any]], ) -> None: - """Test service action.""" - entity_id = "climate.air_conditioner" + """Test climate action.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) state = hass.states.get(entity_id) @@ -87,7 +122,7 @@ async def test_action( blocking=True, ) mock_manager.send_commands.assert_called_once_with( - mock_device.id, [expected_command] + mock_device.id, expected_commands ) diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 19c723b2254b8..fc4f1a8e708e4 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch import pytest @@ -53,48 +54,45 @@ async def test_platform_setup_and_discovery( "mock_device_code", ["cl_zah67ekd"], ) -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) -async def test_open_service( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test open service.""" - entity_id = "cover.kitchen_blinds_curtain" - 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( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - { - ATTR_ENTITY_ID: entity_id, - }, - blocking=True, - ) - mock_manager.send_commands.assert_called_once_with( - mock_device.id, - [ - {"code": "control", "value": "open"}, - {"code": "percent_control", "value": 0}, - ], - ) - - @pytest.mark.parametrize( - "mock_device_code", - ["cl_zah67ekd"], + ("service", "service_data", "expected_commands"), + [ + ( + SERVICE_OPEN_COVER, + {}, + [ + {"code": "control", "value": "open"}, + ], + ), + ( + SERVICE_CLOSE_COVER, + {}, + [ + {"code": "control", "value": "close"}, + ], + ), + ( + SERVICE_SET_COVER_POSITION, + { + ATTR_POSITION: 25, + }, + [ + {"code": "percent_control", "value": 75}, + ], + ), + ], ) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) -async def test_close_service( +async def test_action( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + service: str, + service_data: dict[str, Any], + expected_commands: list[dict[str, Any]], ) -> None: - """Test close service.""" + """Test cover action.""" entity_id = "cover.kitchen_blinds_curtain" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) @@ -102,51 +100,15 @@ async def test_close_service( assert state is not None, f"{entity_id} does not exist" await hass.services.async_call( COVER_DOMAIN, - SERVICE_CLOSE_COVER, + service, { ATTR_ENTITY_ID: entity_id, + **service_data, }, blocking=True, ) mock_manager.send_commands.assert_called_once_with( - mock_device.id, - [ - {"code": "control", "value": "close"}, - {"code": "percent_control", "value": 100}, - ], - ) - - -@pytest.mark.parametrize( - "mock_device_code", - ["cl_zah67ekd"], -) -async def test_set_position( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test set position service (not available on this device).""" - entity_id = "cover.kitchen_blinds_curtain" - 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( - COVER_DOMAIN, - SERVICE_SET_COVER_POSITION, - { - ATTR_ENTITY_ID: entity_id, - ATTR_POSITION: 25, - }, - blocking=True, - ) - mock_manager.send_commands.assert_called_once_with( - mock_device.id, - [ - {"code": "percent_control", "value": 75}, - ], + mock_device.id, expected_commands ) @@ -265,7 +227,6 @@ async def test_clkg_wltqkykhni0papzj_state( {}, [ {"code": "control", "value": "open"}, - {"code": "percent_control", "value": 100}, ], ), ( @@ -280,7 +241,6 @@ async def test_clkg_wltqkykhni0papzj_state( {}, [ {"code": "control", "value": "close"}, - {"code": "percent_control", "value": 0}, ], ), ], diff --git a/tests/components/tuya/test_fan.py b/tests/components/tuya/test_fan.py index d45103ddd0500..d65db472f590e 100644 --- a/tests/components/tuya/test_fan.py +++ b/tests/components/tuya/test_fan.py @@ -2,12 +2,22 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch +import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice, Manager -from homeassistant.const import Platform +from homeassistant.components.fan import ( + DOMAIN as FAN_DOMAIN, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_PRESET_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -29,3 +39,80 @@ async def test_platform_setup_and_discovery( await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]) +@pytest.mark.parametrize( + ("mock_device_code", "entity_id", "service", "service_data", "expected_commands"), + [ + ( + "ks_j9fa8ahzac8uvlfl", + "fan.tower_fan_ca_407g_smart", + SERVICE_OSCILLATE, + {"oscillating": False}, + [{"code": "switch_horizontal", "value": False}], + ), + ( + "ks_j9fa8ahzac8uvlfl", + "fan.tower_fan_ca_407g_smart", + SERVICE_OSCILLATE, + {"oscillating": True}, + [{"code": "switch_horizontal", "value": True}], + ), + ( + "fs_g0ewlb1vmwqljzji", + "fan.ceiling_fan_with_light", + SERVICE_SET_DIRECTION, + {"direction": "forward"}, + [{"code": "fan_direction", "value": "forward"}], + ), + ( + "ks_j9fa8ahzac8uvlfl", + "fan.tower_fan_ca_407g_smart", + SERVICE_SET_PRESET_MODE, + {"preset_mode": "sleep"}, + [{"code": "mode", "value": "sleep"}], + ), + ( + "fs_g0ewlb1vmwqljzji", + "fan.ceiling_fan_with_light", + SERVICE_TURN_OFF, + {}, + [{"code": "switch", "value": False}], + ), + ( + "fs_g0ewlb1vmwqljzji", + "fan.ceiling_fan_with_light", + SERVICE_TURN_ON, + {"preset_mode": "sleep"}, + [{"code": "switch", "value": True}, {"code": "mode", "value": "sleep"}], + ), + ], +) +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_commands: list[dict[str, Any]], +) -> None: + """Test fan 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( + FAN_DOMAIN, + service, + { + ATTR_ENTITY_ID: entity_id, + **service_data, + }, + blocking=True, + ) + mock_manager.send_commands.assert_called_once_with( + mock_device.id, expected_commands + ) diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index 8baa9b5d185c5..8a4d193a56b22 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -66,7 +66,7 @@ async def test_action( service_data: dict[str, Any], expected_command: dict[str, Any], ) -> None: - """Test service action.""" + """Test humidifier action.""" entity_id = "humidifier.dehumidifier" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index 45f1fe387f134..37d70150d1112 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -107,7 +107,7 @@ async def test_action( service_data: dict[str, Any], expected_commands: list[dict[str, Any]], ) -> None: - """Test service action.""" + """Test light action.""" entity_id = "light.garage_light" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) diff --git a/tests/components/tuya/test_siren.py b/tests/components/tuya/test_siren.py index c3de4979a45c8..1cd91656e0177 100644 --- a/tests/components/tuya/test_siren.py +++ b/tests/components/tuya/test_siren.py @@ -2,6 +2,7 @@ from __future__ import annotations +from typing import Any from unittest.mock import patch import pytest @@ -42,43 +43,28 @@ async def test_platform_setup_and_discovery( "mock_device_code", ["sp_sdd5f5f2dl5wydjf"], ) -async def test_turn_on( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test turning on.""" - entity_id = "siren.c9" - 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( - SIREN_DOMAIN, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: entity_id, - }, - blocking=True, - ) - mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "siren_switch", "value": True}] - ) - - -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) @pytest.mark.parametrize( - "mock_device_code", - ["sp_sdd5f5f2dl5wydjf"], + ("service", "expected_commands"), + [ + ( + SERVICE_TURN_ON, + [{"code": "siren_switch", "value": True}], + ), + ( + SERVICE_TURN_OFF, + [{"code": "siren_switch", "value": False}], + ), + ], ) -async def test_turn_off( +async def test_action( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + service: str, + expected_commands: list[dict[str, Any]], ) -> None: - """Test turning off.""" + """Test siren action.""" entity_id = "siren.c9" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) @@ -86,12 +72,12 @@ async def test_turn_off( assert state is not None, f"{entity_id} does not exist" await hass.services.async_call( SIREN_DOMAIN, - SERVICE_TURN_OFF, + service, { ATTR_ENTITY_ID: entity_id, }, blocking=True, ) mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "siren_switch", "value": False}] + mock_device.id, expected_commands ) diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py index 97eb4eabf7e84..072efdb6ee188 100644 --- a/tests/components/tuya/test_switch.py +++ b/tests/components/tuya/test_switch.py @@ -95,43 +95,28 @@ async def test_sfkzq_deprecated_switch( "mock_device_code", ["cz_PGEkBctAbtzKOZng"], ) -async def test_turn_on( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test turning on a switch.""" - entity_id = "switch.din_socket" - 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( - SWITCH_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}] - ) - - -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) @pytest.mark.parametrize( - "mock_device_code", - ["cz_PGEkBctAbtzKOZng"], + ("service", "expected_commands"), + [ + ( + SERVICE_TURN_ON, + [{"code": "switch", "value": True}], + ), + ( + SERVICE_TURN_OFF, + [{"code": "switch", "value": False}], + ), + ], ) -async def test_turn_off( +async def test_action( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + service: str, + expected_commands: list[dict[str, Any]], ) -> None: - """Test turning off a switch.""" + """Test switch action.""" entity_id = "switch.din_socket" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) @@ -139,14 +124,14 @@ async def test_turn_off( assert state is not None, f"{entity_id} does not exist" await hass.services.async_call( SWITCH_DOMAIN, - SERVICE_TURN_OFF, + service, { ATTR_ENTITY_ID: entity_id, }, blocking=True, ) mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "switch", "value": False}] + mock_device.id, expected_commands ) diff --git a/tests/components/tuya/test_vacuum.py b/tests/components/tuya/test_vacuum.py index b51eb405b2fc9..c8d495c433972 100644 --- a/tests/components/tuya/test_vacuum.py +++ b/tests/components/tuya/test_vacuum.py @@ -100,7 +100,7 @@ async def test_action( service_data: dict[str, Any], expected_command: dict[str, Any], ) -> None: - """Test service action.""" + """Test vacuum action.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) state = hass.states.get(entity_id) diff --git a/tests/components/tuya/test_valve.py b/tests/components/tuya/test_valve.py index dd840633da839..a315227a6f804 100644 --- a/tests/components/tuya/test_valve.py +++ b/tests/components/tuya/test_valve.py @@ -43,43 +43,28 @@ async def test_platform_setup_and_discovery( "mock_device_code", ["sfkzq_ed7frwissyqrejic"], ) -async def test_open_valve( - hass: HomeAssistant, - mock_manager: Manager, - mock_config_entry: MockConfigEntry, - mock_device: CustomerDevice, -) -> None: - """Test opening a valve.""" - entity_id = "valve.jie_hashui_fa_valve_1" - 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( - VALVE_DOMAIN, - SERVICE_OPEN_VALVE, - { - ATTR_ENTITY_ID: entity_id, - }, - blocking=True, - ) - mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "switch_1", "value": True}] - ) - - -@patch("homeassistant.components.tuya.PLATFORMS", [Platform.VALVE]) @pytest.mark.parametrize( - "mock_device_code", - ["sfkzq_ed7frwissyqrejic"], + ("service", "expected_commands"), + [ + ( + SERVICE_OPEN_VALVE, + [{"code": "switch_1", "value": True}], + ), + ( + SERVICE_CLOSE_VALVE, + [{"code": "switch_1", "value": False}], + ), + ], ) -async def test_close_valve( +async def test_action( hass: HomeAssistant, mock_manager: Manager, mock_config_entry: MockConfigEntry, mock_device: CustomerDevice, + service: str, + expected_commands: list[dict[str, Any]], ) -> None: - """Test closing a valve.""" + """Test valve action.""" entity_id = "valve.jie_hashui_fa_valve_1" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) @@ -87,14 +72,14 @@ async def test_close_valve( assert state is not None, f"{entity_id} does not exist" await hass.services.async_call( VALVE_DOMAIN, - SERVICE_CLOSE_VALVE, + service, { ATTR_ENTITY_ID: entity_id, }, blocking=True, ) mock_manager.send_commands.assert_called_once_with( - mock_device.id, [{"code": "switch_1", "value": False}] + mock_device.id, expected_commands ) diff --git a/tests/components/wallbox/conftest.py b/tests/components/wallbox/conftest.py index c20c6e59da129..2f643ba4645ef 100644 --- a/tests/components/wallbox/conftest.py +++ b/tests/components/wallbox/conftest.py @@ -1,5 +1,6 @@ """Test fixtures for the Wallbox integration.""" +from datetime import datetime, timedelta from http import HTTPStatus from unittest.mock import MagicMock, Mock, patch @@ -10,6 +11,10 @@ CHARGER_DATA_POST_L1_KEY, CHARGER_DATA_POST_L2_KEY, CHARGER_ENERGY_PRICE_KEY, + CHARGER_JWT_REFRESH_TOKEN, + CHARGER_JWT_REFRESH_TTL, + CHARGER_JWT_TOKEN, + CHARGER_JWT_TTL, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_CHARGING_CURRENT_POST_KEY, CHARGER_MAX_ICP_CURRENT_KEY, @@ -43,6 +48,14 @@ def entry(hass: HomeAssistant) -> MockConfigEntry: CONF_USERNAME: "test_username", CONF_PASSWORD: "test_password", CONF_STATION: "12345", + CHARGER_JWT_TOKEN: "test_token", + CHARGER_JWT_REFRESH_TOKEN: "test_refresh_token", + CHARGER_JWT_TTL: ( + datetime.timestamp(datetime.now() + timedelta(hours=1)) * 1000 + ), + CHARGER_JWT_REFRESH_TTL: ( + datetime.timestamp(datetime.now() + timedelta(hours=1)) * 1000 + ), }, entry_id="testEntry", ) @@ -82,6 +95,14 @@ def mock_wallbox(): ) wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 25}) wallbox.getChargerStatus = Mock(return_value=WALLBOX_STATUS_RESPONSE) + wallbox.jwtToken = "test_token" + wallbox.jwtRefreshToken = "test_refresh_token" + wallbox.jwtTokenTtl = ( + datetime.timestamp(datetime.now() + timedelta(hours=1)) * 1000 + ) + wallbox.jwtRefreshTokenTtl = ( + datetime.timestamp(datetime.now() + timedelta(hours=1)) * 1000 + ) mock.return_value = wallbox yield wallbox diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index 25265aeda4a42..ac0f4ab8743a5 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -9,19 +9,20 @@ CHARGER_CHARGING_POWER_KEY, CHARGER_CHARGING_SPEED_KEY, CHARGER_DATA_KEY, + CHARGER_JWT_REFRESH_TOKEN, + CHARGER_JWT_TOKEN, CHARGER_MAX_AVAILABLE_POWER_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CONF_STATION, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .conftest import http_403_error, http_404_error, setup_integration -from .const import ( - WALLBOX_AUTHORISATION_RESPONSE, - WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED, -) +from .const import WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED from tests.common import MockConfigEntry @@ -62,9 +63,9 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "station": "12345", - "username": "test-username", - "password": "test-password", + CONF_STATION: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) @@ -90,9 +91,9 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "station": "12345", - "username": "test-username", - "password": "test-password", + CONF_STATION: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) @@ -100,32 +101,33 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_validate_input(hass: HomeAssistant) -> None: +async def test_form_validate_input( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: """Test we can validate input.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) with ( patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - return_value=WALLBOX_AUTHORISATION_RESPONSE, - ), - patch( - "homeassistant.components.wallbox.Wallbox.pauseChargingSession", - return_value=test_response, + "homeassistant.components.wallbox.config_flow.Wallbox", + return_value=mock_wallbox, ), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "station": "12345", - "username": "test-username", - "password": "test-password", + CONF_STATION: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) assert result2["title"] == "Wallbox Portal" - assert result2["data"]["station"] == "12345" + assert result2["data"][CONF_STATION] == "12345" + assert result2["data"][CONF_USERNAME] == "test-username" + assert result2["data"][CHARGER_JWT_TOKEN] == "test_token" + assert result2["data"][CHARGER_JWT_REFRESH_TOKEN] == "test_refresh_token" async def test_form_reauth( @@ -148,9 +150,9 @@ async def test_form_reauth( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "station": "12345", - "username": "test-username", - "password": "test-password", + CONF_STATION: "12345", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) @@ -181,9 +183,9 @@ async def test_form_reauth_invalid( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "station": "12345678", - "username": "test-username", - "password": "test-password", + CONF_STATION: "12345678", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index 4d882da7a6eee..0934bcda4644a 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -6,10 +6,11 @@ import pytest from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.components.wallbox.const import CHARGER_JWT_TTL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from .conftest import http_403_error, http_429_error, setup_integration from .const import ( @@ -32,18 +33,6 @@ async def test_wallbox_setup_unload_entry( assert entry.state is ConfigEntryState.NOT_LOADED -async def test_wallbox_unload_entry_connection_error( - hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox -) -> None: - """Test Wallbox Unload Connection Error.""" - with patch.object(mock_wallbox, "authenticate", side_effect=http_403_error): - await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.SETUP_ERROR - - assert await hass.config_entries.async_unload(entry.entry_id) - assert entry.state is ConfigEntryState.NOT_LOADED - - async def test_wallbox_refresh_failed_connection_error_too_many_requests( hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: @@ -69,9 +58,15 @@ async def test_wallbox_refresh_failed_error_auth( await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED + data = dict(entry.data) + data[CHARGER_JWT_TTL] = ( + datetime.timestamp(datetime.now() - timedelta(hours=1)) * 1000 + ) + hass.config_entries.async_update_entry(entry, data=data) + with ( patch.object(mock_wallbox, "authenticate", side_effect=http_403_error), - pytest.raises(HomeAssistantError), + pytest.raises(ConfigEntryAuthFailed), ): await hass.services.async_call( "number",