Skip to content
123 changes: 66 additions & 57 deletions custom_components/liebherr/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

import asyncio
import logging

from homeassistant.components.cover import CoverEntity
from homeassistant.components.cover import CoverEntity, CoverEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_OPENING, STATE_UNKNOWN

from .const import DOMAIN
from .models import AutoDoorControl

_LOGGER = logging.getLogger(__name__)

Expand All @@ -20,94 +21,102 @@ async def async_setup_entry(
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]

appliances = await api.get_appliances()

entities = []

for appliance in appliances:
controls = await api.get_controls(appliance["deviceId"])
if not controls:
_LOGGER.warning("No controls found for appliance %s",
appliance["deviceId"])
_LOGGER.warning("No controls found for appliance %s", appliance["deviceId"])
continue

for control in controls:
if control["type"] == "autodoor":
entities.extend(
[
LiebherrCover(api, coordinator, appliance, control),
]
)
# entities.append(LiebherrCover(api, coordinator, appliance, control))
if control["type"] == "AutoDoorControl":
entities.append(LiebherrCover(api, coordinator, appliance, control))

async_add_entities(entities)


class LiebherrCover(CoverEntity):
"""Representation of a Liebherr cover entity."""
"""Representation of a Liebherr auto door cover."""

def __init__(self, api, coordinator, appliance, control) -> None:
"""Initialize the cover entity."""
self._api = api
self._coordinator = coordinator
self._appliance = appliance
self._control = control

self._device_id = appliance["deviceId"]
self._identifier = control.get("identifier", control["type"])
self._attr_name = f"{appliance['nickname']} {self._identifier}"
self._attr_unique_id = f"{appliance['deviceId']}_{self._identifier}"
self._is_opening = False
self._attr_is_closed = not self.is_open

self._attr_name = f"{appliance.get('nickname', 'Liebherr')} {self._identifier}"
self._attr_unique_id = f"{self._device_id}_{self._identifier}"
self._attr_device_class = "door"
self._attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE

@property
def device_info(self):
"""Return device information for the cover."""
"""Return device information."""
return {
"identifiers": {(DOMAIN, self._appliance["deviceId"])},
"name": self._appliance.get(
"nickname", f"Liebherr HomeAPI Appliance {self._appliance['deviceId']}"
),
"identifiers": {(DOMAIN, self._device_id)},
"name": self._appliance.get("nickname", f"Liebherr {self._device_id}"),
"manufacturer": "Liebherr",
"model": self._appliance.get("model", self._appliance["model"]),
"model": self._appliance.get("model", ""),
"sw_version": self._appliance.get("softwareVersion", ""),
}

@property
def is_open(self):
"""Return true if the cover is open."""
def _get_control_state(self):
"""Return the current state of the control."""
if not self._coordinator.data:
_LOGGER.error("Coordinator data is empty")
return False

controls = []
appliances = self._coordinator.data.get("appliances", [])
for device in appliances:
if device.get("deviceId") == self._appliance["deviceId"]:
controls = device.get("controls", [])
for control in controls:
if control.get("identifier", control["type"]) == self._identifier:
return control.get("active", False)
return False
return None

for device in self._coordinator.data.get("appliances", []):
if device.get("deviceId") != self._device_id:
continue
for control in device.get("controls", []):
if control.get("identifier", control["type"]) == self._identifier:
return control.get("value")

return None

@property
def available(self):
"""Return True if the cover is available."""
return True
def state(self):
"""Return the current state of the cover."""
value = self._get_control_state()
if value == "OPEN":
return STATE_OPEN
if value == "CLOSED":
return STATE_CLOSED
if value == "MOVING":
return STATE_OPENING
return STATE_UNKNOWN

async def async_open_cover(self, **kwargs):
"""Open the cover."""
if self._control["type"] == "autodoor":
await self._api.set_value(
self._appliance["deviceId"] + "/" + self._control["endpoint"],
{"autoDoorMode": "OPEN"},
)
self._is_opening = True
await asyncio.sleep(5)
self._is_opening = False
await self._coordinator.async_request_refresh()
@property
def is_closed(self):
"""Return True if the cover is closed."""
return self._get_control_state() == "CLOSED"

@property
def is_opening(self):
"""Return if the cover is opening or not."""
return self._is_opening
def is_open(self):
"""Return True if the cover is open."""
return self._get_control_state() == "OPEN"

async def async_open_cover(self, **kwargs):
"""Send command to open the cover."""
try:
data = AutoDoorControl(zoneId=self._control.get("zoneId"), value=True)
await self._api.set_value(self._device_id, self._control["name"], data)
await asyncio.sleep(3)
except Exception as e:
_LOGGER.error("Failed to open door %s: %s", self._identifier, e)
await self._coordinator.async_request_refresh()

async def async_close_cover(self, **kwargs):
"""Close the cover."""
# Closing is automatic, no action needed
"""Send command to close the cover."""
try:
data = AutoDoorControl(zoneId=self._control.get("zoneId"), value=False)
await self._api.set_value(self._device_id, self._control["name"], data)
await asyncio.sleep(3)
except Exception as e:
_LOGGER.error("Failed to close door %s: %s", self._identifier, e)
await self._coordinator.async_request_refresh()
10 changes: 10 additions & 0 deletions custom_components/liebherr/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,13 @@ class ModeZoneControlRequest:
@dataclass
class ModeControlRequest:
mode: str

@dataclass
class IceMakerControlRequest:
zoneId: int
iceMakerMode: str # "OFF", "ON", or "MAX_ICE"

@dataclass
class AutoDoorControl:
zoneId: int
value: bool # True = open, False = close
142 changes: 101 additions & 41 deletions custom_components/liebherr/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,39 @@

import asyncio
import logging
from typing import Callable

from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

from .const import DOMAIN
from .models import ModeControlRequest
from .models import ModeControlRequest, IceMakerControlRequest

_LOGGER = logging.getLogger(__name__)

# Control configuration
SELECT_CONFIG: dict[str, dict] = {
"biofreshplus": {
"icon": "mdi:leaf",
"options": None,
"user-options": None,
"attr": "currentMode",
},
"hydrobreeze": {
"icon": "mdi:water",
"options": ["OFF", "LOW", "MEDIUM", "HIGH"],
"user-options": ["Off", "Low", "Medium", "High"],
"attr": "currentMode",
},
"icemaker": {
"icon": "mdi:cube-outline",
"options": lambda ctrl: ["OFF", "ON", "MAX_ICE"] if ctrl.get("hasMaxIce") else ["OFF", "ON"],
"user-options": lambda ctrl: ["Off", "On", "Max Ice"] if ctrl.get("hasMaxIce") else ["Off", "On"],
"attr": "iceMakerMode",
},
}


async def async_setup_entry(
hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities
Expand All @@ -21,22 +44,18 @@ async def async_setup_entry(
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]

appliances = await api.get_appliances()

entities = []

for appliance in appliances:
controls = await api.get_controls(appliance["deviceId"])
if not controls:
_LOGGER.warning("No controls found for appliance %s",
appliance["deviceId"])
_LOGGER.warning("No controls found for appliance %s", appliance["deviceId"])
continue

for control in controls:
if control["type"] in ("biofreshplus", "hydrobreeze"):
entities.extend(
[
LiebherrSelect(api, coordinator, appliance, control),
]
)
ctrl_name = control.get("name", control.get("type"))
if ctrl_name in SELECT_CONFIG:
entities.append(LiebherrSelect(api, coordinator, appliance, control))

async_add_entities(entities)

Expand All @@ -50,57 +69,98 @@ def __init__(self, api, coordinator, appliance, control) -> None:
self._coordinator = coordinator
self._appliance = appliance
self._control = control
self._identifier = control.get("identifier", control["type"])
self._attr_name = f"{appliance['nickname']} {self._identifier}"
self._attr_unique_id = f"{appliance['deviceId']}_{self._identifier}"
self._attr_options = control.get("supportedModes", [])
match control.get("identifier", control["type"]):
case "biofreshplus":
self._attr_icon = "mdi:leaf"
case "hydrobreeze":
self._attr_icon = "mdi:water"
self._attr_options = ["OFF", "LOW", "MEDIUM", "HIGH"]

self._identifier = control.get("name", control.get("type"))
self._device_id = appliance["deviceId"]

nickname = appliance.get("nickname", "Liebherr")
self._attr_name = f"{nickname} {self._identifier}"
self._attr_unique_id = f"{self._device_id}_{self._identifier}"

config = SELECT_CONFIG.get(self._identifier, {})

self._attr_icon = config.get("icon")
self._state_attr_key = config.get("attr", "currentMode")

# Raw options sent to the API
raw_options = config.get("options")
if callable(raw_options):
raw_options = raw_options(control)

# Pretty options shown to user
user_options = config.get("user-options")
if callable(user_options):
user_options = user_options(control)

self._raw_to_user = dict(zip(raw_options, user_options))
self._user_to_raw = dict(zip(user_options, raw_options))
self._attr_options = user_options

def _format_label(self, value: str) -> str:
"""Format raw option label to user-friendly form."""
return value.capitalize().replace("_", "")

@property
def device_info(self):
"""Return device information for the select."""
return {
"identifiers": {(DOMAIN, self._appliance["deviceId"])},
"name": self._appliance.get(
"nickname", f"Liebherr HomeAPI Appliance {self._appliance['deviceId']}"
),
"identifiers": {(DOMAIN, self._device_id)},
"name": self._appliance.get("nickname", f"Liebherr {self._device_id}"),
"manufacturer": "Liebherr",
"model": self._appliance.get("model", self._appliance["model"]),
"sw_version": self._appliance.get("softwareVersion", ""),
}

@property
def current_option(self):
"""Return the current selected option."""
def _get_control_from_coordinator(self):
"""Return the current control from the coordinator data."""
if not self._coordinator.data:
_LOGGER.error("Coordinator data is empty")
return None

controls = []
appliances = self._coordinator.data.get("appliances", [])
for device in appliances:
if device.get("deviceId") == self._appliance["deviceId"]:
controls = device.get("controls", [])
for control in controls:
if control.get("identifier", control["type"]) == self._identifier:
return control.get("currentMode", None)
for device in self._coordinator.data.get("appliances", []):
if device.get("deviceId") != self._device_id:
continue
for control in device.get("controls", []):
if control.get("name", control.get("type")) == self._identifier:
return control
return None

@property
def current_option(self):
"""Return the current selected option."""
control = self._get_control_from_coordinator()
if not control:
return None
raw_value = control.get(self._state_attr_key)
return self._raw_to_user.get(raw_value, raw_value)

async def async_select_option(self, option: str):
"""Change the selected option."""
if option not in self._attr_options:
if option not in self._user_to_raw:
_LOGGER.error("Invalid option selected: %s", option)
return

data = ModeControlRequest(mode=option)
await self.api.set_value(
self._appliance["deviceId"], self._control["name"], data
)
raw_value = self._user_to_raw[option]

try:
if self._control["type"] == "IceMakerControl":
data = IceMakerControlRequest(
zoneId=self._control.get("zoneId"),
iceMakerMode=raw_value,
)
else:
data = ModeControlRequest(mode=raw_value)

await self._api.set_value(
self._device_id,
self._control["name"],
data,
)

await asyncio.sleep(5)

except Exception as e:
_LOGGER.error("Failed to set option '%s' for '%s': %s", option, self._identifier, e)
return

await asyncio.sleep(5)
await self._coordinator.async_request_refresh()
Loading