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 homeassistant/components/control4/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.MEDIA_PLAYER]


@dataclass
Expand Down
220 changes: 220 additions & 0 deletions homeassistant/components/control4/cover.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""Platform for Control4 Covers (blinds and shades)."""

from datetime import timedelta
import logging
from typing import Any

from pyControl4.blind import C4Blind
from pyControl4.error_handling import C4Exception

from homeassistant.components.cover import (
ATTR_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from . import Control4ConfigEntry, get_items_of_category
from .const import CONTROL4_ENTITY_TYPE
from .director_utils import update_variables_for_config_entry
from .entity import Control4Entity

_LOGGER = logging.getLogger(__name__)

CONTROL4_CATEGORY = "blinds_shades"

CONTROL4_LEVEL = "Level"
CONTROL4_FULLY_CLOSED = "Fully Closed"
CONTROL4_FULLY_OPEN = "Fully Open"
CONTROL4_OPENING = "Opening"
CONTROL4_CLOSING = "Closing"

VARIABLES_OF_INTEREST = {
CONTROL4_LEVEL,
CONTROL4_FULLY_CLOSED,
CONTROL4_FULLY_OPEN,
Comment on lines +31 to +38
CONTROL4_OPENING,
CONTROL4_CLOSING,
}


async def async_setup_entry(
hass: HomeAssistant,
entry: Control4ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Control4 covers from a config entry."""
runtime_data = entry.runtime_data

async def async_update_data() -> dict[int, dict[str, Any]]:
"""Fetch data from Control4 director for blinds."""
try:
return await update_variables_for_config_entry(
hass, entry, VARIABLES_OF_INTEREST
)
except C4Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err

coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
hass,
_LOGGER,
name="cover",
update_method=async_update_data,
update_interval=timedelta(seconds=runtime_data.scan_interval),
config_entry=entry,
)

await coordinator.async_refresh()

Comment on lines +70 to +71
Comment on lines +61 to +71
items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY)
entity_list = []
for item in items_of_category:
Comment on lines +72 to +74
try:
if item["type"] != CONTROL4_ENTITY_TYPE:
continue
item_name = item["name"]
item_id = item["id"]
item_parent_id = item["parentId"]
item_manufacturer = None
item_device_name = None
item_model = None

for parent_item in items_of_category:
if parent_item["id"] == item_parent_id:
item_manufacturer = parent_item.get("manufacturer")
item_device_name = parent_item.get("roomName")
item_model = parent_item.get("model")
Comment on lines +72 to +89
Comment on lines +72 to +89
Comment on lines +85 to +89
Comment on lines +85 to +89
except KeyError:
_LOGGER.exception(
"Unknown device properties received from Control4: %s",
item,
)
continue

if item_id not in coordinator.data:
_LOGGER.warning(
"Couldn't get cover state data for %s (ID: %s), skipping setup",
item_name,
item_id,
)
continue
Comment on lines +70 to +103
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

Use coordinator.async_config_entry_first_refresh() (or otherwise guard coordinator.data being None) so setup doesn't crash when the initial refresh fails.

Copilot uses AI. Check for mistakes.
Comment on lines +97 to +103

entity_list.append(
Control4Cover(
runtime_data,
coordinator,
item_name,
item_id,
item_device_name,
item_manufacturer,
item_model,
item_parent_id,
)
)

async_add_entities(entity_list)


class Control4Cover(Control4Entity, CoverEntity):
"""Control4 cover entity."""

_attr_has_entity_name = True
_attr_translation_key = "blind"
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

Either add the cover.blind translation key to strings.json/icons.json or drop _attr_translation_key to avoid missing-translation warnings at runtime.

Suggested change
_attr_translation_key = "blind"

Copilot uses AI. Check for mistakes.
_attr_device_class = CoverDeviceClass.SHADE
_attr_supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.STOP
| CoverEntityFeature.SET_POSITION
)

@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._cover_data is not None

def _create_api_object(self) -> C4Blind:
"""Create a pyControl4 device object.

This exists so the director token used is always the latest one,
without needing to re-init the entire entity.
"""
return C4Blind(self.runtime_data.director, self._idx)

@property
def _cover_data(self) -> dict[str, Any] | None:
"""Return the cover data from the coordinator."""
return self.coordinator.data.get(self._idx)
Comment on lines +148 to +150

@property
def current_cover_position(self) -> int | None:
"""Return current position of cover (0 closed, 100 open)."""
data = self._cover_data
if data is None:
return None
level = data.get(CONTROL4_LEVEL)
if level is None:
return None
return int(level)

@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
data = self._cover_data
if data is None:
return None
if (fully_closed := data.get(CONTROL4_FULLY_CLOSED)) is not None:
return bool(fully_closed)
position = self.current_cover_position
if position is None:
return None
return position == 0

@property
def is_opening(self) -> bool | None:
"""Return if the cover is opening."""
data = self._cover_data
if data is None:
return None
opening = data.get(CONTROL4_OPENING)
if opening is None:
return None
return bool(opening)

@property
def is_closing(self) -> bool | None:
"""Return if the cover is closing."""
data = self._cover_data
if data is None:
return None
closing = data.get(CONTROL4_CLOSING)
if closing is None:
return None
return bool(closing)

async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
c4_blind = self._create_api_object()
await c4_blind.open()
await self.coordinator.async_request_refresh()

async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
c4_blind = self._create_api_object()
await c4_blind.close()
await self.coordinator.async_request_refresh()

async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
c4_blind = self._create_api_object()
await c4_blind.stop()
await self.coordinator.async_request_refresh()

async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
c4_blind = self._create_api_object()
await c4_blind.setLevelTarget(kwargs[ATTR_POSITION])
await self.coordinator.async_request_refresh()
13 changes: 13 additions & 0 deletions tests/components/control4/fixtures/director_all_items.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,18 @@
"manufacturer": "Control4",
"roomName": "Studio",
"model": "C4-TSTAT"
},
{
"id": 234,
"name": "Living Room Shade",
"type": 7,
"parentId": 567,
"categories": ["blinds_shades"]
},
{
"id": 567,
"manufacturer": "Lutron",
"roomName": "Living Room",
"model": "QSWS2-1BRLI"
}
]
55 changes: 55 additions & 0 deletions tests/components/control4/snapshots/test_cover.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# serializer version: 1
# name: test_cover_entities[cover.test_controller_living_room_shade-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'cover',
'entity_category': None,
'entity_id': 'cover.test_controller_living_room_shade',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Living Room Shade',
'options': dict({
}),
'original_device_class': <CoverDeviceClass.SHADE: 'shade'>,
'original_icon': None,
'original_name': 'Living Room Shade',
'platform': 'control4',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 15>,
'translation_key': 'blind',
'unique_id': '234',
'unit_of_measurement': None,
})
# ---
# name: test_cover_entities[cover.test_controller_living_room_shade-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_position': 50,
'device_class': 'shade',
'friendly_name': 'Test Controller Living Room Shade',
'is_closed': False,
'supported_features': <CoverEntityFeature: 15>,
}),
'context': <ANY>,
'entity_id': 'cover.test_controller_living_room_shade',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'open',
})
# ---
Loading
Loading