diff --git a/README.md b/README.md index 882428b..e380df9 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,16 @@ # HomeAssistant-GreeClimateComponent -Custom Gree integration for Home Assistant written in Python 3. Controls ACs supporting the Gree UDP protocol. +Custom Gree integration for Home Assistant written in Python 3. This integration connects directly to your HVAC devices via their IP address on the local network, unlike the official mobile app, which establishes a direct connection only during initial setup and subsequently operates through Gree’s servers. -> [!NOTE] -> This integration only supports the Gree UDP protocol. If you have a newer firmware/device that only communicates using the new MQTT protocol, this integration will not work. -> Use [this](https://github.com/davo22/homeassistant-gree-cloud) for a different feature set and support for Gree Cloud +**This integration only supports the Gree UDP protocol. If you have a newer firmware/device that only communicates using the new MQTT protocol, this integration will not work.** + +> [!IMPORTANT] +> Due to the many issues being created revolving "TimeOut"/"Cannot connect" errors, I will be closing these. Feel free to make a PR fixing your TimeOut/Cannot connect error. +> +> More information on the "why" can be found here: https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues/405#issuecomment-4300110823 For a comprehensive list of tested devices, see [Supported Devices](supported-devices.md). diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py index 1de54d6..04c6de8 100644 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -515,7 +515,7 @@ async def gree_set_status( async def gree_get_device_info( transport: GreeTransport, cipher: CipherBase | None = None -) -> dict[str, str | dict | None]: +) -> dict[str, str | None]: """Tries to retrive the device info.""" data: dict = await get_result_pack( @@ -526,7 +526,7 @@ async def gree_get_device_info( _LOGGER.debug("Got device info: %s", data) - info: dict[str, str | dict | None] = {} + info: dict[str, str | None] = {} info["raw"] = data info["firmware_version"], info["firmware_code"] = extract_version(data) info["mac"] = data.get("mac", "") diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index e6a894c..f0fdfdc 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -151,7 +151,7 @@ async def bind_device(self) -> bool: return True - async def fetch_device_info(self, cipher: CipherBase | None = None): + async def fetch_device_info(self, cipher: CipherBase = None): """Updates the device info fields.""" try: self._raw_info = await gree_get_device_info( @@ -324,7 +324,7 @@ def _set_device_status(self, props: dict[GreeProp, int]) -> None: def _bool_from_raw_state(self, prop: GreeProp, default: int = 0) -> bool: prop_value: int | None = self._get_prop_raw(prop, default) - return bool(prop_value) + return None if prop_value is None else bool(prop_value) def _remove_unsupported_props(self): """Remove unsupported properties from the list to update.""" @@ -510,9 +510,9 @@ def is_bound(self) -> bool: return self._is_bound @property - def has_hvac_error(self) -> bool: + def has_hvac_error(self) -> bool | None: """Return if there is an error with the device.""" - return self._bool_from_raw_state(GreeProp.FAULT) + return self._bool_from_raw_state(GreeProp.FAULT, None) @property def beeper(self) -> bool: diff --git a/custom_components/gree_custom/aiogree/transport.py b/custom_components/gree_custom/aiogree/transport.py index 163a705..5f155e1 100644 --- a/custom_components/gree_custom/aiogree/transport.py +++ b/custom_components/gree_custom/aiogree/transport.py @@ -23,6 +23,27 @@ def __init__( self.max_retries = max_retries self.timeout = timeout + self._stream: asyncio_dgram.DatagramClient | None = None + self._lock: asyncio.Lock = asyncio.Lock() + + async def _get_stream(self) -> asyncio_dgram.DatagramClient: + """Create stream once and reuse it while possible.""" + if self._stream is None: + _LOGGER.debug("Creating stream for %s", self.ip_addr) + self._stream = await asyncio_dgram.connect((self.ip_addr, self.port)) + + return self._stream + + async def _reset_stream(self) -> None: + """Safely reset UDP stream if it gets into a bad state.""" + _LOGGER.debug("Reseting stream for %s", self.ip_addr) + if self._stream is not None: + try: + self._stream.close() + except Exception: + _LOGGER.exception("Could not close stream") + self._stream = None + async def udp_request( self, data: bytes, @@ -31,49 +52,45 @@ async def udp_request( last_error: Exception | None = None - for attempt in range(self.max_retries): - stream: asyncio_dgram.DatagramClient | None = None - - try: - stream = await asyncio_dgram.connect((self.ip_addr, self.port)) - - await stream.send(data) - - recv_task = asyncio.create_task(stream.recv()) + async with self._lock: # prevents concurrent recv/send corruption + for attempt in range(self.max_retries): + stream: asyncio_dgram.DatagramClient | None = None try: - received_data, _ = await asyncio.wait_for(recv_task, self.timeout) - except TimeoutError: - recv_task.cancel() - raise + stream = await self._get_stream() + + await stream.send(data) + + received_data, _ = await asyncio.wait_for( + stream.recv(), timeout=self.timeout + ) + + except TimeoutError as err: + last_error = err + _LOGGER.warning( + "Error communicating with %s. Attempt %d/%d", + self.ip_addr, + attempt + 1, + self.max_retries, + ) + await self._reset_stream() + + except Exception as err: # noqa: BLE001 + last_error = err + _LOGGER.warning( + "Error communicating with %s. Attempt %d/%d", + self.ip_addr, + attempt + 1, + self.max_retries, + ) + await self._reset_stream() + else: return received_data - except Exception as err1: # noqa: BLE001 - _LOGGER.warning( - "Error communicating with %s. Attempt %d/%d", - self.ip_addr, - attempt + 1, - self.max_retries, - ) - last_error = err1 - - finally: - if stream: - try: - stream.close() - except Exception as err2: # noqa: BLE001 - _LOGGER.warning( - "Error communicating with %s. Attempt %d/%d", - self.ip_addr, - attempt + 1, - self.max_retries, - ) - last_error = err2 - - # Apply backoff before retrying - if attempt < self.max_retries - 1: - await asyncio.sleep(0.5 + attempt * 0.3) # 0.5s, 0.8s, 1.1s, ... + # Apply backoff before retrying + if attempt < self.max_retries - 1: + await asyncio.sleep(0.5 + attempt * 0.3) # 0.5s, 0.8s, 1.1s, ... raise GreeConnectionError( f"Failed to communicate with device '{self.ip_addr}:{self.port}' after {self.max_retries} attempts" diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index a2e3661..dcd4593 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -40,12 +40,12 @@ DEFAULT_DISABLE_AVAILABLE_CHECK = False DEFAULT_RESTORE_STATES = True MIN_SCAN_INTERVAL = 5 -DEFAULT_SCAN_INTERVAL = 30 +DEFAULT_SCAN_INTERVAL = 60 DEFAULT_DEVICE_UID = 0 DEFAULT_DEVICE_PORT = 7000 DEFAULT_CONNECTION_MAX_ATTEMPTS = 3 -DEFAULT_CONNECTION_TIMEOUT = 5 +DEFAULT_CONNECTION_TIMEOUT = 10 DEFAULT_DISCOVERY_TIMEOUT = 5 MAX_UNICAST_SCAN_HOSTS = 65536 diff --git a/manual-configuration.yaml b/manual-configuration.yaml index 3d5ca90..110b4a7 100644 --- a/manual-configuration.yaml +++ b/manual-configuration.yaml @@ -83,7 +83,7 @@ gree_custom: external_temperature_sensor: "None" # Sets a given temperature sensor as the sensor for the AC | str (Entity ID) | default = "None" external_humidity_sensor: "None" # Sets a given humidity sensor as the sensor for the AC | str (Entity ID) | default = "None" restore_states: true # Wether to restore the last HA state to device when HA starts | bool | default = true - scan_interval: 30 # Device polling rate | int > 5 | default = 30 + scan_interval: 60 # Device polling rate | int > 5 | default = 60 # Example for multiple AC units: diff --git a/supported-devices.md b/supported-devices.md index 51f8d8f..3380cea 100644 --- a/supported-devices.md +++ b/supported-devices.md @@ -82,4 +82,4 @@ This helps other users find compatible devices and improves the integration's do - **Encryption Version 1**: Older devices, typically uses ECB encryption - **Encryption Version 2**: Newer devices, typically uses GCM encryption - Most devices require encryption version 2, but some older models use encryption version 1 -- If you're unsure, try encryption version 2 first, then fall back to encryption version 1 if connection fails +- If you're unsure, try encryption version 2 first, then fall back to encryption version 1 if connection fails \ No newline at end of file