diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index e4cdd24..a412d85 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,38 +1,56 @@
---
name: Bug report
about: Create a report to help us improve
-title: ''
+title: 'Bug: '
labels: ''
assignees: ''
---
**Describe the bug**
+
A clear and concise description of what the bug is.
**To Reproduce**
-Steps to reproduce the behavior:
+
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
-**Configuration**
-Share your YAML here
-
**Expected behavior**
+
A clear and concise description of what you expected to happen.
**Screenshots**
+
If applicable, add screenshots to help explain your problem.
-**Platform:**
- - OS: [e.g. HASSIO, Hassbian]
- - Browser [e.g. chrome, safari]
- - Version [e.g. 0.92.1]
+**Runtime Information:**
+ - OS: [e.g. HASSIO, Hassbian, Docker]
+ - Home Assistant Version: [e.g. 2026.4]
+ - Integration Version: [e.g. 0.92.1]
+ - Device Firmware Version: [e.g. 1.44]
+ - Device model: [e.g., Gree GWH12ACC-K6DNA1D]
+ - Does the device respond to pings? Yes/No
+
**Additional context**
+
Add any other context about the problem here.
+**Configuration**
+
+Share your configuration entry diagnostics download or YAML here
+
+```json
+Paste the diagnostics json here
+```
+
+```yaml
+If applicable, paste the config here
+```
+
**Logs**
-Please share your Home Assistant logs here. Make sure to remove any personal/secret information.
+
+Please share your Home Assistant logs here. Make sure to remove any personal/secret information. See [here](https://github.com/p-monteiro/HomeAssistant-GreeClimateComponent-Rewrite/tree/gree-rewrite?tab=readme-ov-file#debugging).
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index bbcbbe7..6941d21 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,7 +1,7 @@
---
name: Feature request
about: Suggest an idea for this project
-title: ''
+title: 'Feature Request: '
labels: ''
assignees: ''
diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml
new file mode 100644
index 0000000..07c86f6
--- /dev/null
+++ b/.github/workflows/validate.yaml
@@ -0,0 +1,32 @@
+name: Validate
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+ schedule:
+ - cron: "0 0 * * *"
+
+jobs:
+ hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest/
+ name: Hassfest validation
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout the repository
+ uses: "actions/checkout@v6"
+
+ - name: Run hassfest validation
+ uses: home-assistant/actions/hassfest@master
+
+ hacs: # https://github.com/hacs/action
+ name: HACS validation
+ runs-on: ubuntu-latest
+ steps:
+ - name: Run HACS validation
+ uses: hacs/action@22.5.0
+ with:
+ category: integration
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..e27e6ad
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,41 @@
+# Contributing
+
+This integration follows the development guidelines for Home Assistant integrations, while keeping the repository compatible with HACS.
+
+## Development Environment
+
+Home Assistant provides [several guidelines](https://developers.home-assistant.io/docs/development_environment) regarding the setup of the development environment. Because we are not contributing to the official integrations, there is no need to fork the official [Home Assistant Core](https://github.com/home-assistant/core) repository. However, it is useful to use it as it provides a preconfigured VSCode development environment with the necessary tools.
+
+Here's a general guide to get it working with this integration repository:
+
+1. Create a folder for the development (for example `development/`)
+2. Clone [home-assistant/core ](https://github.com/home-assistant/core) inside of it (`development/core`)
+3. Follow the [guidelines](https://developers.home-assistant.io/docs/development_environment) on getting the devcontainer working
+4. Fork [this](https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent) repository and clone your fork inside of the same folder (`development/YourForkName`)
+5. Create a branch for your changes in the cloned repo `git checkout -b my-branch-name`
+6. Create a mount point for this integration in the devcontainer
+ 1. Open `development/core/devcontainer/devcontainer.json`
+ 2. Add the mounting:
+ ```json
+ "mounts": [
+ "source=${localWorkspaceFolder}/../YourForkName/custom_components/gree_custom,target=/workspaces/core/config/custom_components/gree_custom,type=bind"
+ ],
+ ```
+7. Open `development/core` with VSCode
+8. Use the command **"Dev Containers: Reopen in Container"**
+9. Once inside the container make sure the folder `config/custom_components/gree_custom` exists
+10. You should be now be able to edit the integration files from inside the devcontainer
+11. Make your changes
+12. Push to your fork, rebase with the latest upstream version and submit a pull request
+
+## Testing
+
+Use the **Run Home Assistant Core** Task to start Home Assistant.
+
+You should also be able to set and hit breakpoints in your code.
+
+If you change your code, you have to restart Home Assistant (rerun the Task)
+
+## Styling
+
+Please adhere to the recomended coding style: https://developers.home-assistant.io/docs/development_guidelines
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
index 94a9ed0..f288702 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
- Copyright (C) 2007 Free Software Foundation, Inc.
+ Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
@@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found.
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
- along with this program. If not, see .
+ along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
@@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
-.
+.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
-.
+.
diff --git a/README.md b/README.md
index 77bca02..0c106cf 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,25 @@
-[](https://github.com/hacs/integration)
+[](https://hacs.xyz)
+[](https://www.home-assistant.io)
# HomeAssistant-GreeClimateComponent
-Custom Gree climate component written in Python3 for Home Assistant. Controls ACs supporting the Gree protocol.
+
+Custom Gree integration for Home Assistant written in Python 3. Controls ACs supporting the Gree UDP protocol.
+
+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.
For a comprehensive list of tested devices, see [Supported Devices](supported-devices.md).
-Tested on Home Assistant 2025.6.3
+The integration attempts to obtain the encryption key through the initial setup protocol, which has been reverse-engineered.
-**If you are experiencing issues please be sure to provide details about your device, Home Assistant version and what exactly went wrong.**
+> [!WARNING]
+> If your HVAC device was previously set up for remote access using a mobile app, the integration may fail to retrieve the encryption key automatically. Find out more about methods of obtaining your device key below.
+
+
+**If you are experiencing issues, please read the [Debugging](#debugging) section.**
-This integration connects directly to your HVAC devices via their IP address on the local network, unlike the official mobile app, which establish a direct connection only during initial setup and subsequently operate through Gree’s servers.
-The integration attempts to obtain the encryption key by the initial setup protocol, which has been reverse-engineered.
Official mobile applications:
- [Gree+ Android App](https://play.google.com/store/apps/details?id=com.gree.greeplus)
@@ -18,30 +27,36 @@ Official mobile applications:
- [EWPE Smart Android App](https://play.google.com/store/apps/details?id=com.gree.ewpesmart)
- [EWPE Smart iOS App](https://apps.apple.com/app/ewpe-smart/id1189467454)
-If your HVAC device was previously set up for remote access using a mobile app, the integration may fail to retrieve the encryption key automatically.
+To configure HVAC wifi (without the mobile app): https://github.com/arthurkrupa/gree-hvac-mqtt-bridge#configuring-hvac-wifi
+
-To extract encryption keys from an account on Gree’s cloud server: https://github.com/luc10/gree-api-client
+## Installation
+
+### HACS (recommended)
+
+This integration is added to the HACS default repository list. Search for 'Gree' in the HACS dashboard to find and install it.
+
+### Manual
+
+Copy the `custom_components` folder to your own hassio `/config` folder.
-To configure HVAC wifi (without the mobile app): https://github.com/arthurkrupa/gree-hvac-mqtt-bridge#configuring-hvac-wifi
-## HACS
-This component is added to HACS default repository list.
+## Configuration
+
+### UI Configuration - Config Flow (recommended)
-## Config Flow - UI Configuration (recommended)
The integration can be added from the Home Assistant UI.
-1. Navigate to **Settings** > **Devices & Services** and click **Add Integration**.
-2. Search for **Gree Climate** and fill in the desired `name`, `host`, `port` and `MAC address`.
-3. After setup you can open the integration options to configure additional parameters.
-4. Saving any changes in the options dialog automatically reloads the
- integration, so new settings take effect immediately without
- restarting Home Assistant.
-## Manual Installation
+1. Navigate to **Settings** > **Devices & Services** and click **Add Integration**.
+2. Search for **Gree Climate**
+3. Choose automatic discovery or manual setup and fill in the desired `name`, `host`, and `MAC address`.
+4. After a successful connection with the device, you will be asked to configure the device options.
+You can also **Reconfigure** a device by changing its options. Saving any changes in the options dialog automatically reloads the integration, so new settings take effect immediately without restarting Home Assistant.
-1. *(Skip if using HACS)* Copy the `custom_components` folder to your own hassio `/config` folder.
+### Manual - YAML Configuration
-2. **YAML Configuration:** See [`manual-configuration.yaml`](manual-configuration.yaml) for a complete configuration example with all available options and detailed comments.
+See [`manual-configuration.yaml`](manual-configuration.yaml) for a complete configuration example with all available options and detailed comments.
Basic example:
```yaml
@@ -52,85 +67,108 @@ The integration can be added from the Home Assistant UI.
encryption_version: 2
```
-3. In your configuration.yaml add the following:
+### Obtaining the Encryption Key
- ```yaml
- climate: !include your_configuration.yaml
- ```
+The integration has the capability of automatically retrieve the encryption version and key of a device using the gree protocol, which has been reverse-engineered.
-4. *(Optional)* Add info logging to this component (to see if/how it works)
+However, if your HVAC device was previously set up for remote access using a mobile app, the integration may fail to retrieve the encryption key automatically.
- ```yaml
- logger:
- default: error
- logs:
- custom_components.gree: debug
- custom_components.gree.climate: debug
- ```
+#### Method 1: From Gree's cloud server
-5. *(Optional)* Provide encryption key if you have it or feel like extracting it.
+To extract encryption keys from an account on Gree’s cloud server, follow the instructions in https://github.com/luc10/gree-api-client
- One way is to pull the sqlite db from android device like described here:
+#### Method 2: From the Android app
- https://stackoverflow.com/questions/9997976/android-pulling-sqlite-database-android-device
+One way is to pull the sqlite db from an Android device, as described here:
- ```bash
- adb backup -f ~/backup.ab -noapk com.gree.ewpesmart
- dd if=data.ab bs=1 skip=24 | python -c "import zlib,sys;sys.stdout.write(zlib.decompress(sys.stdin.read()))" | tar -xvf -
- sqlite3 data.ab 'select privateKey from db_device_20170503;' # but table name can differ a little bit.
- ```
+https://stackoverflow.com/questions/9997976/android-pulling-sqlite-database-android-device
+
+```bash
+adb backup -f ~/backup.ab -noapk com.gree.ewpesmart
+dd if=data.ab bs=1 skip=24 | python -c "import zlib,sys;sys.stdout.write(zlib.decompress(sys.stdin.read()))" | tar -xvf -
+sqlite3 data.ab 'select privateKey from db_device_20170503;' # but table name can differ a little bit.
+```
+
+> [!TIP]
+> If you are getting a UTF-8 error (like: "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xda in position 1: invalid continuation byte"), see https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues/318.
+
+Optionally, you can also sniff the `uid` parameter. This is not needed for all devices.
- Write it down in `climate.yaml`: `encryption_key: `.
+### Icon configuration
- > If you are getting an UTF-8 error (like: "UnicodeDecodeError: 'utf-8' codec can't decode byte 0xda in position 1: invalid continuation byte"), see https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues/318.
+You can set custom icons for the climate entity by modifying the icon translation file `icons.json`. Refer to this documentation: https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/icon-translations/
-6. *(Optional)* Provide the `uid` parameter (can be sniffed). This is not needed for all devices.
+## Debugging
-7. *(Optional)* You can set custom icons by modifying the icon translation file `icons.json`. Refer to this documentation: https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/icon-translations/
+If you are having problems with your device, whenever you write a bug report, be sure to provide details about your device, Home Assistant version, and what exactly went wrong.
-## Additional Sensors
+It also helps tremendously if you include debug logs directly in your issue (otherwise, we will just ask for them, and it will take longer). So please enable debug logs in the integration UI, or like this:
-The integration supports additional sensors if your Gree device has them:
+```yaml
+logger:
+ default: error
+ logs:
+ custom_components.gree_custom: debug
+```
-### Outside Temperature Sensor
-If your AC unit has an outside temperature sensor, it will be automatically detected and exposed as:
+## Device Sensors
+
+The integration supports sensors if your Gree device has them:
+
+### Indoor Temperature
+
+If your AC unit has a built-in room temperature sensor, it will be automatically detected and exposed as:
+- **Separate sensor entity**: `sensor.your_ac_indoor_temperature`
+- **Climate entity attribute**: `current_temperature` (accessible via `{{ state_attr('climate.your_ac', 'current_temperature') }}`).
+
+### Outdoor Temperature
+
+If your AC unit has an outdoor temperature sensor, it will be automatically detected and exposed as:
+- **Separate sensor entity**: `sensor.your_ac_outdoor_temperature`
- **Climate entity attribute**: `outside_temperature` (accessible via `{{ state_attr('climate.your_ac', 'outside_temperature') }}`)
-- **Separate sensor entity**: `sensor.your_ac_outside_temperature`
-### Humidity Sensor
+### Indoor Humidity
+
If your AC unit has a built-in room humidity sensor, it will be automatically detected and exposed as:
-- **Climate entity attribute**: `room_humidity` (accessible via `{{ state_attr('climate.your_ac', 'room_humidity') }}`)
- **Separate sensor entity**: `sensor.your_ac_room_humidity`
+- **Climate entity attribute**: `current_humidity` (accessible via `{{ state_attr('climate.your_ac', 'current_humidity') }}`).
+
+### Sensor Overrides
+
+The indoor _temperature_ (`current_temperature`) and _humidity_ (`current_humidity`) values exposed by the Climate entity (`climate.your_ac`) can be overridden during configuration by another HA entity. This is helpful for obtaining a more comprehensive climate entity when the AC does not provide the respective sensors. However, please note that the AC operation is not driven by these values, as they are only exposed for information purposes.
## Available Switches and Controls
-The integration exposes various entities to configure additional features of your Gree AC unit. All entities are created by default when the integration is set up, but their availability depends on the current HVAC mode and status. Entity availability may also vary depending on your specific Gree AC model and firmware version. These controls allow you to toggle special modes and adjust settings:
+Depending on the device configuration, specific Gree AC model, and firmware version, the integration exposes various entities to configure additional features of your Gree AC unit. Entity availability depends on the current HVAC mode and status. These controls allow you to toggle special modes and adjust settings:
-### Basic Control Switches
-- **X-Fan**: Enables or disables the X-Fan mode for extra drying when turning off
-- **Lights**: Controls the display lights on the air conditioner unit
-- **Health**: Enables or disables the Health mode for air ionization and purification
-- **Beeper**: Controls the beeper sounds from the air conditioner unit. When enabled, the unit will make sounds for button presses and status changes
+### Feature Switches
-### Energy and Comfort Switches
+- **Health**: Enables or disables the Health mode for air ionization and purification
- **Power Save**: Enables or disables the power saving mode for energy efficiency. Only available in cooling mode
-- **8°C Heat**: Enables or disables the 8°C heating mode for frost protection. Only available in heating mode
+- **Smart 8°C Heat**: Enables or disables the 8°C heating mode for frost protection. Only available in heating mode
- **Sleep**: Enables or disables the sleep mode for comfortable overnight operation. Only available in cooling or heating mode
-- **Air**: Enables or disables the fresh air circulation mode
-
-### Advanced Control Switches
+- **Fresh Air**: Enables or disables the fresh air circulation mode
+- **X-Fan**: Enables or disables the X-Fan mode that keeps the fan working for a few moments after turning the device off in cooling and dry modes, preventing condensation in the unit
- **Anti Direct Blow**: Prevents direct air flow from blowing on people by adjusting the air deflector position
-- **Light Sensor**: Enables or disables light sensor for automatic brightness. Requires lights to be enabled
+
### Configuration Controls
-- **Auto X-Fan**: Automatically controls the X-Fan mode based on HVAC operations. When enabled, X-Fan will automatically turn on in cooling and dry modes. *Note: This is an integration feature, not an actual AC unit state*
+
+- **Beeper**: Controls the beeper sounds from the air conditioner unit. When enabled, the unit will make sounds for button presses and status changes
+- **Lights**: Controls the display lights on the air conditioner unit
- **Auto Light**: Automatically controls the display lights based on HVAC operations. When enabled, lights will turn on/off with the AC unit. *Note: This is an integration feature, not an actual AC unit state*
+- **Light Sensor**: Enables or disables light sensor for automatic brightness. Requires lights to be enabled
+- **Auto X-Fan**: Automatically controls the X-Fan mode based on HVAC operations. When enabled, X-Fan will automatically turn on in cooling and dry modes. *Note: This is an integration feature, not an actual AC unit state*
- **Temperature Step**: Sets the increment step for adjusting the target temperature. This allows you to configure how much the temperature changes when using the up/down controls in Home Assistant
-- **External Temperature Sensor**: Select a temperature sensor entity to use instead of the built-in AC sensor. Choose 'None' to use the built-in sensor. This is useful if you have a more accurate room temperature sensor that you want the AC to use for temperature readings
+
+### Diagnostics
+
+- **Fault Detection**: Sensor that shows if there is a problem with the device's operation
## Credits
This project is based on the work of several contributors and projects:
- [gree-remote](https://github.com/tomikaa87/gree-remote) - Gree air conditioner remote control protocol
+- [greeclimate](https://github.com/cmroche/greeclimate) - Python package for controlling Gree based minisplit systems
- [Home Assistant Developer Documentation](https://developers.home-assistant.io/) - Official development guidelines and best practices
diff --git a/custom_components/gree/__init__.py b/custom_components/gree/__init__.py
deleted file mode 100644
index a585893..0000000
--- a/custom_components/gree/__init__.py
+++ /dev/null
@@ -1,132 +0,0 @@
-"""Gree climate integration init."""
-
-from __future__ import annotations
-
-# Standard library imports
-import logging
-
-# Third-party imports
-import voluptuous as vol
-
-# Home Assistant imports
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- CONF_HOST,
- CONF_MAC,
- CONF_NAME,
- CONF_PORT,
- Platform,
-)
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.typing import ConfigType
-
-# Local imports
-from .const import (
- CONF_DISABLE_AVAILABLE_CHECK,
- CONF_ENCRYPTION_KEY,
- CONF_ENCRYPTION_VERSION,
- CONF_FAN_MODES,
- CONF_HVAC_MODES,
- CONF_SWING_HORIZONTAL_MODES,
- CONF_SWING_MODES,
- CONF_TEMP_SENSOR_OFFSET,
- CONF_UID,
- DEFAULT_FAN_MODES,
- DEFAULT_HVAC_MODES,
- DEFAULT_PORT,
- DEFAULT_SWING_HORIZONTAL_MODES,
- DEFAULT_SWING_MODES,
- DOMAIN,
- OPTION_KEYS,
-)
-
-PLATFORMS = [Platform.CLIMATE, Platform.SWITCH, Platform.NUMBER, Platform.SELECT, Platform.SENSOR]
-_LOGGER = logging.getLogger(__name__)
-
-# YAML configuration schema
-CLIMATE_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_NAME): cv.string,
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_MAC): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_ENCRYPTION_KEY): cv.string,
- vol.Optional(CONF_UID): cv.positive_int,
- vol.Optional(CONF_ENCRYPTION_VERSION, default=1): vol.In([1, 2]),
- vol.Optional(CONF_HVAC_MODES, default=DEFAULT_HVAC_MODES): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_FAN_MODES, default=DEFAULT_FAN_MODES): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_SWING_MODES, default=DEFAULT_SWING_MODES): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_SWING_HORIZONTAL_MODES, default=DEFAULT_SWING_HORIZONTAL_MODES): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_DISABLE_AVAILABLE_CHECK, default=False): cv.boolean,
- vol.Optional(CONF_TEMP_SENSOR_OFFSET): cv.boolean,
- }
-)
-
-CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.All(cv.ensure_list, [CLIMATE_SCHEMA])}, extra=vol.ALLOW_EXTRA)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Gree component from yaml."""
- if DOMAIN not in config:
- return True
-
- for climate_config in config[DOMAIN]:
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": "import"},
- data=climate_config,
- )
- )
-
- return True
-
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Set up Gree from a config entry."""
- if DOMAIN not in hass.data:
- hass.data[DOMAIN] = {}
-
- # Combine entry data with options
- combined_data = {**entry.data}
- for key, value in entry.options.items():
- if key not in OPTION_KEYS:
- _LOGGER.debug("Ignoring unexpected option key %s", key)
- continue
- if value is None:
- combined_data.pop(key, None)
- else:
- combined_data[key] = value
-
- # Create the Gree device instance here and store it
- from .climate import create_gree_device
-
- device = await create_gree_device(hass, combined_data)
-
- # Store both the config data and the device instance
- hass.data[DOMAIN][entry.entry_id] = {
- "config": combined_data,
- "device": device,
- }
-
- _LOGGER.debug("Setting up config entry %s with data: %s", entry.entry_id, combined_data)
- entry.async_on_unload(entry.add_update_listener(_update_listener))
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- return True
-
-
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Unload a config entry."""
- unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unloaded:
- _LOGGER.debug("Unloaded config entry %s", entry.entry_id)
- hass.data[DOMAIN].pop(entry.entry_id)
- return unloaded
-
-
-async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle options update."""
- _LOGGER.debug("Options updated for entry %s: %s", entry.entry_id, entry.options)
- _LOGGER.debug("Reloading config entry %s after options update", entry.entry_id)
- await hass.config_entries.async_reload(entry.entry_id)
diff --git a/custom_components/gree/climate.py b/custom_components/gree/climate.py
deleted file mode 100644
index 5e248ad..0000000
--- a/custom_components/gree/climate.py
+++ /dev/null
@@ -1,912 +0,0 @@
-"""
-Gree Climate Entity for Home Assistant.
-
-This module defines the climate (HVAC) unit for the Gree integration.
-"""
-
-# Standard library imports
-import base64
-import logging
-from datetime import timedelta
-
-# Third-party imports
-try:
- import simplejson
-except ImportError:
- import json as simplejson
-from Crypto.Cipher import AES
-
-# Home Assistant imports
-from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature, HVACMode
-from homeassistant.const import (
- ATTR_TEMPERATURE,
- ATTR_UNIT_OF_MEASUREMENT,
- CONF_HOST,
- CONF_MAC,
- CONF_NAME,
- CONF_PORT,
-)
-from homeassistant.helpers.device_registry import DeviceInfo
-
-# Local imports
-from .const import (
- DOMAIN,
- DEFAULT_PORT,
- DEFAULT_HVAC_MODES,
- DEFAULT_FAN_MODES,
- DEFAULT_SWING_MODES,
- DEFAULT_SWING_HORIZONTAL_MODES,
- DEFAULT_TARGET_TEMP_STEP,
- MIN_TEMP_C,
- MIN_TEMP_F,
- MAX_TEMP_C,
- MAX_TEMP_F,
- MODES_MAPPING,
- TEMSEN_OFFSET,
- CONF_HVAC_MODES,
- CONF_FAN_MODES,
- CONF_SWING_MODES,
- CONF_SWING_HORIZONTAL_MODES,
- CONF_ENCRYPTION_KEY,
- CONF_UID,
- CONF_ENCRYPTION_VERSION,
- CONF_DISABLE_AVAILABLE_CHECK,
- CONF_TEMP_SENSOR_OFFSET,
-)
-from .gree_protocol import Pad, FetchResult, GetDeviceKey, GetGCMCipher, EncryptGCM, GetDeviceKeyGCM
-from .helpers import TempOffsetResolver, gree_f_to_c, gree_c_to_f, encode_temp_c, decode_temp_c
-
-REQUIREMENTS = ["pycryptodome"]
-
-_LOGGER = logging.getLogger(__name__)
-
-SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
-
-
-async def create_gree_device(hass, config):
- """Create a Gree device instance from config."""
- name = config.get(CONF_NAME, "Gree Climate")
- ip_addr = config.get(CONF_HOST)
- port = config.get(CONF_PORT, DEFAULT_PORT)
- mac_addr = config.get(CONF_MAC).encode().replace(b":", b"")
-
- chm = config.get(CONF_HVAC_MODES)
- hvac_modes = [getattr(HVACMode, mode.upper()) for mode in (chm if chm is not None else DEFAULT_HVAC_MODES)]
-
- cfm = config.get(CONF_FAN_MODES)
- fan_modes = cfm if cfm is not None else DEFAULT_FAN_MODES
- csm = config.get(CONF_SWING_MODES)
- swing_modes = csm if csm is not None else DEFAULT_SWING_MODES
- cshm = config.get(CONF_SWING_HORIZONTAL_MODES)
- swing_horizontal_modes = cshm if cshm is not None else DEFAULT_SWING_HORIZONTAL_MODES
- encryption_key = config.get(CONF_ENCRYPTION_KEY)
- uid = config.get(CONF_UID)
- encryption_version = config.get(CONF_ENCRYPTION_VERSION, 1)
- disable_available_check = config.get(CONF_DISABLE_AVAILABLE_CHECK, False)
- temp_sensor_offset = config.get(CONF_TEMP_SENSOR_OFFSET)
-
- return GreeClimate(
- hass,
- name,
- ip_addr,
- port,
- mac_addr,
- hvac_modes,
- fan_modes,
- swing_modes,
- swing_horizontal_modes,
- encryption_version,
- disable_available_check,
- encryption_key,
- uid,
- temp_sensor_offset,
- )
-
-
-# from the remote control and gree app
-
-# update() interval
-SCAN_INTERVAL = timedelta(seconds=60)
-
-
-async def async_setup_entry(hass, entry, async_add_devices):
- """Set up Gree climate from a config entry."""
- # Get the device that was created in __init__.py
- entry_data = hass.data[DOMAIN][entry.entry_id]
- device = entry_data["device"]
-
- async_add_devices([device])
-
-
-async def async_unload_entry(hass, entry):
- """Unload a config entry."""
- return True
-
-
-class GreeClimate(ClimateEntity):
- # Language is retrieved from translation key
- _attr_translation_key = "gree"
-
- def __init__(
- self,
- hass,
- name,
- ip_addr,
- port,
- mac_addr,
- hvac_modes,
- fan_modes,
- swing_modes,
- swing_horizontal_modes,
- encryption_version,
- disable_available_check,
- encryption_key=None,
- uid=None,
- temp_sensor_offset=None,
- ):
- _LOGGER.info(f"{name}: Initializing Gree climate device")
-
- self.hass = hass
- self._name = name
- self._ip_addr = ip_addr
- self._port = port
- mac_addr_str = mac_addr.decode("utf-8").lower()
- if "@" in mac_addr_str:
- self._sub_mac_addr, self._mac_addr = mac_addr_str.split("@", 1)
- else:
- self._sub_mac_addr = self._mac_addr = mac_addr_str
- self._unique_id = f"{DOMAIN}_{self._sub_mac_addr}"
- self._device_online = None
- self._disable_available_check = disable_available_check
-
- self._target_temperature = None
- # Initialize target temperature step with default value (will be overridden by number entity when available)
- self._target_temperature_step = DEFAULT_TARGET_TEMP_STEP
- # Device uses a combination of Celsius + a set bit for Fahrenheit, so the integration needs to be aware of the units.
- self._unit_of_measurement = hass.config.units.temperature_unit
- _LOGGER.info(f"{self._name}: Unit of measurement: {self._unit_of_measurement}")
-
- self._hvac_modes = hvac_modes
- self._hvac_mode = HVACMode.OFF
- self._fan_modes = fan_modes
- self._fan_mode = None
- self._swing_modes = swing_modes
- self._swing_mode = None
- self._swing_horizontal_modes = swing_horizontal_modes
- self._swing_horizontal_mode = None
-
- self._temp_sensor_offset = temp_sensor_offset
-
- # Store for external temp sensor entity (set by sensor entity)
- self._external_temperature_sensor = None
-
- # Keep unsub callbacks for deregistering listeners
- self._listeners: list = []
-
- self._has_temp_sensor = None
- self._has_anti_direct_blow = None
- self._has_light_sensor = None
- self._has_outside_temp_sensor = None
- self._has_room_humidity_sensor = None
-
- self._current_temperature = None
- self._current_anti_direct_blow = None
- self._current_light_sensor = None
- self._current_outside_temperature = None
- self._current_room_humidity = None
-
- self._firstTimeRun = True
-
- self._enable_turn_on_off_backwards_compatibility = False
-
- self.encryption_version = encryption_version
- self.CIPHER = None
-
- if encryption_key:
- _LOGGER.info(f"{self._name}: Using configured encryption key: {encryption_key}")
- self._encryption_key = encryption_key.encode("utf8")
- if encryption_version == 1:
- # Cipher to use to encrypt/decrypt
- self.CIPHER = AES.new(self._encryption_key, AES.MODE_ECB)
- elif self.encryption_version != 2:
- _LOGGER.error(f"{self._name}: Encryption version {self.encryption_version} is not implemented")
- else:
- self._encryption_key = None
-
- if uid:
- self._uid = uid
- else:
- self._uid = 0
-
- self._acOptions = {
- "Pow": None,
- "Mod": None,
- "SetTem": None,
- "WdSpd": None,
- "Air": None,
- "Blo": None,
- "Health": None,
- "SwhSlp": None,
- "Lig": None,
- "SwingLfRig": None,
- "SwUpDn": None,
- "Quiet": None,
- "Tur": None,
- "StHt": None,
- "TemUn": None,
- "HeatCoolType": None,
- "TemRec": None,
- "SvSt": None,
- "SlpMod": None,
- }
- self._optionsToFetch = ["Pow", "Mod", "SetTem", "WdSpd", "Air", "Blo", "Health", "SwhSlp", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", "StHt", "TemUn", "HeatCoolType", "TemRec", "SvSt", "SlpMod"]
-
- # Initialize auto switches
- self._auto_light = False
- self._auto_xfan = False
-
- # Initialize beeper control
- self._beeper_enabled = True # Default to beeper ON (silent mode OFF)
-
- # helper method to determine TemSen offset
- self._process_temp_sensor = TempOffsetResolver()
-
- async def GreeGetValues(self, propertyNames):
- plaintext = '{"cols":' + simplejson.dumps(propertyNames) + ',"mac":"' + str(self._sub_mac_addr) + '","t":"status"}'
- if self.encryption_version == 1:
- cipher = self.CIPHER
- jsonPayloadToSend = '{"cid":"app","i":0,"pack":"' + base64.b64encode(cipher.encrypt(Pad(plaintext).encode("utf8"))).decode("utf-8") + '","t":"pack","tcid":"' + str(self._mac_addr) + '","uid":{}'.format(self._uid) + "}"
- elif self.encryption_version == 2:
- pack, tag = EncryptGCM(self._encryption_key, plaintext)
- jsonPayloadToSend = '{"cid":"app","i":0,"pack":"' + pack + '","t":"pack","tcid":"' + str(self._mac_addr) + '","uid":{}'.format(self._uid) + ',"tag" : "' + tag + '"}'
- cipher = GetGCMCipher(self._encryption_key)
- result = await FetchResult(cipher, self._ip_addr, self._port, jsonPayloadToSend, encryption_version=self.encryption_version)
- return result["dat"][0] if len(result["dat"]) == 1 else result["dat"]
-
- def SetAcOptions(self, acOptions, newOptionsToOverride, optionValuesToOverride=None):
- if optionValuesToOverride is not None:
- # Build a list of key-value pairs for a single log line
- settings = []
- for key in newOptionsToOverride:
- value = optionValuesToOverride[newOptionsToOverride.index(key)]
- settings.append(f"{key}={value}")
- acOptions[key] = value
- _LOGGER.debug(f"{self._name}: Setting device options with retrieved values: {', '.join(settings)}")
- else:
- # Build a list of key-value pairs for a single log line
- settings = []
- for key, value in newOptionsToOverride.items():
- settings.append(f"{key}={value}")
- acOptions[key] = value
- _LOGGER.debug(f"{self._name}: Overwriting device options with new settings: {', '.join(settings)}")
- return acOptions
-
- async def SendStateToAc(self):
- opt_list = ["Pow", "Mod", "SetTem", "WdSpd", "Air", "Blo", "Health", "SwhSlp", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", "StHt", "TemUn", "HeatCoolType", "TemRec", "SvSt", "SlpMod", "AntiDirectBlow", "LigSen"]
-
- # Collect values from _acOptions
- p_values = [self._acOptions.get(k) for k in opt_list]
-
- # Filter out empty ones
- filtered_opt = []
- filtered_p = []
- for name, val in zip(opt_list, p_values):
- if val not in ("", None):
- filtered_opt.append(f'"{name}"')
- filtered_p.append(str(val))
-
- buzzer_command_value = 0 if self._beeper_enabled else 1
- filtered_opt.append('"Buzzer_ON_OFF"')
- filtered_p.append(str(buzzer_command_value))
- _LOGGER.debug(f"{self._name}: Sending command with beeper {'enabled' if self._beeper_enabled else 'disabled'} (buzzer={buzzer_command_value})")
-
- statePackJson = '{"opt":[' + ",".join(filtered_opt) + '],"p":[' + ",".join(filtered_p) + '],"t":"cmd","sub":"' + self._sub_mac_addr + '"}'
-
- if self.encryption_version == 1:
- cipher = self.CIPHER
- sentJsonPayload = '{"cid":"app","i":0,"pack":"' + base64.b64encode(cipher.encrypt(Pad(statePackJson).encode("utf8"))).decode("utf-8") + '","t":"pack","tcid":"' + str(self._mac_addr) + '","uid":{}'.format(self._uid) + "}"
- elif self.encryption_version == 2:
- pack, tag = EncryptGCM(self._encryption_key, statePackJson)
- sentJsonPayload = '{"cid":"app","i":0,"pack":"' + pack + '","t":"pack","tcid":"' + str(self._mac_addr) + '","uid":{}'.format(self._uid) + ',"tag":"' + tag + '"}'
- cipher = GetGCMCipher(self._encryption_key)
- result = await FetchResult(cipher, self._ip_addr, self._port, sentJsonPayload, encryption_version=self.encryption_version)
- _LOGGER.debug(f"{self._name}: Command sent successfully: {str(result)}")
-
- def UpdateHATargetTemperature(self):
- # Sync set temperature to HA. If 8℃ heating is active we set the temp in HA to 8℃ so that it shows the same as the AC display.
- if self._acOptions["StHt"] and (int(self._acOptions["StHt"]) == 1):
- self._target_temperature = 8
- _LOGGER.debug(f"{self._name}: Target temperature set to 8°C for 8°C heating mode")
- else:
- temp_c = decode_temp_c(SetTem=self._acOptions["SetTem"], TemRec=self._acOptions["TemRec"]) # takes care of 1/2 degrees
- temp_f = gree_c_to_f(SetTem=self._acOptions["SetTem"], TemRec=self._acOptions["TemRec"])
-
- if self._unit_of_measurement == "°C":
- display_temp = temp_c
- elif self._unit_of_measurement == "°F":
- display_temp = temp_f
- else:
- display_temp = temp_c # default to deg c
- _LOGGER.error(f"{self._name}: Unknown unit of measurement: {self._unit_of_measurement}")
-
- self._target_temperature = display_temp
-
- _LOGGER.debug(f"{self._name}: Target temperature set to {self._target_temperature}{self._unit_of_measurement}")
-
- def UpdateHAHvacMode(self):
- # Sync current HVAC operation mode to HA
- if self._acOptions["Pow"] == 0:
- self._hvac_mode = HVACMode.OFF
- else:
- for key, value in MODES_MAPPING.get("Mod").items():
- if value == (self._acOptions["Mod"]):
- self._hvac_mode = key
- _LOGGER.debug(f"{self._name}: HVAC mode updated to {self._hvac_mode}")
-
- def UpdateHACurrentSwingMode(self):
- # Sync current HVAC Swing mode state to HA
- for key, value in MODES_MAPPING.get("SwUpDn").items():
- if value == (self._acOptions["SwUpDn"]):
- self._swing_mode = key
- _LOGGER.debug(f"{self._name}: Swing mode updated to {self._swing_mode}")
-
- def UpdateHACurrentSwingHorizontalMode(self):
- # Sync current HVAC Horizontal Swing mode state to HA
- for key, value in MODES_MAPPING.get("SwingLfRig").items():
- if value == (self._acOptions["SwingLfRig"]):
- self._swing_horizontal_mode = key
- _LOGGER.debug(f"{self._name}: Horizontal swing mode updated to {self._swing_horizontal_mode}")
-
- def UpdateHAFanMode(self):
- # Sync current HVAC Fan mode state to HA
- if int(self._acOptions["Tur"]) == 1:
- turbo_index = self._fan_modes.index("turbo")
- self._fan_mode = self._fan_modes[turbo_index]
- elif int(self._acOptions["Quiet"]) >= 1:
- quiet_index = self._fan_modes.index("quiet")
- self._fan_mode = self._fan_modes[quiet_index]
- else:
- for key, value in MODES_MAPPING.get("WdSpd").items():
- if value == (self._acOptions["WdSpd"]):
- self._fan_mode = key
- _LOGGER.debug(f"{self._name}: Fan mode updated to {self._fan_mode}")
-
- def UpdateHACurrentTemperature(self):
- # Use external temperature sensor if available
- if self._external_temperature_sensor:
- # Use external temperature sensor
- external_sensor_state = self.hass.states.get(self._external_temperature_sensor)
- if external_sensor_state and external_sensor_state.state not in ("unknown", "unavailable"):
- try:
- unit = external_sensor_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
- _LOGGER.debug(f"{self._name}: Using external temperature sensor {self._external_temperature_sensor}: {external_sensor_state.state}{unit}")
- self._current_temperature = self.hass.config.units.temperature(float(external_sensor_state.state), unit)
- _LOGGER.debug(f"{self._name}: Current temperature from external sensor: {self._current_temperature}{self._unit_of_measurement}")
- return
- except (ValueError, TypeError) as ex:
- _LOGGER.error(f"{self._name}: Unable to update from external temp sensor {self._external_temperature_sensor}: {ex}")
-
- # Use built-in AC temperature sensor if available
- if self._has_temp_sensor:
- _LOGGER.debug(f"{self._name}: Built-in temperature sensor reading: {self._acOptions['TemSen']}")
-
- if self._temp_sensor_offset is None: # user hasn't chosen an offset
- # User hasn't set automaticaly, so try to determine the offset
- temp_c = self._process_temp_sensor(self._acOptions["TemSen"])
- _LOGGER.debug("method UpdateHACurrentTemperature: User has not chosen an offset, using process_temp_sensor() to automatically determine offset.")
- else:
- # User set
- if self._temp_sensor_offset is True:
- temp_c = self._acOptions["TemSen"] - TEMSEN_OFFSET
-
- elif self._temp_sensor_offset is False:
- temp_c = self._acOptions["TemSen"]
-
- _LOGGER.debug(f"method UpdateHACurrentTemperature: User has chosen an offset ({self._temp_sensor_offset})")
-
- temp_f = gree_c_to_f(SetTem=temp_c, TemRec=0) # Convert to Fahrenheit using TemRec bit
-
- if self._unit_of_measurement == "°C":
- self._current_temperature = temp_c
- elif self._unit_of_measurement == "°F":
- self._current_temperature = temp_f
- else:
- _LOGGER.error("Unknown unit of measurement: %s" % self._unit_of_measurement)
-
- _LOGGER.debug(f"{self._name}: UpdateHACurrentTemperature: HA current temperature set with device built-in temperature sensor state: {self._current_temperature}{self._unit_of_measurement}")
-
- def UpdateHAOutsideTemperature(self):
- # Update outside temperature from built-in AC outside temperature sensor if available
- if self._has_outside_temp_sensor:
- _LOGGER.debug(f"{self._name}: UpdateHAOutsideTemperature: OutEnvTem: {self._acOptions['OutEnvTem']}")
-
- if self._temp_sensor_offset is None: # user hasn't chosen an offset
- # User hasn't set automatically, so try to determine the offset
- temp_c = self._process_temp_sensor(self._acOptions["OutEnvTem"])
- _LOGGER.debug("method UpdateHAOutsideTemperature: User has not chosen an offset, using process_temp_sensor() to automatically determine offset.")
- else:
- # User set
- if self._temp_sensor_offset is True:
- temp_c = self._acOptions["OutEnvTem"] - TEMSEN_OFFSET
- elif self._temp_sensor_offset is False:
- temp_c = self._acOptions["OutEnvTem"]
-
- _LOGGER.debug(f"method UpdateHAOutsideTemperature: User has chosen an offset ({self._temp_sensor_offset})")
-
- temp_f = gree_c_to_f(SetTem=temp_c, TemRec=0) # Convert to Fahrenheit using TemRec bit
-
- if self._unit_of_measurement == "°C":
- self._current_outside_temperature = temp_c
- elif self._unit_of_measurement == "°F":
- self._current_outside_temperature = temp_f
- else:
- _LOGGER.error("Unknown unit of measurement for outside temperature: %s" % self._unit_of_measurement)
-
- _LOGGER.debug(f"{self._name}: UpdateHAOutsideTemperature: HA outside temperature set with device built-in outside temperature sensor state: {self._current_outside_temperature}{self._unit_of_measurement}")
-
- def UpdateHARoomHumidity(self):
- # Update room humidity from built-in AC room humidity sensor if available
- if self._has_room_humidity_sensor:
- _LOGGER.debug(f"{self._name}: UpdateHARoomHumidity: DwatSen: {self._acOptions['DwatSen']}")
- self._current_room_humidity = self._acOptions["DwatSen"]
- _LOGGER.debug(f"{self._name}: UpdateHARoomHumidity: HA room humidity set with device built-in room humidity sensor state: {self._current_room_humidity}%")
-
- def UpdateHAStateToCurrentACState(self):
- self.UpdateHATargetTemperature()
- self.UpdateHAHvacMode()
- self.UpdateHACurrentSwingMode()
- self.UpdateHACurrentSwingHorizontalMode()
- self.UpdateHAFanMode()
- self.UpdateHACurrentTemperature()
- self.UpdateHAOutsideTemperature()
- self.UpdateHARoomHumidity()
-
- async def SyncState(self, acOptions={}):
- # Fetch current settings from HVAC
- _LOGGER.debug(f"{self._name}: Starting device state sync")
-
- if self._has_temp_sensor is None:
- _LOGGER.debug("Attempt to check whether device has an built-in temperature sensor")
- try:
- temp_sensor = await self.GreeGetValues(["TemSen"])
- except Exception:
- _LOGGER.debug("Could not determine whether device has an built-in temperature sensor. Retrying at next update()")
- else:
- if temp_sensor:
- self._has_temp_sensor = True
- self._acOptions.update({"TemSen": None})
- self._optionsToFetch.append("TemSen")
- _LOGGER.debug("Device has an built-in temperature sensor")
- else:
- self._has_temp_sensor = False
- _LOGGER.debug("Device has no built-in temperature sensor")
-
- # Check if device has anti direct blow feature
- if self._has_anti_direct_blow is None:
- _LOGGER.debug("Attempt to check whether device has an anti direct blow feature")
- try:
- anti_direct_blow = await self.GreeGetValues(["AntiDirectBlow"])
- except Exception:
- _LOGGER.debug("Could not determine whether device has an anti direct blow feature. Retrying at next update()")
- else:
- if anti_direct_blow:
- self._has_anti_direct_blow = True
- self._acOptions.update({"AntiDirectBlow": None})
- self._optionsToFetch.append("AntiDirectBlow")
- _LOGGER.debug("Device has an anti direct blow feature")
- else:
- self._has_anti_direct_blow = False
- _LOGGER.debug("Device has no anti direct blow feature")
-
- # Check if device has light sensor
- if self._has_light_sensor is None:
- _LOGGER.debug("Attempt to check whether device has a built-in light sensor")
- try:
- light_sensor = await self.GreeGetValues(["LigSen"])
- except Exception:
- _LOGGER.debug("Could not determine whether device has a built-in light sensor. Retrying at next update()")
- else:
- if light_sensor:
- self._has_light_sensor = True
- self._acOptions.update({"LigSen": None})
- self._optionsToFetch.append("LigSen")
- _LOGGER.debug("Device has a built-in light sensor")
- else:
- self._has_light_sensor = False
- _LOGGER.debug("Device has no built-in light sensor")
-
- # Check if device has outside temperature sensor
- if self._has_outside_temp_sensor is None:
- _LOGGER.debug("Attempt to check whether device has an outside temperature sensor")
- try:
- outside_temp_sensor = await self.GreeGetValues(["OutEnvTem"])
- except Exception:
- _LOGGER.debug("Could not determine whether device has an outside temperature sensor. Retrying at next update()")
- else:
- if outside_temp_sensor:
- self._has_outside_temp_sensor = True
- self._acOptions.update({"OutEnvTem": None})
- self._optionsToFetch.append("OutEnvTem")
- _LOGGER.debug("Device has an outside temperature sensor")
- else:
- self._has_outside_temp_sensor = False
- _LOGGER.debug("Device has no outside temperature sensor")
-
- # Check if device has room humidity sensor
- if self._has_room_humidity_sensor is None:
- _LOGGER.debug("Attempt to check whether device has a room humidity sensor")
- try:
- humidity_sensor = await self.GreeGetValues(["DwatSen"])
- except Exception:
- _LOGGER.debug("Could not determine whether device has a room humidity sensor. Retrying at next update()")
- else:
- if humidity_sensor:
- self._has_room_humidity_sensor = True
- self._acOptions.update({"DwatSen": None})
- self._optionsToFetch.append("DwatSen")
- _LOGGER.debug("Device has a room humidity sensor")
- else:
- self._has_room_humidity_sensor = False
- _LOGGER.debug("Device has no room humidity sensor")
-
- optionsToFetch = self._optionsToFetch
-
- try:
- currentValues = await self.GreeGetValues(optionsToFetch)
- except Exception as e:
- _LOGGER.warning(f"{self._name}: Failed to communicate with device {self._ip_addr}:{self._port}: {str(e)}")
- if not self._disable_available_check:
- _LOGGER.info(f"{self._name}: Device marked offline after failed communication")
- self._device_online = False
- else:
- if not self._disable_available_check:
- if not self._device_online:
- self._device_online = True
- # Set latest status from device
- self._acOptions = self.SetAcOptions(self._acOptions, optionsToFetch, currentValues)
-
- # Overwrite status with our choices
- if not (acOptions == {}):
- self._acOptions = self.SetAcOptions(self._acOptions, acOptions)
-
- # If not the first (boot) run, update state towards the HVAC
- if not (self._firstTimeRun):
- if not (acOptions == {}):
- # loop used to send changed settings from HA to HVAC
- try:
- await self.SendStateToAc()
- except Exception as e:
- _LOGGER.warning(f"{self._name}: Failed to send state to device {self._ip_addr}:{self._port}: {str(e)}")
- # Mark device as offline if communication fails
- if not self._disable_available_check:
- _LOGGER.info(f"{self._name}: Device marked offline after failed send attempt")
- self._device_online = False
- else:
- # loop used once for Gree Climate initialisation only
- self._firstTimeRun = False
-
- # Update HA state to current HVAC state
- self.UpdateHAStateToCurrentACState()
-
- _LOGGER.debug(f"{self._name}: Finished device state sync")
-
- @property
- def should_poll(self):
- _LOGGER.debug("should_poll()")
- # Return the polling state.
- return True
-
- @property
- def available(self):
- if self._disable_available_check:
- return True
- else:
- if self._device_online:
- _LOGGER.debug("available(): Device is online")
- return True
- else:
- _LOGGER.debug("available(): Device is offline")
- return False
-
- async def async_update(self):
- """Retrieve latest state."""
- _LOGGER.debug("async_update()")
- if not self._encryption_key:
- if self.encryption_version == 1:
- key = await GetDeviceKey(self._mac_addr, self._ip_addr, self._port)
- if key:
- self._encryption_key = key
- self.CIPHER = AES.new(self._encryption_key, AES.MODE_ECB)
- await self.SyncState()
- elif self.encryption_version == 2:
- key = await GetDeviceKeyGCM(self._mac_addr, self._ip_addr, self._port)
- if key:
- self._encryption_key = key
- self.CIPHER = GetGCMCipher(self._encryption_key)
- await self.SyncState()
- else:
- _LOGGER.error("Encryption version %s is not implemented." % self.encryption_version)
- else:
- await self.SyncState()
-
- @property
- def name(self):
- _LOGGER.debug(f"{self._name}: name() = {self._name}")
- # Return the name of the climate device.
- return self._name
-
- @property
- def temperature_unit(self):
- _LOGGER.debug(f"{self._name}: temperature_unit() = {self._unit_of_measurement}")
- # Return the unit of measurement.
- return self._unit_of_measurement
-
- @property
- def current_temperature(self):
- _LOGGER.debug(f"{self._name}: current_temperature() = {self._current_temperature}")
- # Return the current temperature.
- return self._current_temperature
-
- @property
- def min_temp(self):
- if self._unit_of_measurement == "°C":
- MIN_TEMP = MIN_TEMP_C
- else:
- MIN_TEMP = MIN_TEMP_F
-
- _LOGGER.debug(f"{self._name}: min_temp() = {MIN_TEMP}")
- # Return the minimum temperature.
- return MIN_TEMP
-
- @property
- def max_temp(self):
- if self._unit_of_measurement == "°C":
- MAX_TEMP = MAX_TEMP_C
- else:
- MAX_TEMP = MAX_TEMP_F
-
- _LOGGER.debug(f"{self._name}: max_temp() = {MAX_TEMP}")
- # Return the maximum temperature.
- return MAX_TEMP
-
- @property
- def target_temperature(self):
- _LOGGER.debug(f"{self._name}: target_temperature() = {self._target_temperature}")
- # Return the temperature we try to reach.
- return self._target_temperature
-
- @property
- def target_temperature_step(self):
- _LOGGER.debug(f"{self._name}: target_temperature_step() = {self._target_temperature_step}")
- return self._target_temperature_step
-
- @property
- def hvac_mode(self):
- _LOGGER.debug(f"{self._name}: hvac_mode() = {self._hvac_mode}")
- # Return current operation mode ie. heat, cool, idle.
- return self._hvac_mode
-
- @property
- def swing_mode(self):
- if self._swing_modes:
- _LOGGER.debug(f"{self._name}: swing_mode() = {self._swing_mode}")
- # get the current swing mode
- return self._swing_mode
- else:
- return None
-
- @property
- def swing_modes(self):
- _LOGGER.debug(f"{self._name}: swing_modes() = {self._swing_modes}")
- # get the list of available swing modes
- return self._swing_modes
-
- @property
- def swing_horizontal_mode(self):
- if self._swing_horizontal_modes:
- _LOGGER.debug(f"{self._name}: swing_horizontal_mode() = {self._swing_horizontal_mode}")
- # get the current preset mode
- return self._swing_horizontal_mode
- else:
- return None
-
- @property
- def swing_horizontal_modes(self):
- _LOGGER.debug(f"{self._name}: swing_horizontal_modes() = {self._swing_horizontal_modes}")
- # get the list of available preset modes
- return self._swing_horizontal_modes
-
- @property
- def hvac_modes(self):
- _LOGGER.debug(f"{self._name}: hvac_modes() = {self._hvac_modes}")
- # get the list of available operation modes.
- return self._hvac_modes
-
- @property
- def fan_mode(self):
- _LOGGER.debug(f"{self._name}: fan_mode() = {self._fan_mode}")
- # Return the fan mode.
- return self._fan_mode
-
- @property
- def fan_modes(self):
- _LOGGER.debug(f"{self._name}: fan_modes() = {self._fan_modes}")
- # Return the list of available fan modes.
- return self._fan_modes
-
- @property
- def supported_features(self):
- sf = SUPPORT_FLAGS
- if self._swing_modes:
- sf = sf | ClimateEntityFeature.SWING_MODE
- if self._swing_horizontal_modes:
- sf = sf | ClimateEntityFeature.SWING_HORIZONTAL_MODE
- _LOGGER.debug(f"{self._name}: supported_features() = {sf}")
- # Return the list of supported features.
- return sf
-
- @property
- def unique_id(self):
- # Return unique_id
- return self._unique_id
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return device information."""
- return DeviceInfo(
- identifiers={(DOMAIN, self._mac_addr)},
- name=self._name,
- manufacturer="Gree",
- )
-
- @property
- def outside_temperature(self):
- """Return the outside temperature if available."""
- if self._has_outside_temp_sensor:
- _LOGGER.debug(f"{self._name}: outside_temperature() = {self._current_outside_temperature}")
- return self._current_outside_temperature
- return None
-
- @property
- def room_humidity(self):
- """Return the current room humidity if available."""
- if self._has_room_humidity_sensor:
- _LOGGER.debug(f"{self._name}: room_humidity() = {self._current_room_humidity}")
- return self._current_room_humidity
- return None
-
- @property
- def extra_state_attributes(self):
- """Return additional state attributes."""
- attributes = {}
-
- if self.outside_temperature is not None:
- attributes["outside_temperature"] = self.outside_temperature
- attributes["outside_temperature_unit"] = self._unit_of_measurement
-
- if self.room_humidity is not None:
- attributes["room_humidity"] = self.room_humidity
- attributes["room_humidity_unit"] = "%"
-
- return attributes if attributes else None
-
- async def async_set_temperature(self, **kwargs):
- """Set new target temperature."""
- target_temperature = kwargs.get(ATTR_TEMPERATURE)
- if target_temperature is not None:
- # do nothing if temperature is none
- if not (self._acOptions["Pow"] == 0):
- # do nothing if HVAC is switched off
-
- if self._unit_of_measurement == "°C":
- SetTem, TemRec = encode_temp_c(T=target_temperature) # takes care of 1/2 degrees
- elif self._unit_of_measurement == "°F":
- SetTem, TemRec = gree_f_to_c(desired_temp_f=target_temperature)
- else:
- _LOGGER.error("Unable to set temperature. Units not set to °C or °F")
- return
-
- await self.SyncState({"SetTem": int(SetTem), "TemRec": int(TemRec)})
- _LOGGER.debug(f"{self._name}: async_set_temperature: Set Temp to {target_temperature}{self._unit_of_measurement} -> SyncState with SetTem={SetTem}, SyncState with TemRec={TemRec}")
-
- self.async_write_ha_state()
-
- async def async_set_swing_mode(self, swing_mode):
- """Set swing mode."""
- if not (self._acOptions["Pow"] == 0):
- # do nothing if HVAC is switched off
- try:
- sw_up_dn = MODES_MAPPING.get("SwUpDn").get(swing_mode)
- _LOGGER.info(f"{self._name}: SyncState with SwUpDn={sw_up_dn}")
- await self.SyncState({"SwUpDn": sw_up_dn})
- self.async_write_ha_state()
- except ValueError:
- _LOGGER.error(f"Unknown swing mode: {swing_mode}")
- return
-
- async def async_set_swing_horizontal_mode(self, swing_horizontal_mode):
- """Set horizontal swing mode."""
- if not (self._acOptions["Pow"] == 0):
- # do nothing if HVAC is switched off
- try:
- swing_lf_rig = MODES_MAPPING.get("SwingLfRig").get(swing_horizontal_mode)
- _LOGGER.info(f"{self._name}: SyncState with SwingLfRig={swing_lf_rig}")
- await self.SyncState({"SwingLfRig": swing_lf_rig})
- self.async_write_ha_state()
- except ValueError:
- _LOGGER.error(f"Unknown preset mode: {swing_horizontal_mode}")
- return
-
- async def async_set_fan_mode(self, fan):
- """Set fan mode."""
- # Set the fan mode.
- if not (self._acOptions["Pow"] == 0):
- try:
- wd_spd = MODES_MAPPING.get("WdSpd").get(fan)
-
- # Check if this is turbo mode
- if fan == "turbo":
- _LOGGER.info("Enabling turbo mode")
- await self.SyncState({"Tur": 1, "Quiet": 0})
- # Check if this is quiet mode
- elif fan == "quiet":
- _LOGGER.info("Enabling quiet mode")
- await self.SyncState({"Tur": 0, "Quiet": 1})
- else:
- _LOGGER.info(f"{self._name}: Setting normal fan mode to {wd_spd}")
- await self.SyncState({"WdSpd": str(wd_spd), "Tur": 0, "Quiet": 0})
-
- self.async_write_ha_state()
- except ValueError:
- _LOGGER.error(f"Unknown fan mode: {fan}")
- return
-
- async def async_set_hvac_mode(self, hvac_mode):
- """Set new operation mode."""
- _LOGGER.info(f"{self._name}: async_set_hvac_mode(): {hvac_mode}")
- c = {}
- if hvac_mode == HVACMode.OFF:
- c.update({"Pow": 0})
- if hasattr(self, "_auto_light") and self._auto_light:
- c.update({"Lig": 0})
- else:
- mod = MODES_MAPPING.get("Mod").get(hvac_mode)
- c.update({"Pow": 1, "Mod": mod})
- if hasattr(self, "_auto_light") and self._auto_light:
- c.update({"Lig": 1})
- if hasattr(self, "_auto_xfan") and self._auto_xfan:
- if (hvac_mode == HVACMode.COOL) or (hvac_mode == HVACMode.DRY):
- c.update({"Blo": 1})
- await self.SyncState(c)
- self.async_write_ha_state()
-
- async def async_turn_on(self):
- """Turn on."""
- _LOGGER.info("async_turn_on(): ")
- # Turn on.
- c = {"Pow": 1}
- if hasattr(self, "_auto_light") and self._auto_light:
- c.update({"Lig": 1})
- await self.SyncState(c)
- self.async_write_ha_state()
-
- async def async_turn_off(self):
- """Turn off."""
- _LOGGER.info("async_turn_off(): ")
- # Turn off.
- c = {"Pow": 0}
- if hasattr(self, "_auto_light") and self._auto_light:
- c.update({"Lig": 0})
- await self.SyncState(c)
- self.async_write_ha_state()
-
- async def async_added_to_hass(self):
- _LOGGER.info("Gree climate device added to hass()")
- await self.async_update()
-
- async def async_will_remove_from_hass(self) -> None:
- """Clean up when entity is removed."""
- for name, entity_id, unsub in self._listeners:
- _LOGGER.debug("Deregistering %s listener for %s", name, entity_id)
- unsub()
- self._listeners.clear()
diff --git a/custom_components/gree/config_flow.py b/custom_components/gree/config_flow.py
deleted file mode 100644
index 58ca7fc..0000000
--- a/custom_components/gree/config_flow.py
+++ /dev/null
@@ -1,291 +0,0 @@
-"""Config flow for Gree climate integration."""
-
-from __future__ import annotations
-
-# Standard library imports
-import logging
-
-# Third-party imports
-import voluptuous as vol
-
-# Home Assistant imports
-from homeassistant import config_entries
-from homeassistant.const import (
- CONF_HOST,
- CONF_MAC,
- CONF_NAME,
- CONF_PORT,
-)
-from homeassistant.core import callback
-from homeassistant.data_entry_flow import FlowResult
-from homeassistant.helpers import selector
-
-# Local imports
-from .const import (
- CONF_DISABLE_AVAILABLE_CHECK,
- CONF_ENCRYPTION_KEY,
- CONF_ENCRYPTION_VERSION,
- CONF_FAN_MODES,
- CONF_HVAC_MODES,
- CONF_SWING_HORIZONTAL_MODES,
- CONF_SWING_MODES,
- CONF_TEMP_SENSOR_OFFSET,
- CONF_UID,
- DEFAULT_FAN_MODES,
- DEFAULT_HVAC_MODES,
- DEFAULT_PORT,
- DEFAULT_SWING_HORIZONTAL_MODES,
- DEFAULT_SWING_MODES,
- DOMAIN,
- OPTION_KEYS,
-)
-from .gree_protocol import test_connection, discover_gree_devices, detect_device_encryption
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
- """Handle a config flow for Gree climate."""
-
- VERSION = 1
-
- def __init__(self) -> None:
- self._data: dict[str, any] = {}
- self._discovered_devices: list[dict] = []
- self._selected_device: dict | None = None
-
- async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
- """Handle the initial step - show discovery or manual entry."""
- if user_input is not None:
- if user_input.get("discovery") == "discover":
- return await self.async_step_discovery()
- else:
- return await self.async_step_manual()
-
- # Show discovery vs manual choice
- data_schema = vol.Schema(
- {
- vol.Required("discovery", default="discover"): selector.SelectSelector(
- selector.SelectSelectorConfig(
- options=["discover", "manual"],
- translation_key="discovery_method",
- )
- )
- }
- )
- return self.async_show_form(step_id="user", data_schema=data_schema)
-
- async def async_step_discovery(self, user_input: dict | None = None) -> FlowResult:
- """Handle device discovery."""
- if user_input is not None:
- # User selected a discovered device
- selected_device = user_input["device"]
-
- for device in self._discovered_devices:
- device_id = f"{device['mac']}_{device['host']}"
- if device_id == selected_device:
- # Check if already configured
- await self.async_set_unique_id(device["mac"])
- self._abort_if_unique_id_configured()
-
- # Store selected device for next step
- self._selected_device = device
- return await self.async_step_detect_encryption()
-
- # If no matching device found, something went wrong - go to manual
- return await self.async_step_manual()
-
- # Discover devices
- self._discovered_devices = await discover_gree_devices(self.hass)
-
- if not self._discovered_devices:
- # No devices found, go to manual entry
- return await self.async_step_manual()
-
- # Create device selection options
- device_options = {}
- for device in self._discovered_devices:
- device_id = f"{device['mac']}_{device['host']}"
- device_options[device_id] = f"IP: {device['host']}, MAC: {device['mac']}"
-
- data_schema = vol.Schema({vol.Required("device"): vol.In(device_options)})
-
- return self.async_show_form(step_id="discovery", data_schema=data_schema, description_placeholders={"devices_found": str(len(self._discovered_devices))})
-
- async def async_step_detect_encryption(self, user_input: dict | None = None) -> FlowResult:
- """Detect encryption version and configure device."""
- if user_input is not None:
- # User entered device name, proceed with setup
- device_name = user_input[CONF_NAME]
-
- # Create final configuration
- self._data = {
- CONF_NAME: device_name,
- CONF_HOST: self._selected_device["host"],
- CONF_MAC: self._selected_device["mac"],
- CONF_PORT: self._selected_device["port"],
- CONF_ENCRYPTION_KEY: "",
- CONF_ENCRYPTION_VERSION: self._selected_device["encryption_version"],
- }
-
- # Test the connection
- is_connection_valid = await test_connection(self._data)
- if not is_connection_valid:
- return self.async_show_form(
- step_id="detect_encryption",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_NAME, default=device_name): str,
- }
- ),
- errors={"base": "cannot_connect"},
- )
-
- return self.async_create_entry(title=device_name, data=self._data)
-
- # Detect encryption version for selected device
- mac_addr = self._selected_device["mac"]
- ip_addr = self._selected_device["host"]
- port = self._selected_device["port"]
-
- encryption_version = await detect_device_encryption(mac_addr, ip_addr, port)
-
- if encryption_version is None:
- # Could not detect encryption, pre-fill manual form with discovered device info
- self._data = {
- CONF_NAME: self._selected_device["name"],
- CONF_HOST: self._selected_device["host"],
- CONF_MAC: self._selected_device["mac"],
- CONF_PORT: self._selected_device["port"],
- CONF_ENCRYPTION_KEY: "",
- CONF_ENCRYPTION_VERSION: 1, # Default to version 1
- }
- # Show manual form with error about encryption detection failure
- return self.async_show_form(
- step_id="manual",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_NAME, default=self._data.get(CONF_NAME, "")): str,
- vol.Required(CONF_HOST, default=self._data.get(CONF_HOST, "")): str,
- vol.Required(CONF_MAC, default=self._data.get(CONF_MAC, "")): str,
- vol.Required(CONF_PORT, default=self._data.get(CONF_PORT, DEFAULT_PORT)): int,
- vol.Optional(CONF_ENCRYPTION_KEY, default=self._data.get(CONF_ENCRYPTION_KEY, "")): str,
- vol.Optional(CONF_UID): int,
- vol.Optional(CONF_ENCRYPTION_VERSION, default=self._data.get(CONF_ENCRYPTION_VERSION, 1)): int,
- }
- ),
- errors={"base": "cannot_connect"},
- )
-
- # Store detected encryption version
- self._selected_device["encryption_version"] = encryption_version
-
- # Show device naming form with detected info
- data_schema = vol.Schema(
- {
- vol.Required(CONF_NAME, default=self._selected_device["name"]): str,
- }
- )
-
- return self.async_show_form(step_id="detect_encryption", data_schema=data_schema)
-
- async def async_step_manual(self, user_input: dict | None = None) -> FlowResult:
- """Handle manual device entry."""
- errors = {}
- if user_input is not None:
- self._data.update(user_input)
-
- # Check if already configured by MAC
- await self.async_set_unique_id(self._data[CONF_MAC])
- self._abort_if_unique_id_configured()
-
- is_connection_valid = await test_connection(self._data)
- if not is_connection_valid:
- errors["base"] = "cannot_connect"
- else:
- return self.async_create_entry(title=user_input[CONF_NAME], data=self._data)
-
- # Set defaults from user_input if present, else use hardcoded defaults
- defaults = user_input or self._data
- data_schema = vol.Schema(
- {
- vol.Required(CONF_NAME, default=defaults.get(CONF_NAME, "")): str,
- vol.Required(CONF_HOST, default=defaults.get(CONF_HOST, "")): str,
- vol.Required(CONF_MAC, default=defaults.get(CONF_MAC, "")): str,
- vol.Required(CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)): int,
- vol.Optional(CONF_ENCRYPTION_KEY, default=defaults.get(CONF_ENCRYPTION_KEY, "")): str,
- vol.Optional(CONF_UID): int,
- vol.Optional(CONF_ENCRYPTION_VERSION, default=defaults.get(CONF_ENCRYPTION_VERSION, 1)): int,
- }
- )
- return self.async_show_form(step_id="manual", data_schema=data_schema, errors=errors)
-
- async def async_step_import(self, import_data: dict) -> FlowResult:
- """Handle configuration via YAML import."""
- return await self.async_step_user(import_data)
-
- @staticmethod
- @callback
- def async_get_options_flow(
- config_entry: config_entries.ConfigEntry,
- ) -> config_entries.OptionsFlow:
- return OptionsFlowHandler(config_entry)
-
-
-class OptionsFlowHandler(config_entries.OptionsFlow):
- """Handle an options flow for Gree climate."""
-
- def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
- self.config_entry = config_entry
-
- async def async_step_init(self, user_input: dict | None = None) -> FlowResult:
- if user_input is not None:
- _LOGGER.debug("Raw user options input: %s", user_input)
- normalized_input: dict[str, str | None] = {}
- # Only handle known option keys
- for key in OPTION_KEYS:
- if key in user_input:
- value = user_input[key]
- normalized_input[key] = value if value not in (None, "") else None
- elif key in self.config_entry.options:
- normalized_input[key] = None
- _LOGGER.debug("Normalized options to save: %s", normalized_input)
- result = self.async_create_entry(title="", data=normalized_input)
- _LOGGER.debug("Creating entry with options: %s", normalized_input)
- return result
-
- options = {key: value for key, value in self.config_entry.options.items() if key in OPTION_KEYS}
- _LOGGER.debug("Current stored options: %s", options)
- schema = vol.Schema(
- {
- vol.Optional(
- CONF_HVAC_MODES,
- description={"suggested_value": options.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES)},
- default=options.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES),
- ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_HVAC_MODES, multiple=True, custom_value=True, translation_key=CONF_HVAC_MODES))),
- vol.Optional(
- CONF_FAN_MODES,
- description={"suggested_value": options.get(CONF_FAN_MODES, DEFAULT_FAN_MODES)},
- default=options.get(CONF_FAN_MODES, DEFAULT_FAN_MODES),
- ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_FAN_MODES, multiple=True, custom_value=True, translation_key=CONF_FAN_MODES))),
- vol.Optional(
- CONF_SWING_MODES,
- description={"suggested_value": options.get(CONF_SWING_MODES, DEFAULT_SWING_MODES)},
- default=options.get(CONF_SWING_MODES, DEFAULT_SWING_MODES),
- ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_SWING_MODES, multiple=True, custom_value=True, translation_key=CONF_SWING_MODES))),
- vol.Optional(
- CONF_SWING_HORIZONTAL_MODES,
- description={"suggested_value": options.get(CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES)},
- default=options.get(CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES),
- ): vol.Any(None, selector.SelectSelector(selector.SelectSelectorConfig(options=DEFAULT_SWING_HORIZONTAL_MODES, multiple=True, custom_value=True, translation_key=CONF_SWING_HORIZONTAL_MODES))),
- vol.Optional(
- CONF_DISABLE_AVAILABLE_CHECK,
- default=options.get(CONF_DISABLE_AVAILABLE_CHECK, False),
- ): bool,
- vol.Optional(
- CONF_TEMP_SENSOR_OFFSET,
- description={"suggested_value": options.get(CONF_TEMP_SENSOR_OFFSET)},
- ): vol.Any(None, bool),
- }
- )
- return self.async_show_form(step_id="init", data_schema=schema)
diff --git a/custom_components/gree/const.py b/custom_components/gree/const.py
deleted file mode 100644
index 4b6e7e1..0000000
--- a/custom_components/gree/const.py
+++ /dev/null
@@ -1,80 +0,0 @@
-DOMAIN = "gree"
-
-CONF_HVAC_MODES = "hvac_modes"
-CONF_ENCRYPTION_KEY = 'encryption_key'
-CONF_UID = 'uid'
-CONF_FAN_MODES = 'fan_modes'
-CONF_SWING_MODES = 'swing_modes'
-CONF_SWING_HORIZONTAL_MODES = 'swing_horizontal_modes'
-CONF_ENCRYPTION_VERSION = 'encryption_version'
-CONF_DISABLE_AVAILABLE_CHECK = 'disable_available_check'
-CONF_TEMP_SENSOR_OFFSET = 'temp_sensor_offset'
-
-DEFAULT_PORT = 7000
-DEFAULT_TARGET_TEMP_STEP = 1
-
-MIN_TEMP_C = 16
-MAX_TEMP_C = 30
-
-MIN_TEMP_F = 61
-MAX_TEMP_F = 86
-
-TEMSEN_OFFSET = 40
-
-# HVAC modes - these come from Home Assistant and are standard
-DEFAULT_HVAC_MODES = ["auto", "cool", "dry", "fan_only", "heat", "off"]
-
-DEFAULT_FAN_MODES = ["auto", "low", "medium_low", "medium", "medium_high", "high", "turbo", "quiet"]
-DEFAULT_SWING_MODES = ["default", "swing_full", "fixed_upmost", "fixed_middle_up", "fixed_middle", "fixed_middle_low", "fixed_lowest", "swing_downmost", "swing_middle_low", "swing_middle", "swing_middle_up", "swing_upmost"]
-DEFAULT_SWING_HORIZONTAL_MODES = ["default", "swing_full", "fixed_leftmost", "fixed_middle_left", "fixed_middle", "fixed_middle_right", "fixed_rightmost"]
-
-# Keys that can be updated via the options flow
-OPTION_KEYS = {
- CONF_HVAC_MODES,
- CONF_FAN_MODES,
- CONF_SWING_MODES,
- CONF_SWING_HORIZONTAL_MODES,
- CONF_DISABLE_AVAILABLE_CHECK,
- CONF_TEMP_SENSOR_OFFSET,
-}
-
-MODES_MAPPING = {
- "Mod" : {
- "auto" : 0,
- "cool" : 1,
- "dry" : 2,
- "fan_only" : 3,
- "heat" : 4
- },
- "WdSpd" : {
- "auto" : 0,
- "low" : 1,
- "medium_low" : 2,
- "medium" : 3,
- "medium_high" : 4,
- "high" : 5
- },
- "SwUpDn" : {
- "default" : 0,
- "swing_full" : 1,
- "fixed_upmost" : 2,
- "fixed_middle_up" : 3,
- "fixed_middle" : 4,
- "fixed_middle_low" : 5,
- "fixed_lowest" : 6,
- "swing_downmost" : 7,
- "swing_middle_low" : 8,
- "swing_middle" : 9,
- "swing_middle_up" : 10,
- "swing_upmost" : 11
- },
- "SwingLfRig" : {
- "default" : 0,
- "swing_full" : 1,
- "fixed_leftmost" : 2,
- "fixed_middle_left" : 3,
- "fixed_middle" : 4,
- "fixed_middle_right" : 5,
- "fixed_rightmost" : 6
- }
-}
\ No newline at end of file
diff --git a/custom_components/gree/entity.py b/custom_components/gree/entity.py
deleted file mode 100644
index e0f6968..0000000
--- a/custom_components/gree/entity.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""Base entity for Gree integration."""
-
-from __future__ import annotations
-
-# Standard library imports
-from collections.abc import Callable
-from dataclasses import dataclass
-from typing import Any
-
-# Home Assistant imports
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
-from homeassistant.helpers.entity import DeviceInfo, Entity
-
-# Local imports
-from .const import DOMAIN
-
-
-@dataclass
-class GreeEntityDescription:
- """Describes Gree entity."""
-
- property_key: str
- """Fills key and translation_key."""
- key: str = None
- translation_key: str = None
-
- def __post_init__(self):
- self.key = self.property_key
- self.translation_key = self.property_key
-
- name: str = None
- icon: str = None
- entity_category: str = None
- exists_fn: Callable[[object, object], bool] = lambda description, device: True
- value_fn: Callable[[object], Any] = None
- available_fn: Callable[[object], bool] = lambda device: True
- icon_fn: Callable[[Any, object], str] = None
-
-
-class GreeEntity(Entity):
- """Base Gree entity."""
-
- _attr_has_entity_name = True
- entity_description: GreeEntityDescription
-
- def __init__(self, hass, entry, description: GreeEntityDescription) -> None:
- """Initialize Gree entity."""
- # Get the device from the entry data
- entry_data = hass.data.get(DOMAIN, {}).get(entry.entry_id, {})
- self._device = entry_data.get("device")
- self.entity_description = description
- self._set_id()
-
- def _set_id(self) -> None:
- """Set entity ID and unique ID."""
- if self.entity_description:
- if self.entity_description.icon_fn is not None:
- self._attr_icon = self.entity_description.icon_fn(self.native_value, self._device)
- elif self.entity_description.icon is not None:
- self._attr_icon = self.entity_description.icon
-
- self._attr_unique_id = f"{self._device._mac_addr}_{self.entity_description.key}"
-
- @property
- def device_info(self) -> DeviceInfo:
- """Return device information."""
- return DeviceInfo(
- identifiers={(DOMAIN, self._device._mac_addr)},
- name=self._device._name,
- manufacturer="Gree",
- connections={(CONNECTION_NETWORK_MAC, self._device._mac_addr)},
- )
-
- @property
- def available(self) -> bool:
- """Return if entity is available."""
- if self.entity_description.available_fn:
- return self.entity_description.available_fn(self._device)
- return self._device._device_online if hasattr(self._device, "_device_online") else True
-
- @property
- def native_value(self) -> Any:
- """Return the native value of the entity."""
- if self.entity_description.value_fn:
- return self.entity_description.value_fn(self._device)
- return None
diff --git a/custom_components/gree/gree_protocol.py b/custom_components/gree/gree_protocol.py
deleted file mode 100644
index 5e11c93..0000000
--- a/custom_components/gree/gree_protocol.py
+++ /dev/null
@@ -1,311 +0,0 @@
-"""
-Gree protocol/network logic for Home Assistant integration.
-"""
-
-# Standard library imports
-import asyncio
-import base64
-import logging
-import socket
-import time
-
-# Third-party imports
-try:
- import simplejson
-except ImportError:
- import json as simplejson
-from Crypto.Cipher import AES
-
-# Home Assistant imports
-from homeassistant.const import CONF_HOST, CONF_PORT, CONF_MAC
-from homeassistant.components.network import async_get_ipv4_broadcast_addresses
-
-# Local imports
-from .const import (
- CONF_ENCRYPTION_VERSION,
- CONF_ENCRYPTION_KEY,
-)
-
-_LOGGER = logging.getLogger(__name__)
-
-GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13"
-GCM_ADD = b"qualcomm-test"
-GENERIC_GREE_DEVICE_KEY = "a3K8Bx%2r8Y7#xDh"
-GENERIC_GREE_DEVICE_KEY_GCM = b"{yxAHAY_Lm6pbC/<"
-
-
-async def FetchResult(cipher, ip_addr, port, json_data, encryption_version=1, max_retries=8):
- """Send a request to a Gree device and fetch the result, with retries and timeouts."""
-
- _LOGGER.debug(f"Fetching device at: {ip_addr}:{port}, data sent: {json_data})")
-
- timeout = 2
-
- for attempt in range(max_retries):
- clientSock = None
- try:
- clientSock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- clientSock.settimeout(timeout)
-
- # Send data to device
- clientSock.sendto(bytes(json_data, "utf-8"), (ip_addr, port))
-
- # Receive response with event loop yielding
- data, _ = await asyncio.wait_for(asyncio.get_event_loop().run_in_executor(None, clientSock.recvfrom, 64000), timeout=timeout)
-
- # Parse and decrypt response
- received_json = simplejson.loads(data)
- pack = received_json["pack"]
- decoded_pack = base64.b64decode(pack)
- decrypted_pack = cipher.decrypt(decoded_pack)
-
- if encryption_version == 2:
- tag = received_json["tag"]
- cipher.verify(base64.b64decode(tag))
-
- # Clean up response data
- decoded_text = decrypted_pack.decode("utf-8")
- # Remove null bytes and trailing data after last }
- clean_text = decoded_text.replace("\x0f", "")
- last_brace = clean_text.rindex("}")
- clean_text = clean_text[: last_brace + 1]
-
- result = simplejson.loads(clean_text)
-
- _LOGGER.debug(f"Successfully received response on attempt {attempt + 1}")
- return result
-
- except Exception as e:
- if attempt == max_retries - 1:
- error_msg = f"{type(e).__name__}: {str(e)}" if str(e) else f"{type(e).__name__}"
- _LOGGER.error(f"All {max_retries} attempts failed for {ip_addr}:{port}. Error: {error_msg}")
- raise
-
- finally:
- if clientSock:
- try:
- clientSock.close()
- except Exception as e:
- _LOGGER.debug(f"Error closing socket: {str(e)}")
-
- # Progressive backoff before retry
- if attempt < max_retries - 1:
- await asyncio.sleep(0.5 + (attempt * 0.3)) # 0.5s, 0.8s, 1.1s, 1.4s, 1.7s, 2.0s, 2.3s
-
-
-def Pad(s):
- aesBlockSize = 16
- return s + (aesBlockSize - len(s) % aesBlockSize) * chr(aesBlockSize - len(s) % aesBlockSize)
-
-
-async def test_connection(config):
- """Test connection to a Gree device."""
-
- ip_addr = config[CONF_HOST]
- port = config[CONF_PORT]
- encryption_version = config[CONF_ENCRYPTION_VERSION]
- encryption_key = config[CONF_ENCRYPTION_KEY]
-
- mac_addr = config.get(CONF_MAC).encode().replace(b":", b"").decode("utf-8").lower()
- if "@" in mac_addr:
- mac_addr = mac_addr.split("@", 1)[0]
-
- _LOGGER.debug(f"test_connection: host={ip_addr}, port={port}, mac={mac_addr}, encryption_version={encryption_version}, encryption_key={encryption_key}")
-
- try:
- if encryption_version == 1:
- key = await GetDeviceKey(mac_addr, ip_addr, port)
- else:
- key = await GetDeviceKeyGCM(mac_addr, ip_addr, port)
- _LOGGER.debug(f"test_connection: Got device key: {key}")
- return key is not None
- except Exception as e:
- _LOGGER.error(f"Gree device at {ip_addr} is unreachable: {type(e).__name__}: {e}", exc_info=True)
- return False
-
-
-async def GetDeviceKey(mac_addr, ip_addr, port, max_retries=8):
- _LOGGER.debug("Retrieving HVAC encryption key")
- cipher = AES.new(GENERIC_GREE_DEVICE_KEY.encode("utf8"), AES.MODE_ECB)
- pack = base64.b64encode(cipher.encrypt(Pad(f'{{"mac":"{mac_addr}","t":"bind","uid":0}}').encode("utf8"))).decode("utf-8")
- jsonPayloadToSend = f'{{"cid": "app","i": 1,"pack": "{pack}","t":"pack","tcid":"{mac_addr}","uid": 0}}'
- try:
- result = await FetchResult(cipher, ip_addr, port, jsonPayloadToSend, max_retries=max_retries)
- _LOGGER.debug(f"GetDeviceKey: FetchResult: {result}")
- key = result["key"].encode("utf8")
- except Exception:
- _LOGGER.debug("Error getting device encryption key!")
- return None
- else:
- _LOGGER.debug(f"Fetched device encryption key: {str(key)}")
- return key
-
-
-def GetGCMCipher(key):
- cipher = AES.new(key, AES.MODE_GCM, nonce=GCM_IV)
- cipher.update(GCM_ADD)
- return cipher
-
-
-def EncryptGCM(key, plaintext):
- cipher = GetGCMCipher(key)
- encrypted_data, tag = cipher.encrypt_and_digest(plaintext.encode("utf8"))
- pack = base64.b64encode(encrypted_data).decode("utf-8")
- tag = base64.b64encode(tag).decode("utf-8")
- return (pack, tag)
-
-
-async def GetDeviceKeyGCM(mac_addr, ip_addr, port, max_retries=8):
- _LOGGER.debug("Retrieving HVAC encryption key (GCM)")
- plaintext = f'{{"cid":"{mac_addr}", "mac":"{mac_addr}","t":"bind","uid":0}}'
- pack, tag = EncryptGCM(GENERIC_GREE_DEVICE_KEY_GCM, plaintext)
- jsonPayloadToSend = f'{{"cid": "app","i": 1,"pack": "{pack}","t":"pack","tcid":"{mac_addr}","uid": 0, "tag" : "{tag}"}}'
- try:
- result = await FetchResult(GetGCMCipher(GENERIC_GREE_DEVICE_KEY_GCM), ip_addr, port, jsonPayloadToSend, encryption_version=2, max_retries=max_retries)
- _LOGGER.debug(f"GetDeviceKeyGCM: FetchResult: {result}")
- key = result["key"].encode("utf8")
- except Exception:
- _LOGGER.debug("Error getting device encryption key!")
- return None
- else:
- _LOGGER.debug(f"Fetched device encryption key: {str(key)}")
- return key
-
-
-async def discover_gree_devices(hass, timeout=5):
- """Discover Gree devices on the local network using UDP broadcast."""
- _LOGGER.debug("Starting Gree device discovery...")
-
- BROADCAST_PORT = 7000
- DISCOVERY_MESSAGE = b'{"t":"scan"}'
-
- # Set up UDP socket for broadcast
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
- sock.settimeout(timeout)
- sock.bind(("", 0))
-
- devices = []
-
- try:
- # Default broadcast addresses to try
- broadcast_addresses = [
- "255.255.255.255", # Limited broadcast
- "192.168.255.255", # /16 broadcast for 192.168.x.x networks
- "10.255.255.255", # /8 broadcast for 10.x.x.x networks
- "172.31.255.255", # /12 broadcast for 172.16-31.x.x networks
- ]
-
- # Get broadcast addresses from Home Assistant's network helper
- try:
- ha_broadcast_addresses = await async_get_ipv4_broadcast_addresses(hass)
- ha_broadcast_strings = [str(addr) for addr in ha_broadcast_addresses]
- broadcast_addresses.extend(ha_broadcast_strings)
- _LOGGER.debug(f"Found broadcast addresses from HA: {ha_broadcast_strings}")
- except Exception as e:
- _LOGGER.debug(f"Could not get HA broadcast addresses: {e}")
-
- # Remove duplicates
- broadcast_addresses = list(dict.fromkeys(broadcast_addresses))
-
- # Send to all broadcast addresses
- for broadcast_addr in broadcast_addresses:
- try:
- _LOGGER.debug(f"Sending discovery to {broadcast_addr}")
- sock.sendto(DISCOVERY_MESSAGE, (broadcast_addr, BROADCAST_PORT))
- except Exception as e:
- _LOGGER.debug(f"Failed to send to {broadcast_addr}: {e}")
-
- _LOGGER.debug("Sent discovery packets, waiting for replies...")
-
- start = time.time()
- while time.time() - start < timeout:
- try:
- data, addr = sock.recvfrom(1024)
- try:
- # Try to parse as JSON and decrypt if possible
- response = simplejson.loads(data.decode(errors="ignore"))
- if "pack" in response:
- pack = response["pack"]
- decoded_pack = base64.b64decode(pack)
-
- # Discovery responses typically use level 1 encryption (ECB mode)
- # But we need to test which encryption the device actually uses for communication
- pack_json = None
-
- try:
- cipher = AES.new(GENERIC_GREE_DEVICE_KEY.encode("utf-8"), AES.MODE_ECB)
- decrypted_pack = cipher.decrypt(decoded_pack)
- # Remove null bytes and trailing data after last }
- decoded_text = decrypted_pack.decode("utf-8", errors="ignore").replace("\x0f", "")
- last_brace = decoded_text.rfind("}")
- if last_brace != -1:
- clean_text = decoded_text[: last_brace + 1]
- else:
- clean_text = decoded_text
- pack_json = simplejson.loads(clean_text)
- _LOGGER.debug(f"Decrypted discovery response from {addr}")
- except Exception as e:
- _LOGGER.debug(f"Could not decrypt discovery response from {addr}: {e}")
- continue
-
- # If we successfully decrypted and got device info
- if pack_json and pack_json.get("t") == "dev":
- mac_addr = pack_json.get("mac", "")
- if not mac_addr:
- _LOGGER.debug(f"No MAC address in response from {addr}")
- continue
-
- # Just collect basic device info for now - encryption detection happens later
- device_info = {
- "name": pack_json.get("name", "") or f"Gree {mac_addr[-4:]}",
- "host": addr[0],
- "port": BROADCAST_PORT,
- "mac": mac_addr,
- "brand": pack_json.get("brand", "gree"),
- "model": pack_json.get("model", "gree"),
- "version": pack_json.get("ver", ""),
- }
- devices.append(device_info)
- _LOGGER.debug(f"Discovered Gree device: {device_info}")
- else:
- _LOGGER.debug(f"Invalid or missing device info from {addr}")
- else:
- _LOGGER.debug(f"Received response without pack from {addr}: {response}")
- except Exception as e:
- _LOGGER.debug(f"Could not parse response from {addr}: {e}")
- except socket.timeout:
- break
- finally:
- sock.close()
-
- _LOGGER.debug(f"Discovery completed, found {len(devices)} devices")
- return devices
-
-
-async def detect_device_encryption(mac_addr, ip_addr, port):
- """Test which encryption version a device uses for communication."""
- _LOGGER.debug(f"Detecting encryption version for device {mac_addr} at {ip_addr}:{port}")
-
- # Test encryption version 1 first
- try:
- _LOGGER.debug(f"Testing encryption version 1 for device {mac_addr}")
- key = await GetDeviceKey(mac_addr, ip_addr, port, max_retries=1)
- if key:
- _LOGGER.debug(f"Device {mac_addr} uses encryption version 1")
- return 1
- except Exception as e:
- _LOGGER.debug(f"Encryption version 1 failed for device {mac_addr}: {e}")
-
- # Test encryption version 2
- try:
- _LOGGER.debug(f"Testing encryption version 2 for device {mac_addr}")
- key = await GetDeviceKeyGCM(mac_addr, ip_addr, port, max_retries=1)
- if key:
- _LOGGER.debug(f"Device {mac_addr} uses encryption version 2")
- return 2
- except Exception as e:
- _LOGGER.debug(f"Encryption version 2 failed for device {mac_addr}: {e}")
-
- _LOGGER.error(f"Could not determine encryption version for device {mac_addr}")
- return None
diff --git a/custom_components/gree/helpers.py b/custom_components/gree/helpers.py
deleted file mode 100644
index 079e0e7..0000000
--- a/custom_components/gree/helpers.py
+++ /dev/null
@@ -1,131 +0,0 @@
-"""Helper functions and classes for Gree integration."""
-
-from .const import TEMSEN_OFFSET
-
-
-class TempOffsetResolver:
- """
- Detect whether this sensor reports temperatures in °C
- or in (°C + 40). Continues to check, and bases decision
- on historical min and max raw values, since there are extreme
- cases which would result in a switch. Two running values are
- stored (min & max raw).
-
- Note: This could be simplified by just using 40C as a max point
- for the unoffset case and a min point for the offset case. But
- this doesn't account for the marginal cases around 40C as well.
-
- Example:
-
- if raw < 40:
- return raw
- else:
- return raw - 40
- """
-
- def __init__(
- self,
- indoor_min: float = -15.0, # coldest plausible indoor °C
- indoor_max: float = 40.0, # hottest plausible indoor °C
- offset: float = TEMSEN_OFFSET, # device's fixed offset
- margin: float = 2.0, # tolerance before "impossible":
- ):
- self._lo_lim = indoor_min - margin
- self._hi_lim = indoor_max + margin
- self._offset = offset
-
- self._min_raw: float | None = None
- self._max_raw: float | None = None
- self._has_offset: bool | None = None # undecided until True/False
-
- def __call__(self, raw: float) -> float:
- if self._min_raw is None or raw < self._min_raw:
- self._min_raw = raw
- if self._max_raw is None or raw > self._max_raw:
- self._max_raw = raw
- self._evaluate() # evaluate every time, so it can change it's mind as needed
- return raw - self._offset if self._has_offset else raw
-
- def _evaluate(self) -> None:
- lo, hi = self._min_raw, self._max_raw
- penalty_no = self._penalty(lo, hi)
- penalty_off = self._penalty(lo - self._offset, hi - self._offset)
- if penalty_no == penalty_off:
- return # still ambiguous – keep collecting data
- self._has_offset = penalty_off < penalty_no
-
- def _penalty(self, lo: float, hi: float) -> float:
- pen = 0.0
- if lo < self._lo_lim:
- pen += self._lo_lim - lo
- if hi > self._hi_lim:
- pen += hi - self._hi_lim
- return pen
-
-
-def gree_f_to_c(desired_temp_f):
- # Convert to fractional C values for AC
- # See: https://github.com/tomikaa87/gree-remote
- SetTem = round((desired_temp_f - 32.0) * 5.0 / 9.0)
- TemRec = (int)((((desired_temp_f - 32.0) * 5.0 / 9.0) - SetTem) > -0.001)
-
- return SetTem, TemRec
-
-
-def gree_c_to_f(SetTem, TemRec):
- # Convert SetTem back to the minimum and maximum Fahrenheit before rounding
- # We consider the worst case scenario: SetTem could be the result of rounding from any value in a range
- # If TemRec is 1, it indicates the value was closer to the upper range of the rounding
- # If TemRec is 0, it indicates the value was closer to the lower range
-
- if TemRec == 1:
- # SetTem is closer to its higher bound, so we consider SetTem as the lower limit
- min_celsius = SetTem
- max_celsius = SetTem + 0.4999 # Just below the next rounding threshold
- else:
- # SetTem is closer to its lower bound, so we consider SetTem-1 as the potential lower limit
- min_celsius = SetTem - 0.4999 # Just above the previous rounding threshold
- max_celsius = SetTem
-
- # Convert these Celsius values back to Fahrenheit
- min_fahrenheit = (min_celsius * 9.0 / 5.0) + 32.0
- max_fahrenheit = (max_celsius * 9.0 / 5.0) + 32.0
-
- int_fahrenheit = round((min_fahrenheit + max_fahrenheit) / 2.0)
-
- return int_fahrenheit
-
-
-def encode_temp_c(T):
- """
- Used for encoding 1/2 degree Celsius values.
- Encode any floating‐point temperature T into:
- ‣ temp_int: the integer (°C) portion of the nearest 0.0/0.5 step,
- ‣ half_bit: 1 if the nearest step has a ".5", else 0.
-
- This "finds the closest multiple of 0.5" to T, then:
- n = round(T * 2)
- temp_int = n >> 1 (i.e. floor(n/2))
- half_bit = n & 1 (1 if it's an odd half‐step)
- """
- # 1) Compute "twice T" and round to nearest integer:
- # math.floor(T * 2 + 0.5) is equivalent to rounding ties upward.
- n = int(round(T * 2))
-
- # 2) The low bit of n says ".5" (odd) versus ".0" (even):
- TemRec = n & 1
-
- # 3) Shifting right by 1 gives floor(n/2), i.e. the integer °C of that nearest half‐step:
- SetTem = n >> 1
-
- return SetTem, TemRec
-
-
-def decode_temp_c(SetTem: int, TemRec: int) -> float:
- """
- Given:
- SetTem = the "rounded-down" integer (⌊T⌋ or for negatives, floor(T))
- TemRec = 0 or 1, where 1 means "there was a 0.5"
- Returns the original temperature as a float.
- """
- return SetTem + (0.5 if TemRec else 0.0)
diff --git a/custom_components/gree/icons.json b/custom_components/gree/icons.json
deleted file mode 100644
index dc1f10e..0000000
--- a/custom_components/gree/icons.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
- "entity": {
- "climate": {
- "gree": {
- "state_attributes": {
- "fan_mode": {
- "default": "mdi:fan",
- "state": {
- "auto": "mdi:fan-auto",
- "low": "mdi:fan-chevron-down",
- "medium_low": "mdi:fan-minus",
- "medium": "mdi:fan",
- "medium_high": "mdi:fan-plus",
- "high": "mdi:fan-chevron-up",
- "turbo": "mdi:weather-windy",
- "quiet": "mdi:sleep"
- }
- },
- "swing_horizontal_mode": {
- "default": "mdi:arrow-oscillating",
- "state": {
- "default": "mdi:arrow-oscillating-off",
- "swing_full": "mdi:arrow-oscillating"
- }
- },
- "swing_mode": {
- "default": "mdi:arrow-up-down"
- }
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/custom_components/gree/manifest.json b/custom_components/gree/manifest.json
deleted file mode 100644
index b5bc812..0000000
--- a/custom_components/gree/manifest.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "domain": "gree",
- "name": "Gree A/C",
- "version": "3.3.0",
- "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent",
- "dependencies": [],
- "codeowners": [
- "@robhofmann"
- ],
- "requirements": [
- "pycryptodome",
- "aiofiles"
- ],
- "config_flow": true
-}
diff --git a/custom_components/gree/number.py b/custom_components/gree/number.py
deleted file mode 100644
index 2a87c39..0000000
--- a/custom_components/gree/number.py
+++ /dev/null
@@ -1,97 +0,0 @@
-"""Support for Gree number entities (e.g., target temperature step)."""
-
-from __future__ import annotations
-
-# Standard library imports
-import logging
-from collections.abc import Callable
-from dataclasses import dataclass
-
-# Home Assistant imports
-from homeassistant.components.number import (
- NumberEntity,
- NumberEntityDescription,
- NumberMode,
-)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity import EntityCategory
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.restore_state import RestoreEntity
-
-# Local imports
-from .const import DEFAULT_TARGET_TEMP_STEP
-from .entity import GreeEntity, GreeEntityDescription
-
-_LOGGER = logging.getLogger(__name__)
-
-
-@dataclass
-class GreeNumberEntityDescription(GreeEntityDescription, NumberEntityDescription):
- set_fn: Callable[[object, float], None] = None
- restore_state: bool = False
-
-
-NUMBERS: tuple[GreeNumberEntityDescription, ...] = (
- GreeNumberEntityDescription(
- property_key="target_temp_step",
- icon="mdi:arrow-expand-vertical",
- native_min_value=0.1,
- native_max_value=5.0,
- native_step=0.1,
- mode=NumberMode.SLIDER,
- value_fn=lambda device: getattr(device, "_target_temperature_step", DEFAULT_TARGET_TEMP_STEP),
- set_fn=lambda device, value: setattr(device, "_target_temperature_step", value),
- entity_category=EntityCategory.CONFIG,
- restore_state=True,
- ),
-)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
-) -> None:
- """Set up Gree number entities based on a config entry."""
- async_add_entities(GreeNumberEntity(hass, entry, description) for description in NUMBERS)
-
-
-class GreeNumberEntity(GreeEntity, NumberEntity, RestoreEntity):
- """Defines a Gree number entity."""
-
- entity_description: GreeNumberEntityDescription
-
- def __init__(self, hass, entry, description: GreeNumberEntityDescription) -> None:
- super().__init__(hass, entry, description)
- self._attr_native_value = self.native_value
- self._restored = False
-
- async def async_added_to_hass(self):
- await super().async_added_to_hass()
- if self.entity_description.restore_state:
- last_state = await self.async_get_last_state()
- if last_state is not None and last_state.state not in ["unknown", "unavailable"]:
- try:
- value = float(last_state.state)
- # Validate the value is within the entity's range
- if self.entity_description.native_min_value <= value <= self.entity_description.native_max_value:
- setattr(self._device, f"_{self.entity_description.property_key}", value)
- self._attr_native_value = value
- self._restored = True
- except (ValueError, TypeError):
- # If conversion fails, use default value
- pass
-
- @property
- def native_value(self):
- if self.entity_description.restore_state:
- return getattr(self, "_attr_native_value", self.entity_description.value_fn(self._device))
- return self.entity_description.value_fn(self._device)
-
- async def async_set_native_value(self, value: float) -> None:
- if self.entity_description.set_fn:
- await self.hass.async_add_executor_job(self.entity_description.set_fn, self._device, value)
- if self.entity_description.restore_state:
- self._attr_native_value = value
- self.async_write_ha_state()
diff --git a/custom_components/gree/select.py b/custom_components/gree/select.py
deleted file mode 100644
index 4971ac9..0000000
--- a/custom_components/gree/select.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""Support for Gree select entities (e.g., external temperature sensor selection)."""
-
-from __future__ import annotations
-
-# Standard library imports
-import logging
-from collections.abc import Callable
-from dataclasses import dataclass
-
-# Home Assistant imports
-from homeassistant.components.select import (
- SelectEntity,
- SelectEntityDescription,
-)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity import EntityCategory
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.restore_state import RestoreEntity
-
-# Local imports
-from .entity import GreeEntity, GreeEntityDescription
-
-_LOGGER = logging.getLogger(__name__)
-
-
-@dataclass
-class GreeSelectEntityDescription(GreeEntityDescription, SelectEntityDescription):
- """Describes Gree select entity."""
-
- set_fn: Callable[[object, str], None] = None
- restore_state: bool = False
- options_fn: Callable[[object], list[str]] = None
-
-
-def get_temperature_sensor_options(hass: HomeAssistant) -> list[str]:
- """Get list of available temperature sensor entities."""
- options = ["None"] # Always include "None" as first option
-
- # Get all entities from the registry
- for state in hass.states.async_all():
- # Look for temperature sensors
- if state.entity_id.startswith("sensor."):
- # Check for explicit device_class
- if state.attributes.get("device_class") == "temperature":
- options.append(state.entity_id)
- # Also check for temperature units as fallback for helpers/combined sensors
- elif state.attributes.get("unit_of_measurement") in ["°C", "°F", "K"]:
- options.append(state.entity_id)
-
- return options
-
-
-SELECTS: tuple[GreeSelectEntityDescription, ...] = (
- GreeSelectEntityDescription(
- property_key="external_temperature_sensor",
- icon="mdi:thermometer-lines",
- options=[], # Will be populated dynamically
- value_fn=lambda device: getattr(device, "_external_temperature_sensor", "None"),
- set_fn=lambda device, value: setattr(device, "_external_temperature_sensor", None if value == "None" else value),
- entity_category=EntityCategory.CONFIG,
- restore_state=True,
- options_fn=lambda hass: get_temperature_sensor_options(hass),
- ),
-)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
-) -> None:
- """Set up Gree select entities based on a config entry."""
- async_add_entities(GreeSelectEntity(hass, entry, description) for description in SELECTS)
-
-
-class GreeSelectEntity(GreeEntity, SelectEntity, RestoreEntity):
- """Defines a Gree select entity."""
-
- entity_description: GreeSelectEntityDescription
-
- def __init__(self, hass: HomeAssistant, entry, description: GreeSelectEntityDescription) -> None:
- super().__init__(hass, entry, description)
- self._hass = hass
- # Initialize with no external sensor configured
- self._device._external_temperature_sensor = None
- # Set up options dynamically
- if description.options_fn:
- self._attr_options = description.options_fn(hass)
- else:
- self._attr_options = description.options or ["None"]
-
- async def async_added_to_hass(self) -> None:
- """Restore state when entity is added to hass."""
- await super().async_added_to_hass()
-
- # Refresh options when entity is added
- if self.entity_description.options_fn:
- self._attr_options = self.entity_description.options_fn(self._hass)
-
- # Restore the last selected state if available
- if self.entity_description.restore_state:
- restored = await self.async_get_last_state()
- if restored and self.entity_description.set_fn:
- self.entity_description.set_fn(self._device, restored.state)
- _LOGGER.debug("Restored %s state: %s", self.entity_id, restored.state)
-
- @property
- def current_option(self) -> str:
- """Return the current selected option."""
- if self.entity_description.value_fn:
- value = self.entity_description.value_fn(self._device)
- return value or "None"
- return "None"
-
- async def async_select_option(self, option: str) -> None:
- """Select an option."""
- if option not in self._attr_options:
- _LOGGER.error("Option %s not available in %s", option, self._attr_options)
- return
-
- if self.entity_description.set_fn:
- self.entity_description.set_fn(self._device, option)
- self.async_write_ha_state()
- _LOGGER.info("Selected %s: %s", self.entity_description.property_key, option)
-
- async def async_update(self) -> None:
- """Update the entity."""
- # Refresh available temperature sensors periodically
- if self.entity_description.options_fn:
- new_options = self.entity_description.options_fn(self._hass)
- if new_options != self._attr_options:
- self._attr_options = new_options
- _LOGGER.debug("Updated temperature sensor options: %s", self._attr_options)
-
- @property
- def available(self) -> bool:
- """Return if entity is available."""
- return True
diff --git a/custom_components/gree/sensor.py b/custom_components/gree/sensor.py
deleted file mode 100644
index b9b3d1a..0000000
--- a/custom_components/gree/sensor.py
+++ /dev/null
@@ -1,95 +0,0 @@
-"""Support for Gree sensors."""
-
-from __future__ import annotations
-
-# Standard library imports
-import logging
-from dataclasses import dataclass
-
-# Home Assistant imports
-from homeassistant.components.sensor import (
- SensorEntity,
- SensorEntityDescription,
- SensorDeviceClass,
- SensorStateClass,
-)
-from homeassistant.const import (
- PERCENTAGE,
-)
-
-
-# Local imports
-from .const import DOMAIN
-from .entity import GreeEntity, GreeEntityDescription
-
-_LOGGER = logging.getLogger(__name__)
-
-
-@dataclass
-class GreeSensorEntityDescription(GreeEntityDescription, SensorEntityDescription):
- """Describes Gree Sensor entity."""
-
- pass
-
-
-SENSORS: tuple[GreeSensorEntityDescription, ...] = (
- GreeSensorEntityDescription(
- property_key="outside_temperature",
- device_class=SensorDeviceClass.TEMPERATURE,
- state_class=SensorStateClass.MEASUREMENT,
- suggested_display_precision=0,
- value_fn=lambda device: device.outside_temperature if device._has_outside_temp_sensor else None,
- available_fn=lambda device: device.available and device._has_outside_temp_sensor,
- ),
- GreeSensorEntityDescription(
- property_key="room_humidity",
- device_class=SensorDeviceClass.HUMIDITY,
- state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=PERCENTAGE,
- suggested_display_precision=0,
- value_fn=lambda device: device.room_humidity if device._has_room_humidity_sensor else None,
- available_fn=lambda device: device.available and device._has_room_humidity_sensor,
- ),
-)
-
-
-async def async_setup_entry(hass, entry, async_add_entities):
- """Set up Gree sensors from a config entry."""
- # Get the device that was created in __init__.py
- entry_data = hass.data[DOMAIN][entry.entry_id]
- device = entry_data["device"]
-
- sensors = []
-
- for description in SENSORS:
- if description.exists_fn(description, device):
- sensors.append(GreeSensor(hass, entry, description))
- _LOGGER.debug(f"Added {description.property_key} sensor")
-
- if sensors:
- async_add_entities(sensors)
- _LOGGER.info(f"Added {len(sensors)} Gree sensors")
-
-
-class GreeSensor(GreeEntity, SensorEntity):
- """Gree sensor entity."""
-
- entity_description: GreeSensorEntityDescription
-
- def __init__(self, hass, entry, description: GreeSensorEntityDescription) -> None:
- """Initialize Gree sensor."""
- super().__init__(hass, entry, description)
-
- # Set temperature unit for temperature sensors
- if description.device_class == SensorDeviceClass.TEMPERATURE:
- self._attr_native_unit_of_measurement = self._device.temperature_unit
-
- @property
- def native_value(self):
- """Return the native value of the sensor."""
- return self.entity_description.value_fn(self._device)
-
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self.entity_description.available_fn(self._device)
diff --git a/custom_components/gree/switch.py b/custom_components/gree/switch.py
deleted file mode 100644
index e9ebc3b..0000000
--- a/custom_components/gree/switch.py
+++ /dev/null
@@ -1,246 +0,0 @@
-"""Support for Gree switches."""
-
-from __future__ import annotations
-
-# Standard library imports
-import logging
-from collections.abc import Callable
-from dataclasses import dataclass
-from typing import Any
-
-# Home Assistant imports
-from homeassistant.components.climate import HVACMode
-from homeassistant.components.switch import (
- SwitchEntity,
- SwitchEntityDescription,
-)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity import EntityCategory
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.restore_state import RestoreEntity
-
-# Local imports
-from .entity import GreeEntity, GreeEntityDescription
-
-_LOGGER = logging.getLogger(__name__)
-
-
-@dataclass
-class GreeSwitchEntityDescription(GreeEntityDescription, SwitchEntityDescription):
- """Describes Gree Switch entity."""
-
- set_fn: Callable[[object, bool], None] = None
- restore_state: bool = False
- """Whether to restore the state of the switch on startup."""
-
-
-async def _set_xfan(device, value: bool) -> None:
- await device.SyncState({"Blo": 1 if value else 0})
-
-
-async def _set_lights(device, value: bool) -> None:
- await device.SyncState({"Lig": 1 if value else 0})
-
-
-async def _set_health(device, value: bool) -> None:
- await device.SyncState({"Health": 1 if value else 0})
-
-
-async def _set_powersave(device, value: bool) -> None:
- await device.SyncState({"SvSt": 1 if value else 0})
-
-
-async def _set_eightdegheat(device, value: bool) -> None:
- await device.SyncState({"StHt": 1 if value else 0})
-
-
-async def _set_sleep(device, value: bool) -> None:
- await device.SyncState({"SwhSlp": 1 if value else 0, "SlpMod": 1 if value else 0})
-
-
-async def _set_air(device, value: bool) -> None:
- await device.SyncState({"Air": 1 if value else 0})
-
-
-async def _set_anti_direct_blow(device, value: bool) -> None:
- await device.SyncState({"AntiDirectBlow": 1 if value else 0})
-
-
-async def _set_light_sensor(device, value: bool) -> None:
- if value:
- await device.SyncState({"Lig": 1, "LigSen": 0})
- else:
- await device.SyncState({"LigSen": 1})
-
-
-async def _set_auto_xfan(device, value: bool) -> None:
- setattr(device, "_auto_xfan", value)
-
-
-async def _set_auto_light(device, value: bool) -> None:
- setattr(device, "_auto_light", value)
-
-
-async def _set_beeper(device, value: bool) -> None:
- setattr(device, "_beeper_enabled", value)
-
-
-SWITCHES: tuple[GreeSwitchEntityDescription, ...] = (
- GreeSwitchEntityDescription(
- property_key="xfan",
- icon="mdi:fan",
- value_fn=lambda device: device._acOptions.get("Blo") == 1,
- set_fn=_set_xfan,
- ),
- GreeSwitchEntityDescription(
- property_key="lights",
- icon="mdi:lightbulb",
- value_fn=lambda device: device._acOptions.get("Lig") == 1,
- set_fn=_set_lights,
- ),
- GreeSwitchEntityDescription(
- property_key="health",
- icon="mdi:shield-check",
- value_fn=lambda device: device._acOptions.get("Health") == 1,
- set_fn=_set_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,
- ),
- 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,
- ),
- 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),
- ),
- GreeSwitchEntityDescription(
- property_key="air",
- icon="mdi:air-filter",
- value_fn=lambda device: device._acOptions.get("Air") == 1,
- set_fn=_set_air,
- ),
- GreeSwitchEntityDescription(
- property_key="anti_direct_blow",
- icon="mdi:weather-windy",
- value_fn=lambda device: device._acOptions.get("AntiDirectBlow") == 1,
- set_fn=_set_anti_direct_blow,
- available_fn=lambda device: getattr(device, "_has_anti_direct_blow", False),
- ),
- GreeSwitchEntityDescription(
- property_key="light_sensor",
- icon="mdi:lightbulb-on",
- value_fn=lambda device: device._acOptions.get("LigSen") == 0, # LigSen=0 means sensor is active
- set_fn=_set_light_sensor,
- available_fn=lambda device: getattr(device, "_has_light_sensor", False),
- ),
- # These entities are not kept in the climate device
- GreeSwitchEntityDescription(
- property_key="auto_xfan",
- icon="mdi:fan-auto",
- value_fn=lambda device: getattr(device, "_auto_xfan", False),
- set_fn=_set_auto_xfan,
- restore_state=True,
- entity_category=EntityCategory.CONFIG,
- ),
- GreeSwitchEntityDescription(
- property_key="auto_light",
- icon="mdi:lightbulb-auto",
- value_fn=lambda device: getattr(device, "_auto_light", False),
- set_fn=_set_auto_light,
- restore_state=True,
- entity_category=EntityCategory.CONFIG,
- ),
- GreeSwitchEntityDescription(
- property_key="beeper",
- icon="mdi:volume-high",
- value_fn=lambda device: getattr(device, "_beeper_enabled", True),
- set_fn=_set_beeper,
- restore_state=True,
- ),
-)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
-) -> None:
- """Set up Gree switch based on a config entry."""
- async_add_entities(GreeSwitchEntity(hass, entry, description) for description in SWITCHES)
-
-
-class GreeSwitchEntity(GreeEntity, SwitchEntity, RestoreEntity):
- """Defines a Gree Switch entity."""
-
- entity_description: GreeSwitchEntityDescription
-
- def __init__(
- self,
- hass,
- entry,
- description: GreeSwitchEntityDescription,
- ) -> None:
- super().__init__(hass, entry, description)
- self._attr_is_on = bool(self.native_value)
- self._restored = False
-
- async def async_added_to_hass(self):
- await super().async_added_to_hass()
- # Restore state if applicable
- if self.entity_description.restore_state:
- last_state = await self.async_get_last_state()
- if last_state is not None:
- value = last_state.state == "on"
- await self.entity_description.set_fn(self._device, value)
- self._attr_is_on = value
- self._restored = True
-
- @property
- def native_value(self):
- if self.entity_description.restore_state:
- return getattr(self, "_attr_is_on", False)
- return super().native_value
-
- @property
- def is_on(self) -> bool:
- return bool(self.native_value)
-
- async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn on the switch."""
- if not self.available:
- raise HomeAssistantError("Entity unavailable")
-
- if self.entity_description.set_fn:
- await self.entity_description.set_fn(self._device, True)
-
- if self.entity_description.restore_state:
- self._attr_is_on = True
- self.async_write_ha_state()
-
- async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn off the switch."""
- if not self.available:
- raise HomeAssistantError("Entity unavailable")
-
- if self.entity_description.set_fn:
- await self.entity_description.set_fn(self._device, False)
-
- if self.entity_description.restore_state:
- self._attr_is_on = False
- self.async_write_ha_state()
diff --git a/custom_components/gree/translations/en.json b/custom_components/gree/translations/en.json
deleted file mode 100644
index c980ec4..0000000
--- a/custom_components/gree/translations/en.json
+++ /dev/null
@@ -1,254 +0,0 @@
-{
- "config": {
- "error": {
- "cannot_connect": "Unable to connect to the device. Please check the network connection and try again."
- },
- "abort": {
- "already_configured": "A device with this MAC address is already configured."
- },
- "title": "Gree Climate",
- "description": "Configure your Gree air conditioner",
- "step": {
- "user": {
- "title": "Gree Climate Setup",
- "description": "Choose how to add your Gree air conditioner",
- "data": {
- "discovery": "Setup Method"
- }
- },
- "discovery": {
- "title": "Discovered Devices",
- "description": "Found {devices_found} Gree device(s). Select one to add or choose manual setup.",
- "data": {
- "device": "Device"
- }
- },
- "detect_encryption": {
- "title": "Configure Device",
- "description": "Device connection successful. Enter a name for this device.",
- "data": {
- "name": "Device Name"
- }
- },
- "manual": {
- "title": "Manual Setup",
- "description": "Enter the details for your Gree air conditioner",
- "data": {
- "name": "Name",
- "host": "IP Address",
- "port": "Port",
- "mac": "MAC Address",
- "encryption_key": "Encryption Key",
- "uid": "UID",
- "encryption_version": "Encryption Version"
- }
- }
- },
- "data": {
- "name": "Name",
- "host": "IP Address",
- "port": "Port",
- "mac": "MAC Address",
- "hvac_modes" : "HVAC Modes",
- "fan_modes" : "Fan Modes",
- "swing_modes" : "Vertical Swing Modes",
- "swing_horizontal_modes" : "Horizontal Swing Modes",
- "encryption_key": "Encryption Key",
- "uid": "UID",
- "encryption_version": "Encryption Version",
- "disable_available_check": "Disable Available Check",
- "temp_sensor_offset": "Temperature Sensor Offset"
- }
- },
- "options": {
- "step": {
- "init": {
- "title": "Gree Climate Options",
- "data": {
- "hvac_modes" : "HVAC Modes",
- "fan_modes" : "Fan Modes",
- "swing_modes" : "Vertical Swing Modes",
- "swing_horizontal_modes" : "Horizontal Swing Modes",
- "disable_available_check": "Disable Available Check",
- "temp_sensor_offset": "Temperature Sensor Offset"
- }
- }
- }
- },
- "selector": {
- "discovery_method": {
- "options": {
- "discover": "Discover devices automatically",
- "manual": "Add device manually"
- }
- },
- "hvac_modes": {
- "options": {
- "auto": "Auto",
- "cool": "Cool",
- "dry": "Dry",
- "fan_only": "Fan only",
- "heat": "Heat",
- "off": "Off"
- }
- },
- "fan_modes": {
- "options": {
- "auto": "Auto",
- "low": "Low",
- "medium_low": "Medium-Low",
- "medium": "Medium",
- "medium_high": "Medium-High",
- "high": "High",
- "turbo": "Turbo",
- "quiet": "Quiet"
- }
- },
- "swing_modes": {
- "options": {
- "default": "Default",
- "swing_full": "Swing in full range",
- "fixed_upmost": "Fixed in the upmost position",
- "fixed_middle_up": "Fixed in the middle-up position",
- "fixed_middle": "Fixed in the middle position",
- "fixed_middle_low": "Fixed in the middle-low position",
- "fixed_lowest": "Fixed in the lowest position",
- "swing_downmost": "Swing in the downmost region",
- "swing_middle_low": "Swing in the middle-low region",
- "swing_middle": "Swing in the middle region",
- "swing_middle_up": "Swing in the middle-up region",
- "swing_upmost": "Swing in the upmost region"
- }
- },
- "swing_horizontal_modes": {
- "options": {
- "default": "Default",
- "swing_full": "Full swing",
- "fixed_leftmost": "Fixed in the leftmost position",
- "fixed_middle_left": "Fixed in the middle-left position",
- "fixed_middle": "Fixed in the middle position",
- "fixed_middle_right": "Fixed in the middle-right position",
- "fixed_rightmost": "Fixed in the rightmost position"
- }
- }
- },
- "entity": {
- "climate": {
- "gree": {
- "state_attributes": {
- "fan_mode": {
- "state": {
- "auto": "Auto",
- "low": "Low",
- "medium_low": "Medium-Low",
- "medium": "Medium",
- "medium_high": "Medium-High",
- "high": "High",
- "turbo": "Turbo",
- "quiet": "Quiet"
- }
- },
- "swing_mode": {
- "state": {
- "default": "Default",
- "swing_full": "Swing in full range",
- "fixed_upmost": "Fixed in the upmost position",
- "fixed_middle_up": "Fixed in the middle-up position",
- "fixed_middle": "Fixed in the middle position",
- "fixed_middle_low": "Fixed in the middle-low position",
- "fixed_lowest": "Fixed in the lowest position",
- "swing_downmost": "Swing in the downmost region",
- "swing_middle_low": "Swing in the middle-low region",
- "swing_middle": "Swing in the middle region",
- "swing_middle_up": "Swing in the middle-up region",
- "swing_upmost": "Swing in the upmost region"
- }
- },
- "swing_horizontal_mode": {
- "state": {
- "default": "Default",
- "swing_full": "Swing in full range",
- "fixed_leftmost": "Fixed in the leftmost position",
- "fixed_middle_left": "Fixed in the middle-left position",
- "fixed_middle": "Fixed in the middle position",
- "fixed_middle_right": "Fixed in the middle-right position",
- "fixed_rightmost": "Fixed in the rightmost position"
- }
- }
- }
- }
- },
- "number": {
- "target_temp_step": {
- "name": "Temperature Step",
- "description": "Sets the increment step for adjusting the target temperature."
- }
- },
- "select": {
- "external_temperature_sensor": {
- "name": "External Temperature Sensor",
- "description": "Select a temperature sensor entity to use instead of the built-in AC sensor. Choose 'None' to use the built-in sensor."
- }
- },
- "sensor": {
- "outside_temperature": {
- "name": "Outside Temperature",
- "description": "Shows the outside temperature measured by the air conditioner's external sensor."
- },
- "room_humidity": {
- "name": "Room Humidity",
- "description": "Shows the room humidity level measured by the air conditioner's internal sensor."
- }
- },
- "switch": {
- "xfan": {
- "name": "X-Fan",
- "description": "Enables or disables the X-Fan mode for extra drying when turning off."
- },
- "lights": {
- "name": "Lights",
- "description": "Controls the display lights on the air conditioner unit."
- },
- "health": {
- "name": "Health",
- "description": "Enables or disables the Health mode for air ionization and purification."
- },
- "powersave": {
- "name": "Power Save",
- "description": "Enables or disables the power saving mode for energy efficiency. Only available in cooling mode."
- },
- "eightdegheat": {
- "name": "8°C Heat",
- "description": "Enables or disables the 8°C heating mode for frost protection. Only available in heating mode."
- },
- "sleep": {
- "name": "Sleep",
- "description": "Enables or disables the sleep mode for comfortable overnight operation. Only available in cooling or heating mode."
- },
- "air": {
- "name": "Air",
- "description": "Enables or disables the fresh air circulation mode."
- },
- "auto_xfan": {
- "name": "Auto X-Fan",
- "description": "Automatically controls the X-Fan mode based on HVAC operations. When enabled, X-Fan will automatically turn on in cooling and dry modes."
- },
- "auto_light": {
- "name": "Auto Light",
- "description": "Automatically controls the display lights based on HVAC operations. When enabled, lights will turn on/off with the AC unit."
- },
- "anti_direct_blow": {
- "name": "Anti Direct Blow",
- "description": "Prevents direct air flow from blowing on people by adjusting the air deflector position."
- },
- "light_sensor": {
- "name": "Light Sensor",
- "description": "Enables or disables light sensor for automatic brightness. Requires lights to be enabled."
- },
- "beeper": {
- "name": "Beeper",
- "description": "Controls the beeper sounds from the air conditioner unit. When enabled, the unit will make sounds for button presses and status changes."
- }
- }
- }
-}
diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py
new file mode 100755
index 0000000..9e8edd1
--- /dev/null
+++ b/custom_components/gree_custom/__init__.py
@@ -0,0 +1,209 @@
+"""Gree climate integration init."""
+
+from __future__ import annotations
+
+# Standard library imports
+import logging
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_MAC,
+ CONF_PORT,
+ CONF_SCAN_INTERVAL,
+ CONF_TIMEOUT,
+ Platform,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.typing import Any, ConfigType
+
+from .aiogree.device import GreeDevice
+from .aiogree.errors import GreeBindingError, GreeConnectionError
+
+# Local imports
+from .const import (
+ CONF_ADVANCED,
+ CONF_DEV_NAME,
+ CONF_DEVICES,
+ CONF_ENCRYPTION_KEY,
+ CONF_ENCRYPTION_VERSION,
+ CONF_MAX_ONLINE_ATTEMPTS,
+ CONF_UID,
+ DEFAULT_CONNECTION_MAX_ATTEMPTS,
+ DEFAULT_CONNECTION_TIMEOUT,
+ DEFAULT_DEVICE_PORT,
+ DEFAULT_DEVICE_UID,
+ DEFAULT_ENCRYPTION_VERSION,
+ DEFAULT_SCAN_INTERVAL,
+ DOMAIN,
+)
+
+# Home Assistant imports
+from .coordinator import GreeConfigEntry, GreeCoordinator
+from .helpers import try_find_new_ip
+
+PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.CLIMATE,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SWITCH,
+]
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Gree component from yaml."""
+ if DOMAIN not in config:
+ return True
+
+ for climate_config in config[DOMAIN]:
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": "import"},
+ data=climate_config,
+ )
+ )
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool:
+ """Set up Gree from a config entry."""
+
+ _LOGGER.info(
+ "Setup entry '%s': %s at %s",
+ entry.entry_id,
+ entry.data[CONF_MAC],
+ entry.data[CONF_HOST],
+ )
+ _LOGGER.debug(
+ "Setup entry '%s': %s\ndata=%s",
+ entry.entry_id,
+ entry,
+ async_redact_data(entry.data, ["encryption_key"]),
+ )
+
+ conf = entry.data
+ if (
+ conf is None
+ or conf[CONF_MAC] is None
+ or conf[CONF_HOST] is None
+ or conf[CONF_ADVANCED] is None
+ ):
+ _LOGGER.error("Bad config entry, this should not happen")
+ return False
+
+ coordinators: dict[str, GreeCoordinator] = {}
+ for d in conf.get(CONF_DEVICES, []):
+ mac = str(d.get(CONF_MAC, "")) + "@" + conf.get(CONF_MAC)
+ device = GreeDevice(
+ name=d.get(CONF_DEV_NAME, "Gree HVAC"),
+ ip_addr=conf.get(CONF_HOST),
+ mac_addr=mac,
+ port=conf[CONF_ADVANCED].get(CONF_PORT, DEFAULT_DEVICE_PORT),
+ encryption_key=conf[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""),
+ encryption_version=conf[CONF_ADVANCED].get(
+ CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION
+ ),
+ uid=conf[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID),
+ max_connection_attempts=conf[CONF_ADVANCED].get(
+ CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS
+ ),
+ timeout=conf[CONF_ADVANCED].get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT),
+ )
+ try:
+ _LOGGER.debug(
+ "Setup entry '%s': Configuring Gree Device (%s, %s)",
+ entry.entry_id,
+ mac,
+ conf.get(CONF_HOST),
+ )
+
+ try:
+ await device.bind_device()
+ except GreeConnectionError as err_inner:
+ if not await try_find_new_ip(hass, device, entry):
+ raise ConfigEntryNotReady from err_inner
+ await device.bind_device()
+
+ coordinators[device.mac_address] = GreeCoordinator(
+ hass, entry, device, d.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
+ )
+ await coordinators[device.mac_address].async_config_entry_first_refresh()
+
+ _LOGGER.debug("Setup entry '%s': Bound to device %s", entry.entry_id, mac)
+
+ except TimeoutError as err:
+ _LOGGER.exception(
+ "Setup entry '%s': Connection to %s timed out", entry.entry_id, mac
+ )
+ raise ConfigEntryNotReady from err
+
+ except GreeBindingError as err:
+ _LOGGER.exception(
+ "Setup entry '%s': Failed to bind to device %s", entry.entry_id, mac
+ )
+ raise ConfigEntryNotReady from err
+
+ entry.runtime_data = coordinators
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_remove_config_entry_device(
+ hass: HomeAssistant, config_entry: GreeConfigEntry, device_entry: dr.DeviceEntry
+) -> bool:
+ """Remove a device from a config entry."""
+
+ # Find MAC address for this device (from identifiers)
+ mac: str | None = next(
+ (
+ identifier
+ for domain, identifier in device_entry.identifiers
+ if domain == DOMAIN
+ ),
+ None,
+ )
+
+ if mac is None:
+ return False
+
+ runtime_data: GreeCoordinator | None = config_entry.runtime_data.pop(mac, None)
+
+ if not runtime_data:
+ return False
+
+ await runtime_data.async_shutdown()
+
+ data: dict[str, Any] = dict(config_entry.data)
+ device_configs: list[dict] = data.get(CONF_DEVICES, [])
+ new_device_configs = [d for d in device_configs if d.get(CONF_MAC) != mac]
+
+ if len(new_device_configs) == len(device_configs):
+ # Nothing to remove
+ return False
+
+ data[CONF_DEVICES] = new_device_configs
+
+ device_registry = dr.async_get(hass)
+ device_registry.async_remove_device(device_entry.id)
+
+ if new_device_configs:
+ # There are still other devices, update the entry
+ await hass.config_entries.async_update_entry(config_entry, data=data)
+ else:
+ # No other devices, remove the entry
+ await hass.config_entries.async_remove(config_entry.entry_id)
+
+ return True
diff --git a/custom_components/gree_custom/aiogree/__init__.py b/custom_components/gree_custom/aiogree/__init__.py
new file mode 100644
index 0000000..0a6b296
--- /dev/null
+++ b/custom_components/gree_custom/aiogree/__init__.py
@@ -0,0 +1 @@
+"""aiogree provides an interface to comunicate with a Gree device."""
diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py
new file mode 100644
index 0000000..04c6de8
--- /dev/null
+++ b/custom_components/gree_custom/aiogree/api.py
@@ -0,0 +1,663 @@
+"""Contains the API to interface with the Gree device."""
+
+from enum import Enum, IntEnum, unique
+import json
+import logging
+import re
+from typing import Any
+
+from attr import dataclass
+
+from .cipher import CipherBase, EncryptionVersion, get_cipher
+from .const import DEFAULT_DEVICE_PORT
+from .errors import GreeBindingError, GreeConnectionError, GreeError, GreeProtocolError
+from .transport import GreeTransport, async_udp_broadcast_request
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class GreeProp(Enum):
+ """Enumeration of Gree device properties."""
+
+ # HVAC CONTROLS
+ # power state of the device
+ POWER = "Pow"
+ # mode of operation
+ OP_MODE = "Mod"
+ # fan speed mode
+ FAN_SPEED = "WdSpd"
+ # target temperature
+ TARGET_TEMPERATURE = "SetTem"
+ # used to distinguish between Fahrenheit values
+ TARGET_TEMPERATURE_BIT = "TemRec"
+ # defines the unit of temperature for the target temperature
+ TARGET_TEMPERATURE_UNIT = "TemUn"
+ # the swing mode of the horizontal air blades (available on limited number of devices)
+ SWING_HORIZONTAL = "SwingLfRig"
+ # the swing mode of the vertical air blades
+ SWING_VERTICAL = "SwUpDn"
+ # Quiet mode which slows down the fan to its most quiet speed. Not available in Dry and Fan mode.
+ FEAT_QUIET_MODE = "Quiet"
+ # Turbo mode sets fan speed to the maximum. Fan speed cannot be changed while active and only available in Dry and Cool mode
+ FEAT_TURBO_MODE = "Tur"
+
+ # OPTIONAL FEATURES/MODES
+ # controls the state of the fresh air valve (not available on all units)
+ FEAT_FRESH_AIR = "Air"
+ # "Blow" or "X-Fan", this function keeps the fan running for a while after shutting down. Only usable in Dry and Cool mode
+ FEAT_XFAN = "Blo"
+ # controls Health ("Cold plasma") mode, only for devices equipped with "anion generator", which absorbs dust and kills bacteria
+ FEAT_HEALTH = "Health"
+ # sleep mode, which gradually changes the temperature in Cool, Heat and Dry mode
+ FEAT_SLEEP_MODE_SWING = "SwhSlp"
+ FEAT_SLEEP_MODE = "SlpMod"
+ # turns all indicators and the display on the unit on or off
+ FEAT_LIGHT = "Lig"
+ # Anti Freeze maintain the room temperature steadily at 8°C and prevent the room from freezing by heating operation when nobody is at home for long in severe winter
+ FEAT_SMART_HEAT_8C = "StHt"
+ # energy saving mode
+ FEAT_ENERGY_SAVING = "SvSt"
+ # prevents the wind from blowing directly on people
+ FEAT_ANTI_DIRECT_BLOW = "AntiDirectBlow"
+ # use light sensor for unit display
+ FEAT_SENSOR_LIGHT = "LigSen"
+
+ # SENSORS
+ # indoor temperature sensor, used to read the current room temperature, if available
+ SENSOR_TEMPERATURE = "TemSen"
+ # outside temperature sensor, used to read the current outdooors temperature, if available
+ SENSOR_OUTSIDE_TEMPERATURE = "OutEnvTem"
+ # indoor humidity sensor, used to read the current room humidity, if available
+ SENSOR_HUMIDITY = "DwatSen"
+
+ # OTHER
+ _UNKNOWN_HEAT_COOL_TYPE = "HeatCoolType"
+ # If set to 0 the unit will beep on every command
+ BEEPER = "Buzzer_ON_OFF"
+ # If set to 1 the unit will beep on every command (available on newer firmwares)
+ BEEPER_NEW = "BuzzerCtrl"
+
+ # EXPERIMENTAL
+ # error display. 0 if no error, otherwise error
+ FAULT = "FaultDisplay"
+ # MODEL = "ModelType"
+
+
+@unique
+class TemperatureUnits(IntEnum):
+ """Enumeration of temperature units."""
+
+ C = 0
+ F = 1
+
+
+@unique
+class OperationMode(IntEnum):
+ """Enumeration of HVAC modes."""
+
+ auto = 0
+ cool = 1
+ dry = 2
+ fan = 3
+ heat = 4
+
+
+@unique
+class FanSpeed(IntEnum):
+ """Enumeration of fan speeds."""
+
+ auto = 0
+ low = 1
+ medium_low = 2
+ medium = 3
+ medium_high = 4
+ high = 5
+
+
+@unique
+class HorizontalSwingMode(IntEnum):
+ """Enumeration of horizontal swing modes."""
+
+ default = 0
+ full_swing = 1
+ left = 2
+ left_center = 3
+ center = 4
+ right_center = 5
+ right = 6
+
+
+@unique
+class VerticalSwingMode(IntEnum):
+ """Enumeration of vertical swing modes."""
+
+ default = 0
+ full_swing = 1
+ fixed_upper = 2
+ fixed_upper_middle = 3
+ fixed_middle = 4
+ fixed_lower_middle = 5
+ fixed_lower = 6
+ swing_upper = 7
+ swing_upper_middle = 8
+ swing_middle = 9
+ swing_lower_middle = 10
+ swing_lower = 11
+
+
+class GreeCommand(IntEnum):
+ """Enumeration of Gree commands."""
+
+ STATUS = 0
+ BIND = 1
+
+
+@dataclass
+class GreeDiscoveredDevice:
+ """Device discovered data."""
+
+ name: str
+ host: str
+ mac: str
+ port: int
+ brand: str
+ model: str
+ uid: int
+ subdevices: int
+
+
+propkey_to_enum = {prop.value: prop for prop in GreeProp}
+
+
+async def get_result_pack(
+ json_data: dict, cipher: CipherBase, transport: GreeTransport
+) -> dict:
+ """Get the result pack from the device (async)."""
+
+ try:
+ recv_json = await transport.request_json(json_data)
+ data = get_gree_response_data(recv_json, cipher)
+ except GreeConnectionError:
+ raise
+ except json.JSONDecodeError as err:
+ raise GreeProtocolError("Invalid JSON response from device") from err
+ except Exception as err:
+ raise GreeProtocolError("Error in device response") from err
+
+ pack = data.get("pack", None)
+
+ if pack is None:
+ raise GreeProtocolError("Device response missing 'pack' field")
+
+ # Do not modify the original data
+ redacted = data.copy()
+ if "key" in redacted["pack"] and redacted["pack"]["key"]:
+ redacted["pack"] = redacted["pack"].copy()
+ redacted["pack"]["key"] = str(redacted["pack"]["key"])[:5] + "[redacted]"
+
+ _LOGGER.debug("Got data from %s: %s", transport.ip_addr, redacted)
+
+ return pack
+
+
+def get_gree_response_data(
+ recv_json: dict,
+ cipher: CipherBase,
+) -> dict:
+ """Decodes a response from a gree device."""
+
+ encoded_pack = recv_json.get("pack")
+ tag = recv_json.get("tag")
+
+ if encoded_pack:
+ decrypted_pack = cipher.decrypt(encoded_pack, tag)
+ # Replace encrypted pack with decrypted data
+ recv_json["pack"] = json.loads(decrypted_pack)
+
+ return recv_json
+
+
+def gree_encrypt_pack(
+ pack: dict,
+ cipher: CipherBase,
+) -> tuple[str, str | None]:
+ """Create an encrypted pack to send to the device."""
+
+ if cipher is None:
+ raise GreeError("Cipher must not be None")
+
+ encrypted_data, tag = cipher.encrypt(json.dumps(pack))
+
+ return (encrypted_data, tag)
+
+
+def gree_create_bind_pack(mac_addr: str, uid: int, cipher: CipherBase) -> dict:
+ """Create a bind pack to send to the device."""
+
+ pack: dict = {}
+
+ if cipher.version == EncryptionVersion.V1:
+ pack = {"mac": mac_addr, "t": "bind", "uid": uid}
+ elif cipher.version == EncryptionVersion.V2:
+ pack = {"cid": mac_addr, "mac": mac_addr, "t": "bind", "uid": uid}
+
+ _LOGGER.debug("Bind Pack: %s", pack)
+ return pack
+
+
+def gree_create_sub_bind_pack(mac_addr: str) -> dict:
+ """Create a bind pack to send to the device."""
+
+ pack: dict = {"mac": mac_addr, "i": 1}
+
+ _LOGGER.debug("Sub Bind Pack: %s", pack)
+ return pack
+
+
+def gree_create_status_pack(mac_addr: str, props: list[str]) -> dict:
+ """Create a status pack to send to the device."""
+
+ pack: dict = {"cols": props, "mac": mac_addr, "t": "status"}
+
+ _LOGGER.debug("Status Pack: %s", pack)
+ return pack
+
+
+def gree_create_set_pack(mac_addr: str, props: dict[GreeProp, int]) -> dict:
+ """Create a set pack to send to the device."""
+
+ pack: dict = {
+ "opt": [prop.value for prop in props],
+ "p": list(props.values()),
+ "t": "cmd",
+ "sub": mac_addr,
+ }
+
+ _LOGGER.debug("Status Pack: %s", pack)
+ return pack
+
+
+def gree_create_payload(
+ pack: str,
+ payload_type: str,
+ i_command: GreeCommand,
+ mac_addr: str,
+ uid: int,
+ tag: str | None,
+) -> dict:
+ """Create the full payload to send to the device."""
+
+ payload: dict[str, Any] = {
+ "cid": "app",
+ "i": i_command.value,
+ "pack": pack,
+ "t": payload_type,
+ "tcid": mac_addr,
+ "uid": uid,
+ }
+
+ if tag is not None:
+ payload["tag"] = tag
+
+ _LOGGER.debug("Payload: %s", payload)
+ return payload
+
+
+async def gree_try_bind(
+ mac_addr: str,
+ uid: int,
+ version: EncryptionVersion | None,
+ key: str | None,
+ transport: GreeTransport,
+) -> tuple[str, EncryptionVersion]:
+ """Perform bind request to the device and return the valid version and key (async).
+
+ Performs the bind with the provided key or version. Falls back to generic keys.
+ If the provided key or version do not match the device, the function will return the correct device key and version.
+ """
+
+ ret_key: str = ""
+ error: Exception | None = Exception("Binding failed")
+
+ has_version = version is not None
+ has_key = key is not None and bool(key.strip())
+
+ ciphers: list[CipherBase] = []
+
+ if has_version:
+ ciphers.append(get_cipher(version))
+ if has_key:
+ _LOGGER.info(
+ "Trying to perform binding. Prefer provided version (%s) and key (%s)",
+ version,
+ key[:5] + "[redacted]",
+ )
+ else:
+ _LOGGER.info(
+ "Trying to perform binding. Prefer provided version (%s) and generic key ",
+ version,
+ )
+ elif has_key:
+ _LOGGER.info(
+ "Trying to perform binding. Prefering provided key (%s)",
+ key[:5] + "[redacted]",
+ )
+ else:
+ _LOGGER.info(
+ "Trying to perform binding. Testing both versions with generic keys"
+ )
+
+ # Fallback to both default ciphers
+ ciphers.append(get_cipher(EncryptionVersion.V1))
+ ciphers.append(get_cipher(EncryptionVersion.V2))
+
+ for cipher in ciphers:
+ _LOGGER.debug(
+ "Requesting bind to device with encryption key v%d", cipher.version
+ )
+
+ pack = gree_create_bind_pack(mac_addr, uid, cipher)
+ encrypted_pack, tag = gree_encrypt_pack(pack, cipher)
+ json_payload = gree_create_payload(
+ encrypted_pack, "pack", GreeCommand.BIND, mac_addr, uid, tag
+ )
+
+ try:
+ result = await get_result_pack(json_payload, cipher, transport)
+
+ except Exception as err:
+ _LOGGER.exception(
+ "Error in bind request using encryption key with version %d",
+ cipher.version,
+ )
+
+ # In case we are testing multiple ciphers, don't raise,
+ # just save the error so we can continue testing the other ciphers
+ error = err
+ continue
+
+ else:
+ ret_key = result.get("key", "")
+
+ if ret_key.strip() == "":
+ raise GreeBindingError(
+ "Binding failed: Received empty encryption key from device"
+ )
+
+ if has_key and ret_key != key:
+ _LOGGER.warning(
+ "Binding successful with different key. Using retrieved key. Expected '%s', got '%s'",
+ key[:5] + "[redacted]",
+ ret_key[:5] + "[redacted]",
+ )
+
+ if has_version and cipher.version != version:
+ _LOGGER.warning(
+ "Binding successful with different version. Using retrieved version. Expected '%s', got '%s'",
+ version,
+ cipher.version,
+ )
+
+ _LOGGER.info("Bind request with version %d was successful", cipher.version)
+
+ _LOGGER.debug("Fetched encryption key: %s[redacted]", ret_key[:5])
+
+ return ret_key, cipher.version
+
+ raise GreeBindingError(
+ f"Binding failed: Unable to obtain valid encryption version and key pair for {mac_addr} at {transport.ip_addr}"
+ ) from error
+
+
+async def gree_get_status(
+ mac_addr_controller: str,
+ mac_addr: str,
+ uid: int,
+ props: list[GreeProp],
+ cipher: CipherBase,
+ transport: GreeTransport,
+) -> tuple[dict[GreeProp, int], list[GreeProp]]:
+ """Get the status of the device by sending a status request to the device (async). Also returns the props not present."""
+
+ _LOGGER.debug("Trying to get device status")
+
+ status_values_raw: dict[GreeProp, int | None] = {}
+
+ pack = gree_create_status_pack(mac_addr, [prop.value for prop in props])
+ encrypted_pack, tag = gree_encrypt_pack(pack, cipher)
+ json_payload = gree_create_payload(
+ encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag
+ )
+
+ try:
+ result = await get_result_pack(json_payload, cipher, transport)
+
+ except GreeConnectionError, GreeProtocolError:
+ raise
+
+ except Exception as err:
+ raise GreeProtocolError("Error getting device status") from err
+
+ if result["cols"] is None or result["dat"] is None:
+ raise GreeProtocolError("No data received while getting device status")
+
+ cols = [propkey_to_enum[c] for c in result["cols"] if c in propkey_to_enum]
+ values = [int(x) if x != "" else None for x in result["dat"]]
+ status_values_raw = dict(zip(cols, values, strict=True))
+
+ status_values = {k: v for k, v in status_values_raw.items() if v is not None}
+ _LOGGER.debug("Device status values: %s", status_values)
+
+ return status_values, [p for p in props if p not in status_values]
+
+
+async def gree_set_status(
+ mac_addr_controller: str,
+ mac_addr: str,
+ uid: int,
+ props: dict[GreeProp, int],
+ cipher: CipherBase,
+ transport: GreeTransport,
+) -> dict[GreeProp, int]:
+ """Set the status of the device by sending a status request to the device (async)."""
+
+ _LOGGER.debug("Trying to set device status")
+
+ pack = gree_create_set_pack(mac_addr, props)
+ encrypted_pack, tag = gree_encrypt_pack(pack, cipher)
+ json_payload = gree_create_payload(
+ encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag
+ )
+
+ try:
+ result = await get_result_pack(json_payload, cipher, transport)
+
+ except GreeConnectionError, GreeProtocolError:
+ raise
+
+ except Exception as err:
+ raise GreeProtocolError("Error getting device status") from err
+
+ if result["r"] is None or result["r"] != 200:
+ raise GreeProtocolError(
+ f"Error setting device status, response code: {result['r']}"
+ )
+
+ options_set = [propkey_to_enum[c] for c in result["opt"] if c in propkey_to_enum]
+ if options_set is None or len(options_set) == 0:
+ raise GreeProtocolError("No options were set, something went wrong")
+
+ values_set_1 = result.get("p", None)
+ values_set_2 = result.get("val", None) # this one is optional
+
+ if values_set_1 is None:
+ raise GreeProtocolError("No values were set, something went wrong")
+ values_set_1 = list(map(int, values_set_1))
+
+ if values_set_2 is not None:
+ values_set_2 = list(map(int, values_set_2))
+ if len(values_set_1) != len(values_set_2):
+ raise GreeProtocolError(
+ f"Wrong option values received: {values_set_1} {values_set_2}"
+ )
+
+ if len(values_set_1) != len(options_set):
+ raise GreeProtocolError(
+ f"Options and values set mismatch {options_set} {values_set_1}"
+ )
+
+ updated_props = dict(zip(options_set, values_set_1, strict=True))
+ if updated_props != props:
+ _LOGGER.warning("Expected updated props %s but got %s", props, updated_props)
+
+ return updated_props
+
+
+async def gree_get_device_info(
+ transport: GreeTransport, cipher: CipherBase | None = None
+) -> dict[str, str | None]:
+ """Tries to retrive the device info."""
+
+ data: dict = await get_result_pack(
+ {"t": "scan"},
+ cipher or get_cipher(EncryptionVersion.V1),
+ transport,
+ )
+
+ _LOGGER.debug("Got device info: %s", data)
+
+ info: dict[str, str | None] = {}
+ info["raw"] = data
+ info["firmware_version"], info["firmware_code"] = extract_version(data)
+ info["mac"] = data.get("mac", "")
+ info["subdevices_count"] = data.get("subCnt", 0)
+ return info
+
+
+def extract_version(info: dict) -> tuple[str | None, str | None]:
+ """Finds the firmware info."""
+ hid = info.get("hid", "")
+ ver_match = re.search(r"V([\d.]+)\.bin", hid)
+ if ver_match:
+ ver = ver_match.group(1) # version from hid
+ else:
+ ver = info.get("ver")
+ ver = ver.lstrip("V") if ver else None # clean ver or None
+
+ id_match = re.match(r"(\d+)", hid) # leading digits
+ device_id = id_match.group(1) if id_match else None
+ return ver, device_id
+
+
+async def discover_gree_devices(
+ broadcast_addresses: list[str], timeout: int
+) -> list[GreeDiscoveredDevice]:
+ """Discovers gree devices in the network."""
+
+ discovered_devices: list[GreeDiscoveredDevice] = []
+
+ responses = await async_udp_broadcast_request(
+ broadcast_addresses, DEFAULT_DEVICE_PORT, json.dumps({"t": "scan"}), timeout
+ )
+
+ for address, response in responses.items():
+ data = get_gree_response_data(
+ response,
+ get_cipher(EncryptionVersion.V1),
+ )
+ if data is not None:
+ pack = data.get("pack")
+ if pack is not None:
+ if pack.get("t") == "dev":
+ mac_addr = pack.get("mac", "")
+ if not mac_addr:
+ _LOGGER.debug("No MAC address in response from %s", address)
+ continue
+
+ # Just collect basic device info for now - encryption detection happens later
+ discovered_device = GreeDiscoveredDevice(
+ name=pack.get("name", "") or f"Gree {mac_addr[-4:]}",
+ host=address,
+ mac=mac_addr,
+ port=DEFAULT_DEVICE_PORT,
+ brand=pack.get("brand", "gree"),
+ model=pack.get("brand", "gree"),
+ uid=data.get("uid", 0),
+ subdevices=pack.get("subCnt", 0),
+ )
+
+ discovered_devices.append(discovered_device)
+ _LOGGER.debug("Discovered device: %s", discovered_device)
+
+ # # If VRF, the mac is of the main device and we have to query it for the sub devices
+ # # Sub-devices will be created with a mac of sub@main
+ # # check if the device has sub-devices
+ # sub_count = pack.get("subCnt", 0)
+
+ # if sub_count > 0:
+ # # Is VRF with multiple sub devices
+ # _LOGGER.debug(
+ # "Trying to fetching sub-devices for '%s' (subCount=%d)",
+ # mac_addr,
+ # sub_count,
+ # )
+ # try:
+ # discovered_sub_devices = await get_sub_devices_list(
+ # discovered_device.mac,
+ # discovered_device.host,
+ # discovered_device.uid,
+ # max_connection_attempts=2,
+ # timeout=timeout,
+ # )
+
+ # for sub_device in discovered_sub_devices:
+ # sub_mac = sub_device.get("mac", "")
+ # if sub_mac:
+ # discovered_sub_device = GreeDiscoveredDevice(
+ # name=f"{discovered_device.name or f'Gree {mac_addr[-4:]}'}@{sub_mac[:4]}",
+ # host=discovered_device.host,
+ # mac=f"{sub_mac}@{discovered_device.mac}",
+ # port=discovered_device.port,
+ # brand=discovered_device.brand,
+ # model=sub_device.get("mid", discovered_device),
+ # uid=discovered_device.uid,
+ # )
+ # discovered_devices.append(discovered_sub_device)
+ # _LOGGER.debug(
+ # "Discovered sub-device: %s",
+ # discovered_sub_device,
+ # )
+ # except Exception:
+ # _LOGGER.exception("Failed to fetch sub-devices")
+
+ return discovered_devices
+
+
+async def gree_get_sub_devices_list(
+ mac_addr: str, uid: int, cipher: CipherBase, transport: GreeTransport
+) -> list:
+ """Fetch the list of sub-devices for a Gree device."""
+ try:
+ pack = gree_create_sub_bind_pack(mac_addr)
+ encrypted_pack, tag = gree_encrypt_pack(
+ pack,
+ cipher,
+ )
+
+ json_payload = gree_create_payload(
+ encrypted_pack,
+ "subList",
+ GreeCommand.BIND,
+ mac_addr,
+ uid,
+ tag,
+ )
+
+ result = await get_result_pack(json_payload, cipher, transport)
+
+ return result.get("list", [])
+
+ except Exception as err:
+ raise GreeProtocolError(
+ f"Error fetching sub-device list for '{mac_addr}'"
+ ) from err
diff --git a/custom_components/gree_custom/aiogree/cipher.py b/custom_components/gree_custom/aiogree/cipher.py
new file mode 100644
index 0000000..2b3ca0e
--- /dev/null
+++ b/custom_components/gree_custom/aiogree/cipher.py
@@ -0,0 +1,192 @@
+"""Encapsulates device encryption."""
+
+from abc import ABC, abstractmethod
+import base64
+from enum import IntEnum, unique
+import logging
+
+from Crypto.Cipher import AES
+from Crypto.Cipher._mode_ecb import EcbMode
+from Crypto.Cipher._mode_gcm import GcmMode
+from Crypto.Util.Padding import pad, unpad
+
+from .errors import GreeError
+
+_LOGGER = logging.getLogger(__name__)
+
+# GREE PROTOCOL: Fixed parameters obtained by reverse-engineering the Gree protocol spec
+GCM_IV = b"\x54\x40\x78\x44\x49\x67\x5a\x51\x6c\x5e\x63\x13"
+GCM_ADD = b"qualcomm-test"
+GREE_GENERIC_DEVICE_KEY_ECB = "a3K8Bx%2r8Y7#xDh"
+GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<"
+
+AES_BLOCK_SIZE = 16
+
+
+@unique
+class EncryptionVersion(IntEnum):
+ """Available encryption versions for the device."""
+
+ V1 = 1
+ V2 = 2
+
+
+class CipherBase(ABC):
+ """Base class for the encryption module."""
+
+ def __init__(self, key: str) -> None:
+ """Initialize the class."""
+ self.key = key
+
+ @property
+ @abstractmethod
+ def version(self) -> EncryptionVersion:
+ """The encryption version of this cypher."""
+
+ @property
+ def key(self) -> str:
+ """The encryption key."""
+ return self._key.decode()
+
+ @key.setter
+ def key(self, value: str) -> None:
+ self._key = value.encode()
+
+ @abstractmethod
+ def encrypt(self, data: str) -> tuple[str, str | None]:
+ """Encrypts the data. Returns the encrypted data and an optional tag."""
+
+ @abstractmethod
+ def decrypt(self, data: str, tag: str | None) -> str:
+ """Decrypts the data. Optionally checks integrity if tag is provided."""
+
+
+class CipherV1(CipherBase):
+ """Implements the V1 (AES-ECB) type encryption used by Gree."""
+
+ def __init__(self, key: str | None) -> None:
+ """Initialize V1 Encryption."""
+ super().__init__(key or GREE_GENERIC_DEVICE_KEY_ECB)
+
+ def _create_cipher(self) -> EcbMode:
+ return AES.new(self._key, AES.MODE_ECB)
+
+ @property
+ def version(self) -> EncryptionVersion:
+ """The encryption version of this cypher."""
+ return EncryptionVersion.V1
+
+ def encrypt(self, data: str) -> tuple[str, str | None]:
+ """Encrypt data with V1."""
+ _LOGGER.debug("Encrypting data (V1): %s", data)
+
+ cipher = self._create_cipher()
+ padded = pad(data.encode("utf-8"), AES_BLOCK_SIZE)
+
+ encrypted = cipher.encrypt(padded)
+ encoded = base64.b64encode(encrypted).decode("utf-8")
+
+ _LOGGER.debug("Encrypted data (V1): %s", encoded)
+
+ return encoded, None
+
+ def decrypt(self, data: str, tag: str | None = None) -> str:
+ """Decrypt data with V1."""
+ _LOGGER.debug("Decrypting data (V1): %s", data)
+
+ cipher = self._create_cipher()
+
+ decoded = base64.b64decode(data)
+ decrypted = cipher.decrypt(decoded)
+
+ try:
+ plaintext = unpad(decrypted, AES_BLOCK_SIZE).decode()
+ except ValueError:
+ # GREE PROTOCOL: Fallback for some devices sending malformed padding
+ plaintext = decrypted.decode(errors="ignore")
+
+ _LOGGER.debug("Decrypted data successfully (V1)")
+
+ return _trim_json_payload(plaintext)
+
+
+class CipherV2(CipherBase):
+ """Implements the V2 (AES-GCM) type encryption used by Gree."""
+
+ def __init__(self, key: str | None) -> None:
+ """Initialize V2 Encryption."""
+ super().__init__(key or GREE_GENERIC_DEVICE_KEY_GCM)
+
+ def _create_cipher(self) -> GcmMode:
+ cipher = AES.new(self._key, AES.MODE_GCM, nonce=GCM_IV)
+ cipher.update(GCM_ADD)
+ return cipher
+
+ @property
+ def version(self) -> EncryptionVersion:
+ """The encryption version of this cypher."""
+ return EncryptionVersion.V2
+
+ def encrypt(self, data: str) -> tuple[str, str]:
+ """Encrypt data with V2 and return the data with a tag."""
+ _LOGGER.debug("Encrypting data (V2): %s", data)
+
+ cipher = self._create_cipher()
+
+ encrypted, tag = cipher.encrypt_and_digest(data.encode("utf-8"))
+
+ encoded = base64.b64encode(encrypted).decode("utf-8")
+ tag_encoded = base64.b64encode(tag).decode("utf-8")
+
+ _LOGGER.debug("Encrypted data (V2): %s, tag='%s'", encoded, tag_encoded)
+ return encoded, tag_encoded
+
+ def decrypt(self, data: str, tag: str) -> str:
+ """Decrypt data with V2 and verify the data with the tag."""
+ _LOGGER.debug("Decrypting data (V2): %s, tag=%s", data, tag)
+
+ if not tag:
+ raise GreeError("Decrypting data (V2) failed: tag is needed")
+
+ cipher = self._create_cipher()
+
+ decoded = base64.b64decode(data)
+ decoded_tag = base64.b64decode(tag)
+
+ decrypted = cipher.decrypt_and_verify(decoded, decoded_tag)
+ plaintext = decrypted.decode("utf-8")
+
+ _LOGGER.debug("Decrypted data successfully (V2)")
+ return _trim_json_payload(plaintext)
+
+
+def _trim_json_payload(data: str) -> str:
+ """Trims JSON garbage.
+
+ Some devices append garbage after JSON payload.
+ This safely trims everything after the final '}'.
+ """
+
+ end = data.rfind("}")
+
+ if end == -1:
+ raise GreeError("Malformed JSON payload without closing character")
+
+ if end + 1 < len(data):
+ _LOGGER.debug("Trimmed JSON payload garbage: %s", data[end + 1 :])
+
+ return data[: end + 1]
+
+
+def get_cipher(
+ encryption_version: EncryptionVersion, key: str | None = None
+) -> CipherBase:
+ """Get AES cipher object based on encryption version using default keys."""
+
+ match encryption_version:
+ case EncryptionVersion.V1:
+ return CipherV1(key)
+ case EncryptionVersion.V2:
+ return CipherV2(key)
+ case _:
+ raise ValueError(f"Unsupported encryption version: {encryption_version}")
diff --git a/custom_components/gree_custom/aiogree/const.py b/custom_components/gree_custom/aiogree/const.py
new file mode 100644
index 0000000..9114aa2
--- /dev/null
+++ b/custom_components/gree_custom/aiogree/const.py
@@ -0,0 +1,10 @@
+"""Constants for the aiogree."""
+
+MIN_TEMP_C = 16
+MAX_TEMP_C = 30
+
+MIN_TEMP_F = 61
+MAX_TEMP_F = 86
+
+DEFAULT_DEVICE_UID = 0
+DEFAULT_DEVICE_PORT = 7000
diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py
new file mode 100755
index 0000000..bb97ddc
--- /dev/null
+++ b/custom_components/gree_custom/aiogree/device.py
@@ -0,0 +1,769 @@
+"""Contains the API to interface with the Gree device."""
+
+import logging
+from typing import Any
+
+from .api import (
+ EncryptionVersion,
+ FanSpeed,
+ GreeDiscoveredDevice,
+ GreeProp,
+ HorizontalSwingMode,
+ OperationMode,
+ TemperatureUnits,
+ VerticalSwingMode,
+ gree_get_device_info,
+ gree_get_status,
+ gree_get_sub_devices_list,
+ gree_set_status,
+ gree_try_bind,
+)
+from .cipher import CipherBase, get_cipher
+from .const import DEFAULT_DEVICE_UID
+from .errors import GreeBindingError, GreeConnectionError, GreeError, GreeProtocolError
+from .helpers import (
+ TempOffsetResolver,
+ gree_get_target_temp_props_from_c,
+ gree_get_target_temp_props_from_f,
+ gree_get_target_temperature_c,
+ gree_get_target_temperature_f,
+)
+from .transport import GreeTransport
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class GreeDevice:
+ """Representation of a Gree device."""
+
+ def __init__(
+ self,
+ name: str,
+ ip_addr: str,
+ mac_addr: str,
+ port: int,
+ encryption_key: str,
+ encryption_version: EncryptionVersion | None = None,
+ uid: int = DEFAULT_DEVICE_UID,
+ max_connection_attempts: int = 5,
+ timeout: int = 10,
+ ) -> None:
+ """Initialize the Gree device."""
+
+ _LOGGER.info(
+ "Initialize the GREE Device API for: %s (%s:%d)",
+ mac_addr,
+ ip_addr,
+ port,
+ )
+ _LOGGER.debug(
+ "Version: %s, Key: %s[redacted]", encryption_version, encryption_key[:5]
+ )
+
+ self._name: str = name
+ self._ip_addr: str = ip_addr
+ self._port: int = port
+ self._max_connection_attempts: int = max_connection_attempts
+ self._timeout: int = timeout
+
+ # For VRF units, the mac will be in the sub_device@main_device format
+ # where the sub_device is the device we are controling and
+ # main_device is the controller for that sub_device
+ mac_addr = mac_addr.replace(":", "").replace("-", "").lower()
+
+ if "@" in mac_addr:
+ self._mac_addr, self._mac_addr_controller = mac_addr.split("@", 1)
+ else:
+ self._mac_addr = self._mac_addr_controller = mac_addr
+
+ self._transport = GreeTransport(ip_addr, port, max_connection_attempts, timeout)
+
+ self._encryption_version: EncryptionVersion | None = encryption_version
+ self._encryption_key: str = encryption_key
+ self._cipher: CipherBase | None = None
+ self._uid: int = uid
+
+ self._raw_state: dict[GreeProp, int] = {}
+ self._new_raw_state: dict[GreeProp, int] = {}
+ self._is_bound: bool = False
+ self._is_available: bool = False
+ self._uniqueid: str = self._mac_addr
+
+ self._props_to_update: list[GreeProp] = list(GreeProp)
+ # Don't poll the beeper state
+ self._props_to_update.remove(GreeProp.BEEPER)
+ self._props_to_update.remove(GreeProp.BEEPER_NEW)
+
+ self._temp_processor_indoors: TempOffsetResolver | None = None
+ self._temp_processor_outdoors: TempOffsetResolver | None = None
+ self._beeper = False
+
+ self._raw_info: dict[str, str | None] = {}
+ self._firmware_version: str | None = None
+ self._firmware_code: str | None = None
+ self._subdevicesCount: int = 0
+
+ async def bind_device(self) -> bool:
+ """Setup the device (async)."""
+
+ if self._is_bound:
+ return True
+
+ # Use fetch_device_info (targeted scan) to the device
+ # since binding only succeeds after a scan
+ try:
+ await self.fetch_device_info()
+
+ except GreeConnectionError:
+ raise
+
+ except Exception as err:
+ raise GreeBindingError(
+ "Could not fetch device info before binding"
+ ) from err
+
+ try:
+ key, version = await gree_try_bind(
+ self._mac_addr_controller,
+ self._uid,
+ self._encryption_version,
+ self._encryption_key,
+ self._transport,
+ )
+
+ except GreeBindingError:
+ raise
+ except Exception as e:
+ raise GreeBindingError(f"Failed binding to device {self._ip_addr}") from e
+
+ else:
+ self._encryption_key = key
+ self._encryption_version = version
+ _LOGGER.info(
+ "Device is bound with version %s and key %s",
+ version,
+ key[:5] + "[redacted]",
+ )
+
+ self._cipher = get_cipher(version, key)
+ self._is_available = True
+ self._is_bound = True
+
+ return True
+
+ async def fetch_device_info(self, cipher: CipherBase = None):
+ """Updates the device info fields."""
+ try:
+ self._raw_info = await gree_get_device_info(
+ self._transport, cipher or self._cipher
+ )
+ except GreeConnectionError:
+ raise
+
+ except Exception as e:
+ raise GreeProtocolError(
+ f"Failed fetching device info for {self._ip_addr}"
+ ) from e
+
+ else:
+ if self._raw_info.get("mac", "") != self._mac_addr_controller:
+ raise GreeProtocolError(
+ f"Wrong device info for {self._ip_addr}. MAC mismatch {self._raw_info.get('mac', '')} not {self._mac_addr_controller}."
+ )
+ self._firmware_version = self._raw_info.get("firmware_version")
+ self._firmware_code = self._raw_info.get("firmware_code")
+ self._subdevicesCount = int(self._raw_info.get("subdevices_count", 0) or 0)
+
+ async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]:
+ """Get the sub devices list."""
+ _LOGGER.debug("Trying to get subdevices")
+
+ if not self._is_bound:
+ await self.bind_device()
+
+ assert self._cipher is not None
+
+ if not self._subdevicesCount:
+ return []
+
+ if self._mac_addr != self._mac_addr_controller:
+ return [] # For VRF, a non main device does not have subdevices
+
+ discovered_devices: list[GreeDiscoveredDevice] = []
+
+ try:
+ subs = await gree_get_sub_devices_list(
+ self._mac_addr_controller,
+ self._uid,
+ self._cipher, # NOTE: Check if this should use the generic or the device key
+ self._transport,
+ )
+ except GreeProtocolError:
+ self._is_available = False
+ raise
+
+ except Exception as err:
+ self._is_available = False
+ raise GreeError("Error getting subdevices") from err
+
+ else:
+ for sub_device in subs:
+ sub_mac = sub_device.get("mac", "")
+ if sub_mac:
+ discovered_sub_device = GreeDiscoveredDevice(
+ name=f"{sub_device.get('name', '') or f'Gree {sub_mac[:4]}@{self.mac_address_controller[-4:]}'}",
+ host=self._ip_addr,
+ mac=sub_mac,
+ port=self._port,
+ brand=sub_device.get("brand", "Gree"),
+ model=sub_device.get("mid", "HVAC"),
+ uid=self._uid,
+ subdevices=0,
+ )
+ discovered_devices.append(discovered_sub_device)
+ _LOGGER.debug(
+ "Discovered sub-device: %s",
+ discovered_sub_device,
+ )
+
+ _LOGGER.debug("Subdevices of '%s': %s", self._mac_addr_controller, subs)
+ self._is_available = True
+
+ return discovered_devices
+
+ async def fetch_device_status(self):
+ """Get the device status (async)."""
+ _LOGGER.debug("Trying to get device status")
+
+ if not self._is_bound:
+ await self.bind_device()
+
+ assert self._cipher is not None
+
+ try:
+ state, _ = await gree_get_status(
+ self._mac_addr_controller,
+ self._mac_addr,
+ self._uid,
+ self._props_to_update,
+ self._cipher,
+ self._transport,
+ )
+ self._raw_state.update(state)
+
+ # if self._mac_addr != self._mac_addr_sub:
+ # sub_state, _ = await gree_get_status(
+ # self._ip_addr,
+ # self._mac_addr,
+ # self._mac_addr,
+ # self._port,
+ # self._uid,
+ # self._cipher,
+ # props_not_present,
+ # self._max_connection_attempts,
+ # self._timeout,
+ # )
+ # self._raw_state.update(sub_state)
+
+ self._is_available = True
+
+ except GreeConnectionError, GreeProtocolError:
+ self._is_available = False
+ raise
+
+ except Exception as err:
+ self._is_available = False
+ raise GreeError("Error getting device status") from err
+
+ self._remove_unsupported_props()
+
+ async def push_device_status(self):
+ """Send the new local device state to the device and updates local state if successfull."""
+ if not self._is_bound:
+ await self.bind_device()
+
+ assert self._cipher is not None
+
+ # If there is no change in the properties, do nothing
+ has_updated_states = any(
+ self._raw_state.get(k) != v for k, v in self._new_raw_state.items()
+ )
+ if not has_updated_states:
+ _LOGGER.debug("No changes in the properties, skipping update to device")
+ return
+
+ self._new_raw_state[GreeProp.BEEPER] = 0 if self._beeper else 1
+ self._new_raw_state[GreeProp.BEEPER_NEW] = 1 if self._beeper else 0
+
+ try:
+ self._raw_state.update(
+ await gree_set_status(
+ self._mac_addr_controller,
+ self._mac_addr,
+ self._uid,
+ self._new_raw_state,
+ self._cipher,
+ self._transport,
+ )
+ )
+ self._new_raw_state.clear()
+ self._is_available = True
+
+ except GreeConnectionError, GreeProtocolError:
+ self._is_available = False
+ raise
+
+ except Exception as err:
+ self._is_available = False
+ raise GreeError("Error setting device status") from err
+
+ def _set_device_status(self, props: dict[GreeProp, int]) -> None:
+ """Sets a new local device status. Use 'update_device_status' to update the device."""
+ self._new_raw_state.update(props)
+
+ def _bool_from_raw_state(
+ self, prop: GreeProp, default: int | None = 0
+ ) -> bool | None:
+ return self._get_prop_raw(prop, 0) != 0
+
+ def _remove_unsupported_props(self):
+ """Remove unsupported properties from the list to update."""
+
+ # Remove all unsupported properties
+ # A unsupported propery is one that the device returns
+ # with an empty string, or nothing at all
+ # If that is the case, _state_raw should not contain that property
+ # In case it still has it, we remove it here as well
+ for p in list(self._props_to_update):
+ if not self.supports_property(p):
+ self._props_to_update.remove(p)
+ self._raw_state.pop(p, None)
+ _LOGGER.debug("No longer updating property: %s", p)
+
+ # Sensors should also be invalidated if their values are not expected (=0)
+ if (
+ GreeProp.SENSOR_TEMPERATURE in self._props_to_update
+ and self._get_prop_raw(GreeProp.SENSOR_TEMPERATURE, 0) == 0
+ ):
+ self._props_to_update.remove(GreeProp.SENSOR_TEMPERATURE)
+ self._raw_state.pop(GreeProp.SENSOR_TEMPERATURE, None)
+ _LOGGER.debug(
+ "No longer updating property due to bad value: %s",
+ GreeProp.SENSOR_TEMPERATURE,
+ )
+
+ if (
+ GreeProp.SENSOR_OUTSIDE_TEMPERATURE in self._props_to_update
+ and self._get_prop_raw(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, 0) == 0
+ ):
+ self._props_to_update.remove(GreeProp.SENSOR_OUTSIDE_TEMPERATURE)
+ self._raw_state.pop(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, None)
+ _LOGGER.debug(
+ "No longer updating property due to bad value: %s",
+ GreeProp.SENSOR_OUTSIDE_TEMPERATURE,
+ )
+
+ if (
+ GreeProp.SENSOR_HUMIDITY in self._props_to_update
+ and self._get_prop_raw(GreeProp.SENSOR_HUMIDITY, 0) == 0
+ ):
+ self._props_to_update.remove(GreeProp.SENSOR_HUMIDITY)
+ self._raw_state.pop(GreeProp.SENSOR_HUMIDITY, None)
+ _LOGGER.debug(
+ "No longer updating property due to bad value: %s",
+ GreeProp.SENSOR_HUMIDITY,
+ )
+
+ def _get_prop_raw(self, prop: GreeProp, default: int | None = None) -> int | None:
+ """Get the raw value of a property. If does not exist, returns default."""
+ if prop not in self._raw_state:
+ _LOGGER.warning(
+ "Property '%s' not found in state of device '%s'", prop, self.name
+ )
+ return default
+ return self._raw_state.get(prop, default)
+
+ def log_device_info(self):
+ """Log basic device information."""
+
+ capabilities = []
+ if self.supports_property(GreeProp.SENSOR_TEMPERATURE):
+ capabilities.append("Temperature Sensor")
+ if self.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE):
+ capabilities.append("Outside Temperature Sensor")
+ if self.supports_property(GreeProp.SENSOR_HUMIDITY):
+ capabilities.append("Humidity Sensor")
+
+ _LOGGER.info(
+ "Capabilities: %s", ", ".join(capabilities) if capabilities else "None"
+ )
+
+ _LOGGER.info(
+ "Indoor Temperature: %s ºC",
+ self.indoors_temperature_c
+ if self.supports_property(GreeProp.SENSOR_TEMPERATURE)
+ else None,
+ )
+ _LOGGER.info(
+ "Outddor Temperature: %s ºC",
+ self.outdoors_temperature_c
+ if self.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE)
+ else None,
+ )
+ _LOGGER.info(
+ "Target Temperature: %s º%s",
+ self.target_temperature,
+ self.target_temperature_unit.name,
+ )
+ _LOGGER.info("Mode: %s", self.operation_mode.name)
+
+ def gather_diagnostics(self) -> dict[str, Any]:
+ """Returns diagnostic info for the device."""
+ data: dict[str, Any] = {}
+
+ info = {
+ "ip": self._ip_addr,
+ "mac": self._mac_addr,
+ "mac_controller": self._mac_addr_controller,
+ "port": self._port,
+ "timeout": self._timeout,
+ "max_connections": self._max_connection_attempts,
+ "is_bound": self._is_bound,
+ "is_available": self._is_available,
+ "beeper": self.beeper,
+ "encryption": str(self.encryption_version),
+ "key": self.encryption_key[:5] + "[redacted]",
+ }
+
+ data["info"] = info
+ data["raw_info"] = self._raw_info
+ data["state"] = {str(k): v for k, v in self._raw_state.items()}
+ data["state_unsaved"] = {str(k): v for k, v in self._new_raw_state.items()}
+
+ return data
+
+ def supports_property(self, property: GreeProp) -> bool:
+ """Returns True if the device endpoint supports the property."""
+ # We consider a property as unsupported if it is not present in the raw state list
+ # This assumes that the full state is fetched at least once before this method is called
+ return property in self._raw_state if property is not GreeProp.BEEPER else True
+
+ @property
+ def ip(self) -> str:
+ """The IP address assigned to the device."""
+ return self._ip_addr
+
+ def set_ip(self, ip_addr: str):
+ """Updates the IP the device uses for communication."""
+ self._ip_addr = ip_addr
+ self._transport.ip_addr = ip_addr
+
+ @property
+ def name(self) -> str:
+ """Returns the friendly name of the device."""
+ return self._name
+
+ @property
+ def encryption_key(self) -> str:
+ """Return the encryption key of the device."""
+ return self._encryption_key
+
+ @property
+ def encryption_version(self) -> EncryptionVersion | None:
+ """Return the encryption version of the device."""
+ return self._encryption_version
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique ID of the device (MAC)."""
+ return self._uniqueid
+
+ @property
+ def mac_address(self) -> str:
+ """Return the main MAC address of the device."""
+ return self._mac_addr
+
+ @property
+ def mac_address_controller(self) -> str:
+ """Return the secondary MAC address of the device. For non VRF is the same as MAC otherwise is the MAC of the main controller (same as MAC for the main device)."""
+ return self._mac_addr_controller
+
+ @property
+ def firmware_version(self) -> str | None:
+ """Returns the firmware version."""
+ if self._firmware_version and self._firmware_code:
+ return f"{self._firmware_version} ({self._firmware_code})"
+ if self._firmware_version:
+ return self._firmware_version
+ if self._firmware_code:
+ return self._firmware_code
+ return None
+
+ @property
+ def available(self) -> bool:
+ """Return True if the device is bound and last connection was successful."""
+ return self._is_bound and self._is_available
+
+ @property
+ def is_bound(self) -> bool:
+ """Return True if the device is bound."""
+ return self._is_bound
+
+ @property
+ def has_hvac_error(self) -> bool | None:
+ """Return if there is an error with the device."""
+ return self._bool_from_raw_state(GreeProp.FAULT, None)
+
+ @property
+ def beeper(self) -> bool:
+ """Return True if the device beeper is enabled."""
+ return self._beeper
+
+ def set_beeper(self, value: bool) -> None:
+ """Set the device beeper state."""
+ self._beeper = value
+
+ @property
+ def indoors_temperature_c(self) -> int | None:
+ """Return the current temperature if available."""
+ if self.supports_property(GreeProp.SENSOR_TEMPERATURE):
+ if self._temp_processor_indoors is None:
+ self._temp_processor_indoors = TempOffsetResolver()
+
+ raw_c = self._get_prop_raw(GreeProp.SENSOR_TEMPERATURE, None)
+ return (
+ int(self._temp_processor_indoors.evaluate(raw_c))
+ if raw_c is not None
+ else None
+ )
+
+ return None
+
+ @property
+ def outdoors_temperature_c(self) -> int | None:
+ """Return the current outside temperature if available."""
+ if self.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE):
+ if self._temp_processor_outdoors is None:
+ self._temp_processor_outdoors = TempOffsetResolver()
+
+ raw_c = self._get_prop_raw(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, None)
+ return (
+ int(self._temp_processor_outdoors.evaluate(raw_c))
+ if raw_c is not None
+ else None
+ )
+
+ return None
+
+ @property
+ def humidity(self) -> int | None:
+ """Return the current humidity if available."""
+ return self._get_prop_raw(GreeProp.SENSOR_HUMIDITY, None)
+
+ @property
+ def power_mode(self) -> bool:
+ """Return the current power mode."""
+ return self._bool_from_raw_state(GreeProp.POWER)
+
+ def set_power_mode(self, value: bool):
+ """Sets the device power mode."""
+ self._set_device_status({GreeProp.POWER: 1 if value else 0})
+
+ @property
+ def operation_mode(self) -> OperationMode:
+ """Return the current operation mode."""
+ return OperationMode(
+ self._get_prop_raw(GreeProp.OP_MODE, OperationMode.auto.value)
+ )
+
+ def set_operation_mode(self, mode: OperationMode):
+ """Sets the device operation mode."""
+ self._set_device_status({GreeProp.OP_MODE: mode})
+
+ @property
+ def fan_speed(self) -> FanSpeed:
+ """Return the current fan speed."""
+ return FanSpeed(self._get_prop_raw(GreeProp.FAN_SPEED, FanSpeed.auto.value))
+
+ def set_fan_speed(self, speed: FanSpeed):
+ """Sets the device fan speed mode."""
+ self._set_device_status({GreeProp.FAN_SPEED: speed})
+
+ @property
+ def vertical_swing_mode(self) -> VerticalSwingMode:
+ """Return the current vertical swing setting."""
+ return VerticalSwingMode(
+ self._get_prop_raw(GreeProp.SWING_VERTICAL, VerticalSwingMode.default.value)
+ )
+
+ def set_vertical_swing_mode(self, swing_mode: VerticalSwingMode):
+ """Sets the device vertical swing mode."""
+ self._set_device_status({GreeProp.SWING_VERTICAL: swing_mode})
+
+ @property
+ def horizontal_swing_mode(self) -> HorizontalSwingMode:
+ """Return the current horizontal swing setting."""
+ return HorizontalSwingMode(
+ self._get_prop_raw(
+ GreeProp.SWING_HORIZONTAL, HorizontalSwingMode.default.value
+ )
+ )
+
+ def set_horizontal_swing_mode(self, swing_mode: HorizontalSwingMode):
+ """Sets the device horizontal swing mode."""
+ self._set_device_status({GreeProp.SWING_HORIZONTAL: swing_mode})
+
+ @property
+ def target_temperature_unit(self) -> TemperatureUnits:
+ """Return the units of the target temperature."""
+ return TemperatureUnits(
+ self._get_prop_raw(
+ GreeProp.TARGET_TEMPERATURE_UNIT, TemperatureUnits.C.value
+ )
+ )
+
+ def set_target_temperature_unit(self, units: TemperatureUnits):
+ """Sets the units of the target temperature."""
+ self._set_device_status({GreeProp.TARGET_TEMPERATURE_UNIT: units})
+
+ @property
+ def target_temperature(self) -> float:
+ """Return the target temperature in target_temperature_unit."""
+
+ raw_c = self._get_prop_raw(GreeProp.TARGET_TEMPERATURE, 0)
+ tem_rec = self._get_prop_raw(GreeProp.TARGET_TEMPERATURE_BIT, 0)
+
+ if raw_c is not None and tem_rec is not None:
+ if self.target_temperature_unit == TemperatureUnits.F:
+ return gree_get_target_temperature_f(raw_c, tem_rec)
+ if self.target_temperature_unit == TemperatureUnits.C:
+ return gree_get_target_temperature_c(raw_c, tem_rec)
+ return 0.0
+
+ def set_target_temperature(self, value: float) -> None:
+ """Sets the target temperature in target_temperature_unit."""
+
+ if self.target_temperature_unit == TemperatureUnits.F:
+ if not value.is_integer():
+ _LOGGER.warning(
+ "The Gree API does not support floating Fahrenheit values, the applied value will be: %.2f -> %d",
+ value,
+ round(value),
+ )
+ raw_c, tem_rec = gree_get_target_temp_props_from_f(round(value))
+ else:
+ raw_c, tem_rec = gree_get_target_temp_props_from_c(value)
+
+ self._set_device_status(
+ {
+ GreeProp.TARGET_TEMPERATURE: raw_c,
+ GreeProp.TARGET_TEMPERATURE_BIT: tem_rec,
+ }
+ )
+
+ @property
+ def feature_light_sensor(self) -> bool:
+ """Return the light sensor state."""
+ return self._bool_from_raw_state(GreeProp.FEAT_SENSOR_LIGHT)
+
+ def set_feature_light_sensor(self, value: bool) -> None:
+ """Set the light sensor state."""
+ self._set_device_status({GreeProp.FEAT_SENSOR_LIGHT: 1 if value else 0})
+
+ @property
+ def feature_fresh_air(self) -> bool:
+ """Return the fresh air mode state."""
+ return self._bool_from_raw_state(GreeProp.FEAT_FRESH_AIR)
+
+ def set_feature_fresh_air(self, value: bool) -> None:
+ """Set the fresh air mode state."""
+ self._set_device_status({GreeProp.FEAT_FRESH_AIR: 1 if value else 0})
+
+ @property
+ def feature_x_fan(self) -> bool:
+ """Return the x-fan mode state."""
+ return self._bool_from_raw_state(GreeProp.FEAT_XFAN)
+
+ def set_feature_xfan(self, value: bool) -> None:
+ """Set the x-fan mode state."""
+ self._set_device_status({GreeProp.FEAT_XFAN: 1 if value else 0})
+
+ @property
+ def feature_health(self) -> bool:
+ """Return the health mode state."""
+ return self._bool_from_raw_state(GreeProp.FEAT_HEALTH)
+
+ def set_feature_health(self, value: bool) -> None:
+ """Set the health mode state."""
+ self._set_device_status({GreeProp.FEAT_HEALTH: 1 if value else 0})
+
+ @property
+ def feature_sleep(self) -> bool:
+ """Return the sleep mode state."""
+ val1 = self._bool_from_raw_state(GreeProp.FEAT_SLEEP_MODE_SWING)
+ val2 = self._bool_from_raw_state(GreeProp.FEAT_SLEEP_MODE)
+
+ return val1 is True or val2 is True
+
+ def set_feature_sleep(self, value: bool) -> None:
+ """Set the sleep mode state."""
+ self._set_device_status(
+ {
+ GreeProp.FEAT_SLEEP_MODE: 1 if value else 0,
+ GreeProp.FEAT_SLEEP_MODE_SWING: 1 if value else 0,
+ }
+ )
+
+ @property
+ def feature_light(self) -> bool:
+ """Return the light state."""
+ return self._bool_from_raw_state(GreeProp.FEAT_LIGHT)
+
+ def set_feature_light(self, value: bool) -> None:
+ """Set the light state."""
+ self._set_device_status({GreeProp.FEAT_LIGHT: 1 if value else 0})
+
+ @property
+ def feature_quiet(self) -> bool:
+ """Return the quiet mode state."""
+ return self._bool_from_raw_state(GreeProp.FEAT_QUIET_MODE)
+
+ def set_feature_quiet(self, value: bool) -> None:
+ """Set the quiet mode state."""
+ self._set_device_status({GreeProp.FEAT_QUIET_MODE: 1 if value else 0})
+
+ @property
+ def feature_turbo(self) -> bool:
+ """Return the turbo mode state."""
+ return self._bool_from_raw_state(GreeProp.FEAT_TURBO_MODE)
+
+ def set_feature_turbo(self, value: bool) -> None:
+ """Set the turbo mode state."""
+ self._set_device_status({GreeProp.FEAT_TURBO_MODE: 1 if value else 0})
+
+ @property
+ def feature_smart_heat(self) -> bool:
+ """Return the smart heat (8ºC / anti-freeze) mode state."""
+ return self._bool_from_raw_state(GreeProp.FEAT_SMART_HEAT_8C)
+
+ def set_feature_smart_heat(self, value: bool) -> None:
+ """Set the smart heat (8ºC / anti-freeze) mode state."""
+ self._set_device_status({GreeProp.FEAT_SMART_HEAT_8C: 1 if value else 0})
+
+ @property
+ def feature_energy_saving(self) -> bool:
+ """Return the energy saving mode state."""
+ return self._bool_from_raw_state(GreeProp.FEAT_ENERGY_SAVING)
+
+ def set_feature_energy_saving(self, value: bool) -> None:
+ """Set the energy saving mode state."""
+ self._set_device_status({GreeProp.FEAT_ENERGY_SAVING: 1 if value else 0})
+
+ @property
+ def feature_anti_direct_blow(self) -> bool:
+ """Return the anti direct blow mode state."""
+ return self._bool_from_raw_state(GreeProp.FEAT_ANTI_DIRECT_BLOW)
+
+ def set_feature_anti_direct_blow(self, value: bool) -> None:
+ """Set the anti direct blow mode state."""
+ self._set_device_status({GreeProp.FEAT_ANTI_DIRECT_BLOW: 1 if value else 0})
diff --git a/custom_components/gree_custom/aiogree/errors.py b/custom_components/gree_custom/aiogree/errors.py
new file mode 100644
index 0000000..196ac93
--- /dev/null
+++ b/custom_components/gree_custom/aiogree/errors.py
@@ -0,0 +1,17 @@
+"""Errors raised by the integration."""
+
+
+class GreeError(Exception):
+ """Base error for the Gree integration."""
+
+
+class GreeConnectionError(GreeError):
+ """Network communication with device failed."""
+
+
+class GreeProtocolError(GreeError):
+ """Device returned invalid data."""
+
+
+class GreeBindingError(GreeError):
+ """Failed to obtain encryption key."""
diff --git a/custom_components/gree_custom/aiogree/helpers.py b/custom_components/gree_custom/aiogree/helpers.py
new file mode 100644
index 0000000..1142f41
--- /dev/null
+++ b/custom_components/gree_custom/aiogree/helpers.py
@@ -0,0 +1,175 @@
+"""Helpers for the Gree device API."""
+
+import logging
+
+from .const import MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F
+
+TEMSEN_OFFSET = 40
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TempOffsetResolver:
+ """Detect whether this sensor reports temperatures in °C or in (°C + 40)."""
+
+ # Continues to check, and bases decision on historical min and max raw values
+ # since there are extreme cases which would result in a switch.
+ # Two running values are stored (min & max raw).
+ #
+ # Note: This could be simplified by just using 40C as a max point
+ # for the unoffset case and a min point for the offset case. But
+ # this doesn't account for the marginal cases around 40C as well.
+ #
+ # Example:
+
+ # if raw < 40:
+ # return raw
+ # else:
+ # return raw - 40
+
+ def __init__(
+ self,
+ indoor_min: float = -15.0, # coldest plausible indoor °C
+ indoor_max: float = 40.0, # hottest plausible indoor °C
+ offset: float = TEMSEN_OFFSET, # device's fixed offset
+ margin: float = 2.0, # tolerance before "impossible":
+ ) -> None:
+ """Initialize the resolver."""
+ self._lo_lim = indoor_min - margin
+ self._hi_lim = indoor_max + margin
+ self._offset = offset
+
+ self._min_raw: float | None = None
+ self._max_raw: float | None = None
+ self._has_offset: bool | None = None # undecided until True/False
+
+ def evaluate(self, raw: float) -> float:
+ """Evaluate the raw temperature and return corrected value."""
+ if self._min_raw is None or raw < self._min_raw:
+ self._min_raw = raw
+ if self._max_raw is None or raw > self._max_raw:
+ self._max_raw = raw
+ self._check() # evaluate every time, so it can change it's mind as needed
+ return raw - self._offset if self._has_offset else raw
+
+ def _check(self) -> None:
+ if self._min_raw is None or self._max_raw is None:
+ return # not enough data yet
+
+ lo, hi = self._min_raw, self._max_raw
+ penalty_no = self._penalty(lo, hi)
+ penalty_off = self._penalty(lo - self._offset, hi - self._offset)
+ if penalty_no == penalty_off:
+ return # still ambiguous – keep collecting data
+ self._has_offset = penalty_off < penalty_no
+
+ def _penalty(self, lo: float, hi: float) -> float:
+ pen = 0.0
+ if lo < self._lo_lim:
+ pen += self._lo_lim - lo
+ if hi > self._hi_lim:
+ pen += hi - self._hi_lim
+ return pen
+
+
+def gree_get_target_temp_props_from_f(desired_temp_f: int) -> tuple[int, int]:
+ """Get SetTem and TemRec for a given Fahrenheit temperature. Only integer values supported."""
+ # See: https://github.com/tomikaa87/gree-remote
+
+ if desired_temp_f > MAX_TEMP_F:
+ _LOGGER.warning(
+ "The desired temperature is greater than allowed. Clamping to highest value: %d > %d",
+ desired_temp_f,
+ MAX_TEMP_F,
+ )
+ desired_temp_f = MAX_TEMP_F
+
+ if desired_temp_f < MIN_TEMP_F:
+ _LOGGER.warning(
+ "The desired temperature is lower than allowed. Clamping to lowest value: %d < %d",
+ desired_temp_f,
+ MIN_TEMP_F,
+ )
+ desired_temp_f = MIN_TEMP_F
+
+ celsius = (desired_temp_f - 32.0) * 5.0 / 9.0
+ SetTem = round(celsius)
+ TemRec = int((celsius - SetTem) > -0.001)
+
+ return SetTem, TemRec
+
+
+def gree_get_target_temp_props_from_c(desired_temp_c: float) -> tuple[int, int]:
+ """Get SetTem and TemRec for a given 1/2 degree Celsius temperature."""
+
+ if desired_temp_c > MAX_TEMP_C:
+ _LOGGER.warning(
+ "The desired temperature is greater than allowed. Clamping to highest value: %d > %d",
+ desired_temp_c,
+ MAX_TEMP_C,
+ )
+ desired_temp_c = MAX_TEMP_C
+
+ if desired_temp_c < MIN_TEMP_C:
+ _LOGGER.warning(
+ "The desired temperature is lower than allowed. Clamping to lowest value: %d < %d",
+ desired_temp_c,
+ MIN_TEMP_C,
+ )
+ desired_temp_c = MIN_TEMP_C
+
+ # Encode any floating‐point temperature T into:
+ # ‣ temp_int: the integer (°C) portion of the nearest 0.0/0.5 step,
+ # ‣ half_bit: 1 if the nearest step has a ".5", else 0.
+
+ # This "finds the closest multiple of 0.5" to T, then:
+ # n = round(T * 2)
+ # temp_int = n >> 1 (i.e. floor(n/2))
+ # half_bit = n & 1 (1 if it's an odd half‐step)
+
+ # 1) Compute "twice T" and round to nearest integer:
+ # math.floor(T * 2 + 0.5) is equivalent to rounding ties upward.
+ n = int(round(desired_temp_c * 2))
+
+ # 2) The low bit of n says ".5" (odd) versus ".0" (even):
+ TemRec = n & 1
+
+ # 3) Shifting right by 1 gives floor(n/2), i.e. the integer °C of that nearest half‐step:
+ SetTem = n >> 1
+
+ return SetTem, TemRec
+
+
+def gree_get_target_temperature_f(SetTem: int, TemRec: int) -> float:
+ """Convert SetTem and TemRec back to the Fahrenheit temperature."""
+
+ # Convert SetTem back to the minimum and maximum Fahrenheit before rounding
+ # We consider the worst case scenario: SetTem could be the result of rounding from any value in a range
+ # If TemRec is 1, it indicates the value was closer to the upper range of the rounding
+ # If TemRec is 0, it indicates the value was closer to the lower range
+
+ if TemRec == 1:
+ # SetTem is closer to its higher bound, so we consider SetTem as the lower limit
+ min_celsius = SetTem
+ max_celsius = SetTem + 0.4999 # Just below the next rounding threshold
+ else:
+ # SetTem is closer to its lower bound, so we consider SetTem-1 as the potential lower limit
+ min_celsius = SetTem - 0.4999 # Just above the previous rounding threshold
+ max_celsius = SetTem
+
+ # Convert these Celsius values back to Fahrenheit
+ min_fahrenheit = (min_celsius * 9.0 / 5.0) + 32.0
+ max_fahrenheit = (max_celsius * 9.0 / 5.0) + 32.0
+
+ return round((min_fahrenheit + max_fahrenheit) / 2.0)
+
+
+def gree_get_target_temperature_c(SetTem: int, TemRec: int) -> float:
+ """Convert SetTem and TemRec back to the Celsius temperature."""
+
+ # Given:
+ # SetTem = the "rounded-down" integer (⌊T⌋ or for negatives, floor(T))
+ # TemRec = 0 or 1, where 1 means "there was a 0.5"
+ # Returns the original temperature as a float.
+
+ return SetTem + (0.5 if TemRec else 0.0)
diff --git a/custom_components/gree_custom/aiogree/transport.py b/custom_components/gree_custom/aiogree/transport.py
new file mode 100644
index 0000000..9fadd62
--- /dev/null
+++ b/custom_components/gree_custom/aiogree/transport.py
@@ -0,0 +1,167 @@
+"""Handles network connections."""
+
+import asyncio
+import json
+import logging
+
+import asyncio_dgram
+
+from .errors import GreeConnectionError
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class GreeTransport:
+ """Handles the connection with the Gree device."""
+
+ def __init__(
+ self, ip_addr: str, port: int, max_retries: int = 3, timeout: float = 2.0
+ ) -> None:
+ """Initialize the connection object."""
+ self.ip_addr = ip_addr
+ self.port = port
+ self.max_retries = max_retries
+ self.timeout = timeout
+
+ async def udp_request(
+ self,
+ data: bytes,
+ ) -> bytes:
+ """Send a payload data to the device and reads the response."""
+
+ last_error: Exception = 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())
+
+ try:
+ received_data, _ = await asyncio.wait_for(recv_task, self.timeout)
+ except TimeoutError:
+ recv_task.cancel()
+ raise
+ 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, ...
+
+ raise GreeConnectionError(
+ f"Failed to communicate with device '{self.ip_addr}:{self.port}' after {self.max_retries} attempts"
+ ) from last_error
+
+ async def request_json(self, payload: dict) -> dict:
+ """Send and receive a JSON payload."""
+ raw = await self.udp_request(json.dumps(payload).encode("utf-8"))
+ return json.loads(raw.decode("utf-8"))
+
+
+class UDPDiscoveryProtocol(asyncio.DatagramProtocol):
+ """Helper Protocol to handle incoming UDP discovery responses.
+
+ Responses will be added to a 'responses' field which can be queried.
+ """
+
+ def __init__(self, responses: dict[str, dict]) -> None:
+ """Setup Discovery Transport. Use the responses to query the received data."""
+ self.responses = responses
+ self.transport = None
+
+ def connection_made(self, transport: asyncio.DatagramTransport):
+ """Called when the UDP socket is set up."""
+ self.transport = transport
+
+ def datagram_received(self, data: bytes, addr: tuple[str, int]):
+ """Called when a UDP packet is received."""
+ try:
+ # Decode the payload
+ payload = json.loads(data.decode("utf-8", errors="ignore"))
+ ip_address = addr[0]
+
+ self.responses[ip_address] = payload
+ _LOGGER.debug("Received reply from %s", ip_address)
+
+ except json.JSONDecodeError:
+ _LOGGER.exception("Could not parse JSON response from %s: %s", addr, data)
+ except Exception:
+ _LOGGER.exception("Unexpected error processing packet from %s", addr)
+
+ def error_received(self, exc):
+ """Called on underlying network errors."""
+ _LOGGER.error("UDP network error received: %s", exc)
+
+ def connection_lost(self, exc):
+ """Called when the socket is closed."""
+
+
+async def async_udp_broadcast_request(
+ broadcast_addresses: list[str], port: int, json_data: str, timeout: int
+) -> dict[str, dict]:
+ """Sends an async UDP broadcast and waits for responses."""
+ loop = asyncio.get_running_loop()
+ responses: dict[str, dict] = {}
+
+ # Remove duplicates
+ broadcast_addresses = list(dict.fromkeys(broadcast_addresses))
+
+ try:
+ transport, _ = await loop.create_datagram_endpoint(
+ lambda: UDPDiscoveryProtocol(responses),
+ local_addr=(
+ "0.0.0.0",
+ 0,
+ ), # Listen on all interfaces, random ephemeral port
+ allow_broadcast=True,
+ )
+ except OSError as err:
+ _LOGGER.error("Failed to bind UDP socket: %s", err)
+ return responses
+
+ try:
+ # Send out the broadcast payload
+ payload = json_data.encode("utf-8")
+ for addr in broadcast_addresses:
+ try:
+ _LOGGER.debug("Sending broadcast to %s:%s", addr, port)
+ transport.sendto(payload, (addr, port))
+ except Exception:
+ _LOGGER.exception("Failed sending to %s", addr)
+
+ # Wait for devices to reply asynchronously
+ _LOGGER.debug("Waiting %d seconds for UDP replies... ", timeout)
+ await asyncio.sleep(timeout)
+
+ finally:
+ transport.close()
+
+ _LOGGER.debug("Discovery finished. Got %d responses", len(responses))
+ return responses
diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py
new file mode 100644
index 0000000..3a2f3ab
--- /dev/null
+++ b/custom_components/gree_custom/binary_sensor.py
@@ -0,0 +1,158 @@
+"""Gree Binary Sensor Entity for Home Assistant."""
+
+from collections.abc import Callable
+import logging
+
+from attr import dataclass
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import CONF_MAC, EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+
+from .aiogree.device import GreeDevice
+from .const import (
+ CONF_ADVANCED,
+ CONF_DEVICES,
+ CONF_DISABLE_AVAILABLE_CHECK,
+ CONF_FEATURES,
+ CONF_TO_PROP_FEATURE_MAP,
+ DEFAULT_DISABLE_AVAILABLE_CHECK,
+ DEFAULT_SUPPORTED_FEATURES,
+ GATTR_FAULTS,
+)
+from .coordinator import GreeConfigEntry, GreeCoordinator
+from .entity import GreeEntity, GreeEntityDescription
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class GreeBinarySensorDescription(GreeEntityDescription, BinarySensorEntityDescription):
+ """Description of a Gree binary sensor."""
+
+ additional_available_func = lambda _: True # noqa: E731
+ value_func: Callable[[GreeDevice], bool | None]
+
+
+SENSOR_TYPES: list[GreeBinarySensorDescription] = [
+ GreeBinarySensorDescription(
+ key=GATTR_FAULTS,
+ translation_key=GATTR_FAULTS,
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_func=lambda device: device.has_hvac_error,
+ entity_registry_enabled_default=True,
+ entity_registry_visible_default=True,
+ force_update=False,
+ icon=None,
+ has_entity_name=True,
+ name=None,
+ translation_placeholders=None,
+ unit_of_measurement=None,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: GreeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up binary sensors from a config entry."""
+
+ entities: list[GreeBinarySensor] = []
+
+ for d in entry.data.get(CONF_DEVICES, []):
+ mac = d.get(CONF_MAC, "")
+ coordinator: GreeCoordinator = entry.runtime_data[mac]
+ if not coordinator:
+ _LOGGER.error(
+ "Cannot create Gree Binary Sensors. No coordinator found for device '%s'",
+ mac,
+ )
+ continue
+
+ descriptions: list[GreeBinarySensorDescription] = []
+
+ conf_supported_features: list[str] = []
+ supported_features: list[str] = []
+
+ if d.get(CONF_FEATURES, None) is None:
+ _LOGGER.warning("Undefined supported features")
+
+ conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES)
+
+ # Check features with device support before addinig entities
+ for feature in conf_supported_features:
+ prop = CONF_TO_PROP_FEATURE_MAP.get(feature)
+ if prop and coordinator.device.supports_property(prop):
+ supported_features.append(feature)
+
+ descriptions.extend(
+ [
+ description
+ for description in SENSOR_TYPES
+ if description.key in supported_features
+ ]
+ )
+
+ _LOGGER.debug(
+ "Adding Binary Sensor Entities for device '%s': %s",
+ coordinator.device.mac_address,
+ [d.key for d in descriptions],
+ )
+
+ entities.extend(
+ [
+ GreeBinarySensor(
+ description,
+ coordinator,
+ check_availability=(
+ not entry.data[CONF_ADVANCED].get(
+ CONF_DISABLE_AVAILABLE_CHECK,
+ DEFAULT_DISABLE_AVAILABLE_CHECK,
+ )
+ ),
+ )
+ for description in descriptions
+ ]
+ )
+
+ async_add_entities(entities)
+
+
+class GreeBinarySensor(GreeEntity, BinarySensorEntity): # pyright: ignore[reportIncompatibleVariableOverride]
+ """Defines a Gree Binary Sensor entity."""
+
+ entity_description: GreeBinarySensorDescription
+
+ def __init__(
+ self,
+ description: GreeBinarySensorDescription,
+ coordinator: GreeCoordinator,
+ check_availability: bool = True,
+ ) -> None:
+ """Initialize binary sensor."""
+ super().__init__(
+ description,
+ coordinator,
+ restore_state=False,
+ check_availability=check_availability,
+ )
+
+ self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride]
+ _LOGGER.debug(
+ "Initialized binary sensor: %s (check_availability=%s)",
+ self.unique_id,
+ self.check_availability,
+ )
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return the state of the sensor."""
+ return self.entity_description.value_func(self.device)
diff --git a/custom_components/gree_custom/brand/icon.png b/custom_components/gree_custom/brand/icon.png
new file mode 100644
index 0000000..88c4f5d
Binary files /dev/null and b/custom_components/gree_custom/brand/icon.png differ
diff --git a/custom_components/gree_custom/brand/icon@2x.png b/custom_components/gree_custom/brand/icon@2x.png
new file mode 100644
index 0000000..bcefd72
Binary files /dev/null and b/custom_components/gree_custom/brand/icon@2x.png differ
diff --git a/custom_components/gree_custom/brand/logo.png b/custom_components/gree_custom/brand/logo.png
new file mode 100644
index 0000000..be8665f
Binary files /dev/null and b/custom_components/gree_custom/brand/logo.png differ
diff --git a/custom_components/gree_custom/brand/logo@2x.png b/custom_components/gree_custom/brand/logo@2x.png
new file mode 100644
index 0000000..904b8dd
Binary files /dev/null and b/custom_components/gree_custom/brand/logo@2x.png differ
diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py
new file mode 100644
index 0000000..c0b4259
--- /dev/null
+++ b/custom_components/gree_custom/climate.py
@@ -0,0 +1,879 @@
+"""Gree Climate Entity for Home Assistant."""
+
+import logging
+
+from attr import dataclass
+
+from homeassistant.components.climate import (
+ ATTR_FAN_MODE,
+ ATTR_HVAC_MODE,
+ ATTR_SWING_HORIZONTAL_MODE,
+ ATTR_SWING_MODE,
+ ClimateEntity,
+ ClimateEntityDescription,
+ ClimateEntityFeature,
+ HVACMode,
+)
+from homeassistant.const import (
+ ATTR_TEMPERATURE,
+ ATTR_UNIT_OF_MEASUREMENT,
+ CONF_MAC,
+ EVENT_CORE_CONFIG_UPDATE,
+ STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
+ UnitOfTemperature,
+)
+from homeassistant.core import (
+ Event,
+ EventStateChangedData,
+ HomeAssistant,
+ State,
+ callback,
+)
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.typing import UNDEFINED
+from homeassistant.util.unit_conversion import TemperatureConverter
+
+from .aiogree.api import FanSpeed, GreeProp, HorizontalSwingMode, VerticalSwingMode
+from .aiogree.const import MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F
+from .const import (
+ ATTR_EXTERNAL_HUMIDITY_SENSOR,
+ ATTR_EXTERNAL_TEMPERATURE_SENSOR,
+ CONF_ADVANCED,
+ CONF_DEVICES,
+ CONF_DISABLE_AVAILABLE_CHECK,
+ CONF_FAN_MODES,
+ CONF_HVAC_MODES,
+ CONF_RESTORE_STATES,
+ CONF_SWING_HORIZONTAL_MODES,
+ CONF_SWING_MODES,
+ CONF_TEMPERATURE_STEP,
+ DEFAULT_DISABLE_AVAILABLE_CHECK,
+ DEFAULT_FAN_MODES,
+ DEFAULT_HVAC_MODES,
+ DEFAULT_RESTORE_STATES,
+ DEFAULT_SWING_HORIZONTAL_MODES,
+ DEFAULT_SWING_MODES,
+ DEFAULT_TARGET_TEMP_STEP,
+ DOMAIN,
+ GATTR_FEAT_QUIET_MODE,
+ GATTR_FEAT_TURBO,
+ HVAC_MODES_GREE_TO_HA,
+ HVAC_MODES_HA_TO_GREE,
+ UNITS_GREE_TO_HA,
+)
+from .coordinator import GreeConfigEntry, GreeCoordinator
+from .entity import GreeEntity, GreeEntityDescription
+
+_LOGGER = logging.getLogger(__name__)
+
+GATTR_CLIMATE = "hvac"
+
+
+@dataclass(frozen=True, kw_only=True)
+class GreeClimateDescription(GreeEntityDescription, ClimateEntityDescription):
+ """Description of a Gree Climate entity."""
+
+ additional_available_func = lambda _: True # noqa: E731
+ device_class = None
+ entity_category = None
+ entity_registry_enabled_default = True
+ entity_registry_visible_default = True
+ force_update = False
+ icon = None
+ has_entity_name = True
+ name = UNDEFINED
+ translation_key = None
+ translation_placeholders = None
+ unit_of_measurement = None
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: GreeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up sensors from a config entry."""
+
+ entities: list[GreeClimate] = []
+
+ for d in entry.data.get(CONF_DEVICES, []):
+ mac = d.get(CONF_MAC, "")
+ coordinator: GreeCoordinator = entry.runtime_data[mac]
+ if not coordinator:
+ _LOGGER.error(
+ "Cannot create Gree Climate. No coordinator found for device '%s'",
+ mac,
+ )
+ continue
+
+ hvac_modes: list[HVACMode] = [
+ HVACMode[mode.upper()]
+ for mode in (
+ d[CONF_HVAC_MODES]
+ if d[CONF_HVAC_MODES] is not None
+ else DEFAULT_HVAC_MODES
+ )
+ ]
+
+ fan_modes: list[str] = (
+ d[CONF_FAN_MODES] if d[CONF_FAN_MODES] is not None else DEFAULT_FAN_MODES
+ )
+
+ swing_modes: list[str] = (
+ d[CONF_SWING_MODES]
+ if d[CONF_SWING_MODES] is not None
+ else DEFAULT_SWING_MODES
+ )
+
+ swing_horizontal_modes: list[str] = (
+ d[CONF_SWING_HORIZONTAL_MODES]
+ if d[CONF_SWING_HORIZONTAL_MODES] is not None
+ else DEFAULT_SWING_HORIZONTAL_MODES
+ )
+
+ if not hvac_modes:
+ _LOGGER.info(
+ "Climate Entity will not be created because no Climate options and features are available for the device"
+ )
+ return
+
+ _LOGGER.debug(
+ "Adding Climate Entity for device '%s'",
+ coordinator.device.mac_address,
+ )
+
+ entities.append(
+ GreeClimate(
+ GreeClimateDescription(
+ key=GATTR_CLIMATE,
+ translation_key=GATTR_CLIMATE,
+ ),
+ coordinator,
+ hvac_modes,
+ fan_modes,
+ swing_modes,
+ swing_horizontal_modes,
+ temperature_step=d.get(CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP),
+ restore_state=d.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES),
+ check_availability=(
+ not entry.data[CONF_ADVANCED].get(
+ CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK
+ )
+ ),
+ external_temperature_sensor_id=d.get(ATTR_EXTERNAL_TEMPERATURE_SENSOR),
+ external_humidity_sensor_id=d.get(ATTR_EXTERNAL_HUMIDITY_SENSOR),
+ )
+ )
+
+ async_add_entities(entities)
+
+
+class GreeClimate(GreeEntity, ClimateEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride]
+ """Climate Entity."""
+
+ def __init__(
+ self,
+ description: GreeClimateDescription,
+ coordinator: GreeCoordinator,
+ hvac_modes: list[HVACMode],
+ fan_modes: list[str],
+ swing_modes: list[str],
+ swing_horizontal_modes: list[str],
+ temperature_step: int,
+ restore_state: bool = True,
+ check_availability: bool = True,
+ external_temperature_sensor_id: str | None = None,
+ external_humidity_sensor_id: str | None = None,
+ ) -> None:
+ """Initialize the Gree Climate entity."""
+ super().__init__(description, coordinator, restore_state, check_availability)
+
+ self.entity_description = description
+ self._attr_name = None # Main entity
+
+ self._external_temperature_sensor = external_temperature_sensor_id
+ self._external_humidity_sensor = external_humidity_sensor_id
+
+ self._attr_target_temperature_step = temperature_step
+
+ self._attr_hvac_modes = hvac_modes
+
+ if hvac_modes and HVACMode.OFF in hvac_modes:
+ self._attr_supported_features |= ClimateEntityFeature.TURN_OFF
+
+ if any(mode != HVACMode.OFF for mode in hvac_modes):
+ self._attr_supported_features |= ClimateEntityFeature.TURN_ON
+
+ if any(
+ mode in hvac_modes for mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO)
+ ):
+ self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
+
+ if fan_modes:
+ self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
+ self._attr_fan_modes = fan_modes
+ else:
+ self._attr_fan_modes = None
+
+ if swing_modes:
+ self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
+ self._attr_swing_modes = swing_modes
+ else:
+ self._attr_swing_modes = None
+
+ if swing_horizontal_modes:
+ self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
+ self._attr_swing_horizontal_modes = swing_horizontal_modes
+ else:
+ self._attr_swing_horizontal_modes = None
+
+ self._update_attributes()
+ _LOGGER.debug(
+ "Initialized climate: %s (check_availability=%s) Features:\n%s",
+ self.unique_id,
+ self.check_availability,
+ repr(self._attr_supported_features),
+ )
+
+ async def async_added_to_hass(self):
+ """When this entity is added to hass."""
+ await super().async_added_to_hass()
+
+ self._update_attributes()
+
+ # Restore last HA state to device if applicable
+ if self.restore_state:
+ await self._restore_entity_state()
+
+ # When using an external temperature sensor, subscribe to its state changes for updating the current temperature
+ if (
+ self._external_temperature_sensor
+ and self._external_temperature_sensor != "None"
+ ):
+ self._update_current_temperature_from_external(
+ self.hass.states.get(self._external_temperature_sensor)
+ )
+ self.async_on_remove(
+ async_track_state_change_event(
+ self.hass,
+ [self._external_temperature_sensor],
+ self._external_temperature_sensor_listener,
+ )
+ )
+
+ # When using an external himidity sensor, subscribe to its state changes for updating the current humidity
+ if self._external_humidity_sensor and self._external_humidity_sensor != "None":
+ self._update_current_humidity_from_external(
+ self.hass.states.get(self._external_humidity_sensor)
+ )
+ self.async_on_remove(
+ async_track_state_change_event(
+ self.hass,
+ [self._external_humidity_sensor],
+ self._external_humidity_sensor_listener,
+ )
+ )
+
+ # Refresh entity when HA unit system changes
+ self.async_on_remove(
+ self.hass.bus.async_listen(
+ EVENT_CORE_CONFIG_UPDATE, self._handle_unit_change
+ )
+ )
+
+ async def _restore_entity_state(self):
+ last_state = await self.async_get_last_state()
+ if last_state is not None:
+ _LOGGER.debug(
+ "Restoring state for %s:\n%s",
+ self.unique_id,
+ last_state,
+ )
+
+ # hvac mode
+ if last_state.state not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE]:
+ last_hvac_mode: HVACMode | None = HVACMode(last_state.state)
+ if (
+ last_hvac_mode
+ and last_hvac_mode != self._attr_hvac_mode
+ and last_hvac_mode in self._attr_hvac_modes
+ ):
+ try:
+ await self.async_set_hvac_mode(last_hvac_mode)
+ except Exception:
+ _LOGGER.exception(
+ "Failed to restore the hvac_mode: %s", last_hvac_mode
+ )
+ else:
+ _LOGGER.debug(
+ "No need to restore the hvac_mode: %s",
+ last_hvac_mode,
+ )
+
+ # fan mode
+ last_fan_mode: str | None = last_state.attributes.get(ATTR_FAN_MODE)
+ if (
+ last_fan_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE]
+ and self._attr_fan_modes
+ and last_fan_mode != self._attr_fan_mode
+ and last_fan_mode in self._attr_fan_modes
+ ):
+ try:
+ await self.async_set_fan_mode(last_fan_mode)
+ except Exception:
+ _LOGGER.exception(
+ "Failed to restore the fan_mode: %s", last_fan_mode
+ )
+ else:
+ _LOGGER.debug(
+ "No need to restore the fan_mode: %s",
+ last_fan_mode,
+ )
+
+ # swings
+ last_swing_mode: str | None = last_state.attributes.get(ATTR_SWING_MODE)
+ if (
+ last_swing_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE]
+ and self._attr_swing_modes
+ and last_swing_mode != self._attr_swing_mode
+ and last_swing_mode in self._attr_swing_modes
+ ):
+ try:
+ await self.async_set_swing_mode(last_swing_mode)
+ except Exception:
+ _LOGGER.exception(
+ "Failed to restore the swing_mode: %s", last_swing_mode
+ )
+ else:
+ _LOGGER.debug(
+ "No need to restore the swing_mode: %s",
+ last_swing_mode,
+ )
+
+ last_swing_horizontal_mode: str | None = last_state.attributes.get(
+ ATTR_SWING_HORIZONTAL_MODE
+ )
+ if (
+ last_swing_horizontal_mode
+ not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE]
+ and self.swing_horizontal_modes
+ and last_swing_horizontal_mode != self.swing_horizontal_mode
+ and last_swing_horizontal_mode in self.swing_horizontal_modes
+ ):
+ try:
+ await self.async_set_swing_horizontal_mode(
+ last_swing_horizontal_mode
+ )
+ except Exception:
+ _LOGGER.exception(
+ "Failed to restore the swing_horizontal_mode: %s",
+ last_swing_horizontal_mode,
+ )
+ else:
+ _LOGGER.debug(
+ "No need to restore the swing_horizontal_mode: %s",
+ last_swing_horizontal_mode,
+ )
+
+ # target temp
+ last_target_temperature: float | None = last_state.attributes.get(
+ ATTR_TEMPERATURE
+ )
+ if last_target_temperature is not None and last_target_temperature not in [
+ STATE_UNKNOWN,
+ STATE_UNAVAILABLE,
+ ]:
+ # since the ºC and ºF ranges don't overlap we can guess the last state units
+ last_unit: UnitOfTemperature = (
+ UnitOfTemperature.CELSIUS
+ if last_target_temperature <= MAX_TEMP_C
+ else UnitOfTemperature.FAHRENHEIT
+ )
+ last_target_temperature = TemperatureConverter.convert(
+ last_target_temperature,
+ last_unit,
+ self._attr_temperature_unit,
+ )
+ if (
+ self._attr_supported_features
+ & ClimateEntityFeature.TARGET_TEMPERATURE
+ and last_target_temperature != self._attr_target_temperature
+ ):
+ try:
+ await self.async_set_temperature(
+ **{ATTR_TEMPERATURE: last_target_temperature}
+ )
+ except Exception:
+ _LOGGER.exception(
+ "Failed to restore the target_temperature: %s%s",
+ last_target_temperature,
+ last_unit,
+ )
+ else:
+ _LOGGER.debug(
+ "No need to restore the target_temperature: %s%s",
+ last_target_temperature,
+ self.temperature_unit,
+ )
+
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ _LOGGER.debug("Updating Climate Entity for %s", self.device.unique_id)
+ self._update_attributes()
+
+ @callback
+ def _external_temperature_sensor_listener(
+ self, event: Event[EventStateChangedData]
+ ) -> None:
+ """Update current temperature based on external sensor updates."""
+ new_state = event.data.get("new_state")
+ self._update_current_temperature_from_external(new_state)
+
+ def _update_current_temperature_from_external(self, new_state: State | None):
+ """Update current temperature based on external sensor data."""
+ if new_state and new_state.state not in (
+ STATE_UNKNOWN,
+ STATE_UNAVAILABLE,
+ ):
+ try:
+ unit: str = new_state.attributes.get(
+ ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature.CELSIUS
+ )
+ value = float(new_state.state)
+
+ except (ValueError, TypeError) as ex:
+ _LOGGER.error(
+ "Unable to update from external temp sensor %s: %s",
+ self._external_temperature_sensor,
+ ex,
+ )
+ else:
+ _LOGGER.debug(
+ "Using external temperature sensor: %s, value: %s, unit: %s",
+ self._external_temperature_sensor,
+ value,
+ unit,
+ )
+ # Update internal state based on the other entity
+ self._attr_current_temperature = self.hass.config.units.temperature(
+ value, unit
+ )
+ self.async_write_ha_state()
+
+ @callback
+ def _external_humidity_sensor_listener(
+ self, event: Event[EventStateChangedData]
+ ) -> None:
+ """Update current humidity based on external sensor updates."""
+ new_state = event.data.get("new_state")
+ self._update_current_humidity_from_external(new_state)
+
+ def _update_current_humidity_from_external(self, new_state: State | None) -> None:
+ """Update current humidity based on external sensor updates."""
+ if new_state and new_state.state not in (
+ STATE_UNKNOWN,
+ STATE_UNAVAILABLE,
+ ):
+ try:
+ value = float(new_state.state)
+
+ except (ValueError, TypeError) as ex:
+ _LOGGER.error(
+ "Unable to update from humidity temp sensor %s: %s",
+ self._external_humidity_sensor,
+ ex,
+ )
+ else:
+ _LOGGER.debug(
+ "Using external humidity sensor: %s, value: %s",
+ self._external_humidity_sensor,
+ value,
+ )
+ self._attr_current_humidity = value
+
+ async def _handle_unit_change(self, event):
+ """Handle HA unit system change (°C <-> °F)."""
+ # Force refresh from coordinator
+ await self.coordinator.async_request_refresh()
+
+ def _update_attributes(self):
+ """Updates the entity attributes with the device values."""
+ self._attr_available = self.device.available
+
+ if (
+ self._attr_supported_features
+ & (
+ ClimateEntityFeature.TURN_ON
+ | ClimateEntityFeature.TURN_OFF
+ | ClimateEntityFeature.TARGET_TEMPERATURE
+ )
+ or HVACMode.FAN_ONLY in self._attr_hvac_modes
+ ):
+ self._attr_hvac_mode = self.get_hvac_mode()
+
+ if self._attr_supported_features & ClimateEntityFeature.FAN_MODE:
+ self._attr_fan_mode = self.get_fan_mode()
+ if self._attr_supported_features & ClimateEntityFeature.SWING_MODE:
+ self._attr_swing_mode = self.get_swing_mode()
+ if self._attr_supported_features & ClimateEntityFeature.SWING_HORIZONTAL_MODE:
+ self._attr_swing_horizontal_mode = self.get_swing_horizontal_mode()
+
+ self._attr_temperature_unit = self.get_temp_units()
+ self._attr_target_temperature = self.get_current_target_temp()
+
+ if (
+ self._external_temperature_sensor is None
+ or self._external_temperature_sensor == "None"
+ ):
+ self._attr_current_temperature = self.get_current_temp()
+
+ if (
+ self._external_humidity_sensor is None
+ or self._external_humidity_sensor == "None"
+ ):
+ self._attr_current_humidity = self.get_current_humidity()
+
+ if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE:
+ self._attr_max_temp = (
+ MAX_TEMP_C
+ if self._attr_temperature_unit == UnitOfTemperature.CELSIUS
+ else MAX_TEMP_F
+ )
+
+ self._attr_min_temp = (
+ MIN_TEMP_C
+ if self._attr_temperature_unit == UnitOfTemperature.CELSIUS
+ else MIN_TEMP_F
+ )
+
+ if self.hass:
+ self.async_write_ha_state()
+
+ async def async_turn_on(self):
+ """Turn on."""
+ _LOGGER.debug("turn_on(%s)", self.device.unique_id)
+
+ if not self.available:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="entity_unavailable"
+ )
+
+ try:
+ self.device.set_power_mode(True)
+
+ # If Auto Light is enabled, turn the display lights on
+ if self.coordinator.feature_auto_light:
+ self.device.set_feature_light(True)
+
+ await self.coordinator.push_device_status()
+
+ self.async_write_ha_state()
+
+ # notify coordinator listeners of state change so that dependent entities are updated immediately
+ self.coordinator.async_update_listeners()
+ except Exception as err:
+ _LOGGER.exception("Error in '%s'", "async_turn_on")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="generic"
+ ) from err
+
+ finally:
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_off(self):
+ """Turn off."""
+ _LOGGER.debug("turn_off(%s)", self.device.unique_id)
+
+ if not self.available:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="entity_unavailable"
+ )
+
+ try:
+ self.device.set_power_mode(False)
+
+ # If Auto Light is enabled, turn the display lights off
+ if self.coordinator.feature_auto_light:
+ self.device.set_feature_light(False)
+
+ await self.coordinator.push_device_status()
+
+ self.async_write_ha_state()
+
+ # notify coordinator listeners of state change so that dependent entities are updated immediately
+ self.coordinator.async_update_listeners()
+
+ except Exception as err:
+ _LOGGER.exception("Error in '%s'", "async_turn_off")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="generic"
+ ) from err
+
+ finally:
+ await self.coordinator.async_request_refresh()
+
+ def get_hvac_mode(self) -> HVACMode:
+ """Converts Gree Operation Modes to HA."""
+ return (
+ HVACMode.OFF
+ if not self.device.power_mode
+ else HVAC_MODES_GREE_TO_HA[self.device.operation_mode]
+ )
+
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode):
+ """Set the HVAC Mode."""
+ _LOGGER.debug("set_hvac_mode(%s, %s)", self.device.unique_id, hvac_mode)
+
+ if not self.available:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="entity_unavailable"
+ )
+
+ try:
+ if hvac_mode == HVACMode.OFF:
+ await self.async_turn_off()
+ # This will be called in the turn on
+ # await self._device.update_device_status()
+ else:
+ self.device.set_operation_mode(HVAC_MODES_HA_TO_GREE[hvac_mode])
+
+ # The Auto X-FAN enables that feature if the device is set to a hvac mode that supports X-FAN
+ if self.coordinator.feature_auto_xfan:
+ self.device.set_feature_xfan(
+ hvac_mode in (HVACMode.COOL, HVACMode.DRY)
+ )
+
+ await self.async_turn_on()
+
+ # This will be called in the turn on
+ # await self._device.update_device_status()
+
+ # notify coordinator listeners of state change so that dependent entities are updated immediately
+ self.coordinator.async_update_listeners()
+
+ await self.coordinator.async_request_refresh()
+ except Exception as err:
+ _LOGGER.exception("Error in '%s'", "async_set_hvac_mode")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="generic"
+ ) from err
+
+ self.async_write_ha_state()
+
+ def get_fan_mode(self) -> str:
+ """Converts Gree Fan Modes to HA. Accounts for the 2 special modes."""
+ if GATTR_FEAT_QUIET_MODE in self._attr_hvac_modes and self.device.feature_quiet:
+ return GATTR_FEAT_QUIET_MODE
+
+ if GATTR_FEAT_TURBO in self._attr_hvac_modes and self.device.feature_turbo:
+ return GATTR_FEAT_TURBO
+
+ return self.device.fan_speed.name
+
+ async def async_set_fan_mode(self, fan_mode: str):
+ """Set new target fan mode."""
+ _LOGGER.debug(
+ "set_fan_mode(%s, %s -> %s)",
+ self.device.unique_id,
+ self.get_fan_mode(),
+ fan_mode,
+ )
+
+ if not self.available:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="entity_unavailable"
+ )
+
+ if fan_mode == GATTR_FEAT_TURBO and self._attr_hvac_mode in (
+ HVACMode.DRY,
+ HVACMode.FAN_ONLY,
+ ):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="turbo_availability"
+ )
+
+ if fan_mode == GATTR_FEAT_QUIET_MODE and self._attr_hvac_mode not in (
+ HVACMode.DRY,
+ HVACMode.COOL,
+ ):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="quiet_availability"
+ )
+
+ try:
+ self.device.set_feature_quiet(fan_mode == GATTR_FEAT_QUIET_MODE)
+ self.device.set_feature_turbo(fan_mode == GATTR_FEAT_TURBO)
+
+ if fan_mode not in (GATTR_FEAT_QUIET_MODE, GATTR_FEAT_TURBO):
+ self.device.set_fan_speed(FanSpeed[fan_mode])
+
+ await self.coordinator.push_device_status()
+
+ # notify coordinator listeners of state change so that dependent entities are updated immediately
+ self.coordinator.async_update_listeners()
+
+ await self.coordinator.async_request_refresh()
+ except Exception as err:
+ _LOGGER.exception("Error in '%s'", "async_set_fan_mode")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="generic"
+ ) from err
+
+ self.async_write_ha_state()
+
+ def get_swing_mode(self) -> str:
+ """Converts Gree Swing Modes to HA."""
+ return self.device.vertical_swing_mode.name
+
+ async def async_set_swing_mode(self, swing_mode):
+ """Set new target swing operation."""
+ _LOGGER.debug("async_set_swing_mode(%s, %s)", self.device.unique_id, swing_mode)
+
+ if not self.available:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="entity_unavailable"
+ )
+
+ try:
+ self.device.set_vertical_swing_mode(VerticalSwingMode[swing_mode])
+ await self.coordinator.push_device_status()
+
+ # notify coordinator listeners of state change so that dependent entities are updated immediately
+ self.coordinator.async_update_listeners()
+
+ await self.coordinator.async_request_refresh()
+ except Exception as err:
+ _LOGGER.exception("Error in '%s'", "async_set_swing_mode")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="generic"
+ ) from err
+
+ self.async_write_ha_state()
+
+ def get_swing_horizontal_mode(self) -> str:
+ """Converts Gree Swing Horizontal Modes to HA."""
+ return self.device.horizontal_swing_mode.name
+
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode):
+ """Set new target horizontal swing operation."""
+ _LOGGER.debug(
+ "async_set_swing_horizontal_mode(%s, %s)",
+ self.device.unique_id,
+ swing_horizontal_mode,
+ )
+
+ if not self.available:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="entity_unavailable"
+ )
+
+ try:
+ self.device.set_horizontal_swing_mode(
+ HorizontalSwingMode[swing_horizontal_mode]
+ )
+ await self.coordinator.push_device_status()
+
+ # notify coordinator listeners of state change so that dependent entities are updated immediately
+ self.coordinator.async_update_listeners()
+
+ await self.coordinator.async_request_refresh()
+ except Exception as err:
+ _LOGGER.exception("Error in '%s'", "async_set_swing_horizontal_mode")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="generic"
+ ) from err
+
+ self.async_write_ha_state()
+
+ def get_temp_units(self) -> UnitOfTemperature:
+ """Returns the device units of temperature."""
+ return UNITS_GREE_TO_HA[self.device.target_temperature_unit]
+
+ def get_current_temp(self) -> float | None:
+ """Returns the current temperature of the room. Accounting for units."""
+
+ # Gree API always return current temperature in ºC
+ # so here we need to convert to the unit of the entity (same as device)
+ if (
+ self.hass
+ and self.device.supports_property(GreeProp.SENSOR_TEMPERATURE)
+ and self.device.indoors_temperature_c is not None
+ ):
+ return TemperatureConverter.convert(
+ float(self.device.indoors_temperature_c),
+ UnitOfTemperature.CELSIUS,
+ self._attr_temperature_unit,
+ )
+
+ return None
+
+ def get_current_humidity(self) -> float | None:
+ """Returns the current humidity of the room."""
+
+ # Gree API always return current humidity in %
+ if (
+ self.device.supports_property(GreeProp.SENSOR_HUMIDITY)
+ and self.device.humidity is not None
+ ):
+ return float(self.device.humidity)
+
+ return None
+
+ def get_current_target_temp(self) -> float | None:
+ """Returns the current target temperature set on the device."""
+ # Device already return in the temperature_units
+ return self.device.target_temperature
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ _LOGGER.debug("async_set_temperature(%s, %s)", self.device.unique_id, kwargs)
+
+ temperature: float | None = kwargs.get(ATTR_TEMPERATURE)
+ hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE)
+
+ if temperature is None and hvac_mode is None:
+ _LOGGER.error("No temperature or mode received to set")
+ return
+
+ if not self.available:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="entity_unavailable"
+ )
+
+ # Ignore temperature if mode is AUTO
+ if (
+ temperature is not None
+ and hvac_mode is not None
+ and hvac_mode == HVACMode.AUTO
+ ):
+ temperature = None
+ _LOGGER.warning(
+ "Ignoring temperature when setting the device mode to AUTO. Will be overriden by the device factory settings"
+ )
+
+ try:
+ # TODO: Confirm that HA sends the values in this entity's temperature_unit which matches the device unit
+ if temperature is not None:
+ self.device.set_target_temperature(temperature)
+
+ if hvac_mode and hvac_mode in self._attr_hvac_modes:
+ # This will call the set_hvac_mode which internally will send to device
+ await self.async_set_hvac_mode(hvac_mode)
+ else:
+ await self.coordinator.push_device_status()
+
+ # notify coordinator listeners of state change so that dependent entities are updated immediately
+ self.coordinator.async_update_listeners()
+
+ await self.coordinator.async_request_refresh()
+ except Exception as err:
+ _LOGGER.exception("Error in '%s'", "async_set_temperature")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="generic"
+ ) from err
+
+ self.async_write_ha_state()
diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py
new file mode 100644
index 0000000..aee846f
--- /dev/null
+++ b/custom_components/gree_custom/config_flow.py
@@ -0,0 +1,1055 @@
+"""Config flow to configure the Gree integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network
+import logging
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_MAC,
+ CONF_PORT,
+ CONF_SCAN_INTERVAL,
+ CONF_TIMEOUT,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import section
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.selector import (
+ NumberSelector,
+ NumberSelectorConfig,
+ NumberSelectorMode,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+from homeassistant.helpers.storage import Store
+
+from .aiogree.api import GreeDiscoveredDevice, GreeProp, discover_gree_devices
+from .aiogree.cipher import EncryptionVersion
+from .aiogree.device import GreeDevice
+from .aiogree.errors import GreeBindingError, GreeConnectionError
+from .const import (
+ ATTR_EXTERNAL_HUMIDITY_SENSOR,
+ ATTR_EXTERNAL_TEMPERATURE_SENSOR,
+ CONF_ADVANCED,
+ CONF_DEV_NAME,
+ CONF_DEVICES,
+ CONF_DISABLE_AVAILABLE_CHECK,
+ CONF_DISCOVERY_PREFS_KEY,
+ CONF_DISCOVERY_PREFS_VERSION,
+ CONF_ENCRYPTION_KEY,
+ CONF_ENCRYPTION_VERSION,
+ CONF_EXTRA_SCAN_HOSTS,
+ CONF_EXTRA_SCAN_NETWORKS,
+ CONF_FAN_MODES,
+ CONF_FEATURES,
+ CONF_HVAC_MODES,
+ CONF_MAX_ONLINE_ATTEMPTS,
+ CONF_RESTORE_STATES,
+ CONF_SWING_HORIZONTAL_MODES,
+ CONF_SWING_MODES,
+ CONF_TEMPERATURE_STEP,
+ CONF_UID,
+ DEFAULT_CONNECTION_MAX_ATTEMPTS,
+ DEFAULT_CONNECTION_TIMEOUT,
+ DEFAULT_DEVICE_PORT,
+ DEFAULT_DEVICE_UID,
+ DEFAULT_DISABLE_AVAILABLE_CHECK,
+ DEFAULT_DISCOVERY_TIMEOUT,
+ DEFAULT_FAN_MODES,
+ DEFAULT_HVAC_MODES,
+ DEFAULT_RESTORE_STATES,
+ DEFAULT_SCAN_INTERVAL,
+ DEFAULT_SWING_HORIZONTAL_MODES,
+ DEFAULT_SWING_MODES,
+ DEFAULT_TARGET_TEMP_STEP,
+ DOMAIN,
+ GATTR_ANTI_DIRECT_BLOW,
+ GATTR_BEEPER,
+ GATTR_FAULTS,
+ GATTR_FEAT_ENERGY_SAVING,
+ GATTR_FEAT_FRESH_AIR,
+ GATTR_FEAT_HEALTH,
+ GATTR_FEAT_LIGHT,
+ GATTR_FEAT_QUIET_MODE,
+ GATTR_FEAT_SENSOR_LIGHT,
+ GATTR_FEAT_SLEEP_MODE,
+ GATTR_FEAT_SMART_HEAT_8C,
+ GATTR_FEAT_TURBO,
+ GATTR_FEAT_XFAN,
+ MAX_UNICAST_SCAN_HOSTS,
+ MIN_SCAN_INTERVAL,
+)
+from .coordinator import GreeConfigEntry
+from .helpers import get_discovery_addresses
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def build_main_schema(data: Mapping | None) -> vol.Schema:
+ """Builds the main option schema."""
+ if data:
+ _LOGGER.debug("Building main schema with previous values: %s", data)
+
+ return vol.Schema(
+ {
+ vol.Required(
+ CONF_HOST,
+ default="" if data is None else data.get(CONF_HOST, ""),
+ ): str,
+ vol.Required(
+ CONF_MAC,
+ default="" if data is None else data.get(CONF_MAC, ""),
+ ): str,
+ vol.Required(CONF_ADVANCED): section(
+ vol.Schema(
+ {
+ vol.Required(
+ CONF_PORT,
+ default=DEFAULT_DEVICE_PORT
+ if data is None or data.get(CONF_ADVANCED) is None
+ else data[CONF_ADVANCED].get(
+ CONF_PORT, DEFAULT_DEVICE_PORT
+ ),
+ ): cv.port,
+ vol.Required(
+ CONF_ENCRYPTION_VERSION,
+ default="Auto-Detect"
+ if data is None or data.get(CONF_ADVANCED) is None
+ else data[CONF_ADVANCED].get(
+ CONF_ENCRYPTION_VERSION, "Auto-Detect"
+ ),
+ ): vol.In(["Auto-Detect", 1, 2]),
+ vol.Optional(
+ CONF_ENCRYPTION_KEY,
+ default=""
+ if data is None or data.get(CONF_ADVANCED) is None
+ else data[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""),
+ ): TextSelector(
+ TextSelectorConfig(type=TextSelectorType.PASSWORD)
+ ),
+ vol.Required(
+ CONF_UID,
+ default=DEFAULT_DEVICE_UID
+ if data is None or data.get(CONF_ADVANCED) is None
+ else data[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID),
+ ): cv.positive_int,
+ vol.Required(
+ CONF_DISABLE_AVAILABLE_CHECK,
+ default=False
+ if data is None
+ else data.get(
+ CONF_DISABLE_AVAILABLE_CHECK,
+ DEFAULT_DISABLE_AVAILABLE_CHECK,
+ ),
+ ): cv.boolean,
+ vol.Required(
+ CONF_MAX_ONLINE_ATTEMPTS,
+ default=DEFAULT_CONNECTION_MAX_ATTEMPTS
+ if data is None
+ else data.get(
+ CONF_MAX_ONLINE_ATTEMPTS,
+ DEFAULT_CONNECTION_MAX_ATTEMPTS,
+ ),
+ ): cv.positive_int,
+ vol.Required(
+ CONF_TIMEOUT,
+ default=DEFAULT_CONNECTION_TIMEOUT
+ if data is None
+ else data.get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT),
+ ): cv.positive_int,
+ }
+ ),
+ {"collapsed": True},
+ ),
+ }
+ )
+
+
+def build_options_schema(
+ hass: HomeAssistant, device: GreeDevice, data: Mapping | None
+) -> vol.Schema:
+ """Builds the device option schema."""
+ if data:
+ _LOGGER.debug("Building device options schema with previous values: %s", data)
+
+ schema: dict = {}
+ schema.update(
+ {
+ vol.Required(
+ CONF_DEV_NAME,
+ default=f"Gree AC {device.unique_id}"
+ if data is None
+ else data.get(CONF_DEV_NAME, f"Gree AC {device.unique_id}"),
+ ): str
+ }
+ )
+
+ if device.supports_property(GreeProp.OP_MODE):
+ schema.update(
+ {
+ vol.Optional(
+ CONF_HVAC_MODES,
+ default=DEFAULT_HVAC_MODES
+ if data is None
+ else data.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES),
+ ): SelectSelector(
+ config=SelectSelectorConfig(
+ options=DEFAULT_HVAC_MODES,
+ multiple=True,
+ translation_key=CONF_HVAC_MODES,
+ )
+ ),
+ }
+ )
+
+ valid_fan_modes = []
+ if device.supports_property(GreeProp.FAN_SPEED):
+ valid_fan_modes = list(DEFAULT_FAN_MODES)
+ if device.supports_property(GreeProp.FEAT_TURBO_MODE):
+ valid_fan_modes.append(GATTR_FEAT_TURBO)
+ if device.supports_property(GreeProp.FEAT_QUIET_MODE):
+ valid_fan_modes.append(GATTR_FEAT_QUIET_MODE)
+
+ if valid_fan_modes:
+ schema.update(
+ {
+ vol.Optional(
+ CONF_FAN_MODES,
+ default=valid_fan_modes
+ if data is None
+ else data.get(CONF_FAN_MODES, valid_fan_modes),
+ ): SelectSelector(
+ config=SelectSelectorConfig(
+ options=valid_fan_modes,
+ multiple=True,
+ translation_key=CONF_FAN_MODES,
+ )
+ ),
+ }
+ )
+
+ if device.supports_property(GreeProp.SWING_VERTICAL):
+ schema.update(
+ {
+ vol.Optional(
+ CONF_SWING_MODES,
+ default=DEFAULT_SWING_MODES
+ if data is None
+ else data.get(CONF_SWING_MODES, DEFAULT_SWING_MODES),
+ ): SelectSelector(
+ config=SelectSelectorConfig(
+ options=DEFAULT_SWING_MODES,
+ multiple=True,
+ translation_key=CONF_SWING_MODES,
+ )
+ ),
+ }
+ )
+
+ if device.supports_property(GreeProp.SWING_HORIZONTAL):
+ schema.update(
+ {
+ vol.Optional(
+ CONF_SWING_HORIZONTAL_MODES,
+ default=DEFAULT_SWING_HORIZONTAL_MODES
+ if data is None
+ else data.get(
+ CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES
+ ),
+ ): SelectSelector(
+ config=SelectSelectorConfig(
+ options=DEFAULT_SWING_HORIZONTAL_MODES,
+ multiple=True,
+ translation_key=CONF_SWING_HORIZONTAL_MODES,
+ )
+ ),
+ }
+ )
+
+ valid_features = [GATTR_BEEPER]
+ if device.supports_property(GreeProp.FEAT_FRESH_AIR):
+ valid_features.append(GATTR_FEAT_FRESH_AIR)
+ if device.supports_property(GreeProp.FEAT_XFAN):
+ valid_features.append(GATTR_FEAT_XFAN)
+ if device.supports_property(GreeProp.FEAT_SLEEP_MODE) or device.supports_property(
+ GreeProp.FEAT_SLEEP_MODE_SWING
+ ):
+ valid_features.append(GATTR_FEAT_SLEEP_MODE)
+ if device.supports_property(GreeProp.FEAT_SMART_HEAT_8C):
+ valid_features.append(GATTR_FEAT_SMART_HEAT_8C)
+ if device.supports_property(GreeProp.FEAT_LIGHT):
+ valid_features.append(GATTR_FEAT_LIGHT)
+ if device.supports_property(GreeProp.FEAT_LIGHT) and device.supports_property(
+ GreeProp.FEAT_SENSOR_LIGHT
+ ):
+ valid_features.append(GATTR_FEAT_SENSOR_LIGHT)
+ if device.supports_property(GreeProp.FEAT_HEALTH):
+ valid_features.append(GATTR_FEAT_HEALTH)
+ if device.supports_property(GreeProp.FEAT_ANTI_DIRECT_BLOW):
+ valid_features.append(GATTR_ANTI_DIRECT_BLOW)
+ if device.supports_property(GreeProp.FEAT_ENERGY_SAVING):
+ valid_features.append(GATTR_FEAT_ENERGY_SAVING)
+ if device.supports_property(GreeProp.FAULT):
+ valid_features.append(GATTR_FAULTS)
+
+ schema.update(
+ {
+ vol.Optional(
+ CONF_FEATURES,
+ default=valid_features
+ if data is None
+ else data.get(CONF_FEATURES, valid_features),
+ ): SelectSelector(
+ config=SelectSelectorConfig(
+ options=valid_features,
+ multiple=True,
+ translation_key=CONF_FEATURES,
+ )
+ )
+ }
+ )
+
+ if device.supports_property(GreeProp.TARGET_TEMPERATURE):
+ schema.update(
+ {
+ vol.Required(
+ CONF_TEMPERATURE_STEP,
+ default=DEFAULT_TARGET_TEMP_STEP
+ if data is None
+ else data.get(CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP),
+ ): NumberSelector(
+ NumberSelectorConfig(
+ min=0.5,
+ max=5,
+ step=0.5,
+ mode=NumberSelectorMode.BOX,
+ unit_of_measurement="ºC",
+ )
+ )
+ }
+ )
+
+ schema.update(
+ {
+ # Ideally we would use an Optional EntitySelector for external sensors.
+ # Currently we can't because unsetting the value in the UI makes HA
+ # populate the user_input with the previous set value, making the user
+ # unable to unset the external sensors.
+ vol.Required(
+ ATTR_EXTERNAL_TEMPERATURE_SENSOR,
+ default="None"
+ if data is None
+ else data.get(ATTR_EXTERNAL_TEMPERATURE_SENSOR, "None"),
+ ): SelectSelector(
+ config=SelectSelectorConfig(
+ options=get_temperature_sensor_options(hass),
+ multiple=False,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=ATTR_EXTERNAL_TEMPERATURE_SENSOR,
+ )
+ ),
+ vol.Required(
+ ATTR_EXTERNAL_HUMIDITY_SENSOR,
+ default="None"
+ if data is None
+ else data.get(ATTR_EXTERNAL_HUMIDITY_SENSOR, "None"),
+ ): SelectSelector(
+ config=SelectSelectorConfig(
+ options=get_humidity_sensor_options(hass),
+ multiple=False,
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=ATTR_EXTERNAL_HUMIDITY_SENSOR,
+ )
+ ),
+ vol.Required(
+ CONF_RESTORE_STATES,
+ default=True
+ if data is None
+ else data.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES),
+ ): cv.boolean,
+ vol.Required(
+ CONF_SCAN_INTERVAL,
+ default=DEFAULT_SCAN_INTERVAL
+ if data is None
+ else data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL),
+ ): vol.All(vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL)),
+ }
+ )
+ return vol.Schema(schema)
+
+
+def get_temperature_sensor_options(hass: HomeAssistant) -> list[str]:
+ """Get list of available temperature sensor entities."""
+ options: list[str] = [
+ "None"
+ ] # Include None as option since otherwise the user can't unset the external sensor
+
+ # Get all entities from the registry
+ for state in hass.states.async_all():
+ # Look for temperature sensors
+ if state.entity_id.startswith("sensor."):
+ # Check for explicit device_class
+ if state.attributes.get("device_class") == "temperature":
+ options.append(state.entity_id)
+
+ return options
+
+
+def get_humidity_sensor_options(hass: HomeAssistant) -> list[str]:
+ """Get list of available temperature sensor entities."""
+ options: list[str] = [
+ "None"
+ ] # Include None as option since otherwise the user can't unset the external sensor
+
+ # Get all entities from the registry
+ for state in hass.states.async_all():
+ # Look for temperature sensors
+ if state.entity_id.startswith("sensor."):
+ # Check for explicit device_class
+ if state.attributes.get("device_class") == "humidity":
+ options.append(state.entity_id)
+
+ return options
+
+
+def apply_schema_defaults(schema: vol.Schema, data: dict) -> dict:
+ """Fill in defaults for missing required keys (including nested)."""
+ data = dict(data or {})
+ result = {}
+
+ for key_obj, validator in schema.schema.items():
+ key = key_obj.schema # actual string name
+ value = data.get(key, vol.UNDEFINED)
+
+ # Extract default if missing
+ if value is vol.UNDEFINED:
+ default = getattr(key_obj, "default", vol.UNDEFINED)
+ if default is not vol.UNDEFINED:
+ value = default() if callable(default) else default
+
+ # Handle nested schema recursively
+ if isinstance(validator, vol.Schema) and isinstance(value, dict):
+ value = apply_schema_defaults(validator, value)
+
+ # Run individual field validator (type checks etc.)
+ if value is not vol.UNDEFINED:
+ value = validator(value) if callable(validator) else value
+
+ result[key] = value
+
+ return result
+
+
+def format_mac_id(mac_addr: str) -> str:
+ """Returns a formated mac address for use as unique id."""
+ if "@" in mac_addr:
+ _mac_addr_sub, _ = mac_addr.lower().split("@", 1)
+ return format_mac(_mac_addr_sub)
+ return format_mac(mac_addr)
+
+
+DEVICE_OPTIONS_KEYS = {
+ CONF_TIMEOUT,
+ CONF_MAX_ONLINE_ATTEMPTS,
+ CONF_DISABLE_AVAILABLE_CHECK,
+ ATTR_EXTERNAL_HUMIDITY_SENSOR,
+ ATTR_EXTERNAL_TEMPERATURE_SENSOR,
+ CONF_FEATURES,
+ CONF_SWING_HORIZONTAL_MODES,
+ CONF_SWING_MODES,
+ CONF_FAN_MODES,
+ CONF_HVAC_MODES,
+} # keys in the device_options schema
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow from user."""
+
+ VERSION = 2
+ _discovered_devices: list[GreeDiscoveredDevice] | None = None
+ _discovery_selected_device: GreeDiscoveredDevice | None = None
+ _discovery_performed: bool = False
+
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ self._step_main_data: dict | None = None
+ self._main_mac: str = ""
+ self._discovered_subdevices: list[GreeDiscoveredDevice] | None = None
+ self._device_configs: dict = {}
+ self._selected_subdevices_macs: list = []
+ self._reconfiguring_entry: GreeConfigEntry | None = None
+ self._devices: dict[str, GreeDevice] = {}
+ self._is_reconfigure: bool = False
+
+ self.pref_storage = None
+
+ async def async_step_import(
+ self, import_config: dict
+ ) -> config_entries.ConfigFlowResult:
+ """Handle import from configuration.yaml."""
+ _LOGGER.debug("Importing config entry: %s", import_config)
+
+ mac = import_config.get(CONF_MAC, "")
+
+ if not mac:
+ _LOGGER.error("No MAC for imported device: %s", import_config)
+ raise ValueError(f"No MAC for imported device: {import_config}")
+
+ # Combine the schemas
+ schema1 = build_main_schema(import_config)
+ data = apply_schema_defaults(schema1, import_config)
+
+ device: GreeDevice = GreeDevice(
+ f"Temporary Device for {data[CONF_MAC]}",
+ data[CONF_HOST],
+ data[CONF_MAC],
+ data[CONF_ADVANCED][CONF_PORT],
+ data[CONF_ADVANCED][CONF_ENCRYPTION_KEY],
+ EncryptionVersion(int(data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION]))
+ if data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] != "Auto-Detect"
+ else None,
+ data[CONF_ADVANCED][CONF_UID],
+ max_connection_attempts=2, # Use fewer attempts for testing the device
+ timeout=2, # Use smaller timeout for testing the device
+ )
+ await device.fetch_device_status()
+
+ data[CONF_MAC] = device.mac_address_controller
+ data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] = (
+ int(device.encryption_version) if device.encryption_version else 0
+ )
+ data[CONF_ADVANCED][CONF_ENCRYPTION_KEY] = device.encryption_key
+
+ device_configs: list[dict] = import_config.get(CONF_DEVICES, [])
+
+ # add the main device to the configs if not present
+ if not self._get_device_conf(
+ import_config, device.mac_address
+ ) and not self._get_device_conf(import_config, import_config[CONF_MAC]):
+ device_configs.append({CONF_MAC: device.mac_address})
+
+ data[CONF_DEVICES] = []
+ for dev_config in device_configs:
+ mac = dev_config.get(CONF_MAC, "")
+
+ if not mac:
+ _LOGGER.error("No MAC for imported device: %s", dev_config)
+ continue
+
+ dev: GreeDevice = GreeDevice(
+ f"Temporary Device for {mac}",
+ data[CONF_HOST],
+ mac,
+ data[CONF_ADVANCED][CONF_PORT],
+ data[CONF_ADVANCED][CONF_ENCRYPTION_KEY],
+ EncryptionVersion(int(data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION])),
+ data[CONF_ADVANCED][CONF_UID],
+ max_connection_attempts=2, # Use fewer attempts for testing the device
+ timeout=2, # Use smaller timeout for testing the device
+ )
+
+ await dev.fetch_device_status()
+ schema_dev = build_options_schema(self.hass, dev, dev_config)
+ data[CONF_DEVICES].append(
+ {
+ **apply_schema_defaults(schema_dev, import_config),
+ CONF_MAC: dev.mac_address,
+ }
+ )
+
+ unique_id = format_mac_id(device.mac_address_controller)
+ entry = next(
+ (
+ e
+ for e in self.hass.config_entries.async_entries(DOMAIN)
+ if e.unique_id == unique_id
+ ),
+ None,
+ )
+
+ await self.async_set_unique_id(unique_id)
+
+ if entry:
+ return self.async_update_reload_and_abort(
+ entry,
+ title=f"Gree System at {data[CONF_HOST]}",
+ data=data,
+ )
+
+ return self.async_create_entry(
+ title=f"Gree System at {data[CONF_HOST]}", data=data
+ )
+
+ async def async_step_user(
+ self, user_input: dict | None = None
+ ) -> config_entries.ConfigFlowResult:
+ """Handle the initial step - show discovery or manual entry."""
+ if user_input is not None:
+ choice = user_input.get("discovery")
+ if choice == "discover":
+ return await self.async_step_manual_discovery()
+ if choice == "discover_extended":
+ return await self.async_step_discovery_options()
+ return await self.async_step_manual_add()
+
+ # Show discovery vs manual choice
+ data_schema = vol.Schema(
+ {
+ vol.Required("discovery", default="discover"): SelectSelector(
+ SelectSelectorConfig(
+ options=["discover", "discover_extended", "manual"],
+ translation_key="discovery_method",
+ )
+ )
+ }
+ )
+ return self.async_show_form(step_id="user", data_schema=data_schema)
+
+ async def async_step_manual_discovery(
+ self, user_input: dict | None = None
+ ) -> config_entries.ConfigFlowResult:
+ """Handle device discovery."""
+
+ if user_input is not None:
+ # User selected a discovered device
+ selected_device = user_input["device"]
+
+ assert self._discovered_devices
+
+ for device in self._discovered_devices:
+ device_id = device.mac
+ if device_id == selected_device:
+ # Check if already configured
+ await self.async_set_unique_id(format_mac_id(device.mac))
+ self._abort_if_unique_id_configured()
+
+ # Store selected device for next step
+ self._discovery_selected_device = device
+ return await self.async_step_manual_add()
+
+ # If no matching device found, something went wrong - go to manual
+ return await self.async_step_manual_add()
+
+ # Discover devices
+ self._discovery_performed = True
+ self._discovered_devices = await self._discover_devices(self.hass)
+
+ if not self._discovered_devices:
+ # No devices found, go to manual entry
+ return await self.async_step_manual_add()
+
+ # Create device selection options
+ device_options = {}
+ for device in self._discovered_devices:
+ device_id = device.mac
+ if device.subdevices > 0:
+ device_options[device_id] = (
+ f"IP: {device.host}, MAC: {device.mac}, Subdevices: {device.subdevices}"
+ )
+ else:
+ device_options[device_id] = f"IP: {device.host}, MAC: {device.mac}"
+
+ data_schema = vol.Schema({vol.Required("device"): vol.In(device_options)})
+
+ return self.async_show_form(
+ step_id="manual_discovery",
+ data_schema=data_schema,
+ description_placeholders={
+ "devices_found": str(len(self._discovered_devices))
+ },
+ )
+
+ async def async_step_discovery_options(
+ self, user_input: dict[str, Any] | None = None
+ ) -> config_entries.ConfigFlowResult:
+ """Collect optional cross-VLAN scan ranges before running discovery."""
+ errors: dict[str, str] = {}
+ networks_raw = ""
+ hosts_raw = ""
+ self.pref_storage = self.pref_storage or Store(
+ self.hass, CONF_DISCOVERY_PREFS_VERSION, CONF_DISCOVERY_PREFS_KEY
+ )
+
+ # BUG: HA persists the old value if a field is empty. Workaround is to send a [space].
+ if user_input is not None:
+ networks_raw: str = (user_input.get(CONF_EXTRA_SCAN_NETWORKS, "")).strip()
+ hosts_raw: str = (user_input.get(CONF_EXTRA_SCAN_HOSTS, "")).strip()
+
+ extra_networks: list[str] = (
+ [s.strip() for s in networks_raw.split(",") if s.strip()]
+ if networks_raw
+ else []
+ )
+ extra_hosts: list[str] = (
+ [s.strip() for s in hosts_raw.split(",") if s.strip()]
+ if hosts_raw
+ else []
+ )
+
+ num_hosts = 0
+ for cidr in extra_networks:
+ try:
+ net = ip_network(cidr, strict=False)
+ except ValueError:
+ errors[CONF_EXTRA_SCAN_NETWORKS] = "invalid_network"
+ break
+
+ if not isinstance(net, IPv4Network):
+ errors[CONF_EXTRA_SCAN_NETWORKS] = "invalid_network"
+ break
+
+ # /31 => 2 usable, /32 => 1 usable, otherwise subtract net+broadcast
+ usable = (
+ net.num_addresses if net.prefixlen >= 31 else net.num_addresses - 2
+ )
+ if usable > MAX_UNICAST_SCAN_HOSTS:
+ errors[CONF_EXTRA_SCAN_NETWORKS] = "network_too_large"
+ break
+ num_hosts += usable
+
+ for ip in extra_hosts:
+ try:
+ addr = ip_address(ip)
+ except ValueError:
+ errors[CONF_EXTRA_SCAN_HOSTS] = "invalid_host"
+ break
+
+ if not isinstance(addr, IPv4Address):
+ errors[CONF_EXTRA_SCAN_HOSTS] = "invalid_host"
+ break
+ num_hosts += 1
+
+ if num_hosts > MAX_UNICAST_SCAN_HOSTS:
+ errors["base"] = "too_many_targets"
+
+ if not errors:
+ # Persist last-used values for this HA session
+ await self.pref_storage.async_save(
+ {
+ CONF_EXTRA_SCAN_NETWORKS: extra_networks,
+ CONF_EXTRA_SCAN_HOSTS: extra_hosts,
+ }
+ )
+
+ # self._extra_networks = extra_networks or None
+ # self._extra_hosts = extra_hosts or None
+ return await self.async_step_manual_discovery()
+
+ # Prefill from previous run (if any) or from current submission
+
+ prefs = await self.pref_storage.async_load() or {}
+ default_networks: str = networks_raw or ", ".join(
+ prefs.get(CONF_EXTRA_SCAN_NETWORKS, [])
+ )
+ default_hosts: str = hosts_raw or ", ".join(
+ prefs.get(CONF_EXTRA_SCAN_HOSTS, [])
+ )
+
+ # TODO: Use a TextSelector with multiple set to True. Unfortunately, as of now, HA UI has a bug where the focus on the textfield exits at every character
+ data_schema = vol.Schema(
+ {
+ vol.Optional(CONF_EXTRA_SCAN_NETWORKS, default=default_networks): str,
+ vol.Optional(CONF_EXTRA_SCAN_HOSTS, default=default_hosts): str,
+ }
+ )
+ return self.async_show_form(
+ step_id="discovery_options",
+ data_schema=data_schema,
+ errors=errors,
+ )
+
+ async def async_step_manual_add(
+ self, user_input: dict | None = None, reconfigure_input: dict | None = None
+ ) -> config_entries.ConfigFlowResult:
+ """Handle the manual add of a device."""
+ errors = {}
+
+ if user_input is not None:
+ try:
+ _main_device = GreeDevice(
+ f"Gree Device {user_input[CONF_MAC]}",
+ user_input[CONF_HOST],
+ user_input[CONF_MAC],
+ user_input[CONF_ADVANCED][CONF_PORT],
+ user_input[CONF_ADVANCED][CONF_ENCRYPTION_KEY],
+ EncryptionVersion(
+ int(user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION])
+ )
+ if user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION]
+ != "Auto-Detect"
+ else None,
+ user_input[CONF_ADVANCED][CONF_UID],
+ max_connection_attempts=2, # Use fewer attempts for testing the device
+ timeout=2, # Use smaller timeout for testing the device
+ )
+ self._main_mac = _main_device.mac_address_controller
+ await self.async_set_unique_id(format_mac_id(self._main_mac))
+
+ if self._is_reconfigure:
+ self._abort_if_unique_id_mismatch()
+ else:
+ self._abort_if_unique_id_configured()
+
+ self._devices[_main_device.mac_address] = _main_device
+
+ # self._discovered_subdevices = await get_sub_devices(
+ # _main_device.mac_address, user_input[CONF_HOST], 0, 2, 2
+ # )
+ self._discovered_subdevices = await self._devices[
+ _main_device.mac_address
+ ].bind_device()
+
+ self._discovered_subdevices = await self._devices[
+ _main_device.mac_address
+ ].fetch_sub_devices()
+
+ for d in self._discovered_subdevices:
+ subdev = GreeDevice(
+ d.name,
+ user_input[CONF_HOST],
+ f"{d.mac}@{_main_device.mac_address_controller}",
+ user_input[CONF_ADVANCED][CONF_PORT],
+ _main_device.encryption_key,
+ _main_device.encryption_version,
+ user_input[CONF_ADVANCED][CONF_UID],
+ max_connection_attempts=2, # Use fewer attempts for testing the device
+ timeout=2, # Use smaller timeout for testing the device
+ )
+ self._devices[subdev.mac_address] = subdev
+
+ await self._devices[_main_device.mac_address].fetch_device_status()
+ except GreeBindingError:
+ errors["base"] = "cannot_bind"
+ _LOGGER.exception("Error while binding")
+ except GreeConnectionError:
+ errors["base"] = "cannot_connect"
+ _LOGGER.exception("Cannot connect")
+ except Exception:
+ errors["base"] = "unknown"
+ _LOGGER.exception("Unknown error while binding")
+ else:
+ if self._step_main_data:
+ self._step_main_data.update(user_input)
+ else:
+ self._step_main_data = user_input
+ self._step_main_data[CONF_MAC] = _main_device.mac_address_controller
+ self._step_main_data[CONF_ADVANCED].update(
+ {
+ CONF_ENCRYPTION_VERSION: _main_device.encryption_version,
+ CONF_ENCRYPTION_KEY: _main_device.encryption_key,
+ }
+ )
+
+ return await self.async_step_device_options()
+
+ elif self._discovery_selected_device is not None:
+ user_input = {}
+ # user_input[CONF_NAME] = self._selected_device.name
+ user_input[CONF_HOST] = self._discovery_selected_device.host
+ user_input[CONF_MAC] = self._discovery_selected_device.mac
+ user_input[CONF_ADVANCED] = {}
+ user_input[CONF_ADVANCED][CONF_PORT] = self._discovery_selected_device.port
+ user_input[CONF_ADVANCED][CONF_UID] = self._discovery_selected_device.uid
+ elif self._discovery_performed and self._discovery_selected_device is None:
+ errors["base"] = "no_devices_found"
+ elif reconfigure_input is not None:
+ user_input = reconfigure_input
+ self._step_main_data = reconfigure_input
+
+ return self.async_show_form(
+ step_id="manual_add",
+ data_schema=build_main_schema(user_input),
+ errors=errors,
+ )
+
+ async def async_step_device_options(
+ self,
+ user_input: dict | None = None,
+ index: int | None = None,
+ ) -> config_entries.ConfigFlowResult:
+ """Second step: configure features/modes."""
+ if (
+ user_input is not None
+ and self._step_main_data is not None
+ and self._devices[self._main_mac] is not None
+ ):
+ await self.async_set_unique_id(format_mac_id(self._main_mac))
+ if self._is_reconfigure:
+ self._abort_if_unique_id_mismatch()
+ else:
+ self._abort_if_unique_id_configured()
+
+ # Configuring the main device
+ # If it has no subdevices, finalyze entry
+ # Otherwise repeat form while iterating the subdevices
+ if index is None:
+ self._device_configs[self._main_mac] = {
+ # Ignore the subdevice selection item
+ k: v
+ for k, v in user_input.items()
+ if k != CONF_DEVICES
+ }
+
+ self._selected_subdevices_macs = user_input.get(CONF_DEVICES, [])
+ # Remove the device configs for the ones not selected so they are removed from the entry
+ self._device_configs = {
+ k: v
+ for k, v in self._device_configs.items()
+ if k in self._selected_subdevices_macs or k == self._main_mac
+ }
+
+ if self._selected_subdevices_macs:
+ return await self.async_step_device_options(None, 0)
+
+ if self._is_reconfigure:
+ return self._update_entry()
+ return self._create_final_entry()
+
+ # If configuring a subdevice iterate the chosen subdevices
+ # If the last subdevice, finalyze the entry
+ self._device_configs[self._selected_subdevices_macs[index]] = user_input
+ if index + 1 < len(self._selected_subdevices_macs):
+ return await self.async_step_device_options(None, index + 1)
+
+ if self._is_reconfigure:
+ return self._update_entry()
+ return self._create_final_entry()
+
+ if self._step_main_data is None:
+ raise ValueError("No data from main options")
+
+ if self._devices[self._main_mac] is None:
+ raise ValueError("No device created in main options step")
+
+ device: GreeDevice = self._devices[self._main_mac]
+
+ if index is not None and self._discovered_subdevices:
+ device = self._devices[self._selected_subdevices_macs[index]]
+
+ await device.fetch_device_status()
+
+ conf_input = user_input
+ if self._is_reconfigure:
+ conf_input = self._get_device_conf(self._step_main_data, device.mac_address)
+
+ schema = build_options_schema(self.hass, device, conf_input)
+
+ # If we are configuring the main device,
+ # add list of subdevices to include if any
+ if index is None and self._discovered_subdevices:
+ subdev_options = {d.mac: d.name for d in self._discovered_subdevices}
+ selected_options = subdev_options.keys()
+
+ # If reconfiguring, only preselect the devices already configured
+ if self._is_reconfigure:
+ configured_device_macs = [
+ device["mac"] for device in self._step_main_data["devices"]
+ ]
+ selected_options = [
+ mac for mac in subdev_options if mac in configured_device_macs
+ ]
+ schema.extend(
+ {
+ vol.Required(
+ CONF_DEVICES, default=selected_options
+ ): cv.multi_select(subdev_options)
+ }
+ )
+
+ return self.async_show_form(
+ step_id="device_options",
+ data_schema=schema,
+ )
+
+ async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None):
+ """Handle reconfiguration of an existing entry."""
+ entry: GreeConfigEntry = self._get_reconfigure_entry()
+
+ _LOGGER.debug("Reconfiguring: %s", entry)
+ await self.async_set_unique_id(entry.unique_id)
+ self._reconfiguring_entry = entry
+ self._is_reconfigure = True
+
+ return await self.async_step_manual_add(
+ None, dict(entry.data) if entry.data is not None else None
+ )
+
+ # return self.async_show_form(
+ # step_id="reconfigure",
+ # data_schema=build_main_schema(
+ # entry.data if entry.data is not None else user_input
+ # ),
+ # errors=errors,
+ # )
+
+ async def _discover_devices(
+ self, hass: HomeAssistant
+ ) -> list[GreeDiscoveredDevice]:
+ """Discover devices in the network."""
+
+ return await discover_gree_devices(
+ await get_discovery_addresses(hass), DEFAULT_DISCOVERY_TIMEOUT
+ )
+
+ def _create_final_entry(self):
+ """Build final entry data."""
+ data: dict = {}
+
+ if self._step_main_data:
+ data = self._step_main_data.copy()
+
+ # build devices list: main + subdevices
+ devices = []
+ for mac, conf in self._device_configs.items():
+ devices.append({**conf, CONF_MAC: mac})
+
+ data[CONF_DEVICES] = devices
+
+ _LOGGER.debug(
+ "New entry with config: %s",
+ async_redact_data(data, ["encryption_key"]),
+ )
+ return self.async_create_entry(
+ title=f"Gree System at {data[CONF_HOST]}", data=data
+ )
+
+ def _update_entry(self):
+ """Build final entry data."""
+ data: dict = {}
+
+ if self._reconfiguring_entry is None:
+ raise ValueError("Error updating entry which is not set")
+
+ if self._step_main_data:
+ data = self._step_main_data.copy()
+
+ # build devices list: main + subdevices
+ devices = []
+ for mac, conf in self._device_configs.items():
+ devices.append({**conf, CONF_MAC: mac})
+
+ data[CONF_DEVICES] = devices
+
+ _LOGGER.debug("Updating entry with config: %s", data)
+
+ return self.async_update_reload_and_abort(
+ self._reconfiguring_entry,
+ title=f"Gree System at {data[CONF_HOST]}",
+ data=data,
+ )
+
+ def _get_device_conf(self, config: dict, mac: str) -> dict | None:
+ configured_devices = config.get(CONF_DEVICES, [])
+ return next((d for d in configured_devices if d.get(CONF_MAC) == mac), None)
diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py
new file mode 100755
index 0000000..a2e3661
--- /dev/null
+++ b/custom_components/gree_custom/const.py
@@ -0,0 +1,183 @@
+"""Constants for the Gree integration."""
+
+from homeassistant.components.climate import HVACMode
+from homeassistant.const import UnitOfTemperature
+
+from .aiogree.api import (
+ FanSpeed,
+ GreeProp,
+ HorizontalSwingMode,
+ OperationMode,
+ TemperatureUnits,
+ VerticalSwingMode,
+)
+
+DOMAIN = "gree_custom"
+
+CONF_EXTRA_SCAN_NETWORKS = "extra_scan_networks"
+CONF_EXTRA_SCAN_HOSTS = "extra_scan_hosts"
+CONF_DISCOVERY_PREFS_KEY = DOMAIN + "_discovery_prefs"
+CONF_DISCOVERY_PREFS_VERSION = 1
+
+CONF_ADVANCED = "advanced"
+CONF_UID = "uid"
+CONF_ENCRYPTION_KEY = "encryption_key"
+CONF_ENCRYPTION_VERSION = "encryption_version"
+CONF_DISABLE_AVAILABLE_CHECK = "disable_available_check"
+CONF_MAX_ONLINE_ATTEMPTS = "max_online_attempts"
+CONF_RESTORE_STATES = "restore_states"
+CONF_DEVICES = "devices"
+CONF_DEV_NAME = "device_name"
+CONF_HVAC_MODES = "hvac_modes"
+CONF_FAN_MODES = "fan_modes"
+CONF_SWING_MODES = "swing_modes"
+CONF_SWING_HORIZONTAL_MODES = "swing_horizontal_modes"
+CONF_FEATURES = "features"
+CONF_TEMPERATURE_STEP = "target_temp_step"
+
+DEFAULT_TARGET_TEMP_STEP = 1
+DEFAULT_ENCRYPTION_VERSION = None
+DEFAULT_DISABLE_AVAILABLE_CHECK = False
+DEFAULT_RESTORE_STATES = True
+MIN_SCAN_INTERVAL = 5
+DEFAULT_SCAN_INTERVAL = 30
+
+DEFAULT_DEVICE_UID = 0
+DEFAULT_DEVICE_PORT = 7000
+DEFAULT_CONNECTION_MAX_ATTEMPTS = 3
+DEFAULT_CONNECTION_TIMEOUT = 5
+DEFAULT_DISCOVERY_TIMEOUT = 5
+
+MAX_UNICAST_SCAN_HOSTS = 65536
+
+# OPTIONAL FEATURES/MODES
+# use the device beeper on commands
+GATTR_BEEPER = "beeper"
+# controls the state of the fresh air valve (not available on all units)
+GATTR_FEAT_FRESH_AIR = "air"
+# "Blow" or "X-Fan", this function keeps the fan running for a while after shutting down. Only usable in Dry and Cool mode
+GATTR_FEAT_XFAN = "xfan"
+# sleep mode, which gradually changes the temperature in Cool, Heat and Dry mode
+GATTR_FEAT_SLEEP_MODE = "sleep"
+# Anti Freeze maintain the room temperature steadily at 8°C and prevent the room from freezing by heating operation when nobody is at home for long in severe winter
+GATTR_FEAT_SMART_HEAT_8C = "eightdegheat"
+# turns all indicators and the display on the unit on or off
+GATTR_FEAT_LIGHT = "lights"
+# controls Health ("Cold plasma") mode
+GATTR_FEAT_HEALTH = "health"
+# prevents the wind from blowing directly on people
+GATTR_ANTI_DIRECT_BLOW = "anti_direct_blow"
+# energy saving mode
+GATTR_FEAT_ENERGY_SAVING = "powersave"
+# use light sensor for unit display
+GATTR_FEAT_SENSOR_LIGHT = "light_sensor"
+# Quiet mode which slows down the fan to its most quiet speed. Not available in Dry and Fan mode.
+GATTR_FEAT_QUIET_MODE = "quiet"
+# Turbo mode sets fan speed to the maximum. Fan speed cannot be changed while active and only available in Dry and Cool mode
+GATTR_FEAT_TURBO = "turbo"
+
+GATTR_TEMP_UNITS = "temperature_units"
+GATTR_INDOOR_TEMPERATURE = "indoor_temperature"
+GATTR_OUTDOOR_TEMPERATURE = "outdoor_temperature"
+GATTR_HUMIDITY = "rooom_humidity"
+
+GATTR_FAULTS = "faults"
+
+ATTR_EXTERNAL_TEMPERATURE_SENSOR = "external_temperature_sensor"
+ATTR_EXTERNAL_HUMIDITY_SENSOR = "external_humidity_sensor"
+ATTR_AUTO_XFAN = "auto_xfan"
+ATTR_AUTO_LIGHT = "auto_light"
+
+# Map each feature constant to its corresponding GreeProp
+CONF_TO_PROP_FEATURE_MAP = {
+ GATTR_BEEPER: GreeProp.BEEPER,
+ GATTR_FEAT_FRESH_AIR: GreeProp.FEAT_FRESH_AIR,
+ GATTR_FEAT_XFAN: GreeProp.FEAT_XFAN,
+ GATTR_FEAT_SLEEP_MODE: GreeProp.FEAT_SLEEP_MODE,
+ GATTR_FEAT_SMART_HEAT_8C: GreeProp.FEAT_SMART_HEAT_8C,
+ GATTR_FEAT_HEALTH: GreeProp.FEAT_HEALTH,
+ GATTR_ANTI_DIRECT_BLOW: GreeProp.FEAT_ANTI_DIRECT_BLOW,
+ GATTR_FEAT_ENERGY_SAVING: GreeProp.FEAT_ENERGY_SAVING,
+ GATTR_FEAT_LIGHT: GreeProp.FEAT_LIGHT,
+ GATTR_FAULTS: GreeProp.FAULT,
+}
+
+# HVAC modes - these come from Home Assistant and are standard
+DEFAULT_HVAC_MODES = [
+ HVACMode.AUTO,
+ HVACMode.COOL,
+ HVACMode.DRY,
+ HVACMode.FAN_ONLY,
+ HVACMode.HEAT,
+ HVACMode.OFF,
+]
+
+HVAC_MODES_HA_TO_GREE = {
+ HVACMode.AUTO: OperationMode.auto,
+ HVACMode.COOL: OperationMode.cool,
+ HVACMode.DRY: OperationMode.dry,
+ HVACMode.FAN_ONLY: OperationMode.fan,
+ HVACMode.HEAT: OperationMode.heat,
+}
+HVAC_MODES_GREE_TO_HA = {
+ OperationMode.auto: HVACMode.AUTO,
+ OperationMode.cool: HVACMode.COOL,
+ OperationMode.dry: HVACMode.DRY,
+ OperationMode.fan: HVACMode.FAN_ONLY,
+ OperationMode.heat: HVACMode.HEAT,
+}
+
+DEFAULT_FAN_MODES = [
+ FanSpeed.auto.name,
+ FanSpeed.low.name,
+ FanSpeed.medium_low.name,
+ FanSpeed.medium.name,
+ FanSpeed.medium_high.name,
+ FanSpeed.high.name,
+ # GATTR_FEAT_TURBO, # Special mode on Gree device
+ # GATTR_FEAT_QUIET_MODE, # Special mode on Gree device
+]
+
+DEFAULT_SWING_MODES = [
+ VerticalSwingMode.default.name,
+ VerticalSwingMode.full_swing.name,
+ VerticalSwingMode.fixed_upper.name,
+ VerticalSwingMode.fixed_upper_middle.name,
+ VerticalSwingMode.fixed_middle.name,
+ VerticalSwingMode.fixed_lower_middle.name,
+ VerticalSwingMode.fixed_lower.name,
+ VerticalSwingMode.swing_lower.name,
+ VerticalSwingMode.swing_lower_middle.name,
+ VerticalSwingMode.swing_middle.name,
+ VerticalSwingMode.swing_upper_middle.name,
+ VerticalSwingMode.swing_upper.name,
+]
+
+DEFAULT_SWING_HORIZONTAL_MODES = [
+ HorizontalSwingMode.default.name,
+ HorizontalSwingMode.full_swing.name,
+ HorizontalSwingMode.left.name,
+ HorizontalSwingMode.left_center.name,
+ HorizontalSwingMode.center.name,
+ HorizontalSwingMode.right_center.name,
+ HorizontalSwingMode.right.name,
+]
+
+DEFAULT_SUPPORTED_FEATURES = [
+ GATTR_BEEPER,
+ GATTR_FEAT_FRESH_AIR,
+ GATTR_FEAT_XFAN,
+ GATTR_FEAT_SLEEP_MODE,
+ GATTR_FEAT_SMART_HEAT_8C,
+ GATTR_FEAT_LIGHT,
+ GATTR_FEAT_HEALTH,
+ GATTR_ANTI_DIRECT_BLOW,
+ GATTR_FEAT_ENERGY_SAVING,
+ GATTR_FEAT_SENSOR_LIGHT,
+ GATTR_FAULTS,
+]
+
+UNITS_GREE_TO_HA = {
+ TemperatureUnits.C: UnitOfTemperature.CELSIUS,
+ TemperatureUnits.F: UnitOfTemperature.FAHRENHEIT,
+}
diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py
new file mode 100644
index 0000000..251619d
--- /dev/null
+++ b/custom_components/gree_custom/coordinator.py
@@ -0,0 +1,122 @@
+"""Data update coordinator for Gree integration."""
+
+import logging
+from typing import Any
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import (
+ DataUpdateCoordinator,
+ UpdateFailed,
+ timedelta,
+)
+
+from .aiogree.device import GreeDevice
+from .aiogree.errors import GreeBindingError, GreeConnectionError
+from .helpers import try_find_new_ip
+
+_LOGGER = logging.getLogger(__name__)
+
+type GreeConfigEntry = ConfigEntry[dict[str, GreeCoordinator]]
+
+
+class GreeCoordinator(DataUpdateCoordinator[None]):
+ """Gree device coordinator."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: GreeConfigEntry,
+ device: GreeDevice,
+ scan_interval: int,
+ ) -> None:
+ """Initialize coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name="Gree Coordinator " + device.unique_id,
+ config_entry=config_entry,
+ update_interval=timedelta(seconds=scan_interval),
+ always_update=True,
+ )
+ self.device: GreeDevice = device
+ self._feature_auto_xfan: bool = False
+ self._feature_auto_light: bool = False
+
+ async def _async_setup(self):
+ """Set up the coordinator.
+
+ This is the place to set up your coordinator,
+ or to load data, that only needs to be loaded once.
+
+ This method will be called automatically during
+ coordinator.async_config_entry_first_refresh.
+ """
+ await self.device.bind_device()
+
+ async def _async_update_data(self):
+ """Fetch data from API endpoint.
+
+ This is the place to pre-process the data to lookup tables
+ so entities can quickly look up their data.
+ """
+ try:
+ await self.device.fetch_device_status()
+
+ except GreeConnectionError as err:
+ if not await try_find_new_ip(self.hass, self.device, self.config_entry):
+ raise UpdateFailed("Error getting state from device") from err
+
+ # retry once after IP recovery
+ try:
+ await self.device.fetch_device_status()
+ except Exception as err_inner:
+ raise UpdateFailed("Error getting state from device") from err_inner
+
+ except GreeBindingError as err:
+ _LOGGER.exception("Failed to initiate Gree device")
+ raise ConfigEntryAuthFailed("Failed to initiate Gree device") from err
+
+ except Exception as err:
+ _LOGGER.exception("Error getting state from device")
+ raise UpdateFailed("Error getting state from device") from err
+
+ async def push_device_status(self):
+ """Pushes the transient state to the device."""
+ try:
+ await self.device.push_device_status()
+ except GreeConnectionError:
+ if not await try_find_new_ip(self.hass, self.device, self.config_entry):
+ raise # propagate original error if recovery fails
+
+ # retry once after recovering IP
+ await self.device.push_device_status()
+
+ def get_coordinator_diagnostics(self) -> dict[str, Any]:
+ """Returns diagnostic data for the coordinator."""
+ data = self.device.gather_diagnostics()
+ data["coordinator_props"] = {
+ "auto_light": self.feature_auto_light,
+ "auto_xfan": self.feature_auto_xfan,
+ }
+
+ return data
+
+ @property
+ def feature_auto_light(self) -> bool:
+ """Returns the state of the Auto Display Light Feature."""
+ return self._feature_auto_light
+
+ def set_feature_auto_light(self, value: bool) -> None:
+ """Sets the state of the Auto Display Light Feature."""
+ self._feature_auto_light = value
+
+ @property
+ def feature_auto_xfan(self) -> bool:
+ """Returns the state of the Auto X-Fan Feature."""
+ return self._feature_auto_xfan
+
+ def set_feature_auto_xfan(self, value: bool) -> None:
+ """Sets the state of the Auto X-Fan Feature."""
+ self._feature_auto_xfan = value
diff --git a/custom_components/gree_custom/diagnostics.py b/custom_components/gree_custom/diagnostics.py
new file mode 100644
index 0000000..e85b6ec
--- /dev/null
+++ b/custom_components/gree_custom/diagnostics.py
@@ -0,0 +1,55 @@
+"""Provide diagnostics support for entries and devices."""
+
+import logging
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntry
+
+from .const import DOMAIN
+from .coordinator import GreeConfigEntry, GreeCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: GreeConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ _LOGGER.debug("Getting entry diagnostics")
+
+ coordinators: dict[str, GreeCoordinator] = entry.runtime_data
+
+ data: dict[str, Any] = {}
+ for i, c in coordinators.items():
+ data[i] = c.get_coordinator_diagnostics()
+
+ diagnostics = {"entry_data": dict(entry.data.copy()), "data": data}
+ redacted = diagnostics
+ redacted["entry_data"]["advanced"] = diagnostics["entry_data"]["advanced"].copy()
+ redacted["entry_data"]["advanced"]["encryption_key"] = (
+ diagnostics["entry_data"]["advanced"]["encryption_key"][:5] + "[redacted]"
+ )
+ return redacted
+
+
+async def async_get_device_diagnostics(
+ hass: HomeAssistant, entry: GreeConfigEntry, device: DeviceEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a device."""
+ _LOGGER.debug("Getting device diagnostics")
+
+ # Find MAC address for this device (from identifiers)
+ identifiers = device.identifiers
+ mac: str | None = None
+ for domain, identifier in identifiers:
+ if domain == DOMAIN:
+ mac = identifier
+ break
+
+ coordinator = entry.runtime_data.get(mac, None)
+
+ return {
+ "device": device.dict_repr,
+ "data": coordinator.get_coordinator_diagnostics() if coordinator else "",
+ }
diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py
new file mode 100755
index 0000000..f8c048a
--- /dev/null
+++ b/custom_components/gree_custom/entity.py
@@ -0,0 +1,96 @@
+"""Base entity for Gree integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass, field
+
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.helpers.entity import DeviceInfo, EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .aiogree.device import GreeDevice
+from .const import DOMAIN
+from .coordinator import GreeCoordinator
+
+
+class GreeEntity(CoordinatorEntity[GreeCoordinator]):
+ """Base Gree entity."""
+
+ _attr_has_entity_name = True
+ entity_description: GreeEntityDescription
+
+ def __init__(
+ self,
+ description: GreeEntityDescription,
+ coordinator: GreeCoordinator,
+ restore_state: bool,
+ check_availability: bool,
+ ) -> None:
+ """Initialize Gree entity."""
+ super().__init__(coordinator)
+
+ self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride]
+ self.device = coordinator.device
+ self.restore_state = restore_state
+ self.check_availability = check_availability
+
+ @property
+ def unique_id(self) -> str | None:
+ """Returns a unique id for the entity."""
+ return f"{self.device.mac_address}_{self.entity_description.key}"
+
+ @property
+ def device_info(self) -> DeviceInfo:
+ """Return the device info."""
+ if self.device.mac_address != self.device.mac_address_controller:
+ return DeviceInfo(
+ connections={(CONNECTION_NETWORK_MAC, self.device.mac_address)},
+ identifiers={(DOMAIN, self.device.unique_id)},
+ name=self.device.name,
+ manufacturer="Gree",
+ sw_version=self.device.firmware_version,
+ via_device=(DOMAIN, self.device.mac_address_controller),
+ )
+ return DeviceInfo(
+ connections={(CONNECTION_NETWORK_MAC, self.device.mac_address)},
+ identifiers={(DOMAIN, self.device.unique_id)},
+ name=self.device.name,
+ manufacturer="Gree",
+ sw_version=self.device.firmware_version,
+ )
+
+ @property
+ def available(self): # pyright: ignore[reportIncompatibleVariableOverride]
+ """Return True if entity is available.
+
+ If entity has 'check_availability' enabled this uses the device available state
+ Otherwise, it only uses the 'additional_available_func'
+ """
+
+ custom_available = self.entity_description.additional_available_func(
+ self.device
+ )
+
+ if not self.check_availability:
+ return custom_available
+
+ coordinator_ok = self.coordinator.last_update_success
+ device_ok = self.device.available
+
+ return custom_available and coordinator_ok and device_ok
+
+
+@dataclass(frozen=True, kw_only=True)
+class GreeEntityDescription(EntityDescription):
+ """Description of a Gree switch."""
+
+ # Restore the last state by default since the device can be controlled externally,
+ # this way HA sets the device to its last known HA state.
+ # This will be overridden by entry configuration
+ # restore_state: bool = True
+
+ # Use this to conditionally block the entity availability independent of the device availability
+ additional_available_func: Callable[[GreeDevice], bool] = field(
+ default=lambda _: True
+ )
diff --git a/custom_components/gree_custom/helpers.py b/custom_components/gree_custom/helpers.py
new file mode 100644
index 0000000..bde4cb2
--- /dev/null
+++ b/custom_components/gree_custom/helpers.py
@@ -0,0 +1,186 @@
+"""Helpers for the Gree integration."""
+
+from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network
+import logging
+
+from homeassistant.components import network
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.storage import Store
+
+from .aiogree.api import GreeDiscoveredDevice, discover_gree_devices
+from .aiogree.device import GreeDevice
+from .const import (
+ CONF_DISCOVERY_PREFS_KEY,
+ CONF_DISCOVERY_PREFS_VERSION,
+ CONF_EXTRA_SCAN_HOSTS,
+ CONF_EXTRA_SCAN_NETWORKS,
+ DEFAULT_DISCOVERY_TIMEOUT,
+ MAX_UNICAST_SCAN_HOSTS,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def _get_hass_broadcast_addr(hass: HomeAssistant) -> list[str]:
+ """Returns the broadcast adresses from HA."""
+ broadcast_addresses: list[str] = []
+
+ try:
+ # This returns every broadcast address for every enabled network adapter in HA
+ # If only the default adapter is enabled, HA only returns 255.255.255.255
+ ha_broadcast_addresses: set[
+ network.IPv4Address
+ ] = await network.async_get_ipv4_broadcast_addresses(hass)
+
+ ha_broadcast_strings: list[str] = [str(addr) for addr in ha_broadcast_addresses]
+ broadcast_addresses.extend(ha_broadcast_strings)
+ _LOGGER.debug("Found broadcast addresses from HA: %s", ha_broadcast_strings)
+
+ except Exception:
+ _LOGGER.exception("Could not get HA broadcast addresses")
+
+ # Default broadcast addresses to try
+ # default_broadcast_addresses = [
+ # "255.255.255.255", # Limited broadcast
+ # "192.168.255.255", # /16 broadcast for 192.168.x.x networks
+ # "10.255.255.255", # /8 broadcast for 10.x.x.x networks
+ # "172.31.255.255", # /12 broadcast for 172.16-31.x.x networks
+ # ]
+ # broadcast_addresses.extend(default_broadcast_addresses)
+ # NOTE: Try to use the ones from HA only. Uncomment if people report bugs.
+
+ return broadcast_addresses
+
+
+def _expand_unicast_targets(
+ networks: list[str] | None = None,
+ hosts: list[str] | None = None,
+) -> list[str]:
+ """Expand IPv4 CIDRs + individual IPv4s into ordered deduplicated hosts.
+
+ Raises ValueError if any network or total exceeds max_hosts.
+ """
+ targets: dict[str, None] = {}
+ add = targets.setdefault
+
+ for cidr in networks or ():
+ net = ip_network(cidr, strict=False)
+
+ if not isinstance(net, IPv4Network):
+ raise TypeError(f"IPv6 not supported: {cidr}")
+
+ # /31 => 2 usable, /32 => 1 usable, otherwise subtract net+broadcast
+ usable = net.num_addresses if net.prefixlen >= 31 else net.num_addresses - 2
+
+ if usable > MAX_UNICAST_SCAN_HOSTS:
+ raise ValueError(
+ f"Network {cidr} has {usable} hosts, exceeds limit of {MAX_UNICAST_SCAN_HOSTS}"
+ )
+
+ for host in net.hosts():
+ add(str(host), None)
+
+ if len(targets) > MAX_UNICAST_SCAN_HOSTS:
+ raise ValueError(
+ f"Total unicast targets ({len(targets)}) exceed limit of {MAX_UNICAST_SCAN_HOSTS}"
+ )
+
+ for raw_ip in hosts or ():
+ addr = ip_address(raw_ip)
+
+ if not isinstance(addr, IPv4Address):
+ raise TypeError(f"IPv6 not supported: {raw_ip}")
+
+ add(str(addr), None)
+
+ if len(targets) > MAX_UNICAST_SCAN_HOSTS:
+ raise ValueError(
+ f"Total unicast targets ({len(targets)}) exceed limit of {MAX_UNICAST_SCAN_HOSTS}"
+ )
+
+ _LOGGER.debug("Expanded unicast addresses: %s found", len(targets))
+ return list(targets)
+
+
+async def get_discovery_addresses(
+ hass: HomeAssistant,
+) -> list[str]:
+ """Gathers a list of broadcast and unicast addresses."""
+
+ addresses: list[str] = []
+
+ # Collect HA broadcast addresses
+ broadcast_addresses = await _get_hass_broadcast_addr(hass)
+ addresses.extend(broadcast_addresses)
+
+ # Collect unicast addresses from HASS prefs
+ pref_storage = Store(hass, CONF_DISCOVERY_PREFS_VERSION, CONF_DISCOVERY_PREFS_KEY)
+ prefs = await pref_storage.async_load() or {}
+
+ extra_networks: list[str] = prefs.get(CONF_EXTRA_SCAN_NETWORKS, [])
+ extra_hosts: list[str] = prefs.get(CONF_EXTRA_SCAN_HOSTS, [])
+ unicast_addresses = _expand_unicast_targets(extra_networks, extra_hosts)
+ addresses.extend(unicast_addresses)
+
+ return addresses
+
+
+async def try_find_new_ip(
+ hass: HomeAssistant,
+ device: GreeDevice,
+ config_entry: ConfigEntry,
+) -> bool:
+ """This will try find the IP of this device controller MAC address and update it."""
+
+ _LOGGER.debug(
+ "Trying to find a new IP address for %s", device.mac_address_controller
+ )
+
+ previous_ip = device.ip
+
+ # Perform device discovery
+ discovery_addresses = await get_discovery_addresses(hass)
+ discovered_devices: list[GreeDiscoveredDevice] = await discover_gree_devices(
+ discovery_addresses, DEFAULT_DISCOVERY_TIMEOUT
+ )
+
+ # Search for a match device
+ match_device: GreeDiscoveredDevice | None = next(
+ (d for d in discovered_devices if d.mac == device.mac_address_controller),
+ None,
+ )
+
+ if not match_device:
+ _LOGGER.debug(
+ "No device with mac '%s' found in the discovered devices",
+ device.mac_address_controller,
+ )
+ return False
+
+ if previous_ip == match_device.host:
+ _LOGGER.debug(
+ "IP for device with mac '%s' is already correct",
+ device.mac_address_controller,
+ )
+ return False
+
+ # Update the device IP
+ device.set_ip(match_device.host)
+
+ # Update config entry to save the new IP
+ new_data = {**config_entry.data, CONF_HOST: device.ip}
+ if not hass.config_entries.async_update_entry(
+ config_entry, title=f"Gree System at {device.ip}", data=new_data
+ ):
+ _LOGGER.debug("Failed to save new IP in config entry data")
+
+ _LOGGER.info(
+ "IP for device with mac '%s' updated: %s -> %s",
+ device.mac_address_controller,
+ previous_ip,
+ device.ip,
+ )
+
+ return True
diff --git a/custom_components/gree_custom/icons.json b/custom_components/gree_custom/icons.json
new file mode 100755
index 0000000..d8da3a7
--- /dev/null
+++ b/custom_components/gree_custom/icons.json
@@ -0,0 +1,92 @@
+{
+ "entity": {
+ "climate": {
+ "hvac": {
+ "state_attributes": {
+ "fan_mode": {
+ "state": {
+ "auto": "mdi:fan-auto",
+ "low": "mdi:fan-chevron-down",
+ "medium_low": "mdi:fan-minus",
+ "medium": "mdi:fan",
+ "medium_high": "mdi:fan-plus",
+ "high": "mdi:fan-chevron-up",
+ "turbo": "mdi:weather-windy",
+ "quiet": "mdi:sleep"
+ }
+ },
+ "swing_horizontal_mode": {
+ "state": {
+ "default": "mdi:arrow-oscillating",
+ "full_swing": "mdi:arrow-oscillating",
+ "left": "mdi:arrow-left",
+ "left_center": "mdi:arrow-bottom-left",
+ "center": "mdi:arrow-down",
+ "right_center": "mdi:arrow-bottom-right",
+ "right": "mdi:arrow-left"
+ }
+ },
+ "swing_mode": {
+ "state": {
+ "default": "mdi:arrow-up-down",
+ "full_swing": "mdi:arrow-up-down",
+ "fixed_upper": "mdi:arrow-up",
+ "fixed_upper_middle": "mdi:arrow-top-left",
+ "fixed_middle": "mdi:arrow-left",
+ "fixed_lower_middle": "mdi:arrow-bottom-left",
+ "fixed_lower": "mdi:arrow-down",
+ "swing_lower": "mdi:arrow-up-down",
+ "swing_lower_middle": "mdi:arrow-up-down",
+ "swing_middle": "mdi:arrow-up-down",
+ "swing_upper_middle": "mdi:arrow-up-down",
+ "swing_upper": "mdi:arrow-up-down"
+ }
+ }
+ }
+ }
+ },
+ "select": {
+ "temperature_units": {
+ "default": "mdi:thermometer-alert"
+ }
+ },
+ "switch": {
+ "air": {
+ "default": "mdi:air-filter"
+ },
+ "xfan": {
+ "default": "mdi:fan"
+ },
+ "sleep": {
+ "default": "mdi:sleep"
+ },
+ "eightdegheat": {
+ "default": "mdi:thermometer-low"
+ },
+ "health": {
+ "default": "mdi:shield-check"
+ },
+ "anti_direct_blow": {
+ "default": "mdi:weather-windy"
+ },
+ "powersave": {
+ "default": "mdi:leaf"
+ },
+ "lights": {
+ "default": "mdi:lightbulb"
+ },
+ "light_sensor": {
+ "default": "mdi:brightness-auto"
+ },
+ "auto_xfan": {
+ "default": "mdi:fan-auto"
+ },
+ "auto_light": {
+ "default": "mdi:lightbulb-auto"
+ },
+ "beeper": {
+ "default": "mdi:volume-high"
+ }
+ }
+ }
+}
diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json
new file mode 100755
index 0000000..b1d3c4b
--- /dev/null
+++ b/custom_components/gree_custom/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "gree_custom",
+ "name": "Gree A/C",
+ "codeowners": ["@robhofmann"],
+ "config_flow": true,
+ "dependencies": ["network"],
+ "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent",
+ "integration_type": "hub",
+ "iot_class": "local_polling",
+ "issue_tracker": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues",
+ "requirements": ["pycryptodome", "asyncio_dgram"],
+ "version": "4.0.0-alpha.96"
+}
diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py
new file mode 100644
index 0000000..c2b2fff
--- /dev/null
+++ b/custom_components/gree_custom/select.py
@@ -0,0 +1,215 @@
+"""Support for Gree select entities (e.g., external temperature sensor selection)."""
+
+from collections.abc import Callable
+import logging
+from typing import Generic, TypeVar
+
+from attr import dataclass
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.const import CONF_MAC, EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from .aiogree.api import GreeProp, TemperatureUnits
+from .aiogree.device import GreeDevice
+from .const import (
+ CONF_ADVANCED,
+ CONF_DEVICES,
+ CONF_DISABLE_AVAILABLE_CHECK,
+ CONF_RESTORE_STATES,
+ DEFAULT_DISABLE_AVAILABLE_CHECK,
+ DEFAULT_RESTORE_STATES,
+ GATTR_TEMP_UNITS,
+)
+from .coordinator import GreeConfigEntry, GreeCoordinator
+from .entity import GreeEntity, GreeEntityDescription
+
+_LOGGER = logging.getLogger(__name__)
+
+T = TypeVar("T") # T can be any type
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: GreeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up switches from a config entry."""
+
+ entities: list[GreeSelect] = []
+
+ for d in entry.data.get(CONF_DEVICES, []):
+ mac = d.get(CONF_MAC, "")
+ coordinator: GreeCoordinator = entry.runtime_data[mac]
+ if not coordinator:
+ _LOGGER.error(
+ "Cannot create Gree Selectors. No coordinator found for device '%s'",
+ mac,
+ )
+ continue
+
+ descriptions: list[GreeSelectDescription] = []
+
+ if coordinator.device.supports_property(GreeProp.TARGET_TEMPERATURE_UNIT):
+ descriptions.append(
+ GreeSelectDescription[GreeDevice](
+ key=GATTR_TEMP_UNITS,
+ translation_key=GATTR_TEMP_UNITS,
+ entity_category=EntityCategory.CONFIG,
+ options=[f"º{member.name}" for member in TemperatureUnits],
+ value_func=lambda device: f"º{device.target_temperature_unit.name}",
+ set_func=lambda device, value: device.set_target_temperature_unit(
+ TemperatureUnits[value.replace("º", "")]
+ ),
+ updates_device=True,
+ )
+ )
+
+ _LOGGER.debug(
+ "Adding Select Entities for device '%s': %s",
+ coordinator.device.mac_address,
+ [d.key for d in descriptions],
+ )
+
+ entities.extend(
+ GreeSelect(
+ description,
+ coordinator,
+ d.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES),
+ check_availability=(
+ not entry.data[CONF_ADVANCED].get(
+ CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK
+ )
+ ),
+ )
+ for description in descriptions
+ )
+
+ async_add_entities(entities)
+
+
+@dataclass(frozen=True, kw_only=True)
+class GreeSelectDescription(GreeEntityDescription, SelectEntityDescription, Generic[T]):
+ """Description of a Gree switch."""
+
+ additional_available_func = lambda _: True # noqa: E731
+ device_class = None
+ entity_category = None
+ entity_registry_enabled_default = True
+ entity_registry_visible_default = True
+ force_update = False
+ icon = None
+ has_entity_name = True
+ name = None
+ translation_key = None
+ translation_placeholders = None
+ unit_of_measurement = None
+ options_func: Callable[[], list[str]] | None = None
+ value_func: Callable[[T], str | None]
+ set_func: Callable[[T, str], None]
+ updates_device: bool = True
+
+
+class GreeSelect(GreeEntity, SelectEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride]
+ """A Gree select entity."""
+
+ entity_description: GreeSelectDescription
+
+ def __init__(
+ self,
+ description: GreeSelectDescription,
+ coordinator: GreeCoordinator,
+ restore_state: bool = True,
+ check_availability: bool = True,
+ ) -> None:
+ """Initialize select."""
+ super().__init__(description, coordinator, restore_state, check_availability)
+
+ self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride]
+
+ # Set up options dynamically
+ if description.options_func:
+ self._attr_options = description.options_func()
+ else:
+ self._attr_options = description.options or ["None"]
+
+ self._attr_current_option = self.entity_description.value_func(self.device)
+ _LOGGER.debug(
+ "Initialized select: %s (check_availability=%s) Options: %s",
+ self.unique_id,
+ self.check_availability,
+ self._attr_options,
+ )
+
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ _LOGGER.debug("Updating Select Entity for %s", self.device.unique_id)
+ self._attr_current_option = self.entity_description.value_func(self.device)
+
+ @property
+ def current_option(self) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride]
+ """Return the selected entity option to represent the entity state."""
+ return self.entity_description.value_func(self.device)
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ _LOGGER.debug(
+ "async_select_option(%s, %s, %s -> %s)",
+ self.device.unique_id,
+ self.entity_description.key,
+ self.current_option,
+ option,
+ )
+
+ try:
+ self.entity_description.set_func(self.device, option)
+
+ if self.entity_description.updates_device:
+ await self.coordinator.push_device_status()
+
+ # notify coordinator listeners of state change so that dependent entities are updated immediately
+ self.coordinator.async_update_listeners()
+
+ await self.coordinator.async_request_refresh()
+ except Exception as err:
+ _LOGGER.debug(
+ "Error in async_select_option(%s, %s, %s -> %s)",
+ self.device.unique_id,
+ self.entity_description.key,
+ self.current_option,
+ option,
+ )
+ raise HomeAssistantError(
+ "Failed to select a different temperature unit."
+ ) from err
+
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+
+ # Restore last HA state to device if applicable
+ if self.restore_state:
+ last_state = await self.async_get_last_state()
+ if last_state is not None:
+ _LOGGER.debug(
+ "Restoring state for %s: %s", self.unique_id, last_state.state
+ )
+ if last_state.state not in ("unknown", "unavailable"):
+ try:
+ self.entity_description.set_func(self.device, last_state.state)
+
+ if self.entity_description.updates_device:
+ await self.coordinator.push_device_status()
+
+ self._attr_current_option = last_state.state
+ except Exception as err: # noqa: BLE001
+ _LOGGER.error(
+ "Failed to restore state for %s: %s",
+ self.entity_id,
+ repr(err),
+ )
diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py
new file mode 100644
index 0000000..8c728dd
--- /dev/null
+++ b/custom_components/gree_custom/sensor.py
@@ -0,0 +1,167 @@
+"""Gree Sensor Entity for Home Assistant."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import logging
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import CONF_MAC, PERCENTAGE, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from .aiogree.api import GreeProp
+from .aiogree.device import GreeDevice
+from .const import (
+ CONF_ADVANCED,
+ CONF_DEVICES,
+ CONF_DISABLE_AVAILABLE_CHECK,
+ DEFAULT_DISABLE_AVAILABLE_CHECK,
+ GATTR_HUMIDITY,
+ GATTR_INDOOR_TEMPERATURE,
+ GATTR_OUTDOOR_TEMPERATURE,
+)
+from .coordinator import GreeConfigEntry, GreeCoordinator
+from .entity import GreeEntity, GreeEntityDescription
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: GreeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up sensors from a config entry."""
+
+ entities: list[GreeSensor] = []
+
+ for d in entry.data.get(CONF_DEVICES, []):
+ mac = d.get(CONF_MAC, "")
+ coordinator: GreeCoordinator = entry.runtime_data[mac]
+ if not coordinator:
+ _LOGGER.error(
+ "Cannot create Gree Sensors. No coordinator found for device '%s'",
+ mac,
+ )
+ continue
+
+ descriptions: list[GreeSensorDescription] = []
+ if coordinator.device.supports_property(GreeProp.SENSOR_TEMPERATURE):
+ descriptions.append(
+ GreeSensorDescription(
+ key=GATTR_INDOOR_TEMPERATURE,
+ translation_key=GATTR_INDOOR_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ suggested_display_precision=0,
+ value_func=lambda device: device.indoors_temperature_c,
+ )
+ )
+ if coordinator.device.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE):
+ descriptions.append(
+ GreeSensorDescription(
+ key=GATTR_OUTDOOR_TEMPERATURE,
+ translation_key=GATTR_OUTDOOR_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ suggested_display_precision=0,
+ value_func=lambda device: device.outdoors_temperature_c,
+ )
+ )
+ if coordinator.device.supports_property(GreeProp.SENSOR_HUMIDITY):
+ descriptions.append(
+ GreeSensorDescription(
+ key=GATTR_HUMIDITY,
+ translation_key=GATTR_HUMIDITY,
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=0,
+ value_func=lambda device: device.humidity,
+ )
+ )
+
+ _LOGGER.debug(
+ "Adding Sensor Entities for device '%s': %s",
+ coordinator.device.mac_address,
+ [d.key for d in descriptions],
+ )
+
+ entities.extend(
+ GreeSensor(
+ description,
+ coordinator,
+ restore_state=False,
+ check_availability=(
+ not entry.data[CONF_ADVANCED].get(
+ CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK
+ )
+ ),
+ )
+ for description in descriptions
+ )
+
+ async_add_entities(entities)
+
+
+@dataclass(frozen=True, kw_only=True)
+class GreeSensorDescription(GreeEntityDescription, SensorEntityDescription):
+ """Description of a Gree temperature sensor."""
+
+ value_func: Callable[[GreeDevice], float | None]
+
+
+class GreeSensor(GreeEntity, SensorEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride]
+ """A Gree Sensor."""
+
+ entity_description: GreeSensorDescription
+
+ def __init__(
+ self,
+ description: GreeSensorDescription,
+ coordinator: GreeCoordinator,
+ restore_state: bool = True,
+ check_availability: bool = True,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(description, coordinator, restore_state, check_availability)
+
+ self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride]
+ _LOGGER.debug(
+ "Initialized sensor: %s (check_availability=%s)",
+ self.unique_id,
+ self.check_availability,
+ )
+
+ @property
+ def native_value(self): # pyright: ignore[reportIncompatibleVariableOverride]
+ """Return the state of the sensor."""
+ return self.entity_description.value_func(self.device)
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ # Restore last HA state to device if applicable
+ if self.restore_state:
+ last_state = await self.async_get_last_state()
+ if last_state is not None:
+ _LOGGER.debug(
+ "Restoring state for %s: %s", self.unique_id, last_state.state
+ )
+ if last_state.state not in (None, "unknown", "unavailable"):
+ try:
+ self._attr_native_value = float(last_state.state)
+ except ValueError as err:
+ _LOGGER.error(
+ "Failed to restore state for %s: %s",
+ self.entity_id,
+ repr(err),
+ )
diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py
new file mode 100644
index 0000000..4b53bc8
--- /dev/null
+++ b/custom_components/gree_custom/switch.py
@@ -0,0 +1,361 @@
+"""Gree Switch Entity for Home Assistant."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import logging
+from typing import Any
+
+from homeassistant.components.switch import (
+ SwitchDeviceClass,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
+from homeassistant.const import CONF_MAC, EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from .aiogree.api import GreeProp, OperationMode
+from .aiogree.device import GreeDevice
+from .const import (
+ ATTR_AUTO_LIGHT,
+ ATTR_AUTO_XFAN,
+ CONF_ADVANCED,
+ CONF_DEVICES,
+ CONF_DISABLE_AVAILABLE_CHECK,
+ CONF_FEATURES,
+ CONF_RESTORE_STATES,
+ CONF_TO_PROP_FEATURE_MAP,
+ DEFAULT_DISABLE_AVAILABLE_CHECK,
+ DEFAULT_RESTORE_STATES,
+ DEFAULT_SUPPORTED_FEATURES,
+ GATTR_ANTI_DIRECT_BLOW,
+ GATTR_BEEPER,
+ GATTR_FEAT_ENERGY_SAVING,
+ GATTR_FEAT_FRESH_AIR,
+ GATTR_FEAT_HEALTH,
+ GATTR_FEAT_LIGHT,
+ GATTR_FEAT_SENSOR_LIGHT,
+ GATTR_FEAT_SLEEP_MODE,
+ GATTR_FEAT_SMART_HEAT_8C,
+ GATTR_FEAT_XFAN,
+)
+from .coordinator import GreeConfigEntry, GreeCoordinator
+from .entity import GreeEntity, GreeEntityDescription
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class GreeSwitchDescription(GreeEntityDescription, SwitchEntityDescription):
+ """Description of a Gree switch."""
+
+ set_func: Callable[[GreeDevice, GreeCoordinator, bool], None]
+ device_class = SwitchDeviceClass.SWITCH
+ value_func: Callable[[GreeDevice, GreeCoordinator], bool]
+ updates_device: bool = True
+
+
+SWITCH_TYPES: list[GreeSwitchDescription] = [
+ GreeSwitchDescription(
+ key=GATTR_FEAT_FRESH_AIR,
+ translation_key=GATTR_FEAT_FRESH_AIR,
+ value_func=lambda device, _: device.feature_fresh_air,
+ set_func=lambda device, _, value: device.set_feature_fresh_air(value),
+ ),
+ GreeSwitchDescription(
+ key=GATTR_FEAT_XFAN,
+ translation_key=GATTR_FEAT_XFAN,
+ additional_available_func=lambda device: (
+ device.operation_mode in [OperationMode.cool, OperationMode.dry]
+ ),
+ value_func=lambda device, _: device.feature_x_fan,
+ set_func=lambda device, _, value: device.set_feature_xfan(value),
+ ),
+ GreeSwitchDescription(
+ key=GATTR_FEAT_SLEEP_MODE,
+ translation_key=GATTR_FEAT_SLEEP_MODE,
+ additional_available_func=(
+ lambda device: (
+ device.operation_mode
+ in [OperationMode.cool, OperationMode.dry, OperationMode.heat]
+ )
+ ),
+ value_func=lambda device, _: device.feature_sleep,
+ set_func=lambda device, _, value: device.set_feature_sleep(value),
+ ),
+ GreeSwitchDescription(
+ key=GATTR_FEAT_SMART_HEAT_8C,
+ translation_key=GATTR_FEAT_SMART_HEAT_8C,
+ value_func=lambda device, _: device.feature_smart_heat,
+ set_func=lambda device, _, value: device.set_feature_smart_heat(value),
+ ),
+ GreeSwitchDescription(
+ key=GATTR_FEAT_HEALTH,
+ translation_key=GATTR_FEAT_HEALTH,
+ value_func=lambda device, _: device.feature_health,
+ set_func=lambda device, _, value: device.set_feature_health(value),
+ ),
+ GreeSwitchDescription(
+ key=GATTR_ANTI_DIRECT_BLOW,
+ translation_key=GATTR_ANTI_DIRECT_BLOW,
+ value_func=lambda device, _: device.feature_anti_direct_blow,
+ set_func=lambda device, _, value: device.set_feature_anti_direct_blow(value),
+ ),
+ GreeSwitchDescription(
+ key=GATTR_FEAT_ENERGY_SAVING,
+ translation_key=GATTR_FEAT_ENERGY_SAVING,
+ value_func=lambda device, _: device.feature_energy_saving,
+ set_func=lambda device, _, value: device.set_feature_energy_saving(value),
+ ),
+ GreeSwitchDescription(
+ key=GATTR_FEAT_LIGHT,
+ translation_key=GATTR_FEAT_LIGHT,
+ value_func=lambda device, _: device.feature_light,
+ set_func=lambda device, _, value: device.set_feature_light(value),
+ entity_category=EntityCategory.CONFIG,
+ ),
+ GreeSwitchDescription(
+ key=GATTR_FEAT_SENSOR_LIGHT,
+ translation_key=GATTR_FEAT_SENSOR_LIGHT,
+ additional_available_func=lambda device: device.feature_light,
+ value_func=lambda device, _: device.feature_light_sensor,
+ set_func=lambda device, _, value: device.set_feature_light_sensor(value),
+ entity_category=EntityCategory.CONFIG,
+ ),
+ GreeSwitchDescription(
+ key=GATTR_BEEPER,
+ translation_key=GATTR_BEEPER,
+ value_func=lambda device, _: device.beeper,
+ set_func=lambda device, _, value: device.set_beeper(value),
+ entity_category=EntityCategory.CONFIG,
+ updates_device=False, # Local entity
+ ),
+]
+
+SWITCH_TYPE_AUTO_LIGHT = GreeSwitchDescription(
+ key=ATTR_AUTO_LIGHT,
+ translation_key=ATTR_AUTO_LIGHT,
+ value_func=(lambda _, coordinator: coordinator.feature_auto_light),
+ set_func=(lambda _, coordinator, value: coordinator.set_feature_auto_light(value)),
+ updates_device=False,
+ entity_category=EntityCategory.CONFIG,
+)
+
+SWITCH_TYPE_AUTO_XFAN = GreeSwitchDescription(
+ key=ATTR_AUTO_XFAN,
+ translation_key=ATTR_AUTO_XFAN,
+ value_func=lambda _, coordinator: coordinator.feature_auto_xfan,
+ set_func=lambda _, coordinator, value: coordinator.set_feature_auto_xfan(value),
+ updates_device=False,
+ entity_category=EntityCategory.CONFIG,
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: GreeConfigEntry,
+ async_add_entities: AddEntitiesCallback,
+) -> None:
+ """Set up switches from a config entry."""
+
+ entities: list[GreeSwitch] = []
+
+ for d in entry.data.get(CONF_DEVICES, []):
+ mac = d.get(CONF_MAC, "")
+ coordinator: GreeCoordinator = entry.runtime_data[mac]
+ if not coordinator:
+ _LOGGER.error(
+ "Cannot create Gree Switches. No coordinator found for device '%s'",
+ mac,
+ )
+ continue
+
+ descriptions: list[GreeSwitchDescription] = []
+
+ conf_restore_states: bool = d.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES)
+ conf_check_availability: bool = not entry.data[CONF_ADVANCED].get(
+ CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK
+ )
+
+ supported_features: list[str] = []
+
+ if not d.get(CONF_FEATURES):
+ _LOGGER.warning("Undefined supported features")
+
+ conf_supported_features = d.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES)
+
+ # Check features with device support before adding the entities
+ for feature in conf_supported_features:
+ if feature == GATTR_FEAT_SENSOR_LIGHT:
+ if coordinator.device.supports_property(
+ GreeProp.FEAT_SENSOR_LIGHT
+ ) and coordinator.device.supports_property(GreeProp.FEAT_LIGHT):
+ supported_features.append(GATTR_FEAT_SENSOR_LIGHT)
+ continue
+
+ # For all other mapped features
+ prop = CONF_TO_PROP_FEATURE_MAP.get(feature)
+ if prop and coordinator.device.supports_property(prop):
+ supported_features.append(feature)
+
+ descriptions.extend(
+ [
+ description
+ for description in SWITCH_TYPES
+ if description.key in supported_features
+ ]
+ )
+
+ _LOGGER.debug(
+ "Adding Switch Entities for device '%s': %s",
+ coordinator.device.mac_address,
+ [d.key for d in descriptions],
+ )
+
+ entities.extend(
+ [
+ GreeSwitch(
+ description,
+ coordinator,
+ restore_state=(
+ conf_restore_states
+ if description.key != GATTR_BEEPER # Always restore beeper
+ else True
+ ),
+ check_availability=(
+ conf_check_availability
+ if description.key != GATTR_BEEPER # Beeper is always available
+ else False
+ ),
+ )
+ for description in descriptions
+ ]
+ )
+
+ # Add Auto Light if device supports Light
+ if GATTR_FEAT_LIGHT in supported_features:
+ entities.append(
+ GreeSwitch(
+ SWITCH_TYPE_AUTO_LIGHT,
+ coordinator,
+ restore_state=True, # Always restore Auto Light
+ check_availability=conf_check_availability,
+ )
+ )
+
+ # Add XFan if device supports XFan
+ if GATTR_FEAT_XFAN in supported_features:
+ entities.append(
+ GreeSwitch(
+ SWITCH_TYPE_AUTO_XFAN,
+ coordinator,
+ restore_state=True, # Always restore Auto XFan
+ check_availability=conf_check_availability,
+ )
+ )
+
+ async_add_entities(entities)
+
+
+class GreeSwitch(GreeEntity, SwitchEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride]
+ """Defines a Gree Switch entity."""
+
+ entity_description: GreeSwitchDescription
+
+ def __init__(
+ self,
+ description: GreeSwitchDescription,
+ coordinator: GreeCoordinator,
+ restore_state: bool = True,
+ check_availability: bool = True,
+ ) -> None:
+ """Initialize switch."""
+ super().__init__(description, coordinator, restore_state, check_availability)
+
+ self.entity_description = description # pyright: ignore[reportIncompatibleVariableOverride]
+ _LOGGER.debug(
+ "Initialized switch: %s (check_availability=%s)",
+ self.unique_id,
+ self.check_availability,
+ )
+
+ @property
+ def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride]
+ """Return true if the switch is on."""
+ return self.entity_description.value_func(self.device, self.coordinator)
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ # Restore last HA state to device if applicable
+ if self.restore_state:
+ last_state = await self.async_get_last_state()
+ if last_state is not None:
+ _LOGGER.debug(
+ "Restoring state for %s: %s", self.unique_id, last_state.state
+ )
+ if last_state.state in ("on", "off"):
+ value: bool = last_state.state == "on"
+ try:
+ self.entity_description.set_func(
+ self.device, self.coordinator, value
+ )
+
+ if self.entity_description.updates_device:
+ await self.coordinator.push_device_status()
+
+ self._attr_is_on = value
+ except Exception as err: # noqa: BLE001
+ _LOGGER.error(
+ "Failed to restore state for %s: %s",
+ self.entity_id,
+ repr(err),
+ )
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ if not self.available:
+ raise HomeAssistantError("Entity unavailable")
+
+ try:
+ self.entity_description.set_func(self.device, self.coordinator, True)
+
+ if self.entity_description.updates_device:
+ await self.coordinator.push_device_status()
+
+ # notify coordinator listeners of state change so that dependent entities are updated immediately
+ self.coordinator.async_update_listeners()
+
+ if (
+ self.entity_description.key != GATTR_BEEPER
+ ): # ignore HA-only dependent entities
+ await self.coordinator.async_request_refresh()
+ except Exception as err:
+ raise HomeAssistantError("Failed to turn on switch") from err
+
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ if not self.available:
+ raise HomeAssistantError("Entity unavailable")
+
+ try:
+ self.entity_description.set_func(self.device, self.coordinator, False)
+
+ if self.entity_description.updates_device:
+ await self.coordinator.push_device_status()
+
+ # notify coordinator listeners of state change so that dependent entities are updated immediately
+ self.coordinator.async_update_listeners()
+
+ if (
+ self.entity_description.key != GATTR_BEEPER
+ ): # ignore HA-only dependent entities
+ await self.coordinator.async_request_refresh()
+ except Exception as err:
+ raise HomeAssistantError("Failed to turn off switch") from err
+
+ self.async_write_ha_state()
diff --git a/custom_components/gree/translations/de.json b/custom_components/gree_custom/translation-to-review/de.json
old mode 100644
new mode 100755
similarity index 96%
rename from custom_components/gree/translations/de.json
rename to custom_components/gree_custom/translation-to-review/de.json
index 86b4b98..242bf0a
--- a/custom_components/gree/translations/de.json
+++ b/custom_components/gree_custom/translation-to-review/de.json
@@ -9,6 +9,7 @@
"host": "IP-Adresse",
"port": "Port",
"mac": "MAC-Adresse",
+ "timeout": "Zeitüberschreitung",
"encryption_key": "Verschlüsselungsschlüssel",
"uid": "UID",
"encryption_version": "Verschlüsselungsversion"
@@ -20,6 +21,7 @@
"host": "IP-Adresse",
"port": "Port",
"mac": "MAC-Adresse",
+ "timeout": "Zeitüberschreitung",
"hvac_modes" : "HVAC-Modi",
"fan_modes" : "Lüftermodi",
"swing_modes" : "Vertikale Swing-Modi",
@@ -28,6 +30,7 @@
"uid": "UID",
"encryption_version": "Verschlüsselungsversion",
"disable_available_check": "Verfügbarkeitsprüfung deaktivieren",
+ "max_online_attempts": "Maximale Online-Versuche",
"temp_sensor_offset": "Temperatursensor-Offset"
}
},
@@ -41,6 +44,7 @@
"swing_modes" : "Vertikale Swing-Modi",
"swing_horizontal_modes" : "Horizontale Swing-Modi",
"disable_available_check": "Verfügbarkeitsprüfung deaktivieren",
+ "max_online_attempts": "Maximale Online-Versuche",
"temp_sensor_offset": "Temperatursensor-Offset"
}
}
diff --git a/custom_components/gree/translations/he.json b/custom_components/gree_custom/translation-to-review/he.json
old mode 100644
new mode 100755
similarity index 93%
rename from custom_components/gree/translations/he.json
rename to custom_components/gree_custom/translation-to-review/he.json
index 97e0483..841efa0
--- a/custom_components/gree/translations/he.json
+++ b/custom_components/gree_custom/translation-to-review/he.json
@@ -9,6 +9,7 @@
"host": "כתובת IP",
"port": "פורט",
"mac": "כתובת MAC",
+ "timeout": "זמן קצוב להתחברות",
"encryption_key": "מפתח הצפנה",
"uid": "UID",
"encryption_version": "גרסת הצפנה"
@@ -20,6 +21,7 @@
"host": "כתובת IP",
"port": "פורט",
"mac": "כתובת MAC",
+ "timeout": "פסק זמן",
"hvac_modes" : "מצבי HVAC",
"fan_modes" : "מצבי מאוורר",
"swing_modes" : "מצבי נדנוד אנכי",
@@ -28,6 +30,7 @@
"uid": "UID",
"encryption_version": "גרסת הצפנה",
"disable_available_check": "השבת בדיקת זמינות",
+ "max_online_attempts": "ניסיונות מקסימליים להתחברות",
"temp_sensor_offset": "היסט חיישן טמפרטורה"
}
},
@@ -41,6 +44,7 @@
"swing_modes" : "מצבי נדנוד אנכי",
"swing_horizontal_modes" : "מצבי נדנוד אופקי",
"disable_available_check": "השבת בדיקת זמינות",
+ "max_online_attempts": "ניסיונות מקסימליים להתחברות",
"temp_sensor_offset": "היסט חיישן טמפרטורה"
}
}
diff --git a/custom_components/gree/translations/hu.json b/custom_components/gree_custom/translation-to-review/hu.json
old mode 100644
new mode 100755
similarity index 96%
rename from custom_components/gree/translations/hu.json
rename to custom_components/gree_custom/translation-to-review/hu.json
index cee62af..e1ce659
--- a/custom_components/gree/translations/hu.json
+++ b/custom_components/gree_custom/translation-to-review/hu.json
@@ -9,6 +9,7 @@
"host": "IP-cím",
"port": "Port",
"mac": "MAC-cím",
+ "timeout": "Időtúllépés",
"encryption_key": "Titkosítási kulcs",
"uid": "UID",
"encryption_version": "Titkosítási verzió"
@@ -20,6 +21,7 @@
"host": "IP-cím",
"port": "Port",
"mac": "MAC-cím",
+ "timeout": "Időtúllépés",
"hvac_modes" : "HVAC módok",
"fan_modes" : "Ventilátor módok",
"swing_modes" : "Függőleges lengési módok",
@@ -28,6 +30,7 @@
"uid": "UID",
"encryption_version": "Titkosítási verzió",
"disable_available_check": "Elérhetőség ellenőrzésének letiltása",
+ "max_online_attempts": "Maximális online próbálkozások",
"temp_sensor_offset": "Hőmérséklet érzékelő eltolás"
}
},
@@ -41,6 +44,7 @@
"swing_modes" : "Függőleges lengési módok",
"swing_horizontal_modes" : "Vízszintes lengési módok",
"disable_available_check": "Elérhetőség ellenőrzésének letiltása",
+ "max_online_attempts": "Maximális online próbálkozások",
"temp_sensor_offset": "Hőmérséklet érzékelő eltolás"
}
}
diff --git a/custom_components/gree/translations/it.json b/custom_components/gree_custom/translation-to-review/it.json
old mode 100644
new mode 100755
similarity index 97%
rename from custom_components/gree/translations/it.json
rename to custom_components/gree_custom/translation-to-review/it.json
index 3f1cabf..d0ee2da
--- a/custom_components/gree/translations/it.json
+++ b/custom_components/gree_custom/translation-to-review/it.json
@@ -9,6 +9,7 @@
"host": "Indirizzo IP",
"port": "Porta",
"mac": "Indirizzo MAC",
+ "timeout": "Timeout",
"encryption_key": "Chiave di crittografia",
"uid": "UID",
"encryption_version": "Versione crittografia"
@@ -20,6 +21,7 @@
"host": "Indirizzo IP",
"port": "Porta",
"mac": "Indirizzo MAC",
+ "timeout": "Timeout",
"hvac_modes" : "Modalità HVAC",
"fan_modes" : "Modalità ventola",
"swing_modes" : "Modalità oscillazione verticale",
@@ -28,6 +30,7 @@
"uid": "UID",
"encryption_version": "Versione crittografia",
"disable_available_check": "Disabilita controllo disponibilità",
+ "max_online_attempts": "Tentativi massimi online",
"temp_sensor_offset": "Offset sensore temperatura"
}
},
@@ -41,6 +44,7 @@
"swing_modes" : "Modalità oscillazione verticale",
"swing_horizontal_modes" : "Modalità oscillazione orizzontale",
"disable_available_check": "Disabilita controllo disponibilità",
+ "max_online_attempts": "Tentativi massimi online",
"temp_sensor_offset": "Offset sensore temperatura"
}
}
diff --git a/custom_components/gree/translations/pl.json b/custom_components/gree_custom/translation-to-review/pl.json
old mode 100644
new mode 100755
similarity index 97%
rename from custom_components/gree/translations/pl.json
rename to custom_components/gree_custom/translation-to-review/pl.json
index d8f94dd..0590457
--- a/custom_components/gree/translations/pl.json
+++ b/custom_components/gree_custom/translation-to-review/pl.json
@@ -9,6 +9,7 @@
"host": "Adres IP",
"port": "Port",
"mac": "Adres MAC",
+ "timeout": "Limit czasu odpowiedzi",
"encryption_key": "Klucz szyfrowania",
"uid": "UID",
"encryption_version": "Wersja szyfrowania"
@@ -20,6 +21,7 @@
"host": "Adres IP",
"port": "Port",
"mac": "Adres MAC",
+ "timeout": "Limit czasu odpowiedzi",
"hvac_modes" : "Tryby pracy",
"fan_modes" : "Tryby wentylatora",
"swing_modes" : "Tryby pionowego ruchu",
@@ -28,6 +30,7 @@
"uid": "UID",
"encryption_version": "Wersja szyfrowania",
"disable_available_check": "Wyłącz sprawdzanie dostępności",
+ "max_online_attempts": "Maksymalna liczba prób połączenia",
"temp_sensor_offset": "Offset czujnika temperatury"
}
},
@@ -41,6 +44,7 @@
"swing_modes" : "Tryby pionowego ruchu",
"swing_horizontal_modes" : "Tryby poziomego ruchu",
"disable_available_check": "Wyłącz sprawdzanie dostępności",
+ "max_online_attempts": "Maksymalna liczba prób połączenia",
"temp_sensor_offset": "Offset czujnika temperatury"
}
}
diff --git a/custom_components/gree/translations/pt-BR.json b/custom_components/gree_custom/translation-to-review/pt-BR.json
old mode 100644
new mode 100755
similarity index 96%
rename from custom_components/gree/translations/pt-BR.json
rename to custom_components/gree_custom/translation-to-review/pt-BR.json
index 00f83f2..48decb3
--- a/custom_components/gree/translations/pt-BR.json
+++ b/custom_components/gree_custom/translation-to-review/pt-BR.json
@@ -9,6 +9,7 @@
"host": "Endereço IP",
"port": "Porta",
"mac": "Endereço MAC",
+ "timeout": "Tempo de Espera (Timeout)",
"encryption_key": "Chave de Criptografia",
"uid": "UID",
"encryption_version": "Versão da Criptografia"
@@ -20,6 +21,7 @@
"host": "Endereço IP",
"port": "Porta",
"mac": "Endereço MAC",
+ "timeout": "Tempo de Espera (Timeout)",
"hvac_modes" : "Climatização",
"fan_modes" : "Ventilação",
"swing_modes" : "Oscilação Vertical",
@@ -28,6 +30,7 @@
"uid": "UID",
"encryption_version": "Versão da Criptografia",
"disable_available_check": "Desativar Verificação de Disponibilidade",
+ "max_online_attempts": "Máximo de Tentativas de Conexão",
"temp_sensor_offset": "Ajuste do Sensor de Temperatura"
}
},
@@ -41,6 +44,7 @@
"swing_modes" : "Oscilação Vertical",
"swing_horizontal_modes" : "Oscilação Horizontal",
"disable_available_check": "Desativar Verificação de Disponibilidade",
+ "max_online_attempts": "Máximo de Tentativas de Conexão",
"temp_sensor_offset": "Ajuste do Sensor de Temperatura"
}
}
diff --git a/custom_components/gree/translations/ro.json b/custom_components/gree_custom/translation-to-review/ro.json
old mode 100644
new mode 100755
similarity index 96%
rename from custom_components/gree/translations/ro.json
rename to custom_components/gree_custom/translation-to-review/ro.json
index b26a209..c096732
--- a/custom_components/gree/translations/ro.json
+++ b/custom_components/gree_custom/translation-to-review/ro.json
@@ -9,6 +9,7 @@
"host": "Adresă IP",
"port": "Port",
"mac": "Adresă MAC",
+ "timeout": "Timp de așteptare",
"encryption_key": "Cheie de criptare",
"uid": "UID",
"encryption_version": "Versiune criptare"
@@ -20,6 +21,7 @@
"host": "Adresă IP",
"port": "Port",
"mac": "Adresă MAC",
+ "timeout": "Timp de așteptare",
"hvac_modes" : "Moduri HVAC",
"fan_modes" : "Moduri ventilator",
"swing_modes" : "Moduri balansare verticală",
@@ -28,6 +30,7 @@
"uid": "UID",
"encryption_version": "Versiune criptare",
"disable_available_check": "Dezactivează verificarea disponibilității",
+ "max_online_attempts": "Număr maxim de încercări online",
"temp_sensor_offset": "Offset senzor temperatură"
}
},
@@ -41,6 +44,7 @@
"swing_modes" : "Moduri balansare verticală",
"swing_horizontal_modes" : "Moduri balansare orizontală",
"disable_available_check": "Dezactivează verificarea disponibilității",
+ "max_online_attempts": "Număr maxim de încercări online",
"temp_sensor_offset": "Offset senzor temperatură"
}
}
diff --git a/custom_components/gree/translations/ru.json b/custom_components/gree_custom/translation-to-review/ru.json
old mode 100644
new mode 100755
similarity index 97%
rename from custom_components/gree/translations/ru.json
rename to custom_components/gree_custom/translation-to-review/ru.json
index a162792..cc0aa51
--- a/custom_components/gree/translations/ru.json
+++ b/custom_components/gree_custom/translation-to-review/ru.json
@@ -9,6 +9,7 @@
"host": "IP-адрес",
"port": "Порт",
"mac": "MAC-адрес",
+ "timeout": "Тайм-аут",
"encryption_key": "Ключ шифрования",
"uid": "UID",
"encryption_version": "Версия шифрования"
@@ -20,6 +21,7 @@
"host": "IP-адрес",
"port": "Порт",
"mac": "MAC-адрес",
+ "timeout": "Тайм-аут",
"hvac_modes" : "Режимы HVAC",
"fan_modes" : "Режимы вентилятора",
"swing_modes" : "Режимы вертикального качания",
@@ -28,6 +30,7 @@
"uid": "UID",
"encryption_version": "Версия шифрования",
"disable_available_check": "Отключить проверку доступности",
+ "max_online_attempts": "Максимум попыток онлайн",
"temp_sensor_offset": "Смещение датчика температуры"
}
},
@@ -41,6 +44,7 @@
"swing_modes" : "Режимы вертикального качания",
"swing_horizontal_modes" : "Режимы горизонтального качания",
"disable_available_check": "Отключить проверку доступности",
+ "max_online_attempts": "Максимум попыток онлайн",
"temp_sensor_offset": "Смещение датчика температуры"
}
}
diff --git a/custom_components/gree/translations/zh-Hans.json b/custom_components/gree_custom/translation-to-review/zh-Hans.json
old mode 100644
new mode 100755
similarity index 96%
rename from custom_components/gree/translations/zh-Hans.json
rename to custom_components/gree_custom/translation-to-review/zh-Hans.json
index 00ba01f..ec50026
--- a/custom_components/gree/translations/zh-Hans.json
+++ b/custom_components/gree_custom/translation-to-review/zh-Hans.json
@@ -9,6 +9,7 @@
"host": "IP 地址",
"port": "端口",
"mac": "MAC 地址",
+ "timeout": "超时",
"encryption_key": "加密密钥",
"uid": "UID",
"encryption_version": "加密版本"
@@ -20,6 +21,7 @@
"host": "IP 地址",
"port": "端口",
"mac": "MAC 地址",
+ "timeout": "超时",
"hvac_modes": "空调模式",
"fan_modes": "风速模式",
"swing_modes": "垂直扫风模式",
@@ -28,6 +30,7 @@
"uid": "UID",
"encryption_version": "加密版本",
"disable_available_check": "禁用可用性检查",
+ "max_online_attempts": "最大在线尝试次数",
"temp_sensor_offset": "温度传感器偏移"
}
},
@@ -41,6 +44,7 @@
"swing_modes": "垂直扫风模式",
"swing_horizontal_modes": "水平扫风模式",
"disable_available_check": "禁用可用性检查",
+ "max_online_attempts": "最大在线尝试次数",
"temp_sensor_offset": "温度传感器偏移"
}
}
diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json
new file mode 100755
index 0000000..c6cbadc
--- /dev/null
+++ b/custom_components/gree_custom/translations/en.json
@@ -0,0 +1,316 @@
+{
+ "config": {
+ "error": {
+ "unknown": "Something went wrong, please try again. If the issue persists, please check the logs.",
+ "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again. If the issue persists, please check the logs.",
+ "cannot_bind": "Unable to bind the device. It was not possible to find the device encryption version or key. If the issue persists, please check the logs.",
+ "no_devices_found": "Couldn't find any Gree device in the network. Please add your device manually.",
+ "invalid_network": "Invalid CIDR. Example: 192.168.30.0/24",
+ "invalid_host": "Invalid IP address. Example: 192.168.30.50",
+ "network_too_large": "Network exceeds the maximum of 65536 hosts (a /16). Split into multiple CIDRs or list specific hosts.",
+ "too_many_targets": "The specified networks and hosts exceed the maximum of 65536 hosts (a /16). Split into multiple CIDRs or list specific hosts."
+ },
+ "abort": {
+ "already_configured": "A device with this MAC address is already configured."
+ },
+ "step": {
+ "user": {
+ "title": "Gree Climate Setup",
+ "description": "Choose how to add your Gree air conditioner",
+ "data": {
+ "discovery": "Setup Method"
+ }
+ },
+ "manual_discovery": {
+ "title": "Discovered Devices",
+ "description": "Found {devices_found} Gree device(s). Select one to add or choose manual setup.",
+ "data": {
+ "device": "Device"
+ }
+ },
+ "discovery_options": {
+ "title": "Extended Discovery",
+ "description": "Devices on a different subnet or VLAN? Enter one or more networks and/or specific IP addresses to probe via unicast. Inter-VLAN routing and firewall rules must allow UDP port 7000 from Home Assistant to the target subnet.",
+ "data": {
+ "extra_scan_networks": "Networks (comma-separated CIDRs, e.g. 192.168.20.0/24,192.168.30.0/24)",
+ "extra_scan_hosts": "Hosts (comma-separated IPs, e.g. 192.168.30.50,192.168.30.51)"
+ }
+ },
+ "manual_add": {
+ "title": "Device configuration",
+ "data": {
+ "host": "IP Address",
+ "mac": "MAC Address"
+ },
+ "sections": {
+ "advanced": {
+ "name": "Advanced Settings",
+ "description": "Configure advanced setting of the device",
+ "data": {
+ "port": "Port",
+ "encryption_key": "Encryption Key",
+ "encryption_version": "Encryption Version",
+ "uid": "UID",
+ "disable_available_check": "Disable Available Check",
+ "max_online_attempts": "Max Connection Attempts",
+ "timeout": "Connection Timeout"
+ },
+ "data_description": {
+ "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable",
+ "timeout": "The timeout for each of the connection attempts"
+ }
+ }
+ }
+ },
+ "device_options": {
+ "title": "Device features",
+ "description": "The Gree API doesn't have a reliable method of getting the supported features of a device. Please use the options bellow to the best of your knowledge about your device.",
+ "data": {
+ "device_name": "Device Name",
+ "hvac_modes": "HVAC Modes",
+ "fan_modes": "Fan Speeds",
+ "swing_modes": "Vertical Swing Modes",
+ "swing_horizontal_modes": "Horizontal Swing Modes",
+ "features": "Device Features and Modes",
+ "external_temperature_sensor": "External Temperature Sensor",
+ "external_humidity_sensor": "External Humidity Sensor",
+ "restore_states": "Restore Entities",
+ "target_temp_step": "Temperature Step",
+ "scan_interval": "Scan Interval"
+ },
+ "data_description": {
+ "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor",
+ "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor",
+ "restore_states": "If set to true, when the integration is started the device will be overriden by previous states of the integration instead of using the current device state.",
+ "target_temp_step": "Sets the increment step for adjusting the target temperature. Fahrenheit degrees are clamped to the nearest integer.",
+ "scan_interval": "Frequency of the device data polling (in seconds)"
+ }
+ },
+ "reconfigure": {
+ "title": "Device configuration",
+ "data": {
+ "name": "Name",
+ "host": "IP Address",
+ "mac": "MAC Address"
+ },
+ "sections": {
+ "advanced": {
+ "name": "Advanced Settings",
+ "description": "Configure advanced setting of the device",
+ "data": {
+ "port": "Port",
+ "encryption_key": "Encryption Key",
+ "encryption_version": "Encryption Version",
+ "uid": "UID",
+ "disable_available_check": "Disable Available Check",
+ "max_online_attempts": "Max Connection Attempts",
+ "timeout": "Connection Timeout"
+ },
+ "data_description": {
+ "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable",
+ "timeout": "The timeout for each of the connection attempts"
+ }
+ }
+ }
+ }
+ }
+ },
+ "selector": {
+ "discovery_method": {
+ "options": {
+ "discover": "Discover devices on the local network",
+ "discover_extended": "Discover devices on the local network and other VLANs/subnets",
+ "manual": "Add device manually"
+ }
+ },
+ "hvac_modes": {
+ "options": {
+ "auto": "Auto",
+ "cool": "Cool",
+ "dry": "Dry",
+ "fan_only": "Fan only",
+ "heat": "Heat",
+ "off": "Off"
+ }
+ },
+ "fan_modes": {
+ "options": {
+ "auto": "Auto",
+ "low": "Low",
+ "medium_low": "Medium-Low",
+ "medium": "Medium",
+ "medium_high": "Medium-High",
+ "high": "High",
+ "turbo": "Turbo",
+ "quiet": "Quiet"
+ }
+ },
+ "swing_modes": {
+ "options": {
+ "default": "Default",
+ "full_swing": "Swing in full range",
+ "fixed_upper": "Fixed in the upmost position",
+ "fixed_upper_middle": "Fixed in the middle-up position",
+ "fixed_middle": "Fixed in the middle position",
+ "fixed_lower_middle": "Fixed in the middle-low position",
+ "fixed_lower": "Fixed in the lowest position",
+ "swing_lower": "Swing in the downmost region",
+ "swing_lower_middle": "Swing in the middle-low region",
+ "swing_middle": "Swing in the middle region",
+ "swing_upper_middle": "Swing in the middle-up region",
+ "swing_upper": "Swing in the upmost region"
+ }
+ },
+ "swing_horizontal_modes": {
+ "options": {
+ "default": "Default",
+ "full_swing": "Swing in full range",
+ "left": "Fixed in the leftmost position",
+ "left_center": "Fixed in the middle-left position",
+ "center": "Fixed in the middle position",
+ "right_center": "Fixed in the middle-right position",
+ "right": "Fixed in the rightmost position"
+ }
+ },
+ "features": {
+ "options": {
+ "beeper": "Beeper",
+ "air": "Fresh Air",
+ "xfan": "X-Fan",
+ "sleep": "Sleep",
+ "eightdegheat": "8ºC Smart Heat",
+ "lights": "Display Light",
+ "health": "Health",
+ "anti_direct_blow": "Anti Direct Blow",
+ "powersave": "Energy Saving",
+ "light_sensor": "Display Auto Brightness",
+ "faults": "Fault Detection"
+ }
+ }
+ },
+ "entity": {
+ "sensor": {
+ "indoor_temperature": {
+ "name": "Indoor Temperature"
+ },
+ "outdoor_temperature": {
+ "name": "Outdoor Temperature"
+ },
+ "room_humidity": {
+ "name": "Indoor Humidity"
+ }
+ },
+ "binary_sensor": {
+ "faults": {
+ "name": "Fault Detection"
+ }
+ },
+ "climate": {
+ "hvac": {
+ "state_attributes": {
+ "fan_mode": {
+ "state": {
+ "auto": "Auto",
+ "low": "Low",
+ "medium_low": "Medium-Low",
+ "medium": "Medium",
+ "medium_high": "Medium-High",
+ "high": "High",
+ "turbo": "Turbo",
+ "quiet": "Quiet"
+ }
+ },
+ "swing_mode": {
+ "state": {
+ "default": "Default",
+ "full_swing": "Swing in full range",
+ "fixed_upper": "Fixed in the upmost position",
+ "fixed_upper_middle": "Fixed in the middle-up position",
+ "fixed_middle": "Fixed in the middle position",
+ "fixed_lower_middle": "Fixed in the middle-low position",
+ "fixed_lower": "Fixed in the lowest position",
+ "swing_lower": "Swing in the downmost region",
+ "swing_lower_middle": "Swing in the middle-low region",
+ "swing_middle": "Swing in the middle region",
+ "swing_upper_middle": "Swing in the middle-up region",
+ "swing_upper": "Swing in the upmost region"
+ }
+ },
+ "swing_horizontal_mode": {
+ "state": {
+ "default": "Default",
+ "full_swing": "Swing in full range",
+ "left": "Fixed in the leftmost position",
+ "left_center": "Fixed in the middle-left position",
+ "center": "Fixed in the middle position",
+ "right_center": "Fixed in the middle-right position",
+ "right": "Fixed in the rightmost position"
+ }
+ }
+ }
+ }
+ },
+ "number": {
+ "target_temp_step": {
+ "name": "Temperature Step"
+ }
+ },
+ "select": {
+ "temperature_units": {
+ "name": "Temperature Units"
+ }
+ },
+ "switch": {
+ "auto_light": {
+ "name": "Auto Display Light"
+ },
+ "auto_xfan": {
+ "name": "Auto X-Fan"
+ },
+ "lights": {
+ "name": "Display Light"
+ },
+ "xfan": {
+ "name": "X-Fan"
+ },
+ "health": {
+ "name": "Health"
+ },
+ "powersave": {
+ "name": "Power Save"
+ },
+ "eightdegheat": {
+ "name": "Smart Heat 8ºC"
+ },
+ "sleep": {
+ "name": "Sleep"
+ },
+ "air": {
+ "name": "Fresh Air"
+ },
+ "anti_direct_blow": {
+ "name": "Anti Direct Blow"
+ },
+ "light_sensor": {
+ "name": "Display Auto Brightness"
+ },
+ "beeper": {
+ "name": "Beeper"
+ }
+ }
+ },
+ "exceptions": {
+ "turbo_availability": {
+ "message": "Turbo mode is not available in Dry and Fan-only modes."
+ },
+ "quiet_availability": {
+ "message": "Quiet mode is only available in Dry and Cool modes."
+ },
+ "entity_unavailable": {
+ "message": "The entity is unavailable."
+ },
+ "generic": {
+ "message": "There was a problem performing the requested change, please consult the integration log."
+ }
+ }
+}
diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json
new file mode 100755
index 0000000..10aeb94
--- /dev/null
+++ b/custom_components/gree_custom/translations/pt.json
@@ -0,0 +1,324 @@
+{
+ "config": {
+ "error": {
+ "unknown": "Ocorreu algo de errado, tente de novo. Se o problema persistir, verifique os registos.",
+ "cannot_connect": "Não foi possível ligar ao dispositivo. Verifique as configurações do dispositivo e de rede e tente de novo.ain. Se o problema persistir, verifique os registos.",
+ "cannot_bind": "Não foi possível encontrar uma combinação da versão de encriptação e chave do dispositivo válida. Se o problema persistir, verifique os registos.",
+ "no_devices_found": "Não foram encontrados novos dispositivos Gree compatíveis na rede. Por favor, adicione o dispositivo manualmente.",
+ "invalid_network": "CIDR Inválido. Exemplo: 192.168.30.0/24",
+ "invalid_host": "Endereço IP Inválido. Exemplo: 192.168.30.50",
+ "network_too_large": "Uma das redes introduzidas excede o número máximo de dispositivos (65536, rede /16). Divida a rede em múltiplos CIDRs ou especifique os dispositivos.",
+ "too_many_targets": "O conjunto de redes e dispositivos introduzidos excede o número máximo de dispositivos (65536, rede /16). Divida a rede em múltiplos CIDRs ou especifique os dispositivos."
+ },
+ "abort": {
+ "already_configured": "Um dispositivo com este endereço MAC já foi configurado previamente."
+ },
+ "step": {
+ "user": {
+ "title": "Configuração Gree",
+ "description": "Escolha como adicionar o seu dispositivo Gree",
+ "data": {
+ "discovery": "Método"
+ }
+ },
+ "manual_discovery": {
+ "title": "Dispositivos Encontrados",
+ "description": "Foram encontrado(s) {devices_found} dispositivo(s) Gree. Selecione um para adicionar ou escolha a configuração manual.",
+ "data": {
+ "device": "Dispositivo"
+ }
+ },
+ "discovery_options": {
+ "title": "Procura Expandida",
+ "description": "Tem dispositivos numa VLAN? Introduza uma ou mais redes e/ou IP de dispositivos para expandir a procura automática. Rotas Inter-VLAN e regras de firewall devem existir para permitir tráfego UDP na porta 7000 desde o Home Assistant até às redes e dispositivos especificados.",
+ "data": {
+ "extra_scan_networks": "Redes (CIDRs separados por vírgula, e.g. 192.168.20.0/24,192.168.30.0/24)",
+ "extra_scan_hosts": "Dispositivos (IPs separados por vírgula, e.g. 192.168.30.50,192.168.30.51)"
+ }
+ },
+ "manual_add": {
+ "title": "Configuração do dispositivo",
+ "data": {
+ "host": "Endereço IP",
+ "mac": "Endereço MAC"
+ },
+ "sections": {
+ "advanced": {
+ "name": "Definições Avançadas",
+ "description": "Configure as definições avançadas do dispositivo",
+ "data": {
+ "port": "Porta",
+ "encryption_key": "Chave de Encriptação",
+ "encryption_version": "Versão de Encriptação",
+ "uid": "UID",
+ "disable_available_check": "Desativar Verificação de Disponibilidade",
+ "max_online_attempts": "Máximo de Tentativas de Ligação",
+ "timeout": "Tempo Limite de Ligação"
+ },
+ "data_description": {
+ "max_online_attempts": "Número máximo de tentativas de comunicação com o dispositivo antes de ele ser marcado como indisponível",
+ "timeout": "Tempo limite de espera das respostas de cada ligação ao dispositivo"
+ }
+ }
+ }
+ },
+ "device_options": {
+ "title": "Funcionalidades do dispositivo",
+ "description": "A API da Gree não fornece um método robusto para obter as funcionalidades de um dispositivo. Por favor, use as opções abaixo com base nos seus conhecimentos sobre o dispositivo.",
+ "data": {
+ "device_name": "Nome do Dispositivo",
+ "hvac_modes": "Modos de Climatização",
+ "fan_modes": "Velocidades da Ventoinha",
+ "swing_modes": "Modos de Oscilamento Vertical",
+ "swing_horizontal_modes": "Modos de Oscilamento Horizontal",
+ "features": "Outas Funcionalidades e Modos",
+ "external_temperature_sensor": "Sensor de Temperatura",
+ "external_humidity_sensor": "Sensor de Humidade",
+ "restore_states": "Restaurar Entidades",
+ "target_temp_step": "Incremento de Temperatura",
+ "scan_interval": "Taxa de Atualização"
+ },
+ "data_description": {
+ "external_temperature_sensor": "Se definido, substitui o sensor integrado de temperatura interior do dispositivo",
+ "external_humidity_sensor": "Se definido, substitui o sensor integrado de humidade interior do dispositivo",
+ "restore_states": "Se ativo, quando a integração é iniciada, o estado do dispositivo será reposto para o último estado observado na integração.",
+ "target_temp_step": "Define o incremento da temperatura quando esta é ajustada. Graus Fahrenheit são arredondados para às unidades.",
+ "scan_interval": "Frequência de atualização dos dados do dispositivo (em segundos)"
+ }
+ },
+ "reconfigure": {
+ "title": "Configuração do dispositivo",
+ "data": {
+ "name": "Nomw",
+ "host": "Endereço IP",
+ "mac": "Endereço MAC"
+ },
+ "sections": {
+ "advanced": {
+ "name": "Definições Avançadas",
+ "description": "Configure as definições avançadas do dispositivo",
+ "data": {
+ "port": "Porta",
+ "encryption_key": "Chave de Encriptação",
+ "encryption_version": "Versão de Encriptação",
+ "uid": "UID",
+ "disable_available_check": "Desativar Verificação de Disponibilidade",
+ "max_online_attempts": "Máximo de Tentativas de Ligação",
+ "timeout": "Tempo Limite de Ligação"
+ },
+ "data_description": {
+ "max_online_attempts": "Número máximo de tentativas de comunicação com o dispositivo antes de ele ser marcado como indisponível",
+ "timeout": "Tempo limite de espera das respostas de cada ligação ao dispositivo"
+ }
+ }
+ }
+ }
+ }
+ },
+ "selector": {
+ "discovery_method": {
+ "options": {
+ "discover": "Procura automática de dispositivos",
+ "discover_extended": "Procura automática de dispositivos expandida",
+ "manual": "Adicionar manualmente"
+ }
+ },
+ "hvac_modes": {
+ "options": {
+ "auto": "Automático",
+ "cool": "Arrefecer",
+ "dry": "Secar",
+ "fan_only": "Ventilação",
+ "heat": "Aquecer",
+ "off": "Desligado"
+ }
+ },
+ "fan_modes": {
+ "options": {
+ "auto": "Automática",
+ "low": "Baixa",
+ "medium_low": "Média-Baixa",
+ "medium": "Média",
+ "medium_high": "Média-Alta",
+ "high": "Alta",
+ "turbo": "Turbo",
+ "quiet": "Silenciosa"
+ }
+ },
+ "swing_modes": {
+ "options": {
+ "default": "Por defeito",
+ "full_swing": "Oscilação completa",
+ "fixed_upper": "Fixo no topo",
+ "fixed_upper_middle": "Fixo entre o meio e topo",
+ "fixed_middle": "Fixo no meio",
+ "fixed_lower_middle": "Fixo entre o meio e baixo",
+ "fixed_lower": "Fixo em baixo",
+ "swing_lower": "Oscilação na região inferior",
+ "swing_lower_middle": "Oscilação na região média-inferior",
+ "swing_middle": "Oscilação na região intermédia",
+ "swing_upper_middle": "Oscilação na região média-superior",
+ "swing_upper": "Oscilação na região superior"
+ }
+ },
+ "swing_horizontal_modes": {
+ "options": {
+ "default": "Por defeito",
+ "full_swing": "Oscilação completa",
+ "left": "Fixo à esquerda",
+ "left_center": "Fixo entre o meio e a esquerda",
+ "center": "Fixo no meio",
+ "right_center": "Fixo entre o meio e a direita",
+ "right": "Fixo à direita"
+ }
+ },
+ "features": {
+ "options": {
+ "beeper": "Aviso Sonoro",
+ "air": "Ar Fresco",
+ "xfan": "X-Fan",
+ "sleep": "Dormir",
+ "eightdegheat": "Fora de Casa",
+ "lights": "Visor",
+ "health": "Saúde",
+ "anti_direct_blow": "Anti Sopro Direto",
+ "powersave": "Poupança de Energia",
+ "light_sensor": "Brilho Automático do Visor",
+ "faults": "Falha de Operação"
+ }
+ }
+ },
+ "entity": {
+ "sensor": {
+ "indoor_temperature": {
+ "name": "Temperatura Interior"
+ },
+ "outdoor_temperature": {
+ "name": "Temperatura Exterior"
+ },
+ "room_humidity": {
+ "name": "Humidade Interior"
+ }
+ },
+ "binary_sensor": {
+ "faults": {
+ "name": "Falha de Operação"
+ }
+ },
+ "climate": {
+ "hvac": {
+ "state": {
+ "auto": "Automático",
+ "cool": "Arrefecer",
+ "dry": "Secar",
+ "fan_only": "Ventilação",
+ "heat": "Aquecer",
+ "off": "Desligado"
+ },
+ "state_attributes": {
+ "fan_mode": {
+ "state": {
+ "auto": "Automática",
+ "low": "Baixa",
+ "medium_low": "Média-Baixa",
+ "medium": "Média",
+ "medium_high": "Média-Alta",
+ "high": "Alta",
+ "turbo": "Turbo",
+ "quiet": "Silenciosa"
+ }
+ },
+ "swing_mode": {
+ "state": {
+ "default": "Por defeito",
+ "full_swing": "Oscilação completa",
+ "fixed_upper": "Fixo no topo",
+ "fixed_upper_middle": "Fixo entre o meio e topo",
+ "fixed_middle": "Fixo no meio",
+ "fixed_lower_middle": "Fixo entre o meio e baixo",
+ "fixed_lower": "Fixo em baixo",
+ "swing_lower": "Oscilação na região inferior",
+ "swing_lower_middle": "Oscilação na região média-inferior",
+ "swing_middle": "Oscilação na região intermédia",
+ "swing_upper_middle": "Oscilação na região média-superior",
+ "swing_upper": "Oscilação na região superior"
+ }
+ },
+ "swing_horizontal_mode": {
+ "state": {
+ "default": "Por defeito",
+ "full_swing": "Oscilação completa",
+ "left": "Fixo à esquerda",
+ "left_center": "Fixo entre o meio e a esquerda",
+ "center": "Fixo no meio",
+ "right_center": "Fixo entre o meio e a direita",
+ "right": "Fixo à direita"
+ }
+ }
+ }
+ }
+ },
+ "number": {
+ "target_temp_step": {
+ "name": "Incremento de Temperatura"
+ }
+ },
+ "select": {
+ "temperature_units": {
+ "name": "Unidade de Temperatura"
+ }
+ },
+ "switch": {
+ "auto_light": {
+ "name": "Visor Automático"
+ },
+ "auto_xfan": {
+ "name": "X-Fan Automática"
+ },
+ "lights": {
+ "name": "Visor"
+ },
+ "xfan": {
+ "name": "X-Fan"
+ },
+ "health": {
+ "name": "Saúde"
+ },
+ "powersave": {
+ "name": "Poupança de Energia"
+ },
+ "eightdegheat": {
+ "name": "Fora de Casa"
+ },
+ "sleep": {
+ "name": "Dormir"
+ },
+ "air": {
+ "name": "Ar Fresco"
+ },
+ "anti_direct_blow": {
+ "name": "Anti Sopro Direto"
+ },
+ "light_sensor": {
+ "name": "Brilho Automático do Visor"
+ },
+ "beeper": {
+ "name": "Aviso Sonoro"
+ }
+ }
+ },
+ "exceptions": {
+ "turbo_availability": {
+ "message": "Modo Turbo não está disponível nos modos de Secar e Ventilação."
+ },
+ "quiet_availability": {
+ "message": "Modo Silencioso apenas disponível nos modos de Secar e Ventilação."
+ },
+ "entity_unavailable": {
+ "message": "A entidade não está disponível."
+ },
+ "generic": {
+ "message": "Ocorreu um erro a realizar a ação pretendida, consulto os registos da integração."
+ }
+ }
+}
\ No newline at end of file
diff --git a/hacs.json b/hacs.json
new file mode 100644
index 0000000..76ed70f
--- /dev/null
+++ b/hacs.json
@@ -0,0 +1,4 @@
+{
+ "name": "Gree A/C",
+ "homeassistant": "2026.1"
+}
\ No newline at end of file
diff --git a/manual-configuration.yaml b/manual-configuration.yaml
index 7a0a261..3d5ca90 100644
--- a/manual-configuration.yaml
+++ b/manual-configuration.yaml
@@ -4,101 +4,97 @@
# when using YAML configuration instead of the UI config flow.
#
# Copy the sections you need to your configuration.yaml file.
+#
+# If an option is not provided the default values will be used.
+# For option lists, pass empty list ([]) to disable the option.
+#
+# MAC address Format can be XX:XX:XX:XX:XX:XX, XX-XX-XX-XX-XX-XX, xxxxxxxxxxxx
+#
+# For VRF units the MAC is usually xxxxxxxxxxxx@yyyyyyyyyyyy where
+# the first part (x) is the device MAC and the second (y) the main device MAC which controls the device
+# In this case it is preferred to place the main device MAC on the top config and
+# the sub device MAC on the device list config
+
+
+gree_custom:
+ - host: "192.168.1.100" # IP Address of AC | required | str
+ mac: "20-FA-BB-12-34-56" # MAC Address of Main AC Unit | required | str
+ advanced:
+ port: 7000 # Port number to connect to the device | int | default = 7000
+ encryption_version: 2 # The encryption version to use with the device | options = "Auto-Detect", 1, 2 | default = "Auto-Detect"
+ encryption_key: "my_device_key" # Custom encryption key | str | default =
+ uid: 0 # Device identifier which is not needed for all devices, can be sniffed if required | positive int | default = 0
+ disable_available_check: false # boolean | default = false
+ max_online_attempts: 3 # Number connection attempts made with device before it is marked as unavailable | positive int | default = 5
+ timeout: 10 # Seconds before a connection attempt times out | positive int (seconds) | default = 10
+ devices: # List of the devices that will be created (optional if only one device and no configuration required)
+ - device_name: "Gree AC" # Name for the AC unit | str | default = "Gree AC ]"
+ mac: "20-FA-BB-12-34-56" # MAC Address of the sub AC unit (same as Main device if not VRF) | required | str
+ hvac_modes: # Standard Home Assistant HVAC Modes to enable | list | options = ["auto", "cool", "dry", "fan_only", "heat", "off"] | default = all options
+ - "auto"
+ - "cool"
+ - "dry"
+ - "fan_only"
+ - "heat"
+ - "off"
+ fan_modes: # Supported fan modes | list | options = ["Auto", "Low", "MediumLow", "Medium", "MediumHigh", "High", "turbo", "quiet"] | default = all options
+ - "Auto"
+ - "Low"
+ - "MediumLow"
+ - "Medium"
+ - "MediumHigh"
+ - "High"
+ - "turbo"
+ - "quiet"
+ swing_modes: # Supported vertical swing modes | list | options = ["Default", "FullSwing", "FixedUpper", "FixedUpperMiddle", "FixedMiddle", "FixedLowerMiddle", "FixedLower", "SwingLower", "SwingLowerMiddle", "SwingMiddle", "SwingUpperMiddle", "SwingUpper"] | default = all options
+ - "Default"
+ - "FullSwing"
+ - "FixedUpper"
+ - "FixedUpperMiddle"
+ - "FixedMiddle"
+ - "FixedLowerMiddle"
+ - "FixedLower"
+ - "SwingLower"
+ - "SwingLowerMiddle"
+ - "SwingMiddle"
+ - "SwingUpperMiddle"
+ - "SwingUpper"
+ swing_horizontal_modes: # Supported horizontal swing modes | list | options = ["Default", "FullSwing", "Left", "LeftCenter", "Center", "RightCenter", "Right"] | default = all options
+ - "Default"
+ - "FullSwing"
+ - "Left"
+ - "LeftCenter"
+ - "Center"
+ - "RightCenter"
+ - "Right"
+ features: # Supported device features | list | options = ["beeper", "air", "xfan", "sleep", "eightdegheat", "lights", "health", "anti_direct_blow", "powersave", "light_sensor", "faults"] | default = all options
+ - "beeper"
+ - "air"
+ - "xfan"
+ - "sleep"
+ - "eightdegheat"
+ - "lights"
+ - "health"
+ - "anti_direct_blow"
+ - "powersave"
+ - "light_sensor"
+ - "faults"
+ target_temp_step: 1 # Number of degrees increase or decrease when changing the temperature | 0.5 < int < 5, 0.5 increments | default = 1
+ 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
-gree:
- # Name for the AC unit (required)
- - name: "First AC"
-
- # IP Address of AC (required)
- host: "192.168.1.101"
-
- # MAC address of the device (required)
- # Format can be XX:XX:XX:XX:XX:XX, XX-XX-XX-XX-XX-XX, xxxxxxxxxxxx
- # or xxxxxxxxxxxx@yyyyyyyyyyyy (for VRF units) depending on your model
- mac: "20-FA-BB-12-34-56"
-
- # Encryption version (optional, defaults to 1)
- encryption_version: 1
-
- # Port number to connect to the device (optional, defaults to 7000)
- # port: 7000
-
- # Custom encryption key (optional, auto-fetched if empty)
- # If you have extracted your encryption key, you can specify it here
- # encryption_key: "A1B2C3D4E5F6"
-
- # Device identifier (optional)
- # This is not needed for all devices, can be sniffed if required
- # uid: 123
-
- # Standard Home Assistant HVAC Modes to enable (optional)
- # Default: ["auto", "cool", "dry", "fan_only", "heat", "off"]
- # hvac_modes:
- # - "auto"
- # - "cool"
- # - "dry"
- # - "fan_only"
- # - "heat"
- # - "off"
-
- # Fan modes (optional)
- # Default: ["auto", "low", "medium_low", "medium", "medium_high", "high", "turbo", "quiet"]
- # fan_modes:
- # - "auto"
- # - "low"
- # - "medium_low"
- # - "medium"
- # - "medium_high"
- # - "high"
- # - "turbo"
- # - "quiet"
-
- # Fan vertical swing modes (optional)
- # Pass empty list ([]) to disable vertical swing
- # Default: ["default", "swing_full", "fixed_upmost", "fixed_middle_up", "fixed_middle", "fixed_middle_low", "fixed_lowest", "swing_downmost", "swing_middle_low", "swing_middle", "swing_middle_up", "swing_upmost"]
- # swing_modes:
- # - "default"
- # - "swing_full"
- # - "fixed_upmost"
- # - "fixed_middle_up"
- # - "fixed_middle"
- # - "fixed_middle_low"
- # - "fixed_lowest"
- # - "swing_downmost"
- # - "swing_middle_low"
- # - "swing_middle"
- # - "swing_middle_up"
- # - "swing_upmost"
-
- # Fan horizontal swing modes (optional)
- # Pass empty list ([]) to disable horizontal swing
- # Default: ["default", "swing_full", "fixed_leftmost", "fixed_middle_left", "fixed_middle", "fixed_middle_right", "fixed_rightmost"]
- # swing_horizontal_modes:
- # - "default"
- # - "swing_full"
- # - "fixed_leftmost"
- # - "fixed_middle_left"
- # - "fixed_middle"
- # - "fixed_middle_right"
- # - "fixed_rightmost"
-
- # Keep AC always available in HA (optional, defaults to false)
- # Disables connection checking - useful for devices that don't respond reliably
- # disable_available_check: false
-
- # Display offset for temp sensor (optional, auto-detected if not set)
- # Set to true to apply -40°C offset, false for no offset, or leave unset for auto-detection
- # temp_sensor_offset: true
# Example for multiple AC units:
-# gree:
-# - name: "Living Room AC"
-# host: "192.168.1.101"
+# gree_custom:
+# - host: "192.168.1.101"
# mac: "20-FA-BB-12-34-56"
-# encryption_version: 2
-#
-# - name: "Bedroom AC"
-# host: "192.168.1.102"
+# advanced:
+# encryption_version: 2
+
+# - host: "192.168.1.102"
# mac: "20-FA-BB-12-34-57"
-# encryption_version: 1
-# port: 7001
+# devices:
+# - name: "Gree AC"
+# mac: "20-FA-BB-12-34-57"