bosch_shc: create cover entities for micromodule shutters and blinds (#181416)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas
2026-09-15 20:11:32 +02:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 323c3faad1
commit bb76014aad
4 changed files with 316 additions and 4 deletions
+71 -3
View File
@@ -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 @@ class ShutterControlCover(SHCEntity, CoverEntity):
"""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)
+4 -1
View File
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, override
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(
+51
View File
@@ -10,8 +10,11 @@ from boschshcpy import (
PowerSwitchService,
SHCBatteryDevice,
SHCLightSwitchBSM,
SHCMicromoduleBlinds,
SHCMicromoduleRelay,
SHCShutterControl,
SHCThermostat,
ShutterControlService,
ThermostatService,
)
import pytest
@@ -64,6 +67,7 @@ _EMPTY_DEVICE_BUCKETS: dict[str, list[Any]] = {
"roomthermostats",
"shutter_contacts",
"shutter_contacts2",
"shutter_controls",
"smart_plugs",
"smart_plugs_compact",
"smoke_detectors",
@@ -134,6 +138,53 @@ def battery_only_device(
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
def thermostat_device(
device_id: str = "hdm:ZigBee:thermostat1",
name: str = "Thermostat",
+190
View File
@@ -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()