Skip to content
Open
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
78 changes: 78 additions & 0 deletions custom_components/gree/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# Standard library imports
import base64
import logging
import time
from datetime import timedelta

# Third-party imports
Expand All @@ -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 (
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -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()")
Expand Down Expand Up @@ -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()
Expand Down
32 changes: 29 additions & 3 deletions custom_components/gree/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -93,47 +115,51 @@ 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",
icon="mdi:leaf",
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",
icon="mdi:thermometer-low",
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",
Expand Down