From 528902db0fb0ee0576a2b74ef4e41504fe0ddaa5 Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Wed, 2 Jul 2025 00:54:46 +0300 Subject: [PATCH 01/11] Add icemaker control --- custom_components/liebherr/models.py | 5 +++++ custom_components/liebherr/select.py | 26 +++++++++++++++++++------- custom_components/liebherr/switch.py | 25 +++++++++++-------------- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/custom_components/liebherr/models.py b/custom_components/liebherr/models.py index e89ef8f..9f40d12 100644 --- a/custom_components/liebherr/models.py +++ b/custom_components/liebherr/models.py @@ -30,3 +30,8 @@ class ModeZoneControlRequest: @dataclass class ModeControlRequest: mode: str + +@dataclass +class IceMakerControlRequest: + zoneId: int + iceMakerMode: str # "OFF", "ON", or "MAX_ICE" diff --git a/custom_components/liebherr/select.py b/custom_components/liebherr/select.py index 6ca0911..824d4c4 100644 --- a/custom_components/liebherr/select.py +++ b/custom_components/liebherr/select.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN -from .models import ModeControlRequest +from .models import ModeControlRequest, IceMakerControlRequest _LOGGER = logging.getLogger(__name__) @@ -31,7 +31,7 @@ async def async_setup_entry( continue for control in controls: - if control["type"] in ("biofreshplus", "hydrobreeze"): + if control["type"] in ("biofreshplus", "hydrobreeze", "IceMakerControl"): entities.extend( [ LiebherrSelect(api, coordinator, appliance, control), @@ -60,6 +60,9 @@ def __init__(self, api, coordinator, appliance, control) -> None: case "hydrobreeze": self._attr_icon = "mdi:water" self._attr_options = ["OFF", "LOW", "MEDIUM", "HIGH"] + case "IceMakerControl": + self._attr_icon = "mdi:cube-outline" + self._attr_options = ["OFF", "ON", "MAX_ICE"] @property def device_info(self): @@ -88,7 +91,10 @@ def current_option(self): controls = device.get("controls", []) for control in controls: if control.get("identifier", control["type"]) == self._identifier: - return control.get("currentMode", None) + if control["type"] == "IceMakerControl": + return control.get("iceMakerMode", None) + else: + return control.get("currentMode", None) return None async def async_select_option(self, option: str): @@ -97,10 +103,16 @@ async def async_select_option(self, option: str): _LOGGER.error("Invalid option selected: %s", option) return - data = ModeControlRequest(mode=option) - await self.api.set_value( - self._appliance["deviceId"], self._control["name"], data - ) + if self._control["type"] == "IceMakerControl": + data = IceMakerControlRequest(zoneId=self._control.get("zoneId"), iceMakerMode=option) + await self._api.set_value( + self._appliance["deviceId"], self._control["name"], data + ) + else: + data = ModeControlRequest(mode=option) + await self.api.set_value( + self._appliance["deviceId"], self._control["name"], data + ) await asyncio.sleep(5) await self._coordinator.async_request_refresh() diff --git a/custom_components/liebherr/switch.py b/custom_components/liebherr/switch.py index 70add1a..b25890f 100644 --- a/custom_components/liebherr/switch.py +++ b/custom_components/liebherr/switch.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN -from .models import BaseToggleControlRequest, ZoneToggleControlRequest +from .models import BaseToggleControlRequest, ZoneToggleControlRequest, IceMakerControlRequest _LOGGER = logging.getLogger(__name__) @@ -88,8 +88,6 @@ def __init__(self, api, coordinator, appliance, control, zoneId) -> None: self._attr_icon = "mdi:weather-night" case "bottletimer": self._attr_icon = "mdi:timer-sand" - case "icemaker": - self._attr_icon = "mdi:ice-cream" @property def device_info(self): @@ -120,7 +118,16 @@ def is_on(self): if self._control_name == control.get("name"): if self._zoneId == control.get("zoneId"): _LOGGER.debug(control) - return control.get("value", False) + control_type = control.get("type") + # Handle control types individually + if control_type == "ToggleControl": + return control.get("value") is True + else: + _LOGGER.warning( + "Unsupported control type '%s' for control '%s'", + control_type, self._control_name + ) + return False return False def setControlValue(self, value): @@ -152,11 +159,6 @@ def available(self): async def async_turn_on(self, **kwargs): """Turn the switch on.""" - if self._control["type"] == "IceMaker": - await self._api.set_value( - self._appliance["deviceId"] + "/" + self._control["name"], - {"iceMakerMode": "ON"}, - ) if self._control["type"] == "BottleTimer": await self._api.set_value( self._appliance["deviceId"] + "/" + self._control["name"], @@ -182,11 +184,6 @@ async def async_turn_on(self, **kwargs): async def async_turn_off(self, **kwargs): """Turn the switch off.""" - if self._control["type"] == "icemaker": - await self._api.set_value( - self._appliance["deviceId"] + "/" + self._control["name"], - {"iceMakerMode": "OFF"}, - ) if self._control["type"] == "bottletimer": await self._api.set_value( self._appliance["deviceId"] + "/" + self._control["name"], From 273282f406ba42610c29c30e06adf3d37596551e Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Wed, 2 Jul 2025 01:26:14 +0300 Subject: [PATCH 02/11] Fix: update IceMakerControl options based on hasMaxIce --- custom_components/liebherr/select.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/liebherr/select.py b/custom_components/liebherr/select.py index 824d4c4..084e02c 100644 --- a/custom_components/liebherr/select.py +++ b/custom_components/liebherr/select.py @@ -62,7 +62,11 @@ def __init__(self, api, coordinator, appliance, control) -> None: self._attr_options = ["OFF", "LOW", "MEDIUM", "HIGH"] case "IceMakerControl": self._attr_icon = "mdi:cube-outline" - self._attr_options = ["OFF", "ON", "MAX_ICE"] + has_max_ice = control.get("hasMaxIce", False) + if has_max_ice: + self._attr_options = ["OFF", "ON", "MAX_ICE"] + else: + self._attr_options = ["OFF", "ON"] @property def device_info(self): From b73c737bf2e23224d6471b41cec87bcdcf11a491 Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Wed, 2 Jul 2025 23:52:12 +0300 Subject: [PATCH 03/11] Add AutoDoorControl select entity and sensor for door state, not tested --- custom_components/liebherr/models.py | 5 +++++ custom_components/liebherr/select.py | 33 +++++++++++++++++++++++++--- custom_components/liebherr/sensor.py | 2 +- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/custom_components/liebherr/models.py b/custom_components/liebherr/models.py index 9f40d12..e4416b9 100644 --- a/custom_components/liebherr/models.py +++ b/custom_components/liebherr/models.py @@ -35,3 +35,8 @@ class ModeControlRequest: class IceMakerControlRequest: zoneId: int iceMakerMode: str # "OFF", "ON", or "MAX_ICE" + +@dataclass +class AutoDoorControl: + zoneId: int + value: bool # True = open, False = close diff --git a/custom_components/liebherr/select.py b/custom_components/liebherr/select.py index 084e02c..0046e4d 100644 --- a/custom_components/liebherr/select.py +++ b/custom_components/liebherr/select.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN -from .models import ModeControlRequest, IceMakerControlRequest +from .models import ModeControlRequest, IceMakerControlRequest, AutoDoorControl _LOGGER = logging.getLogger(__name__) @@ -31,7 +31,7 @@ async def async_setup_entry( continue for control in controls: - if control["type"] in ("biofreshplus", "hydrobreeze", "IceMakerControl"): + if control["type"] in ("biofreshplus", "hydrobreeze", "IceMakerControl", "AutoDoorControl"): entities.extend( [ LiebherrSelect(api, coordinator, appliance, control), @@ -67,6 +67,9 @@ def __init__(self, api, coordinator, appliance, control) -> None: self._attr_options = ["OFF", "ON", "MAX_ICE"] else: self._attr_options = ["OFF", "ON"] + case "AutoDoorControl": + self._attr_icon = "mdi:door" + self._attr_options = [] # options will be dynamic @property def device_info(self): @@ -97,6 +100,19 @@ def current_option(self): if control.get("identifier", control["type"]) == self._identifier: if control["type"] == "IceMakerControl": return control.get("iceMakerMode", None) + elif control["type"] == "AutoDoorControl": + val = control.get("value") + self._current_api_value = val + if val == "MOVING": + self._attr_options = [] # disable + return None + elif val == "OPEN": + self._attr_options = ["CLOSE"] + return "OPEN" + elif val == "CLOSED": + self._attr_options = ["OPEN"] + return "CLOSE" + return None else: return control.get("currentMode", None) return None @@ -112,9 +128,20 @@ async def async_select_option(self, option: str): await self._api.set_value( self._appliance["deviceId"], self._control["name"], data ) + elif self._control["type"] == "AutoDoorControl": + if option == "OPEN": + value = True + elif option == "CLOSE": + value = False + else: + _LOGGER.error("Invalid option for AutoDoorControl: %s", option) + return + data = AutoDoorControl(zoneId=self._control.get("zoneId"), value=value) + await self._api.set_value( + self._appliance["deviceId"], self._control["name"], data.__dict__) else: data = ModeControlRequest(mode=option) - await self.api.set_value( + await self._api.set_value( self._appliance["deviceId"], self._control["name"], data ) diff --git a/custom_components/liebherr/sensor.py b/custom_components/liebherr/sensor.py index 0af2085..a7bb244 100644 --- a/custom_components/liebherr/sensor.py +++ b/custom_components/liebherr/sensor.py @@ -43,7 +43,7 @@ async def async_setup_entry( "mdi:thermometer", ) ) - case "autodoor": + case "AutoDoorControl": entities.append( LiebherrSensor( api, From fadcb2cbc4975618a1ddcb4f2d7a53cf796a9618 Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Thu, 3 Jul 2025 09:29:02 +0300 Subject: [PATCH 04/11] Improve autodoorcontrol feature --- custom_components/liebherr/cover.py | 80 +++++++++++++++++++--------- custom_components/liebherr/select.py | 31 +---------- 2 files changed, 57 insertions(+), 54 deletions(-) diff --git a/custom_components/liebherr/cover.py b/custom_components/liebherr/cover.py index 09b73de..03715ab 100644 --- a/custom_components/liebherr/cover.py +++ b/custom_components/liebherr/cover.py @@ -6,6 +6,9 @@ from homeassistant.components.cover import CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_OPENING, STATE_UNKNOWN + from .const import DOMAIN @@ -30,7 +33,7 @@ async def async_setup_entry( continue for control in controls: - if control["type"] == "autodoor": + if control["type"] == "AutoDoorControl": entities.extend( [ LiebherrCover(api, coordinator, appliance, control), @@ -54,7 +57,18 @@ def __init__(self, api, coordinator, appliance, control) -> None: 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 + + @property + def supported_features(self): + if self.state == STATE_CLOSED: + return CoverEntityFeature.OPEN + elif self.state == STATE_OPEN: + return CoverEntityFeature.CLOSE + return CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + @property + def device_class(self): + return "door" @property def device_info(self): @@ -68,23 +82,37 @@ def device_info(self): "model": self._appliance.get("model", self._appliance["model"]), "sw_version": self._appliance.get("softwareVersion", ""), } + + def _get_current_value(self): + if not self._coordinator.data: + return None + for device in self._coordinator.data.get("appliances", []): + if device.get("deviceId") == self._appliance["deviceId"]: + for control in device.get("controls", []): + if control.get("identifier", control["type"]) == self._identifier: + return control.get("value") + return None + + @property + def is_closed(self): + """Return true if the cover is closed.""" + return self._get_current_value() == "CLOSED" @property def is_open(self): """Return true if the cover is open.""" - if not self._coordinator.data: - _LOGGER.error("Coordinator data is empty") - return False + return self._get_current_value() == "OPEN" - 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 + @property + def state(self): + value = self._get_current_value() + if value == "OPEN": + return STATE_OPEN + elif value == "CLOSED": + return STATE_CLOSED + elif value == "MOVING": + return STATE_OPENING + return STATE_UNKNOWN @property def available(self): @@ -93,21 +121,23 @@ def available(self): 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"}, - ) + if self._control["type"] == "AutoDoorControl": + payload = { + "zoneId": self._control["zoneId"], + "value": True + } + await self._api.set_value(self._appliance["deviceId"], self._control["name"], payload) self._is_opening = True await asyncio.sleep(5) self._is_opening = False await self._coordinator.async_request_refresh() - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self._is_opening - async def async_close_cover(self, **kwargs): """Close the cover.""" - # Closing is automatic, no action needed + if self._control["type"] == "AutoDoorControl": + payload = { + "zoneId": self._control["zoneId"], + "value": False + } + await self._api.set_value(self._appliance["deviceId"], self._control["name"], payload) + await self._coordinator.async_request_refresh() diff --git a/custom_components/liebherr/select.py b/custom_components/liebherr/select.py index 0046e4d..20c0252 100644 --- a/custom_components/liebherr/select.py +++ b/custom_components/liebherr/select.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN -from .models import ModeControlRequest, IceMakerControlRequest, AutoDoorControl +from .models import ModeControlRequest, IceMakerControlRequest _LOGGER = logging.getLogger(__name__) @@ -31,7 +31,7 @@ async def async_setup_entry( continue for control in controls: - if control["type"] in ("biofreshplus", "hydrobreeze", "IceMakerControl", "AutoDoorControl"): + if control["type"] in ("biofreshplus", "hydrobreeze", "IceMakerControl"): entities.extend( [ LiebherrSelect(api, coordinator, appliance, control), @@ -67,9 +67,6 @@ def __init__(self, api, coordinator, appliance, control) -> None: self._attr_options = ["OFF", "ON", "MAX_ICE"] else: self._attr_options = ["OFF", "ON"] - case "AutoDoorControl": - self._attr_icon = "mdi:door" - self._attr_options = [] # options will be dynamic @property def device_info(self): @@ -100,19 +97,6 @@ def current_option(self): if control.get("identifier", control["type"]) == self._identifier: if control["type"] == "IceMakerControl": return control.get("iceMakerMode", None) - elif control["type"] == "AutoDoorControl": - val = control.get("value") - self._current_api_value = val - if val == "MOVING": - self._attr_options = [] # disable - return None - elif val == "OPEN": - self._attr_options = ["CLOSE"] - return "OPEN" - elif val == "CLOSED": - self._attr_options = ["OPEN"] - return "CLOSE" - return None else: return control.get("currentMode", None) return None @@ -128,17 +112,6 @@ async def async_select_option(self, option: str): await self._api.set_value( self._appliance["deviceId"], self._control["name"], data ) - elif self._control["type"] == "AutoDoorControl": - if option == "OPEN": - value = True - elif option == "CLOSE": - value = False - else: - _LOGGER.error("Invalid option for AutoDoorControl: %s", option) - return - data = AutoDoorControl(zoneId=self._control.get("zoneId"), value=value) - await self._api.set_value( - self._appliance["deviceId"], self._control["name"], data.__dict__) else: data = ModeControlRequest(mode=option) await self._api.set_value( From f39d8c5d0eddc36030b7a075443f3f25326ddbe0 Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Fri, 4 Jul 2025 00:31:07 +0300 Subject: [PATCH 05/11] Fix for AutoDoorControl --- custom_components/liebherr/cover.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/custom_components/liebherr/cover.py b/custom_components/liebherr/cover.py index 03715ab..a9c99b9 100644 --- a/custom_components/liebherr/cover.py +++ b/custom_components/liebherr/cover.py @@ -11,6 +11,7 @@ from .const import DOMAIN +from .models import AutoDoorControl _LOGGER = logging.getLogger(__name__) @@ -79,7 +80,7 @@ def device_info(self): "nickname", f"Liebherr HomeAPI Appliance {self._appliance['deviceId']}" ), "manufacturer": "Liebherr", - "model": self._appliance.get("model", self._appliance["model"]), + "model": self._appliance.get("model", ""), "sw_version": self._appliance.get("softwareVersion", ""), } @@ -121,12 +122,8 @@ def available(self): async def async_open_cover(self, **kwargs): """Open the cover.""" - if self._control["type"] == "AutoDoorControl": - payload = { - "zoneId": self._control["zoneId"], - "value": True - } - await self._api.set_value(self._appliance["deviceId"], self._control["name"], payload) + data = AutoDoorControl(zoneId=self._control.get("zoneId"), value=True) + await self._api.set_value(self._appliance["deviceId"], self._control["name"], data) self._is_opening = True await asyncio.sleep(5) self._is_opening = False @@ -135,9 +132,6 @@ async def async_open_cover(self, **kwargs): async def async_close_cover(self, **kwargs): """Close the cover.""" if self._control["type"] == "AutoDoorControl": - payload = { - "zoneId": self._control["zoneId"], - "value": False - } - await self._api.set_value(self._appliance["deviceId"], self._control["name"], payload) + data = AutoDoorControl(zoneId=self._control.get("zoneId"), value=False) + await self._api.set_value(self._appliance["deviceId"], self._control["name"], data) await self._coordinator.async_request_refresh() From 5b1e12e20b4d976594eacf342436092efe5b5439 Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Sat, 5 Jul 2025 00:07:08 +0300 Subject: [PATCH 06/11] Code refactoring and iprovements --- custom_components/liebherr/cover.py | 113 ++++++++--------- custom_components/liebherr/select.py | 132 ++++++++++++-------- custom_components/liebherr/sensor.py | 179 +++++++++++++-------------- custom_components/liebherr/switch.py | 38 +++--- 4 files changed, 232 insertions(+), 230 deletions(-) diff --git a/custom_components/liebherr/cover.py b/custom_components/liebherr/cover.py index a9c99b9..faca9b6 100644 --- a/custom_components/liebherr/cover.py +++ b/custom_components/liebherr/cover.py @@ -2,14 +2,11 @@ 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.components.cover import CoverEntityFeature from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_OPENING, STATE_UNKNOWN - from .const import DOMAIN from .models import AutoDoorControl @@ -24,29 +21,23 @@ 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"] == "AutoDoorControl": - entities.extend( - [ - LiebherrCover(api, coordinator, appliance, control), - ] - ) - # entities.append(LiebherrCover(api, coordinator, appliance, control)) + 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.""" @@ -54,84 +45,78 @@ 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._is_opening = False - @property - def supported_features(self): - if self.state == STATE_CLOSED: - return CoverEntityFeature.OPEN - elif self.state == STATE_OPEN: - return CoverEntityFeature.CLOSE - return CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + self._device_id = appliance["deviceId"] + self._identifier = control.get("identifier", control["type"]) - @property - def device_class(self): - return "door" + 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", ""), "sw_version": self._appliance.get("softwareVersion", ""), } - - def _get_current_value(self): + + def _get_control_state(self): + """Return the current state of the control.""" if not self._coordinator.data: return None - for device in self._coordinator.data.get("appliances", []): - if device.get("deviceId") == self._appliance["deviceId"]: - for control in device.get("controls", []): - if control.get("identifier", control["type"]) == self._identifier: - return control.get("value") - return None - @property - def is_closed(self): - """Return true if the cover is closed.""" - return self._get_current_value() == "CLOSED" + 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") - @property - def is_open(self): - """Return true if the cover is open.""" - return self._get_current_value() == "OPEN" + return None @property def state(self): - value = self._get_current_value() + """Return the current state of the cover.""" + value = self._get_control_state() if value == "OPEN": return STATE_OPEN - elif value == "CLOSED": + if value == "CLOSED": return STATE_CLOSED - elif value == "MOVING": + if value == "MOVING": return STATE_OPENING return STATE_UNKNOWN @property - def available(self): - """Return True if the cover is available.""" - return True + def is_closed(self): + """Return True if the cover is closed.""" + return self._get_control_state() == "CLOSED" + + @property + def is_open(self): + """Return True if the cover is open.""" + return self._get_control_state() == "OPEN" async def async_open_cover(self, **kwargs): - """Open the cover.""" - data = AutoDoorControl(zoneId=self._control.get("zoneId"), value=True) - await self._api.set_value(self._appliance["deviceId"], self._control["name"], data) - self._is_opening = True - await asyncio.sleep(5) - self._is_opening = False + """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.""" - if self._control["type"] == "AutoDoorControl": + """Send command to close the cover.""" + try: data = AutoDoorControl(zoneId=self._control.get("zoneId"), value=False) - await self._api.set_value(self._appliance["deviceId"], self._control["name"], data) + 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() diff --git a/custom_components/liebherr/select.py b/custom_components/liebherr/select.py index 20c0252..b1070f5 100644 --- a/custom_components/liebherr/select.py +++ b/custom_components/liebherr/select.py @@ -2,6 +2,7 @@ import asyncio import logging +from typing import Callable from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry @@ -12,6 +13,25 @@ _LOGGER = logging.getLogger(__name__) +# Control configuration +SELECT_CONFIG: dict[str, dict] = { + "biofreshplus": { + "icon": "mdi:leaf", + "options": None, + "attr": "currentMode", + }, + "hydrobreeze": { + "icon": "mdi:water", + "options": ["OFF", "LOW", "MEDIUM", "HIGH"], + "attr": "currentMode", + }, + "IceMakerControl": { + "icon": "mdi:cube-outline", + "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 @@ -21,22 +41,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", "IceMakerControl"): - entities.extend( - [ - LiebherrSelect(api, coordinator, appliance, control), - ] - ) + ctrl_id = control.get("identifier", control["type"]) + if ctrl_id in SELECT_CONFIG: + entities.append(LiebherrSelect(api, coordinator, appliance, control)) async_add_entities(entities) @@ -50,73 +66,85 @@ 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"] - case "IceMakerControl": - self._attr_icon = "mdi:cube-outline" - has_max_ice = control.get("hasMaxIce", False) - if has_max_ice: - self._attr_options = ["OFF", "ON", "MAX_ICE"] - else: - self._attr_options = ["OFF", "ON"] + 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") + + options = config.get("options") + if callable(options): + self._attr_options = options(control) + elif options is not None: + self._attr_options = options + else: + self._attr_options = control.get("supportedModes", []) @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: - if control["type"] == "IceMakerControl": - return control.get("iceMakerMode", None) - else: - 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("identifier", control["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 + return control.get(self._state_attr_key) + async def async_select_option(self, option: str): """Change the selected option.""" if option not in self._attr_options: _LOGGER.error("Invalid option selected: %s", option) return - if self._control["type"] == "IceMakerControl": - data = IceMakerControlRequest(zoneId=self._control.get("zoneId"), iceMakerMode=option) - await self._api.set_value( - self._appliance["deviceId"], self._control["name"], data - ) - else: - data = ModeControlRequest(mode=option) + try: + if self._control["type"] == "IceMakerControl": + data = IceMakerControlRequest( + zoneId=self._control.get("zoneId"), + iceMakerMode=option, + ) + else: + data = ModeControlRequest(mode=option) + await self._api.set_value( - self._appliance["deviceId"], self._control["name"], data + self._device_id, + self._control["name"], + data, ) - await asyncio.sleep(5) + # Wait a bit for the fridge to apply changes before refreshing + await asyncio.sleep(5) + + except Exception as e: + _LOGGER.error("Failed to set option '%s' for '%s': %s", option, self._identifier, e) + return + await self._coordinator.async_request_refresh() diff --git a/custom_components/liebherr/sensor.py b/custom_components/liebherr/sensor.py index a7bb244..e03902a 100644 --- a/custom_components/liebherr/sensor.py +++ b/custom_components/liebherr/sensor.py @@ -1,7 +1,6 @@ """Support for Liebherr sensors.""" import logging - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -17,84 +16,59 @@ async def async_setup_entry( """Set up sensors.""" api = hass.data[DOMAIN][config_entry.entry_id]["api"] 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: - match control["type"]: - case "biofresh": - entities.append( - LiebherrSensor( - api, - coordinator, - appliance, - control, - "current", - "°C", - SensorDeviceClass.TEMPERATURE, - "mdi:thermometer", - ) - ) - case "AutoDoorControl": - entities.append( - LiebherrSensor( - api, - coordinator, - appliance, - control, - "enabled", - None, - None, - "mdi:toggle-switch-off-outline", - enabled_default=False, - ) - ) - entities.append( - LiebherrSensor( - api, - coordinator, - appliance, - control, - "calibrated", - None, - None, - "mdi:door", - enabled_default=False, - ) - ) - entities.append( - LiebherrSensor( - api, - coordinator, - appliance, - control, - "doorState", - None, - None, - "mdi:door-open", - ) - ) - case "hydrobreeze": - entities.append( - LiebherrSensor( - api, - coordinator, - appliance, - control, - "currentMode", - None, - None, - "mdi:air-humidifier", - ) + zone_id = control.get("zoneId", 0) + control_type = control.get("type") + + # Define known sensors + sensor_map = { + "biofresh": { + "attribute": "current", + "unit": "°C", + "device_class": SensorDeviceClass.TEMPERATURE, + "icon": "mdi:thermometer", + }, + "autodoorcontrol": { + "attribute": "value", + "unit": None, + "device_class": None, + "icon": "mdi:door-open", + }, + "hydrobreeze": { + "attribute": "currentMode", + "unit": None, + "device_class": None, + "icon": "mdi:air-humidifier", + }, + } + + config = sensor_map.get(control_type.lower()) + if config: + entities.append( + LiebherrSensor( + api, + coordinator, + appliance, + control, + zone_id, + config["attribute"], + config["unit"], + config["device_class"], + config["icon"], ) + ) + else: + _LOGGER.debug("Unsupported sensor type: %s", control_type) async_add_entities(entities) @@ -102,68 +76,91 @@ async def async_setup_entry( class LiebherrSensor(SensorEntity): """Representation of a Liebherr sensor entity.""" - should_poll = True - def __init__( self, api, coordinator, appliance, control, + zone_id, attribute, - sensor_type, + unit, device_class, icon, enabled_default=True, - ) -> None: + ): """Initialize the sensor entity.""" self._api = api self._coordinator = coordinator self._appliance = appliance self._control = control - self._identifier = control.get("identifier", control["type"]) - self._attr_name = f"{appliance['nickname']} {self._identifier} {attribute}" - self._attr_unique_id = f"{appliance['deviceId']}_{self._identifier}_{attribute}" + self._zone_id = control.get("zoneId", zone_id) self._attribute = attribute - self._endpoint = control.get("endpoint") - self._sensor_type = sensor_type + self._unit = unit self._device_class = device_class self._icon = icon - self._attr_state = None + self._enabled_default = enabled_default + + self._identifier = control.get("identifier", control["type"]) + self._device_id = appliance["deviceId"] + + nickname = appliance.get("nickname", "Liebherr") + self._attr_name = f"{nickname} {self._identifier} {attribute}" + if self._zone_id: + self._attr_name += f" Zone {self._zone_id}" + + self._attr_unique_id = f"{DOMAIN}_{self._device_id}_{self._identifier}_{attribute}_zone{self._zone_id}" self._attr_entity_registry_enabled_default = enabled_default @property def device_info(self): """Return device information for the sensor.""" 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 Appliance {self._device_id}"), "manufacturer": "Liebherr", - "model": self._appliance.get("model", self._appliance["model"]), + "model": self._appliance.get("model"), "sw_version": self._appliance.get("softwareVersion", ""), } + def _get_current_value(self): + """Get the most recent value from the coordinator.""" + data = self._coordinator.data or {} + for device in 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 + and control.get("zoneId", 0) == self._zone_id + ): + return control.get(self._attribute) + return None + @property def state(self): """Return the state of the sensor.""" - if not self._coordinator.data: - _LOGGER.error("Coordinator data is empty") - return None - - return self._control.get(self._attribute) + value = self._get_current_value() + if value is None: + _LOGGER.debug( + "No value for sensor %s on zone %s, attribute %s", + self._identifier, + self._zone_id, + self._attribute, + ) + return value @property def unit_of_measurement(self): - """Return the unit_of_measurement of the sensor.""" - return self._sensor_type + """Return the unit_of_measurement of the sensor""" + return self._unit @property def device_class(self): """Return the device_class of the sensor.""" return self._device_class + @property def available(self): """Return True if the sensor is available.""" return True diff --git a/custom_components/liebherr/switch.py b/custom_components/liebherr/switch.py index b25890f..4774b06 100644 --- a/custom_components/liebherr/switch.py +++ b/custom_components/liebherr/switch.py @@ -30,15 +30,11 @@ async def async_setup_entry( continue for control in controls: - if control["type"] in ( - "ToggleControl" - ): # ("toggle", "icemaker", "bottletimer"): + zone_id = control.get("zoneId") or 0 + if control["type"] in ("ToggleControl"): entities.extend( [ - LiebherrSwitch( - api, coordinator, appliance, control, control.get( - "zoneId") - ), + LiebherrSwitch(api, coordinator, appliance, control, zone_id), ] ) @@ -57,7 +53,7 @@ def __init__(self, api, coordinator, appliance, control, zoneId) -> None: self._coordinator = coordinator self._appliance = appliance self._control = control - self._zoneId = control.get("zoneId", zoneId) + self._zoneId = zoneId self._identifier = ( appliance.get("nickname") + "_" + control.get("name", control.get("type")) @@ -66,8 +62,6 @@ def __init__(self, api, coordinator, appliance, control, zoneId) -> None: self._identifier += f"_{control['zonePosition']}" elif "zoneId" in control: self._identifier += f"_{control['zoneId']}" - self._appliance = appliance - self._zoneId = control.get("zoneId", zoneId) self._control_name = control.get("name") self._attr_name = appliance.get("nickname") + " " + control.get("name") if "zonePosition" in control: @@ -75,19 +69,17 @@ def __init__(self, api, coordinator, appliance, control, zoneId) -> None: elif "zoneId" in control: self._attr_name += f" {control['zoneId']}" self._attr_unique_id = "liebherr_" + self._identifier - match control.get("name"): - case "supercool": - self._attr_icon = "mdi:snowflake" - case "superfrost": - self._attr_icon = "mdi:snowflake-variant" - case "partymode": - self._attr_icon = "mdi:party-popper" - case "holidaymode": - self._attr_icon = "mdi:beach" - case "nightmode": - self._attr_icon = "mdi:weather-night" - case "bottletimer": - self._attr_icon = "mdi:timer-sand" + self._attr_icon = self._get_icon(self._control_name.lower()) + + def _get_icon(self, name: str) -> str | None: + return { + "supercool": "mdi:snowflake", + "superfrost": "mdi:snowflake-variant", + "partymode": "mdi:party-popper", + "holidaymode": "mdi:beach", + "nightmode": "mdi:weather-night", + "bottletimer": "mdi:timer-sand", + }.get(name) @property def device_info(self): From 37f42bc3e1eb3682beae1c60468204e005f25b96 Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Sat, 5 Jul 2025 22:36:31 +0300 Subject: [PATCH 07/11] Improved icemaker display options --- custom_components/liebherr/select.py | 50 ++++++++++++++++++---------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/custom_components/liebherr/select.py b/custom_components/liebherr/select.py index b1070f5..67d3bf9 100644 --- a/custom_components/liebherr/select.py +++ b/custom_components/liebherr/select.py @@ -18,16 +18,19 @@ "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", }, - "IceMakerControl": { + "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", }, } @@ -50,8 +53,8 @@ async def async_setup_entry( continue for control in controls: - ctrl_id = control.get("identifier", control["type"]) - if ctrl_id in SELECT_CONFIG: + 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) @@ -67,7 +70,7 @@ def __init__(self, api, coordinator, appliance, control) -> None: self._appliance = appliance self._control = control - self._identifier = control.get("identifier", control["type"]) + self._identifier = control.get("name", control.get("type")) self._device_id = appliance["deviceId"] nickname = appliance.get("nickname", "Liebherr") @@ -75,16 +78,27 @@ def __init__(self, api, coordinator, appliance, control) -> None: 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") - options = config.get("options") - if callable(options): - self._attr_options = options(control) - elif options is not None: - self._attr_options = options - else: - self._attr_options = control.get("supportedModes", []) + # 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): @@ -107,7 +121,7 @@ def _get_control_from_coordinator(self): if device.get("deviceId") != self._device_id: continue for control in device.get("controls", []): - if control.get("identifier", control["type"]) == self._identifier: + if control.get("name", control.get("type")) == self._identifier: return control return None @@ -117,22 +131,25 @@ def current_option(self): control = self._get_control_from_coordinator() if not control: return None - return control.get(self._state_attr_key) + 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 + raw_value = self._user_to_raw[option] + try: if self._control["type"] == "IceMakerControl": data = IceMakerControlRequest( zoneId=self._control.get("zoneId"), - iceMakerMode=option, + iceMakerMode=raw_value, ) else: - data = ModeControlRequest(mode=option) + data = ModeControlRequest(mode=raw_value) await self._api.set_value( self._device_id, @@ -140,7 +157,6 @@ async def async_select_option(self, option: str): data, ) - # Wait a bit for the fridge to apply changes before refreshing await asyncio.sleep(5) except Exception as e: From 116f402b76e45f061bd437d6379207fe9940f480 Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Sat, 5 Jul 2025 22:40:43 +0300 Subject: [PATCH 08/11] force sensor update after 5sec --- custom_components/liebherr/sensor.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/custom_components/liebherr/sensor.py b/custom_components/liebherr/sensor.py index e03902a..54638ca 100644 --- a/custom_components/liebherr/sensor.py +++ b/custom_components/liebherr/sensor.py @@ -136,20 +136,24 @@ def _get_current_value(self): ): return control.get(self._attribute) return None - + @property def state(self): """Return the state of the sensor.""" value = self._get_current_value() - if value is None: - _LOGGER.debug( - "No value for sensor %s on zone %s, attribute %s", - self._identifier, - self._zone_id, - self._attribute, - ) + + if value == "MOVING": + # Schedule another update in 5 seconds + self.hass.loop.create_task(self._delayed_refresh()) + return value + async def _delayed_refresh(self): + """Force refresh after delay if moving detected.""" + await asyncio.sleep(5) + await self._coordinator.async_request_refresh() + + @property def unit_of_measurement(self): """Return the unit_of_measurement of the sensor""" From c878ad452435bc07632b0e14ef66756d7fd0bfe2 Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Sat, 5 Jul 2025 22:48:36 +0300 Subject: [PATCH 09/11] Added debounce timer to improve transitions --- custom_components/liebherr/cover.py | 73 +++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/custom_components/liebherr/cover.py b/custom_components/liebherr/cover.py index faca9b6..348f182 100644 --- a/custom_components/liebherr/cover.py +++ b/custom_components/liebherr/cover.py @@ -1,7 +1,9 @@ -"""Support for Liebherr autodoor devices.""" +"""Support for Liebherr autodoor devices with debounce logic.""" import asyncio import logging +from datetime import datetime, timedelta + from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -12,6 +14,8 @@ _LOGGER = logging.getLogger(__name__) +DEBOUNCE_SECONDS = 5 # time to wait before confirming final door state + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities @@ -37,7 +41,7 @@ async def async_setup_entry( class LiebherrCover(CoverEntity): - """Representation of a Liebherr auto door cover.""" + """Representation of a Liebherr auto door cover with debounce.""" def __init__(self, api, coordinator, appliance, control) -> None: """Initialize the cover entity.""" @@ -54,6 +58,12 @@ def __init__(self, api, coordinator, appliance, control) -> None: self._attr_device_class = "door" self._attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + # For debounce: + self._last_state = None + self._last_state_change = None + self._debounce_task = None + self._confirmed_state = STATE_UNKNOWN + @property def device_info(self): """Return device information.""" @@ -79,34 +89,58 @@ def _get_control_state(self): return None + async def _debounce_state(self, new_state): + """Debounce door state changes to avoid flickering.""" + # Cancel existing debounce task if any + if self._debounce_task and not self._debounce_task.done(): + self._debounce_task.cancel() + + # If door is MOVING, update immediately but do not confirm + if new_state == "MOVING": + self._confirmed_state = STATE_OPENING + self.async_write_ha_state() + return + + # If state changed from last confirmed state, start debounce delay + if new_state != self._confirmed_state: + self._last_state = new_state + self._last_state_change = datetime.now() + + async def wait_and_confirm(): + try: + await asyncio.sleep(DEBOUNCE_SECONDS) + # After waiting, confirm the new state + self._confirmed_state = ( + STATE_OPEN if new_state == "OPEN" else STATE_CLOSED + ) + self.async_write_ha_state() + except asyncio.CancelledError: + # Debounce canceled because new update arrived + pass + + self._debounce_task = asyncio.create_task(wait_and_confirm()) + @property 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 + """Return the current debounced state of the cover.""" + return self._confirmed_state @property def is_closed(self): """Return True if the cover is closed.""" - return self._get_control_state() == "CLOSED" + return self._confirmed_state == STATE_CLOSED @property def is_open(self): """Return True if the cover is open.""" - return self._get_control_state() == "OPEN" + return self._confirmed_state == 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) + await asyncio.sleep(3) # Let the door start moving except Exception as e: _LOGGER.error("Failed to open door %s: %s", self._identifier, e) await self._coordinator.async_request_refresh() @@ -116,7 +150,16 @@ async def async_close_cover(self, **kwargs): 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) + await asyncio.sleep(3) # Let the door start moving except Exception as e: _LOGGER.error("Failed to close door %s: %s", self._identifier, e) await self._coordinator.async_request_refresh() + + async def async_update(self): + """Called by coordinator on data update; update state with debounce.""" + raw_state = self._get_control_state() + if raw_state is None: + self._confirmed_state = STATE_UNKNOWN + self.async_write_ha_state() + return + await self._debounce_state(raw_state) From 9c7c41a2bb9f14df09e8dce50665231790e13eba Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Mon, 7 Jul 2025 21:20:15 +0300 Subject: [PATCH 10/11] Revert "Added debounce timer to improve transitions" This reverts commit c878ad452435bc07632b0e14ef66756d7fd0bfe2. --- custom_components/liebherr/cover.py | 73 ++++++----------------------- 1 file changed, 15 insertions(+), 58 deletions(-) diff --git a/custom_components/liebherr/cover.py b/custom_components/liebherr/cover.py index 348f182..faca9b6 100644 --- a/custom_components/liebherr/cover.py +++ b/custom_components/liebherr/cover.py @@ -1,9 +1,7 @@ -"""Support for Liebherr autodoor devices with debounce logic.""" +"""Support for Liebherr autodoor devices.""" import asyncio import logging -from datetime import datetime, timedelta - from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -14,8 +12,6 @@ _LOGGER = logging.getLogger(__name__) -DEBOUNCE_SECONDS = 5 # time to wait before confirming final door state - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities @@ -41,7 +37,7 @@ async def async_setup_entry( class LiebherrCover(CoverEntity): - """Representation of a Liebherr auto door cover with debounce.""" + """Representation of a Liebherr auto door cover.""" def __init__(self, api, coordinator, appliance, control) -> None: """Initialize the cover entity.""" @@ -58,12 +54,6 @@ def __init__(self, api, coordinator, appliance, control) -> None: self._attr_device_class = "door" self._attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - # For debounce: - self._last_state = None - self._last_state_change = None - self._debounce_task = None - self._confirmed_state = STATE_UNKNOWN - @property def device_info(self): """Return device information.""" @@ -89,58 +79,34 @@ def _get_control_state(self): return None - async def _debounce_state(self, new_state): - """Debounce door state changes to avoid flickering.""" - # Cancel existing debounce task if any - if self._debounce_task and not self._debounce_task.done(): - self._debounce_task.cancel() - - # If door is MOVING, update immediately but do not confirm - if new_state == "MOVING": - self._confirmed_state = STATE_OPENING - self.async_write_ha_state() - return - - # If state changed from last confirmed state, start debounce delay - if new_state != self._confirmed_state: - self._last_state = new_state - self._last_state_change = datetime.now() - - async def wait_and_confirm(): - try: - await asyncio.sleep(DEBOUNCE_SECONDS) - # After waiting, confirm the new state - self._confirmed_state = ( - STATE_OPEN if new_state == "OPEN" else STATE_CLOSED - ) - self.async_write_ha_state() - except asyncio.CancelledError: - # Debounce canceled because new update arrived - pass - - self._debounce_task = asyncio.create_task(wait_and_confirm()) - @property def state(self): - """Return the current debounced state of the cover.""" - return self._confirmed_state + """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 @property def is_closed(self): """Return True if the cover is closed.""" - return self._confirmed_state == STATE_CLOSED + return self._get_control_state() == "CLOSED" @property def is_open(self): """Return True if the cover is open.""" - return self._confirmed_state == STATE_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) # Let the door start moving + 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() @@ -150,16 +116,7 @@ async def async_close_cover(self, **kwargs): 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) # Let the door start moving + 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() - - async def async_update(self): - """Called by coordinator on data update; update state with debounce.""" - raw_state = self._get_control_state() - if raw_state is None: - self._confirmed_state = STATE_UNKNOWN - self.async_write_ha_state() - return - await self._debounce_state(raw_state) From 20d2d50b96d50b65628fbde80ce60d4f3f5c18b1 Mon Sep 17 00:00:00 2001 From: Spyros Katsavos Date: Mon, 7 Jul 2025 21:20:35 +0300 Subject: [PATCH 11/11] Revert "force sensor update after 5sec" This reverts commit 116f402b76e45f061bd437d6379207fe9940f480. --- custom_components/liebherr/sensor.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/custom_components/liebherr/sensor.py b/custom_components/liebherr/sensor.py index 54638ca..e03902a 100644 --- a/custom_components/liebherr/sensor.py +++ b/custom_components/liebherr/sensor.py @@ -136,24 +136,20 @@ def _get_current_value(self): ): return control.get(self._attribute) return None - + @property def state(self): """Return the state of the sensor.""" value = self._get_current_value() - - if value == "MOVING": - # Schedule another update in 5 seconds - self.hass.loop.create_task(self._delayed_refresh()) - + if value is None: + _LOGGER.debug( + "No value for sensor %s on zone %s, attribute %s", + self._identifier, + self._zone_id, + self._attribute, + ) return value - async def _delayed_refresh(self): - """Force refresh after delay if moving detected.""" - await asyncio.sleep(5) - await self._coordinator.async_request_refresh() - - @property def unit_of_measurement(self): """Return the unit_of_measurement of the sensor"""