From 823a2de0304deeabbf1f5cc8488bf528245f7f4c Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 15:26:30 +0300 Subject: [PATCH 1/2] fix(cover): pass the yaml `class:` through to WHO 2 covers (#39) `class:` / `device_class:` in myhome.yaml was validated by the cover schema but never handed to the entity, so every WHO 2 cover was a `shutter` (or a `blind` with slat_tilt). The configured class now sets the entity's device class and wins over the slat-tilt default. Verified on a physical F461 2.0.11 with an F411/2 at WHERE 05: `class: curtain` gives `device_class: curtain`. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- custom_components/myhome/cover.py | 16 +++++++- docs/configuration/covers.md | 12 ++++++ tests/test_cover_device_class.py | 66 +++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/test_cover_device_class.py diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index f1d672824..353b86531 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -26,7 +26,7 @@ See the cover docs for the details and the known limits. - **Diagnostics**: the gateway profile block reports the OWNd profile's real `model_name`, `display_name`, `event_keepalive_interval` and `keepalive_summary` instead of `Generic` / 90 s for every gateway. - **Expose Home Assistant to the bus**: a declarative `expose` engine projects HA entities and the clock onto free SCS addresses: virtual WHO 1 / WHO 2 actuators that a wall control can drive (mirroring HA state back to its LEDs), WHO 4 virtual temperature probes, WHO 18 virtual energy meters and the WHO 13 bus clock. Custom actions run as Home Assistant scripts (#750). -- **Covers**: Venetian blind slat tilt as timed slat pulses (`slat_tilt`, `slat_time`) (#508); impulse garage and gate covers on WHO 1 relays with optional safety approval, door-entry strike locks and doorbell events (#667); native broadcast for general covers behind an interface (#679); a cover stop overtakes other queued frames (#694); cover kinematics split into pluggable strategies (#739). +- **Covers**: `class:` in `myhome.yaml` now reaches WHO 2 covers (a `curtain` is no longer shown as a shutter; #39); Venetian blind slat tilt as timed slat pulses (`slat_tilt`, `slat_time`) (#508); impulse garage and gate covers on WHO 1 relays with optional safety approval, door-entry strike locks and doorbell events (#667); native broadcast for general covers behind an interface (#679); a cover stop overtakes other queued frames (#694); cover kinematics split into pluggable strategies (#739). - **Lighting**: 2-digit Area 0 point addresses `01`–`09` (#713); ON dimmers on a fresh install are probed for brightness support (#742); timed turn-on uses the OWNd builders (#659); lights the startup sweep already reported are not polled again (#701); area and group re-syncs only skip once every member has echoed, counting only the members' own echoes in the commanded state (#740, #749). - **Thermoregulation (WHO 4)**: season frames, a generic setpoint mode and the central unit's season (#657, #725); holiday and vacation program attributes (#689); zone humidity remembered and queried at startup (#712); F461 zones polled, AUTO on standalone zones, fan coils per season, season kept across restarts (#714, #715); `hvac_action` derived from the season when the actuator carries no heating flag (#721); standalone default follows the plant (#698); 10 s command timeouts on the F455 removed (#675); migrated to the `native_*` temperature API (#665). - **Sound system**: tuner frequency, preset and RDS read from WHO 22, with optional per-frequency pictures (#695); sound source commands and wall transport steps (#651); amplifiers no longer woken by volume status reports (#670); one decision engine for the power state (#683); an idle decoder can be claimed from a parked group (#723). diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index e5c2d4eb4..a4557d43d 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -226,6 +226,13 @@ def build(ctx: DeviceContext) -> MyHOMECover: # yaml covers keep their configured travel time, but a slat time set # with myhome.set_cover_travel_time wins until it is reset. kwargs["calibration"] = {CONF_SLAT_TIME: stored[CONF_SLAT_TIME]} + # cover_schema already folds a yaml `device_class:` into CONF_DEVICE_CLASS. + raw_dev_class = cfg.get(CONF_DEVICE_CLASS) + try: + # The device schema defaults an absent class to "switch": not a cover class, so unset. + device_class = CoverDeviceClass(str(raw_dev_class)) if raw_dev_class else None + except ValueError: + device_class = None cls: type[MyHOMECover] = MyHOMECover if (scope := CoverScope.of(ctx.address.where, ctx.address.interface, cfg.get(CONF_MEMBERS) or ())) is not None: cls = MyHOMEScopeCover @@ -248,6 +255,7 @@ def build(ctx: DeviceContext) -> MyHOMECover: travel_time=int(cfg.get(CONF_TRAVEL_TIME, DEFAULT_TRAVEL_TIME)), min_motor_pulse=float(cfg.get(CONF_MIN_MOTOR_PULSE, DEFAULT_MIN_MOTOR_PULSE)), min_position_delta=int(cfg.get(CONF_MIN_POSITION_DELTA, MIN_POSITION_DELTA)), + device_class=device_class, **kwargs, ) family.add(cover) @@ -377,6 +385,7 @@ def __init__( # type: ignore slat_tilt: bool = False, slat_time: float = DEFAULT_SLAT_TIME, strategy_name: str | None = None, + device_class: CoverDeviceClass | None = None, ): super().__init__( hass=hass, @@ -393,6 +402,10 @@ def __init__( # type: ignore self._interface = interface self._full_where = f"{self._where}#4#{self._interface}" if self._interface is not None else self._where + # A configured class (myhome.yaml `class:`) wins; slat tilt only picks BLIND without one (#39). + self._configured_device_class = device_class + if device_class is not None: + self._attr_device_class = device_class #: Set on a MyHOMEScopeCover; ``None`` for a point-to-point cover. self.scope: CoverScope | None = None self._family: CoverFamily | None = None @@ -888,7 +901,8 @@ def _enable_slat_tilt(self) -> None: self._slat_tilt = True # The strategy decides: built-in timed becomes Venetian, others stay. self._strategy = self._strategy.with_slat_tilt() - self._attr_device_class = CoverDeviceClass.BLIND + if getattr(self, "_configured_device_class", None) is None: + self._attr_device_class = CoverDeviceClass.BLIND current_features = self._attr_supported_features or CoverEntityFeature(0) self._attr_supported_features = ( current_features diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index 65d0e2a6b..c89314e2f 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -190,6 +190,18 @@ cover: Higher tilt is assumed to be *open* (an opening pulse raises the angle, verified on an F401 roller shutter with tilting slats in [discussion #770](https://github.com/orgs/OpenWebNet-HA/discussions/770#discussioncomment-19144521) with `slat_time: 1.4`). Dedicated Venetian actuators (such as the F411U2 in Venetian mode) still await a keypad / app capture (#492). +### Device class (`class`) + +A WHO 2 cover is a `shutter` unless `/config/myhome.yaml` says otherwise. `class:` (or `device_class:`) accepts the Home Assistant cover classes `awning`, `blind`, `curtain`, `damper`, `door`, `garage`, `gate`, `shade`, `shutter` and `window`, and picks the entity's icon and the wording of its states. A configured class also wins over the `blind` that `slat_tilt: true` would otherwise set. + +```yaml +cover: + living_room_curtain: + where: '32' + name: Living Room Curtain + class: curtain +``` + ### Protocol Note: Dimension 11 is Shutter Level In OpenWebNet WHO 2, **Dimension 11** (`*#2**#11#PRIORITY*LEVEL##`) represents an absolute **Go to level / Shutter position** command, **not** slat tilt angle. Gateway firmwares (MH200N, MyHomeServer1) reject query frames (`*#2**11##`) with a NACK because Dimension 11 is write-only. diff --git a/tests/test_cover_device_class.py b/tests/test_cover_device_class.py new file mode 100644 index 000000000..19a7c1642 --- /dev/null +++ b/tests/test_cover_device_class.py @@ -0,0 +1,66 @@ +"""A WHO 2 cover takes its device class from myhome.yaml ``class:`` / ``device_class:`` (#39).""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.components.cover import CoverDeviceClass +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from custom_components.myhome.const import ( + CONF_DEVICE_CLASS, + CONF_PLATFORMS, + CONF_SLAT_TILT, + CONF_WHERE, + DOMAIN, +) +from custom_components.myhome.cover import PLATFORM, async_setup_entry +from custom_components.myhome.validate import cover_schema +from tests.conftest import attach_runtime + + +@pytest.fixture +def mock_gateway(): + gw = MagicMock() + gw.mac = "00:03:50:00:00:39" + gw.unique_id = "00:03:50:00:00:39" + gw.log_id = "[Test Gateway]" + gw.send = AsyncMock() + gw.send_status_request = AsyncMock() + return gw + + +async def _setup_from_yaml(hass: HomeAssistant, mock_gateway, yaml_covers: dict) -> list: + """Validate ``yaml_covers`` as myhome.yaml does and set the cover platform up from it.""" + mac = mock_gateway.mac + hass.data = {DOMAIN: {mac: {"entity": mock_gateway, CONF_PLATFORMS: {PLATFORM: cover_schema(yaml_covers)}}}} + config_entry = MagicMock() + config_entry.data = {"mac": mac} + config_entry.entry_id = "test_entry" + added: list = [] + with patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[]), \ + patch("homeassistant.helpers.entity_registry.async_get", return_value=MagicMock()): + attach_runtime(hass, config_entry) + await async_setup_entry(hass, config_entry, added.extend) + return added + + +@pytest.mark.parametrize( + ("cfg", "expected"), + [ + ({}, CoverDeviceClass.SHUTTER), + ({CONF_DEVICE_CLASS: "curtain"}, CoverDeviceClass.CURTAIN), + ({"device_class": "awning"}, CoverDeviceClass.AWNING), + ({CONF_DEVICE_CLASS: "garage"}, CoverDeviceClass.GARAGE), + ({CONF_SLAT_TILT: True}, CoverDeviceClass.BLIND), + ({CONF_SLAT_TILT: True, CONF_DEVICE_CLASS: "shade"}, CoverDeviceClass.SHADE), + ], +) +async def test_who2_cover_uses_the_configured_class(hass: HomeAssistant, mock_gateway, cfg, expected) -> None: + """`class: curtain` reaches the entity; slat tilt picks BLIND only when no class is configured (#39).""" + added = await _setup_from_yaml( + hass, mock_gateway, {"c": {CONF_WHERE: "41", CONF_NAME: "Cover 41", **cfg}} + ) + assert len(added) == 1 + assert added[0].device_class == expected From 7696194244bd306b38e915a85476a0f39c9eaeef Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 22:35:13 +0300 Subject: [PATCH 2/2] docs(cover): promote the device class section, keep the raw-dict fallback, add scope and venetian test cases (review follow-up) Review follow-up on #796 (#39), the three non-blocking items: - docs/configuration/covers.md: "Device class (`class`)" is a section of its own next to the general cover YAML configuration instead of a subsection of the Venetian section; the Venetian bullet and the `slat_tilt` parameter row say "blind unless `class` is set"; UI covers are pointed to Settings -> Devices & Services -> Entities -> Show As, as switches.md and binary-sensors.md do. - cover.py build(): `cfg.get(CONF_DEVICE_CLASS) or cfg.get("device_class")` again, for symmetry with build_impulse(), switch.py, binary_sensor.py and sensor.py when a caller seeds hass.data without cover_schema. - tests/test_cover_device_class.py: `strategy: venetian` with and without `class: shade`, an area `where: "1"` MyHOMEScopeCover with `class: curtain`, and a general `where: "0"` scope cover. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/cover.py | 5 +++-- docs/configuration/covers.md | 33 ++++++++++++++++++------------- tests/test_cover_device_class.py | 12 ++++++++++- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index a4557d43d..fac7e4630 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -226,8 +226,9 @@ def build(ctx: DeviceContext) -> MyHOMECover: # yaml covers keep their configured travel time, but a slat time set # with myhome.set_cover_travel_time wins until it is reset. kwargs["calibration"] = {CONF_SLAT_TIME: stored[CONF_SLAT_TIME]} - # cover_schema already folds a yaml `device_class:` into CONF_DEVICE_CLASS. - raw_dev_class = cfg.get(CONF_DEVICE_CLASS) + # cover_schema folds a yaml `device_class:` into CONF_DEVICE_CLASS; the raw + # key is kept as a fallback for callers that bypass the schema, as build_impulse does. + raw_dev_class = cfg.get(CONF_DEVICE_CLASS) or cfg.get("device_class") try: # The device schema defaults an absent class to "switch": not a cover class, so unset. device_class = CoverDeviceClass(str(raw_dev_class)) if raw_dev_class else None diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index c89314e2f..bec4ac474 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -43,6 +43,23 @@ An explicit `strategy` cannot be combined with a flag that selects another model --- +## 🏷️ Device class (`class`) + +A WHO 2 cover is a `shutter` unless `/config/myhome.yaml` says otherwise. `class:` (or `device_class:`) accepts the Home Assistant cover classes `awning`, `blind`, `curtain`, `damper`, `door`, `garage`, `gate`, `shade`, `shutter` and `window`, and picks the entity's icon and the wording of its states. It applies to every WHO 2 cover, point-to-point as well as [general, area and group covers](#general-area-and-group-covers). A configured class also wins over the `blind` that `slat_tilt: true` (or `strategy: venetian`) would otherwise set. + +```yaml +# /config/myhome.yaml +cover: + living_room_curtain: + where: '32' + name: Living Room Curtain + class: curtain +``` + +Covers set up in the UI (without `myhome.yaml`) change their device class in Home Assistant with the **"Show As"** feature, like [switches](switches.md) and [binary sensors](binary-sensors.md): **Settings → Devices & Services → Entities**, select the cover entity, click the **Settings (gear)** icon and pick the class under **Show As**. + +--- + ## ⏱️ Timed Cover Position Engine For standard covers without hardware position feedback, the integration provides a software estimator enabling full `open_cover`, `close_cover`, `stop_cover`, and slider-based `set_cover_position` support: @@ -151,7 +168,7 @@ target: For Venetian blinds, motorized louvres, and external venetian blinds (EVBs/BSO) with adjustable slat tilt angles, the integration provides timed slat travel (inching): * **Supported Features**: `SET_TILT_POSITION`, `OPEN_TILT` (100%), `CLOSE_TILT` (0%), and `STOP_TILT`. -* **Device Class**: Sets the entity device class to `blind` (`CoverDeviceClass.BLIND`). +* **Device Class**: Sets the entity device class to `blind` (`CoverDeviceClass.BLIND`) unless [`class`](#device-class-class) is set. * **Attributes**: Exposes `current_cover_tilt_position` (0–100%), `slat_tilt: true`, and `slat_time: `. ### Physical Architecture & Slat Travel Time @@ -185,23 +202,11 @@ cover: | Parameter | Type | Default | Description | | :--- | :--- | :--- | :--- | -| `slat_tilt` | boolean | `false` | Enables Venetian blind slat tilt controls and sets device class to `blind`. | +| `slat_tilt` | boolean | `false` | Enables Venetian blind slat tilt controls and sets device class to `blind` unless `class` is set. | | `slat_time` | float | `2.0` | Full slat rotation travel time in seconds (allowed range: `0.5`–`10.0` s). A value set with `myhome.set_cover_travel_time` is stored and wins over this one after a restart, until `myhome.reset_cover_travel_time`. | Higher tilt is assumed to be *open* (an opening pulse raises the angle, verified on an F401 roller shutter with tilting slats in [discussion #770](https://github.com/orgs/OpenWebNet-HA/discussions/770#discussioncomment-19144521) with `slat_time: 1.4`). Dedicated Venetian actuators (such as the F411U2 in Venetian mode) still await a keypad / app capture (#492). -### Device class (`class`) - -A WHO 2 cover is a `shutter` unless `/config/myhome.yaml` says otherwise. `class:` (or `device_class:`) accepts the Home Assistant cover classes `awning`, `blind`, `curtain`, `damper`, `door`, `garage`, `gate`, `shade`, `shutter` and `window`, and picks the entity's icon and the wording of its states. A configured class also wins over the `blind` that `slat_tilt: true` would otherwise set. - -```yaml -cover: - living_room_curtain: - where: '32' - name: Living Room Curtain - class: curtain -``` - ### Protocol Note: Dimension 11 is Shutter Level In OpenWebNet WHO 2, **Dimension 11** (`*#2**#11#PRIORITY*LEVEL##`) represents an absolute **Go to level / Shutter position** command, **not** slat tilt angle. Gateway firmwares (MH200N, MyHomeServer1) reject query frames (`*#2**11##`) with a NACK because Dimension 11 is write-only. diff --git a/tests/test_cover_device_class.py b/tests/test_cover_device_class.py index 19a7c1642..d8660efa0 100644 --- a/tests/test_cover_device_class.py +++ b/tests/test_cover_device_class.py @@ -12,10 +12,12 @@ CONF_DEVICE_CLASS, CONF_PLATFORMS, CONF_SLAT_TILT, + CONF_STRATEGY, CONF_WHERE, DOMAIN, ) from custom_components.myhome.cover import PLATFORM, async_setup_entry +from custom_components.myhome.cover_scope import MyHOMEScopeCover from custom_components.myhome.validate import cover_schema from tests.conftest import attach_runtime @@ -55,12 +57,20 @@ async def _setup_from_yaml(hass: HomeAssistant, mock_gateway, yaml_covers: dict) ({CONF_DEVICE_CLASS: "garage"}, CoverDeviceClass.GARAGE), ({CONF_SLAT_TILT: True}, CoverDeviceClass.BLIND), ({CONF_SLAT_TILT: True, CONF_DEVICE_CLASS: "shade"}, CoverDeviceClass.SHADE), + ({CONF_STRATEGY: "venetian"}, CoverDeviceClass.BLIND), + ({CONF_STRATEGY: "venetian", CONF_DEVICE_CLASS: "shade"}, CoverDeviceClass.SHADE), + ({CONF_WHERE: "1", CONF_DEVICE_CLASS: "curtain"}, CoverDeviceClass.CURTAIN), + ({CONF_WHERE: "0"}, CoverDeviceClass.SHUTTER), ], ) async def test_who2_cover_uses_the_configured_class(hass: HomeAssistant, mock_gateway, cfg, expected) -> None: - """`class: curtain` reaches the entity; slat tilt picks BLIND only when no class is configured (#39).""" + """`class: curtain` reaches the entity; slat tilt picks BLIND only when no class is configured (#39). + + Also covers `strategy: venetian` (implies slat tilt) and an area / general `MyHOMEScopeCover`. + """ added = await _setup_from_yaml( hass, mock_gateway, {"c": {CONF_WHERE: "41", CONF_NAME: "Cover 41", **cfg}} ) assert len(added) == 1 assert added[0].device_class == expected + assert isinstance(added[0], MyHOMEScopeCover) is (cfg.get(CONF_WHERE) in ("0", "1"))