diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a7ae883be81258..d8e2b92c39bc32 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 3 + CACHE_VERSION: 2 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 HA_SHORT_VERSION: "2026.1" diff --git a/homeassistant/components/airobot/__init__.py b/homeassistant/components/airobot/__init__.py index 0b10707cba2649..9289ccdbfb3d91 100644 --- a/homeassistant/components/airobot/__init__.py +++ b/homeassistant/components/airobot/__init__.py @@ -7,7 +7,7 @@ from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool: diff --git a/homeassistant/components/airobot/icons.json b/homeassistant/components/airobot/icons.json new file mode 100644 index 00000000000000..2ea387512e4880 --- /dev/null +++ b/homeassistant/components/airobot/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "number": { + "hysteresis_band": { + "default": "mdi:delta" + } + } + } +} diff --git a/homeassistant/components/airobot/number.py b/homeassistant/components/airobot/number.py new file mode 100644 index 00000000000000..8cdd0b56a4c89d --- /dev/null +++ b/homeassistant/components/airobot/number.py @@ -0,0 +1,99 @@ +"""Number platform for Airobot thermostat.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from pyairobotrest.const import HYSTERESIS_BAND_MAX, HYSTERESIS_BAND_MIN +from pyairobotrest.exceptions import AirobotError + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AirobotConfigEntry +from .const import DOMAIN +from .coordinator import AirobotDataUpdateCoordinator +from .entity import AirobotEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AirobotNumberEntityDescription(NumberEntityDescription): + """Describes Airobot number entity.""" + + value_fn: Callable[[AirobotDataUpdateCoordinator], float] + set_value_fn: Callable[[AirobotDataUpdateCoordinator, float], Awaitable[None]] + + +NUMBERS: tuple[AirobotNumberEntityDescription, ...] = ( + AirobotNumberEntityDescription( + key="hysteresis_band", + translation_key="hysteresis_band", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_min_value=HYSTERESIS_BAND_MIN / 10.0, + native_max_value=HYSTERESIS_BAND_MAX / 10.0, + native_step=0.1, + value_fn=lambda coordinator: coordinator.data.settings.hysteresis_band, + set_value_fn=lambda coordinator, value: coordinator.client.set_hysteresis_band( + value + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AirobotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Airobot number platform.""" + coordinator = entry.runtime_data + async_add_entities( + AirobotNumber(coordinator, description) for description in NUMBERS + ) + + +class AirobotNumber(AirobotEntity, NumberEntity): + """Representation of an Airobot number entity.""" + + entity_description: AirobotNumberEntityDescription + + def __init__( + self, + coordinator: AirobotDataUpdateCoordinator, + description: AirobotNumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}" + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.entity_description.value_fn(self.coordinator) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + try: + await self.entity_description.set_value_fn(self.coordinator, value) + except AirobotError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="set_value_failed", + translation_placeholders={"error": str(err)}, + ) from err + else: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/airobot/quality_scale.yaml b/homeassistant/components/airobot/quality_scale.yaml index 4f905e892b1296..b7213eb04a54bb 100644 --- a/homeassistant/components/airobot/quality_scale.yaml +++ b/homeassistant/components/airobot/quality_scale.yaml @@ -48,7 +48,7 @@ rules: docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: status: exempt comment: Single device integration, no dynamic device discovery needed. @@ -57,7 +57,7 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/airobot/strings.json b/homeassistant/components/airobot/strings.json index 430994f2497f4a..f6808229416451 100644 --- a/homeassistant/components/airobot/strings.json +++ b/homeassistant/components/airobot/strings.json @@ -44,6 +44,11 @@ } }, "entity": { + "number": { + "hysteresis_band": { + "name": "Hysteresis band" + } + }, "sensor": { "air_temperature": { "name": "Air temperature" @@ -74,6 +79,9 @@ }, "set_temperature_failed": { "message": "Failed to set temperature to {temperature}." + }, + "set_value_failed": { + "message": "Failed to set value: {error}" } } } diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index a8c0ac9946db19..81f1e71c1324f0 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -773,10 +773,15 @@ async def async_setup_entry( continue if key_meta := SENSOR_META.get(key): if key_meta.include: - items = filter(key_meta.include.search, items) + items = {k: v for k, v in items.items() if key_meta.include.search(k)} if key_meta.exclude: - items = [x for x in items if not key_meta.exclude.search(x)] - for item in items: + items = { + k: v for k, v in items.items() if not key_meta.exclude.search(k) + } + for item, value in items.items(): + if value is None: + _LOGGER.debug("Ignoring sensor %s.%s due to None value", key, item) + continue if not (desc := SENSOR_META[key].descriptions.get(item)): _LOGGER.debug( # pylint: disable=hass-logger-period # false positive ( diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 993081f8049c42..4a7000cecdd562 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -20,7 +20,7 @@ "loggers": ["roborock"], "quality_scale": "silver", "requirements": [ - "python-roborock==3.19.0", + "python-roborock==3.20.1", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index b014abfeb2c0ba..db783e0e5d3750 100644 --- a/homeassistant/components/saunum/__init__.py +++ b/homeassistant/components/saunum/__init__.py @@ -12,6 +12,7 @@ from .coordinator import LeilSaunaCoordinator PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR, diff --git a/homeassistant/components/saunum/binary_sensor.py b/homeassistant/components/saunum/binary_sensor.py new file mode 100644 index 00000000000000..1a50b8f4abd6b0 --- /dev/null +++ b/homeassistant/components/saunum/binary_sensor.py @@ -0,0 +1,120 @@ +"""Binary sensor platform for Saunum Leil Sauna Control Unit integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from pysaunum import SaunumData + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LeilSaunaConfigEntry +from .entity import LeilSaunaEntity + +if TYPE_CHECKING: + from .coordinator import LeilSaunaCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class LeilSaunaBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Leil Sauna binary sensor entity.""" + + value_fn: Callable[[SaunumData], bool | None] + + +BINARY_SENSORS: tuple[LeilSaunaBinarySensorEntityDescription, ...] = ( + LeilSaunaBinarySensorEntityDescription( + key="door_open", + device_class=BinarySensorDeviceClass.DOOR, + value_fn=lambda data: data.door_open, + ), + LeilSaunaBinarySensorEntityDescription( + key="alarm_door_open", + translation_key="alarm_door_open", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.alarm_door_open, + ), + LeilSaunaBinarySensorEntityDescription( + key="alarm_door_sensor", + translation_key="alarm_door_sensor", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.alarm_door_sensor, + ), + LeilSaunaBinarySensorEntityDescription( + key="alarm_thermal_cutoff", + translation_key="alarm_thermal_cutoff", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.alarm_thermal_cutoff, + ), + LeilSaunaBinarySensorEntityDescription( + key="alarm_internal_temp", + translation_key="alarm_internal_temp", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.alarm_internal_temp, + ), + LeilSaunaBinarySensorEntityDescription( + key="alarm_temp_sensor_short", + translation_key="alarm_temp_sensor_short", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.alarm_temp_sensor_short, + ), + LeilSaunaBinarySensorEntityDescription( + key="alarm_temp_sensor_open", + translation_key="alarm_temp_sensor_open", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.alarm_temp_sensor_open, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LeilSaunaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Saunum Leil Sauna binary sensors from a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + LeilSaunaBinarySensorEntity(coordinator, description) + for description in BINARY_SENSORS + if description.value_fn(coordinator.data) is not None + ) + + +class LeilSaunaBinarySensorEntity(LeilSaunaEntity, BinarySensorEntity): + """Representation of a Saunum Leil Sauna binary sensor.""" + + entity_description: LeilSaunaBinarySensorEntityDescription + + def __init__( + self, + coordinator: LeilSaunaCoordinator, + description: LeilSaunaBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{description.key}" + self.entity_description = description + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/saunum/climate.py b/homeassistant/components/saunum/climate.py index 2f10cef9f97623..f90703edea2dca 100644 --- a/homeassistant/components/saunum/climate.py +++ b/homeassistant/components/saunum/climate.py @@ -100,6 +100,12 @@ def hvac_action(self) -> HVACAction | None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new HVAC mode.""" + if hvac_mode == HVACMode.HEAT and self.coordinator.data.door_open: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="door_open", + ) + try: if hvac_mode == HVACMode.HEAT: await self.coordinator.client.async_start_session() diff --git a/homeassistant/components/saunum/strings.json b/homeassistant/components/saunum/strings.json index 003d48fb734c2d..bc7bf3803ed7f4 100644 --- a/homeassistant/components/saunum/strings.json +++ b/homeassistant/components/saunum/strings.json @@ -30,6 +30,26 @@ } }, "entity": { + "binary_sensor": { + "alarm_door_open": { + "name": "Door open during heating alarm" + }, + "alarm_door_sensor": { + "name": "Door open too long alarm" + }, + "alarm_internal_temp": { + "name": "Internal temperature alarm" + }, + "alarm_temp_sensor_open": { + "name": "Temperature sensor disconnected alarm" + }, + "alarm_temp_sensor_short": { + "name": "Temperature sensor shorted alarm" + }, + "alarm_thermal_cutoff": { + "name": "Thermal cutoff alarm" + } + }, "light": { "light": { "name": "[%key:component::light::title%]" @@ -49,6 +69,9 @@ "communication_error": { "message": "Communication error: {error}" }, + "door_open": { + "message": "Cannot start sauna session when sauna door is open" + }, "session_not_active": { "message": "Cannot change fan mode when sauna session is not active" }, diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json index a99cf9b4891ccc..8fa49838245c32 100644 --- a/homeassistant/components/smarla/manifest.json +++ b/homeassistant/components/smarla/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["pysmarlaapi", "pysignalr"], "quality_scale": "bronze", - "requirements": ["pysmarlaapi==0.9.2"] + "requirements": ["pysmarlaapi==0.9.3"] } diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index ff1d8b5e12062d..e25763bc89421b 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -276,6 +276,8 @@ async def make_device_data( "Color Bulb", "RGBICWW Floor Lamp", "RGBICWW Strip Light", + "Ceiling Light", + "Ceiling Light Pro", ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id diff --git a/homeassistant/components/switchbot_cloud/light.py b/homeassistant/components/switchbot_cloud/light.py index 77062702831ae4..55296fd4349e57 100644 --- a/homeassistant/components/switchbot_cloud/light.py +++ b/homeassistant/components/switchbot_cloud/light.py @@ -4,6 +4,7 @@ from typing import Any from switchbot_api import ( + CeilingLightCommands, CommonCommands, Device, Remote, @@ -53,6 +54,16 @@ class SwitchBotCloudLight(SwitchBotCloudEntity, LightEntity): _attr_color_mode = ColorMode.UNKNOWN + def _get_default_color_mode(self) -> ColorMode: + """Return the default color mode.""" + if not self.supported_color_modes: + return ColorMode.UNKNOWN + if ColorMode.RGB in self.supported_color_modes: + return ColorMode.RGB + if ColorMode.COLOR_TEMP in self.supported_color_modes: + return ColorMode.COLOR_TEMP + return ColorMode.UNKNOWN + def _set_attributes(self) -> None: """Set attributes from coordinator data.""" if self.coordinator.data is None: @@ -83,8 +94,9 @@ async def async_turn_on(self, **kwargs: Any) -> None: brightness: int | None = kwargs.get("brightness") rgb_color: tuple[int, int, int] | None = kwargs.get("rgb_color") color_temp_kelvin: int | None = kwargs.get("color_temp_kelvin") + if brightness is not None: - self._attr_color_mode = ColorMode.RGB + self._attr_color_mode = self._get_default_color_mode() await self._send_brightness_command(brightness) elif rgb_color is not None: self._attr_color_mode = ColorMode.RGB @@ -93,7 +105,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: self._attr_color_mode = ColorMode.COLOR_TEMP await self._send_color_temperature_command(color_temp_kelvin) else: - self._attr_color_mode = ColorMode.RGB + self._attr_color_mode = self._get_default_color_mode() await self.send_api_command(CommonCommands.ON) await asyncio.sleep(AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() @@ -149,11 +161,36 @@ async def _send_rgb_color_command(self, rgb_color: tuple) -> None: ) +class SwitchBotCloudCeilingLight(SwitchBotCloudLight): + """Representation of SwitchBot Ceiling Light.""" + + _attr_max_color_temp_kelvin = 6500 + _attr_min_color_temp_kelvin = 2700 + + _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + + async def _send_brightness_command(self, brightness: int) -> None: + """Send a brightness command.""" + await self.send_api_command( + CeilingLightCommands.SET_BRIGHTNESS, + parameters=str(value_map_brightness(brightness)), + ) + + async def _send_color_temperature_command(self, color_temp_kelvin: int) -> None: + """Send a color temperature command.""" + await self.send_api_command( + CeilingLightCommands.SET_COLOR_TEMPERATURE, + parameters=str(color_temp_kelvin), + ) + + @callback def _async_make_entity( api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator -) -> SwitchBotCloudStripLight | SwitchBotCloudRGBWWLight: +) -> SwitchBotCloudStripLight | SwitchBotCloudRGBWWLight | SwitchBotCloudCeilingLight: """Make a SwitchBotCloudLight.""" if device.device_type == "Strip Light": return SwitchBotCloudStripLight(api, device, coordinator) + if device.device_type in ["Ceiling Light", "Ceiling Light Pro"]: + return SwitchBotCloudCeilingLight(api, device, coordinator) return SwitchBotCloudRGBWWLight(api, device, coordinator) diff --git a/homeassistant/components/upnp/icons.json b/homeassistant/components/upnp/icons.json index 429fb31b57f16b..8f6b1c493662e9 100644 --- a/homeassistant/components/upnp/icons.json +++ b/homeassistant/components/upnp/icons.json @@ -1,41 +1,26 @@ { "entity": { "sensor": { - "data_received": { - "default": "mdi:server-network" - }, - "data_sent": { - "default": "mdi:server-network" - }, - "download_speed": { - "default": "mdi:server-network" - }, "external_ip": { - "default": "mdi:server-network" + "default": "mdi:ip" }, "packet_download_speed": { - "default": "mdi:server-network" + "default": "mdi:transmission-tower" }, "packet_upload_speed": { - "default": "mdi:server-network" + "default": "mdi:transmission-tower" }, "packets_received": { - "default": "mdi:server-network" + "default": "mdi:database" }, "packets_sent": { - "default": "mdi:server-network" + "default": "mdi:database" }, "port_mapping_number_of_entries_ipv4": { "default": "mdi:server-network" }, - "upload_speed": { - "default": "mdi:server-network" - }, - "uptime": { - "default": "mdi:server-network" - }, "wan_status": { - "default": "mdi:server-network" + "default": "mdi:network" } } } diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 829f48e6f521c1..83b8bce6c0c2b6 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -23,7 +23,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: todo docs-installation-parameters: todo diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 96ef91e81745f4..34190536df8136 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -2,11 +2,15 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from velbusaio.channels import ButtonCounter, LightSensor, SensorNumber, Temperature from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.core import HomeAssistant @@ -17,6 +21,48 @@ PARALLEL_UPDATES = 0 +type VelbusSensorChannel = ButtonCounter | Temperature | LightSensor | SensorNumber + + +@dataclass(frozen=True, kw_only=True) +class VelbusSensorEntityDescription(SensorEntityDescription): + """Describes Velbus sensor entity.""" + + value_fn: Callable[[VelbusSensorChannel], float | None] = lambda channel: float( + channel.get_state() + ) + unit_fn: Callable[[VelbusSensorChannel], str | None] = ( + lambda channel: channel.get_unit() + ) + unique_id_suffix: str = "" + + +SENSOR_DESCRIPTIONS: dict[str, VelbusSensorEntityDescription] = { + "power": VelbusSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + "temperature": VelbusSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + "measurement": VelbusSensorEntityDescription( + key="measurement", + state_class=SensorStateClass.MEASUREMENT, + ), + "counter": VelbusSensorEntityDescription( + key="counter", + device_class=SensorDeviceClass.ENERGY, + icon="mdi:counter", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda channel: float(channel.get_counter_state()), + unit_fn=lambda channel: channel.get_counter_unit(), + unique_id_suffix="-counter", + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -25,50 +71,51 @@ async def async_setup_entry( ) -> None: """Set up Velbus switch based on config_entry.""" await entry.runtime_data.scan_task - entities = [] + entities: list[VelbusSensor] = [] for channel in entry.runtime_data.controller.get_all_sensor(): - entities.append(VelbusSensor(channel)) + # Determine which description to use for the main sensor if channel.is_counter_channel(): - entities.append(VelbusSensor(channel, True)) + description = SENSOR_DESCRIPTIONS["power"] + elif channel.is_temperature(): + description = SENSOR_DESCRIPTIONS["temperature"] + else: + description = SENSOR_DESCRIPTIONS["measurement"] + + entities.append(VelbusSensor(channel, description)) + + # Add counter entity if applicable + if channel.is_counter_channel(): + entities.append( + VelbusSensor(channel, SENSOR_DESCRIPTIONS["counter"], is_counter=True) + ) + async_add_entities(entities) class VelbusSensor(VelbusEntity, SensorEntity): """Representation of a sensor.""" - _channel: ButtonCounter | Temperature | LightSensor | SensorNumber + _channel: VelbusSensorChannel + entity_description: VelbusSensorEntityDescription def __init__( self, - channel: ButtonCounter | Temperature | LightSensor | SensorNumber, - counter: bool = False, + channel: VelbusSensorChannel, + description: VelbusSensorEntityDescription, + is_counter: bool = False, ) -> None: """Initialize a sensor Velbus entity.""" super().__init__(channel) - self._is_counter: bool = counter - if self._is_counter: - self._attr_device_class = SensorDeviceClass.ENERGY - self._attr_icon = "mdi:counter" + self.entity_description = description + self._is_counter = is_counter + self._attr_native_unit_of_measurement = description.unit_fn(channel) + self._attr_unique_id = f"{self._attr_unique_id}{description.unique_id_suffix}" + + # Modify name for counter entities + if is_counter: self._attr_name = f"{self._attr_name}-counter" - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - self._attr_unique_id = f"{self._attr_unique_id}-counter" - elif channel.is_counter_channel(): - self._attr_device_class = SensorDeviceClass.POWER - self._attr_state_class = SensorStateClass.MEASUREMENT - elif channel.is_temperature(): - self._attr_device_class = SensorDeviceClass.TEMPERATURE - self._attr_state_class = SensorStateClass.MEASUREMENT - else: - self._attr_state_class = SensorStateClass.MEASUREMENT - # unit - if self._is_counter: - self._attr_native_unit_of_measurement = channel.get_counter_unit() - else: - self._attr_native_unit_of_measurement = channel.get_unit() @property def native_value(self) -> float | int | None: """Return the state of the sensor.""" - if self._is_counter: - return float(self._channel.get_counter_state()) - return float(self._channel.get_state()) + return self.entity_description.value_fn(self._channel) diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py index de1fb1b2fc868a..773d3d742d0984 100644 --- a/homeassistant/components/velbus/services.py +++ b/homeassistant/components/velbus/services.py @@ -2,7 +2,6 @@ from __future__ import annotations -from contextlib import suppress import os import shutil from typing import TYPE_CHECKING @@ -12,7 +11,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.storage import STORAGE_DIR @@ -36,8 +35,7 @@ def async_setup_services(hass: HomeAssistant) -> None: async def get_config_entry(call: ServiceCall) -> VelbusConfigEntry: """Get the config entry for this service call.""" - if CONF_CONFIG_ENTRY in call.data: - entry_id = call.data[CONF_CONFIG_ENTRY] + entry_id: str = call.data[CONF_CONFIG_ENTRY] if not (entry := hass.config_entries.async_get_entry(entry_id)): raise ServiceValidationError( translation_domain=DOMAIN, @@ -55,26 +53,52 @@ async def get_config_entry(call: ServiceCall) -> VelbusConfigEntry: async def scan(call: ServiceCall) -> None: """Handle a scan service call.""" entry = await get_config_entry(call) - await entry.runtime_data.controller.scan() + try: + await entry.runtime_data.controller.scan() + except OSError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="scan_failed", + translation_placeholders={"error": str(exc)}, + ) from exc async def syn_clock(call: ServiceCall) -> None: """Handle a sync clock service call.""" entry = await get_config_entry(call) - await entry.runtime_data.controller.sync_clock() + try: + await entry.runtime_data.controller.sync_clock() + except OSError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sync_clock_failed", + translation_placeholders={"error": str(exc)}, + ) from exc async def set_memo_text(call: ServiceCall) -> None: """Handle Memo Text service call.""" entry = await get_config_entry(call) memo_text = call.data[CONF_MEMO_TEXT] - module = entry.runtime_data.controller.get_module(call.data[CONF_ADDRESS]) + address = call.data[CONF_ADDRESS] + module = entry.runtime_data.controller.get_module(address) if not module: - raise ServiceValidationError("Module not found") - await module.set_memo_text(memo_text) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="module_not_found", + translation_placeholders={"address": str(address)}, + ) + try: + await module.set_memo_text(memo_text) + except OSError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_memo_text_failed", + translation_placeholders={"error": str(exc)}, + ) from exc async def clear_cache(call: ServiceCall) -> None: """Handle a clear cache service call.""" entry = await get_config_entry(call) - with suppress(FileNotFoundError): + try: if call.data.get(CONF_ADDRESS): await hass.async_add_executor_job( os.unlink, @@ -88,6 +112,14 @@ async def clear_cache(call: ServiceCall) -> None: shutil.rmtree, hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}/"), ) + except FileNotFoundError: + pass # It's okay if the file doesn't exist + except OSError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="clear_cache_failed", + translation_placeholders={"error": str(exc)}, + ) from exc # call a scan to repopulate await scan(call) diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index cdbe7328955baa..ff51ad066e5a01 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -57,14 +57,29 @@ } }, "exceptions": { + "clear_cache_failed": { + "message": "Could not cleat the Velbus cache: {error}" + }, "integration_not_found": { "message": "Integration \"{target}\" not found in registry." }, "invalid_hvac_mode": { "message": "Climate mode {hvac_mode} is not supported." }, + "module_not_found": { + "message": "Module with address {address} not found." + }, "not_loaded": { "message": "{target} is not loaded." + }, + "scan_failed": { + "message": "Scan service: {error}." + }, + "set_memo_text_failed": { + "message": "Failed to set the memo text on the Velbus module: {error}." + }, + "sync_clock_failed": { + "message": "Failed to sync the Velbus clock: {error}." } }, "issues": { diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 457514c0f3f3dd..3c7cec96e4c653 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -12,7 +12,12 @@ EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -48,8 +53,15 @@ async def async_reboot_gateway(service_call: ServiceCall) -> None: # (this is no change to the previous behavior, the alternative would be to reboot all) for entry in hass.config_entries.async_entries(DOMAIN): if entry.state is ConfigEntryState.LOADED: - await entry.runtime_data.reboot_gateway() - return + try: + await entry.runtime_data.reboot_gateway() + except (OSError, PyVLXException) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="reboot_failed", + ) from err + else: + return raise ServiceValidationError( translation_domain=DOMAIN, @@ -74,6 +86,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo LOGGER.debug("Retrieving nodes from %s", host) await pyvlx.load_nodes() except (OSError, PyVLXException) as ex: + # Since pyvlx raises the same exception for auth and connection errors, + # we need to check the exception message to distinguish them. + # Ultimately this should be fixed in pyvlx to raise specialized exceptions, + # right now it's been a while since the last pyvlx release, so we do this workaround here. + if ( + isinstance(ex, PyVLXException) + and ex.description == "Login to KLF 200 failed, check credentials" + ): + raise ConfigEntryAuthFailed( + f"Invalid authentication for Velux gateway at {host}" + ) from ex + # Defer setup and retry later as the bridge is not ready/available raise ConfigEntryNotReady( f"Unable to connect to Velux gateway at {host}. " diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py index 159d412a9778ca..eb6805fc65eb4a 100644 --- a/homeassistant/components/velux/config_flow.py +++ b/homeassistant/components/velux/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Velux integration.""" +from collections.abc import Mapping from typing import Any from pyvlx import PyVLX, PyVLXException @@ -28,6 +29,15 @@ async def _check_connection(host: str, password: str) -> dict[str, Any]: await pyvlx.connect() await pyvlx.disconnect() except (PyVLXException, ConnectionError) as err: + # since pyvlx raises the same exception for auth and connection errors, + # we need to check the exception message to distinguish them + if ( + isinstance(err, PyVLXException) + and err.description == "Login to KLF 200 failed, check credentials" + ): + LOGGER.debug("Invalid password") + return {"base": "invalid_auth"} + LOGGER.debug("Cannot connect: %s", err) return {"base": "cannot_connect"} except Exception as err: # noqa: BLE001 @@ -69,6 +79,42 @@ async def async_step_user( errors=errors, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth flow.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Handle reauth flow when password has changed.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + + if user_input is not None: + errors = await _check_connection( + reauth_entry.data[CONF_HOST], user_input[CONF_PASSWORD] + ) + if not errors: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + } + ), + errors=errors, + description_placeholders={ + "host": reauth_entry.data[CONF_HOST], + }, + ) + async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: diff --git a/homeassistant/components/velux/quality_scale.yaml b/homeassistant/components/velux/quality_scale.yaml index 6e7321971d77ec..4511f640817ba9 100644 --- a/homeassistant/components/velux/quality_scale.yaml +++ b/homeassistant/components/velux/quality_scale.yaml @@ -30,7 +30,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: status: todo comment: add tests where missing diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 261412e7709554..84857dc0ac76dd 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "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": { @@ -17,6 +19,15 @@ }, "description": "Please enter the password for {name} ({host})" }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The password for your KLF200 gateway." + }, + "description": "The password for {host} is incorrect. Please enter the correct password." + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/requirements_all.txt b/requirements_all.txt index 5ae79035f5d396..eddaf11541f989 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2412,7 +2412,7 @@ pysma==1.0.2 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.2 +pysmarlaapi==0.9.3 # homeassistant.components.smartthings pysmartthings==3.5.1 @@ -2572,7 +2572,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==3.19.0 +python-roborock==3.20.1 # homeassistant.components.smarttub python-smarttub==0.0.46 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 029163f41b87e4..bd3b464b8505f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2032,7 +2032,7 @@ pysma==1.0.2 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.2 +pysmarlaapi==0.9.3 # homeassistant.components.smartthings pysmartthings==3.5.1 @@ -2156,7 +2156,7 @@ python-pooldose==0.8.1 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==3.19.0 +python-roborock==3.20.1 # homeassistant.components.smarttub python-smarttub==0.0.46 diff --git a/script/licenses.py b/script/licenses.py index 370c1426a1f589..15d10643fec35f 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -203,7 +203,6 @@ def from_dict(cls, data: PackageMetadata) -> PackageDefinition: "sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14 "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 "ujson", # https://github.com/ultrajson/ultrajson/blob/main/LICENSE.txt - "pyparsing", # MIT } # fmt: off diff --git a/tests/components/airobot/snapshots/test_number.ambr b/tests/components/airobot/snapshots/test_number.ambr new file mode 100644 index 00000000000000..e98999a1563aba --- /dev/null +++ b/tests/components/airobot/snapshots/test_number.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_number_entities[number.test_thermostat_hysteresis_band-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 0.5, + 'min': 0.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_thermostat_hysteresis_band', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hysteresis band', + 'platform': 'airobot', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hysteresis_band', + 'unique_id': 'T01A1B2C3_hysteresis_band', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities[number.test_thermostat_hysteresis_band-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Thermostat Hysteresis band', + 'max': 0.5, + 'min': 0.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_thermostat_hysteresis_band', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- diff --git a/tests/components/airobot/test_number.py b/tests/components/airobot/test_number.py new file mode 100644 index 00000000000000..cf8d5fdc3cfa96 --- /dev/null +++ b/tests/components/airobot/test_number.py @@ -0,0 +1,78 @@ +"""Test the Airobot number platform.""" + +from unittest.mock import AsyncMock + +from pyairobotrest.exceptions import AirobotError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.NUMBER] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_number_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the number entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_number_set_hysteresis_band( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, +) -> None: + """Test setting hysteresis band value.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.test_thermostat_hysteresis_band", + ATTR_VALUE: 0.3, + }, + blocking=True, + ) + + mock_airobot_client.set_hysteresis_band.assert_called_once_with(0.3) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_number_set_value_error( + hass: HomeAssistant, + mock_airobot_client: AsyncMock, +) -> None: + """Test error handling when setting number value fails.""" + mock_airobot_client.set_hysteresis_band.side_effect = AirobotError("Device error") + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.test_thermostat_hysteresis_band", + ATTR_VALUE: 0.3, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == "airobot" + assert exc_info.value.translation_key == "set_value_failed" diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 5d79ea4458acf5..d44141ddaf61e5 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -45,7 +45,7 @@ '128': 0, '133': 1, }), - 'duid': '**REDACTED**', + 'duid': '******bc123', 'extra': '{"RRPhotoPrivacyVersion": "1"}', 'featureSet': '2234201184108543', 'fv': '02.56.02', @@ -266,7 +266,7 @@ '128': 0, '133': 1, }), - 'duid': '**REDACTED**', + 'duid': '******ice_2', 'extra': '{"RRPhotoPrivacyVersion": "1"}', 'featureSet': '2234201184108543', 'fv': '02.56.02', @@ -510,7 +510,7 @@ '235': 0, '237': 0, }), - 'duid': '**REDACTED**', + 'duid': '******_duid', 'f': False, 'fv': '01.12.34', 'iconUrl': '', @@ -864,7 +864,7 @@ '227': 1, '232': 0, }), - 'duid': '**REDACTED**', + 'duid': '******_duid', 'f': False, 'featureSet': '0', 'fv': '01.00.94', @@ -1194,7 +1194,7 @@ '141': 0, '142': 0, }), - 'duid': '**REDACTED**', + 'duid': '******_duid', 'extra': '{"1749518432": "0", "1753581557": "0", "clean_finish": "{}"}', 'f': False, 'fv': '03.01.71', diff --git a/tests/components/saunum/snapshots/test_binary_sensor.ambr b/tests/components/saunum/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..561f803da941ca --- /dev/null +++ b/tests/components/saunum/snapshots/test_binary_sensor.ambr @@ -0,0 +1,344 @@ +# serializer version: 1 +# name: test_entities[binary_sensor.saunum_leil_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.saunum_leil_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Saunum Leil Door', + }), + 'context': , + 'entity_id': 'binary_sensor.saunum_leil_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_door_open_during_heating_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.saunum_leil_door_open_during_heating_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door open during heating alarm', + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_door_open', + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-alarm_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_door_open_during_heating_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Saunum Leil Door open during heating alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.saunum_leil_door_open_during_heating_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_door_open_too_long_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.saunum_leil_door_open_too_long_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door open too long alarm', + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_door_sensor', + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-alarm_door_sensor', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_door_open_too_long_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Saunum Leil Door open too long alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.saunum_leil_door_open_too_long_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_internal_temperature_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.saunum_leil_internal_temperature_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Internal temperature alarm', + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_internal_temp', + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-alarm_internal_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_internal_temperature_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Saunum Leil Internal temperature alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.saunum_leil_internal_temperature_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_temperature_sensor_disconnected_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.saunum_leil_temperature_sensor_disconnected_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature sensor disconnected alarm', + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_temp_sensor_open', + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-alarm_temp_sensor_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_temperature_sensor_disconnected_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Saunum Leil Temperature sensor disconnected alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.saunum_leil_temperature_sensor_disconnected_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_temperature_sensor_shorted_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.saunum_leil_temperature_sensor_shorted_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature sensor shorted alarm', + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_temp_sensor_short', + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-alarm_temp_sensor_short', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_temperature_sensor_shorted_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Saunum Leil Temperature sensor shorted alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.saunum_leil_temperature_sensor_shorted_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_thermal_cutoff_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.saunum_leil_thermal_cutoff_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermal cutoff alarm', + 'platform': 'saunum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_thermal_cutoff', + 'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA-alarm_thermal_cutoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.saunum_leil_thermal_cutoff_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Saunum Leil Thermal cutoff alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.saunum_leil_thermal_cutoff_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/saunum/test_binary_sensor.py b/tests/components/saunum/test_binary_sensor.py new file mode 100644 index 00000000000000..7b45c4c20d01ba --- /dev/null +++ b/tests/components/saunum/test_binary_sensor.py @@ -0,0 +1,93 @@ +"""Test the Saunum binary sensor platform.""" + +from __future__ import annotations + +from dataclasses import replace + +from freezegun.api import FrozenDateTimeFactory +from pysaunum import SaunumException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.BINARY_SENSOR] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_binary_sensor_not_created_when_value_is_none( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_saunum_client, +) -> None: + """Test binary sensors are not created when initial value is None.""" + base_data = mock_saunum_client.async_get_data.return_value + mock_saunum_client.async_get_data.return_value = replace( + base_data, + door_open=None, + alarm_door_open=None, + alarm_door_sensor=None, + alarm_thermal_cutoff=None, + alarm_internal_temp=None, + alarm_temp_sensor_short=None, + alarm_temp_sensor_open=None, + ) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.saunum_leil_door") is None + assert hass.states.get("binary_sensor.saunum_leil_alarm_door_open") is None + assert hass.states.get("binary_sensor.saunum_leil_alarm_door_sensor") is None + assert hass.states.get("binary_sensor.saunum_leil_alarm_thermal_cutoff") is None + assert hass.states.get("binary_sensor.saunum_leil_alarm_internal_temp") is None + assert hass.states.get("binary_sensor.saunum_leil_alarm_temp_sensor_short") is None + assert hass.states.get("binary_sensor.saunum_leil_alarm_temp_sensor_open") is None + + +@pytest.mark.usefixtures("init_integration") +async def test_entity_unavailable_on_update_failure( + hass: HomeAssistant, + mock_saunum_client, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that entity becomes unavailable when coordinator update fails.""" + entity_id = "binary_sensor.saunum_leil_door" + + # Verify entity is initially available + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # Make the next update fail + mock_saunum_client.async_get_data.side_effect = SaunumException("Read error") + + # Move time forward to trigger a coordinator update (60 seconds) + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Entity should now be unavailable + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/saunum/test_climate.py b/tests/components/saunum/test_climate.py index a7942ee8f86f0d..73b72b7923a02d 100644 --- a/tests/components/saunum/test_climate.py +++ b/tests/components/saunum/test_climate.py @@ -232,6 +232,31 @@ async def test_hvac_mode_error_handling( assert exc_info.value.translation_domain == "saunum" +@pytest.mark.usefixtures("init_integration") +async def test_hvac_mode_door_open_validation( + hass: HomeAssistant, + mock_saunum_client, +) -> None: + """Test validation error when trying to heat with door open.""" + entity_id = "climate.saunum_leil" + + # Set door to open + mock_saunum_client.async_get_data.return_value.door_open = True + + # Try to turn on heating with door open + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + # Verify the exception has the correct translation key + assert exc_info.value.translation_key == "door_open" + assert exc_info.value.translation_domain == "saunum" + + @pytest.mark.usefixtures("init_integration") async def test_temperature_error_handling( hass: HomeAssistant, diff --git a/tests/components/switchbot_cloud/test_light.py b/tests/components/switchbot_cloud/test_light.py index e4f39c0d5306b1..c8e1b10f32f15e 100644 --- a/tests/components/switchbot_cloud/test_light.py +++ b/tests/components/switchbot_cloud/test_light.py @@ -2,9 +2,14 @@ from unittest.mock import patch -from switchbot_api import Device, SwitchBotAPI +import pytest +from switchbot_api import CeilingLightCommands, CommonCommands, Device, SwitchBotAPI -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_COLOR_MODE, + DOMAIN as LIGHT_DOMAIN, + ColorMode, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, @@ -298,3 +303,122 @@ async def test_rgbww_light_turn_on( mock_send_command.assert_called() state = hass.states.get(entity_id) assert state.state is STATE_ON + + +@pytest.mark.parametrize("device_type", ["Ceiling Light", "Ceiling Light Pro"]) +async def test_ceiling_light_turn_on( + hass: HomeAssistant, mock_list_devices, mock_get_status, device_type +) -> None: + """Test ceiling light turn on.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType=device_type, + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "colorTemperature": 4567}, + {"power": "on", "brightness": 10, "colorTemperature": 5555}, + {"power": "on", "brightness": 10, "colorTemperature": 5555}, + {"power": "on", "brightness": 10, "colorTemperature": 5555}, + {"power": "on", "brightness": 10, "colorTemperature": 5555}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + state = hass.states.get(entity_id) + assert state.state is STATE_OFF + + # Test turn on with brightness + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "brightness": 99}, + blocking=True, + ) + mock_send_command.assert_called_with( + "light-id-1", + CeilingLightCommands.SET_BRIGHTNESS, + "command", + "38", + ) + state = hass.states.get(entity_id) + assert state.state is STATE_ON + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + + # Test turn on with color temp + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, "color_temp_kelvin": 3333}, + blocking=True, + ) + mock_send_command.assert_called_with( + "light-id-1", + CeilingLightCommands.SET_COLOR_TEMPERATURE, + "command", + "3333", + ) + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + # Test turn on without arguments + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mock_send_command.assert_called_with( + "light-id-1", + CommonCommands.ON, + "command", + "default", + ) + state = hass.states.get(entity_id) + assert state.state is STATE_ON + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + + +@pytest.mark.parametrize("device_type", ["Ceiling Light", "Ceiling Light Pro"]) +async def test_ceiling_light_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status, device_type +) -> None: + """Test ceiling light turn off.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType=device_type, + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "brightness": 1, "colorTemperature": 4567}, + {"power": "off", "brightness": 1, "colorTemperature": 4567}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + state = hass.states.get(entity_id) + assert state.state is STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_with( + "light-id-1", + CommonCommands.OFF, + "command", + "default", + ) + state = hass.states.get(entity_id) + assert state.state is STATE_OFF diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py index bd59e4946bab7f..f7f480b9260e0c 100644 --- a/tests/components/velbus/test_services.py +++ b/tests/components/velbus/test_services.py @@ -1,6 +1,6 @@ """Velbus services tests.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest import voluptuous as vol @@ -16,7 +16,7 @@ ) from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from . import init_integration @@ -64,6 +64,26 @@ async def test_global_services_with_config_entry( blocking=True, ) + # Test scan with OSError + config_entry.runtime_data.controller.scan.side_effect = OSError("Boom") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SCAN, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + + # Test sync_clock with OSError + config_entry.runtime_data.controller.sync_clock.side_effect = OSError("Boom") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SYNC, + {CONF_CONFIG_ENTRY: config_entry.entry_id}, + blocking=True, + ) + async def test_set_memo_text( hass: HomeAssistant, @@ -87,9 +107,26 @@ async def test_set_memo_text( 1 ).set_memo_text.assert_called_once_with("Test") + # Test with OSError + controller.return_value.get_module.return_value.set_memo_text.side_effect = OSError( + "Boom" + ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MEMO_TEXT, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_MEMO_TEXT: "Test", + CONF_ADDRESS: 2, + }, + blocking=True, + ) + controller.return_value.get_module.return_value.set_memo_text.side_effect = None + # Test with unfound module controller.return_value.get_module.return_value = None - with pytest.raises(ServiceValidationError, match="Module not found"): + with pytest.raises(ServiceValidationError, match="Module with address 2 not found"): await hass.services.async_call( DOMAIN, SERVICE_SET_MEMO_TEXT, @@ -124,3 +161,18 @@ async def test_clear_cache( blocking=True, ) assert config_entry.runtime_data.controller.scan.call_count == 2 + + # Test with OSError + with ( + patch("os.unlink", side_effect=OSError("Boom")), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_CACHE, + { + CONF_CONFIG_ENTRY: config_entry.entry_id, + CONF_ADDRESS: 2, + }, + blocking=True, + ) diff --git a/tests/components/velux/test_config_flow.py b/tests/components/velux/test_config_flow.py index 960d025efbd7d1..5a35033ca7c456 100644 --- a/tests/components/velux/test_config_flow.py +++ b/tests/components/velux/test_config_flow.py @@ -60,6 +60,10 @@ async def test_user_flow( @pytest.mark.parametrize( ("exception", "error"), [ + ( + PyVLXException("Login to KLF 200 failed, check credentials"), + "invalid_auth", + ), (PyVLXException("DUMMY"), "cannot_connect"), (Exception("DUMMY"), "unknown"), ], @@ -138,6 +142,94 @@ async def test_user_flow_duplicate_entry( assert result["reason"] == "already_configured" +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyvlx: AsyncMock, +) -> None: + """Test that reauth flow works with valid credentials.""" + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "New Password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_PASSWORD] == "New Password" + + mock_pyvlx.connect.assert_called_once() + mock_pyvlx.disconnect.assert_called_once() + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + PyVLXException("Login to KLF 200 failed, check credentials"), + "invalid_auth", + ), + (PyVLXException("DUMMY"), "cannot_connect"), + (Exception("DUMMY"), "unknown"), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, + mock_pyvlx: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + error: str, +) -> None: + """Test error handling in reauth flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_pyvlx.connect.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "New Password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_pyvlx.connect.assert_called_once() + mock_pyvlx.disconnect.assert_not_called() + + mock_pyvlx.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: "New Password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_config_entry.data[CONF_PASSWORD] == "New Password" + + mock_pyvlx.disconnect.assert_called_once() + + async def test_dhcp_discovery( hass: HomeAssistant, mock_pyvlx: AsyncMock, @@ -175,6 +267,10 @@ async def test_dhcp_discovery( @pytest.mark.parametrize( ("exception", "error"), [ + ( + PyVLXException("Login to KLF 200 failed, check credentials"), + "invalid_auth", + ), (PyVLXException("DUMMY"), "cannot_connect"), (Exception("DUMMY"), "unknown"), ], diff --git a/tests/components/velux/test_init.py b/tests/components/velux/test_init.py index fc17df0a8030f5..8f737375d4118f 100644 --- a/tests/components/velux/test_init.py +++ b/tests/components/velux/test_init.py @@ -13,9 +13,12 @@ import pytest from pyvlx.exception import PyVLXException +from homeassistant.components.velux.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.setup import async_setup_component from tests.common import AsyncMock, ConfigEntry, MockConfigEntry @@ -61,6 +64,30 @@ async def test_setup_retry_on_oserror_during_scenes( mock_pyvlx.load_nodes.assert_not_called() +async def test_setup_auth_error( + mock_config_entry: ConfigEntry, hass: HomeAssistant, mock_pyvlx: AsyncMock +) -> None: + """Test that PyVLXException with auth message raises ConfigEntryAuthFailed and starts reauth flow.""" + + mock_pyvlx.load_scenes.side_effect = PyVLXException( + "Login to KLF 200 failed, check credentials" + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # ConfigEntryAuthFailed results in SETUP_ERROR state + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + mock_pyvlx.load_scenes.assert_awaited_once() + mock_pyvlx.load_nodes.assert_not_called() + + @pytest.fixture def platform() -> Platform: """Fixture to specify platform to test.""" @@ -100,3 +127,45 @@ async def test_unload_does_not_disconnect_if_platform_unload_fails( # Verify disconnect was NOT called since platform unload failed mock_pyvlx.disconnect.assert_not_awaited() + + +@pytest.mark.usefixtures("setup_integration") +async def test_reboot_gateway_service_raises_on_exception( + hass: HomeAssistant, mock_pyvlx: AsyncMock +) -> None: + """Test that reboot_gateway service raises HomeAssistantError on exception.""" + + mock_pyvlx.reboot_gateway.side_effect = OSError("Connection failed") + with pytest.raises(HomeAssistantError, match="Failed to reboot gateway"): + await hass.services.async_call( + "velux", + "reboot_gateway", + blocking=True, + ) + + mock_pyvlx.reboot_gateway.side_effect = PyVLXException("Reboot failed") + with pytest.raises(HomeAssistantError, match="Failed to reboot gateway"): + await hass.services.async_call( + "velux", + "reboot_gateway", + blocking=True, + ) + + +async def test_reboot_gateway_service_raises_validation_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that reboot_gateway service raises ServiceValidationError when no gateway is loaded.""" + # Add the config entry but don't set it up + mock_config_entry.add_to_hass(hass) + + # Set up the velux integration's async_setup to register the service + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError, match="No loaded Velux gateway found"): + await hass.services.async_call( + "velux", + "reboot_gateway", + blocking=True, + )