Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/release-notes/2.0.0b16.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
17 changes: 16 additions & 1 deletion custom_components/myhome/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,14 @@ 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 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
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
Expand All @@ -248,6 +256,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)
Expand Down Expand Up @@ -377,6 +386,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,
Expand All @@ -393,6 +403,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
Expand Down Expand Up @@ -888,7 +902,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
Expand Down
21 changes: 19 additions & 2 deletions docs/configuration/covers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: <seconds>`.

### Physical Architecture & Slat Travel Time
Expand Down Expand Up @@ -185,7 +202,7 @@ 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).
Expand Down
76 changes: 76 additions & 0 deletions tests/test_cover_device_class.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""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_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


@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),
({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),
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking test suggestion: Nice, concise parametrized coverage — this already hits 100% of the changed lines in cover.py (including the "switch" ValueError path). If you happen to touch this file, adding a quick case for an area/group MyHOMEScopeCover (e.g. where: "1") or strategy: "venetian" + class: "shade" could be nice extra defense-in-depth, though both paths work cleanly as written.

)
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).

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"))
Loading