diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index 9512db0db49429..5c29c5bf137c13 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -2,10 +2,11 @@ from typing import TYPE_CHECKING, Any, override -from boschshcpy import SHCShutterControl, ShutterControlService +from boschshcpy import SHCMicromoduleBlinds, SHCShutterControl, ShutterControlService from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, @@ -38,7 +39,19 @@ async def async_setup_entry( parent_id=shc_info.unique_id, entry_id=config_entry.entry_id, ) - for cover in session.device_helper.shutter_controls + for cover in ( + *session.device_helper.shutter_controls, + *session.device_helper.micromodule_shutter_controls, + ) + ) + async_add_entities( + BlindsControlCover( + hass=hass, + device=blind, + parent_id=shc_info.unique_id, + entry_id=config_entry.entry_id, + ) + for blind in session.device_helper.micromodule_blinds ) @@ -46,7 +59,6 @@ class ShutterControlCover(SHCEntity, CoverEntity): """Representation of a SHC shutter control device.""" _attr_name = None - _attr_device_class = CoverDeviceClass.SHUTTER _device: SHCShutterControl _attr_supported_features = ( CoverEntityFeature.OPEN @@ -55,6 +67,14 @@ class ShutterControlCover(SHCEntity, CoverEntity): | CoverEntityFeature.SET_POSITION ) + @property + @override + def device_class(self) -> CoverDeviceClass: + """Return the device class.""" + if self._device.device_model == "MICROMODULE_AWNING": + return CoverDeviceClass.AWNING + return CoverDeviceClass.SHUTTER + @property @override def current_cover_position(self) -> int: @@ -99,3 +119,51 @@ def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] self._device.level = position / 100.0 + + +class BlindsControlCover(ShutterControlCover): + """Representation of a SHC micromodule blinds cover device.""" + + _device: SHCMicromoduleBlinds + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + @property + @override + def device_class(self) -> CoverDeviceClass: + """Return the device class.""" + return CoverDeviceClass.BLIND + + @override + def stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + self._device.stop_blinds() + + @property + @override + def current_cover_tilt_position(self) -> int: + """Return the current cover tilt position.""" + return round((1.0 - self._device.current_angle) * 100.0) + + @override + def open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + self._device.target_angle = 0.0 + + @override + def close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + self._device.target_angle = 1.0 + + @override + def set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + tilt_position = kwargs[ATTR_TILT_POSITION] + self._device.target_angle = 1.0 - (tilt_position / 100.0) diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 114decd4880bc5..a5974a0b152787 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -6,6 +6,7 @@ from boschshcpy import ( SHCLightSwitchBSM, + SHCMicromoduleShutterControl, SHCSmartPlug, SHCSmartPlugCompact, SHCThermostat, @@ -47,7 +48,7 @@ class SHCSensorEntityDescription[_DeviceT: SHCDevice](SensorEntityDescription): attributes_fn: Callable[[_DeviceT], dict[str, Any]] | None = None -_PowerMeterDevice = SHCSmartPlug | SHCLightSwitchBSM +_PowerMeterDevice = SHCSmartPlug | SHCLightSwitchBSM | SHCMicromoduleShutterControl TEMPERATURE_SENSOR = "temperature" HUMIDITY_SENSOR = "humidity" @@ -263,6 +264,8 @@ async def async_setup_entry( power_meter_devices: list[_PowerMeterDevice] = [ *session.device_helper.smart_plugs, *session.device_helper.light_switches_bsm, + *session.device_helper.micromodule_shutter_controls, + *session.device_helper.micromodule_blinds, ] entities.extend( SHCSensor( diff --git a/tests/components/bosch_shc/conftest.py b/tests/components/bosch_shc/conftest.py index 67f3f5ac407e98..89ccf8830cae85 100644 --- a/tests/components/bosch_shc/conftest.py +++ b/tests/components/bosch_shc/conftest.py @@ -5,7 +5,13 @@ from typing import Any from unittest.mock import MagicMock, create_autospec, patch -from boschshcpy import BatteryLevelService, SHCBatteryDevice +from boschshcpy import ( + BatteryLevelService, + SHCBatteryDevice, + SHCMicromoduleBlinds, + SHCShutterControl, + ShutterControlService, +) import pytest from homeassistant.components.bosch_shc.const import ( @@ -43,9 +49,12 @@ def mock_config_entry() -> MockConfigEntry: _EMPTY_DEVICE_BUCKETS: dict[str, list[Any]] = { bucket: [] for bucket in ( + "micromodule_blinds", + "micromodule_shutter_controls", "motion_detectors", "shutter_contacts", "shutter_contacts2", + "shutter_controls", "smoke_detectors", "thermostats", "twinguards", @@ -112,3 +121,50 @@ def battery_only_device( device.status = "AVAILABLE" device.deleted = False return device + + +def shutter_control_device( + device_id: str = "hdm:ZigBee:shutter1", + name: str = "Shutter", + device_model: str = "BBL", + level: float = 1.0, + operation_state: ShutterControlService.State = ShutterControlService.State.STOPPED, +) -> SHCShutterControl: + """Build a minimal device double for the shutter_controls/micromodule_shutter_controls buckets.""" + device = create_autospec(SHCShutterControl, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = device_model + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.level = level + device.operation_state = operation_state + return device + + +def micromodule_blinds_device( + device_id: str = "hdm:ZigBee:blinds1", + name: str = "Blinds", + level: float = 1.0, + current_angle: float = 0.0, + operation_state: ShutterControlService.State = ShutterControlService.State.STOPPED, +) -> SHCMicromoduleBlinds: + """Build a minimal device double for the micromodule_blinds bucket.""" + device = create_autospec(SHCMicromoduleBlinds, instance=True, spec_set=True) + device.name = name + device.id = device_id + device.root_device_id = "test-mac" + device.serial = f"serial-{device_id}" + device.manufacturer = "Bosch" + device.device_model = "MICROMODULE_BLINDS" + device.device_services = [] + device.deleted = False + device.status = "AVAILABLE" + device.level = level + device.current_angle = current_angle + device.operation_state = operation_state + return device diff --git a/tests/components/bosch_shc/test_cover.py b/tests/components/bosch_shc/test_cover.py new file mode 100644 index 00000000000000..a826ba94fb1770 --- /dev/null +++ b/tests/components/bosch_shc/test_cover.py @@ -0,0 +1,190 @@ +"""Tests for the Bosch SHC cover platform.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + Platform, +) +from homeassistant.core import HomeAssistant + +from .conftest import ( + micromodule_blinds_device, + setup_integration, + shutter_control_device, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def platforms() -> Generator[None]: + """Restrict bosch_shc setup to the cover platform.""" + with patch("homeassistant.components.bosch_shc.PLATFORMS", [Platform.COVER]): + yield + + +@pytest.mark.parametrize( + "device_buckets", + [{"shutter_controls": [shutter_control_device(device_model="BBL", level=0.75)]}], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_bbl_shutter_control( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A plain BBL shutter is exposed as a shutter-class cover.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.shutter_controls[0] + + state = hass.states.get("cover.shutter") + assert state is not None + assert state.attributes["device_class"] == "shutter" + assert state.attributes["current_position"] == 75 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.shutter"}, + blocking=True, + ) + assert device.level == 1.0 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.shutter"}, + blocking=True, + ) + assert device.level == 0.0 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: "cover.shutter", ATTR_POSITION: 42}, + blocking=True, + ) + assert device.level == pytest.approx(0.42) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.shutter"}, + blocking=True, + ) + device.stop.assert_called_once() + + +@pytest.mark.parametrize( + ("device_buckets", "expected_device_class"), + [ + pytest.param( + { + "micromodule_shutter_controls": [ + shutter_control_device(device_model="MICROMODULE_SHUTTER") + ] + }, + "shutter", + id="micromodule_shutter", + ), + pytest.param( + { + "micromodule_shutter_controls": [ + shutter_control_device(device_model="MICROMODULE_AWNING") + ] + }, + "awning", + id="micromodule_awning", + ), + ], + indirect=["device_buckets"], +) +@pytest.mark.usefixtures("mock_session") +async def test_micromodule_shutter_device_class( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + expected_device_class: str, +) -> None: + """A micromodule shutter/awning device (#181407) gets the right device class.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("cover.shutter") + assert state is not None + assert state.attributes["device_class"] == expected_device_class + + +@pytest.mark.parametrize( + "device_buckets", + [ + { + "micromodule_blinds": [ + micromodule_blinds_device(level=0.6, current_angle=0.25) + ] + } + ], + indirect=True, +) +@pytest.mark.usefixtures("mock_session") +async def test_micromodule_blinds_tilt( + hass: HomeAssistant, + mock_session: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Micromodule blinds (#181407) expose tilt controls in addition to lift.""" + await setup_integration(hass, mock_config_entry) + device = mock_session.device_helper.micromodule_blinds[0] + + state = hass.states.get("cover.blinds") + assert state is not None + assert state.attributes["device_class"] == "blind" + assert state.attributes["current_position"] == 60 + assert state.attributes["current_tilt_position"] == 75 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.blinds"}, + blocking=True, + ) + assert device.target_angle == 0.0 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.blinds"}, + blocking=True, + ) + assert device.target_angle == 1.0 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.blinds", ATTR_TILT_POSITION: 30}, + blocking=True, + ) + assert device.target_angle == pytest.approx(0.7) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.blinds"}, + blocking=True, + ) + device.stop_blinds.assert_called_once()