From db53f5fb7c5aca62af01b62666eb2b1de0e64d87 Mon Sep 17 00:00:00 2001 From: meirlo <6350224+meirlo@users.noreply.github.com> Date: Fri, 18 Sep 2026 13:41:32 +0300 Subject: [PATCH] Hold VRF sub-unit UI state until the gateway catches up; hide unsupported switches Two VRF (multi-split) usability issues, both caused by the gateway caching each indoor unit's state and answering with a stale snapshot for a few seconds after a command. 1. Commands appeared to "bounce back". After setting e.g. a new target temperature, the next poll (up to 60s away) could read the gateway's old cached value and revert the UI. climate.py now remembers the options it just commanded and re-asserts them over the read-back until the device's own reported value confirms the change or an 8s TTL expires. Confirmation is checked against the value the device actually reported this cycle, not the optimistic overlay, so a genuinely-rejected command is not forced forever. A one-shot delayed refresh (~2s) also lets the UI confirm the change quickly instead of waiting for the next scan interval. The pending refresh handle is cancelled on entity removal. 2. Non-functional switches were shown. Many VRF indoor units return an empty string for properties they do not implement (Lig, Health, StHt, ...). A real, supported property comes back as an integer. switch.py adds a _prop_supported() check so xfan/lights/health/powersave/eightdegheat/ sleep/air are hidden when the unit reports the property as empty after a successful sync. State is left untouched until the first sync so nothing flickers on startup. Standalone (non-VRF) units are unaffected: they report real integers, so the switches stay available and the pending overlay simply confirms on the next read. --- custom_components/gree/climate.py | 78 +++++++++++++++++++++++++++++++ custom_components/gree/switch.py | 32 +++++++++++-- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py index 28818a87..5f37b65b 100644 --- a/custom_components/gree/climate.py +++ b/custom_components/gree/climate.py @@ -7,6 +7,7 @@ # Standard library imports import base64 import logging +import time from datetime import timedelta # Third-party imports @@ -27,6 +28,7 @@ CONF_PORT, ) from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.event import async_call_later # Local imports from .const import ( @@ -260,6 +262,18 @@ def __init__( # Initialize beeper control self._beeper_enabled = True # Default to beeper ON (silent mode OFF) + # VRF gateways cache each sub-unit's state and can report a stale + # snapshot for a few seconds after we send a command. To stop a poll in + # that window from reverting the UI to the old value, remember the + # options we just commanded and re-apply them over freshly-read state + # until the gateway catches up (or the window expires). + self._pending_options: dict = {} + self._pending_expiry: float = 0.0 + # How long to trust our commanded values over device read-back. + self._pending_ttl = 8.0 + # Cancel handle for a one-shot delayed refresh after a command. + self._pending_refresh_unsub = None + # helper method to determine TemSen offset self._process_temp_sensor = TempOffsetResolver() @@ -606,6 +620,11 @@ async def SyncState(self, acOptions={}): # Set latest status from device self._acOptions = self.SetAcOptions(self._acOptions, optionsToFetch, currentValues) + # Snapshot what the device actually reported this cycle, so we can + # confirm pending commands against the *device's* value rather than + # our own optimistic overlay. + read_values = dict(zip(optionsToFetch, currentValues)) if isinstance(currentValues, list) else {} + # Overwrite status with our choices if not (acOptions == {}): self._acOptions = self.SetAcOptions(self._acOptions, acOptions) @@ -623,15 +642,71 @@ async def SyncState(self, acOptions={}): _LOGGER.info(f"{self._name}: Device marked offline after failed send attempt") self._device_online = False self._handle_comms_failure() + else: + # Command sent successfully: remember these values so a + # stale gateway read-back on the next poll(s) doesn't + # revert the UI before the change propagates. Merge with + # any still-unconfirmed options from earlier commands. + self._pending_options.update(acOptions) + self._pending_expiry = time.monotonic() + self._pending_ttl + # Schedule a quick follow-up read so the UI confirms the + # change within a couple of seconds instead of waiting + # for the next scan interval (up to 60s). + self._schedule_pending_refresh() else: # loop used once for Gree Climate initialisation only self._firstTimeRun = False + # Re-assert recently commanded values over the (possibly stale) + # read-back. Confirm against the value the DEVICE reported this + # cycle (read_values), not our optimistic overlay, so we keep + # forcing the commanded value until the gateway actually catches up. + # Skip confirmation on the same cycle that just sent the command + # (there is no fresh read reflecting it yet). + if self._pending_options: + if time.monotonic() >= self._pending_expiry: + self._pending_options = {} + else: + just_sent = acOptions != {} + still_pending = {} + for key, want in self._pending_options.items(): + device_val = read_values.get(key) + # If the device already reports the commanded value, + # it's confirmed; stop forcing it. Don't confirm on the + # same cycle we sent the command. + if not just_sent and device_val is not None and str(device_val) == str(want): + continue + self._acOptions[key] = want + still_pending[key] = want + self._pending_options = still_pending + # Update HA state to current HVAC state self.UpdateHAStateToCurrentACState() _LOGGER.debug(f"{self._name}: Finished device state sync") + def _schedule_pending_refresh(self, delay: float = 2.0) -> None: + """Schedule a one-shot state refresh shortly after a command. + + VRF gateways need a moment to propagate a command to the indoor unit + and update their cached state. A quick follow-up poll lets the UI + confirm the new value within a couple of seconds rather than waiting + for the next scan interval. + """ + if self._pending_refresh_unsub is not None: + self._pending_refresh_unsub() + self._pending_refresh_unsub = None + + async def _do_refresh(_now): + self._pending_refresh_unsub = None + try: + await self.async_update() + self.async_write_ha_state() + except Exception as e: # noqa: BLE001 - best-effort refresh + _LOGGER.debug(f"{self._name}: delayed refresh failed: {e}") + + self._pending_refresh_unsub = async_call_later(self.hass, delay, _do_refresh) + @property def should_poll(self): _LOGGER.debug("should_poll()") @@ -951,6 +1026,9 @@ async def async_added_to_hass(self): async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" + if self._pending_refresh_unsub is not None: + self._pending_refresh_unsub() + self._pending_refresh_unsub = None for name, entity_id, unsub in self._listeners: _LOGGER.debug("Deregistering %s listener for %s", name, entity_id) unsub() diff --git a/custom_components/gree/switch.py b/custom_components/gree/switch.py index e9ebc3b9..4b87cad7 100644 --- a/custom_components/gree/switch.py +++ b/custom_components/gree/switch.py @@ -27,6 +27,28 @@ _LOGGER = logging.getLogger(__name__) +def _prop_supported(device, prop: str) -> bool: + """Return True if the entity should be available for ``prop``. + + Gree (V)RF indoor units return an empty string for properties they do not + implement (e.g. ``Lig``, ``Health``, ``StHt`` on many VRF units). A real, + supported property comes back as an integer. Treat empty string / None / + missing as "not supported" so we don't expose non-functional switches. + + While the device is offline / not yet synced we can't tell, so mirror the + device's online state (as the base entity does) instead of hiding. + """ + value = device._acOptions.get(prop, "") + if value in ("", None): + # Unknown until the first successful sync populates _acOptions. If we + # have synced at least once and the value is still empty, the unit + # genuinely doesn't support this property. + if getattr(device, "_firstTimeRun", False): + return getattr(device, "_device_online", True) + return False + return getattr(device, "_device_online", True) if hasattr(device, "_device_online") else True + + @dataclass class GreeSwitchEntityDescription(GreeEntityDescription, SwitchEntityDescription): """Describes Gree Switch entity.""" @@ -93,18 +115,21 @@ async def _set_beeper(device, value: bool) -> None: icon="mdi:fan", value_fn=lambda device: device._acOptions.get("Blo") == 1, set_fn=_set_xfan, + available_fn=lambda device: _prop_supported(device, "Blo"), ), GreeSwitchEntityDescription( property_key="lights", icon="mdi:lightbulb", value_fn=lambda device: device._acOptions.get("Lig") == 1, set_fn=_set_lights, + available_fn=lambda device: _prop_supported(device, "Lig"), ), GreeSwitchEntityDescription( property_key="health", icon="mdi:shield-check", value_fn=lambda device: device._acOptions.get("Health") == 1, set_fn=_set_health, + available_fn=lambda device: _prop_supported(device, "Health"), ), GreeSwitchEntityDescription( property_key="powersave", @@ -112,7 +137,7 @@ async def _set_beeper(device, value: bool) -> None: value_fn=lambda device: device._acOptions.get("SvSt") == 1, set_fn=_set_powersave, exists_fn=lambda description, device: HVACMode.COOL in device._hvac_modes, - available_fn=lambda device: device._hvac_mode == HVACMode.COOL, + available_fn=lambda device: device._hvac_mode == HVACMode.COOL and _prop_supported(device, "SvSt"), ), GreeSwitchEntityDescription( property_key="eightdegheat", @@ -120,20 +145,21 @@ async def _set_beeper(device, value: bool) -> None: value_fn=lambda device: device._acOptions.get("StHt") == 1, set_fn=_set_eightdegheat, exists_fn=lambda description, device: HVACMode.HEAT in device._hvac_modes, - available_fn=lambda device: device._hvac_mode == HVACMode.HEAT, + available_fn=lambda device: device._hvac_mode == HVACMode.HEAT and _prop_supported(device, "StHt"), ), GreeSwitchEntityDescription( property_key="sleep", icon="mdi:sleep", value_fn=lambda device: device._acOptions.get("SwhSlp") == 1 and device._acOptions.get("SlpMod") == 1, set_fn=_set_sleep, - available_fn=lambda device: device._hvac_mode in (HVACMode.COOL, HVACMode.HEAT), + available_fn=lambda device: device._hvac_mode in (HVACMode.COOL, HVACMode.HEAT) and _prop_supported(device, "SwhSlp"), ), GreeSwitchEntityDescription( property_key="air", icon="mdi:air-filter", value_fn=lambda device: device._acOptions.get("Air") == 1, set_fn=_set_air, + available_fn=lambda device: _prop_supported(device, "Air"), ), GreeSwitchEntityDescription( property_key="anti_direct_blow",