From 3d0b5a419f5a7617bb0f77430a4dcc4be7a65081 Mon Sep 17 00:00:00 2001 From: Rob Hofmann Date: Sun, 20 Sep 2026 02:18:36 +0200 Subject: [PATCH 1/5] vNext: YAML import for the gree_custom block The integration already started an import flow for every item under gree_custom: in configuration.yaml, but async_step_import aborted with not_implemented and there was no CONFIG_SCHEMA, so the YAML was never validated and never applied. Changes: - config_schema.py: CONFIG_SCHEMA for the shape in manual-configuration.yaml. Normalizes MAC keys (separators and case), derives the controller MACs when they are not given, fills the connection defaults, validates the mode and feature lists, and checks that every device can be reached (a local block, or a cloud account plus a cloud block). Only one item may leave out the cloud block. __init__.py imports the schema so Home Assistant and hassfest find it. - config_flow.py: async_step_import resolves the target entry (local-only or cloud account), skips devices that already belong to another entry, then creates the entry or updates it with async_update_reload_and_abort. Unchanged YAML causes no reload. Cloud login happens only when no entry stores the same email, region and password; otherwise the stored uid and token are reused. - A yaml_import_failed repair issue and an import_failed abort reason. - Docs: README example was still the 4.x flat shape. manual-configuration.yaml now matches the schema (faults was listed as a feature, the controller MACs are optional). docs/config-entry.md and docs/architecture.md describe the import. Tested on HA 2026.9.3 against the real unit (U-CS532Z, encryption v1): update of an existing UI entry (name and features applied, one setup, no reload), unchanged YAML on restart (entry not modified, one setup), and creation after the entry was removed (source import, bound, six entities). Schema harness: 19/19 checks. ruff check and format pass. Pylint and mypy were not run, neither is available here. --- README.md | 21 +- custom_components/gree_custom/__init__.py | 38 ++ custom_components/gree_custom/config_flow.py | 138 ++++++- .../gree_custom/config_schema.py | 370 ++++++++++++++++++ .../gree_custom/translations/en.json | 7 +- docs/architecture.md | 3 +- docs/config-entry.md | 16 + manual-configuration.yaml | 47 ++- 8 files changed, 613 insertions(+), 27 deletions(-) create mode 100644 custom_components/gree_custom/config_schema.py diff --git a/README.md b/README.md index b14c7ff..b4db202 100644 --- a/README.md +++ b/README.md @@ -51,17 +51,26 @@ While reconfiguring, devices not selected will be removed from the entry. ### Manual - YAML Configuration -See [`manual-configuration.yaml`](manual-configuration.yaml) for a complete configuration example with all available options and detailed comments. +You can set the integration up in `configuration.yaml` instead of the UI. -Basic example: +Minimal example with one local device: ```yaml gree_custom: - - host: "192.168.1.100" - mac: "20-FA-BB-12-34-56" - devices: - - device_name: "Gree AC" + - devices: + "20-FA-BB-12-34-56": + connection: + local: + host: "192.168.1.100" + options: + name: "Gree AC" ``` +Home Assistant reads the YAML at every start. It creates the config entry when it is missing and updates it when the YAML changed. An unchanged YAML causes no reload. A device you remove from the YAML is removed from the config entry, so do not change a YAML managed entry in the UI: the next restart puts the YAML values back. + +Every item in the list is one config entry. An item with a `cloud` block is the entry for that Gree account, and the one item without a `cloud` block is the entry for all local-only devices. A device that is already in another config entry is skipped, with an error in the log and a repair issue. + +See [`manual-configuration.yaml`](manual-configuration.yaml) for a complete configuration example with all available options and detailed comments. + ## Connection Methods and Configuration The integration supports both the local UDP protocol and the MQTT cloud protocol to communicate with the devices. It also supports enhancing local devices with cloud info during discovery. diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 41118eb..9d63013 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -30,6 +30,7 @@ from .aiogree.errors import GreeConnectionError from .aiogree.transport_mqtt import GreeMqttTransport from .aiogree.transport_udp import GreeUdpTransport +from .config_schema import CONFIG_SCHEMA as CONFIG_SCHEMA # Local imports from .const import ( @@ -66,6 +67,7 @@ from .services import async_setup_services ISSUE_DEVICE_CONNECTION_FAILED = "device_connection_failed" +ISSUE_YAML_IMPORT_FAILED = "yaml_import_failed" PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -335,6 +337,42 @@ def delete_device_connection_issue( ) +def _yaml_import_issue_id(item_id: str) -> str: + return f"{ISSUE_YAML_IMPORT_FAILED}_{item_id}" + + +def create_yaml_import_issue( + hass: HomeAssistant, + item_id: str, + reason: str, +) -> None: + """Create a YAML import issue.""" + ir.async_create_issue( + hass, + DOMAIN, + _yaml_import_issue_id(item_id), + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key=ISSUE_YAML_IMPORT_FAILED, + translation_placeholders={ + "item": item_id, + "reason": reason, + }, + ) + + +def delete_yaml_import_issue( + hass: HomeAssistant, + item_id: str, +) -> None: + """Delete a YAML import issue.""" + ir.async_delete_issue( + hass, + DOMAIN, + _yaml_import_issue_id(item_id), + ) + + def cleanup_device_connection_issues( hass: HomeAssistant, config_entry_id: str, diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 4b6cc18..27bc001 100755 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -57,6 +57,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.storage import Store +from . import create_yaml_import_issue, delete_yaml_import_issue from .aiogree.api import ( GreeDiscoveredDevice, GreeProp, @@ -145,6 +146,19 @@ _LOGGER = logging.getLogger(__name__) +def _matches_cloud_account( + stored: Mapping[str, Any] | None, cloud_conf: Mapping[str, Any] +) -> bool: + """Tell if a stored cloud block is the same account as a YAML cloud block.""" + if not stored: + return False + + return all( + stored.get(key) == cloud_conf.get(key) + for key in (CONF_EMAIL, CONF_REGION, CONF_PASSWORD) + ) + + SETUP_SCHEMA = probatio.Schema( { probatio.Required(CONF_DISCOVERY, default=["cloud", "local"]): SelectSelector( @@ -557,8 +571,128 @@ def __init__(self) -> None: async def async_step_import(self, import_config: dict) -> ConfigFlowResult: """Handle import from configuration.yaml.""" - # TODO: Implement YAML import - return self.async_abort(reason="not_implemented") + + cloud_conf: dict[str, Any] | None = import_config.get(CONF_CLOUD) + devices: dict[str, Any] = dict(import_config[CONF_DEVICES]) + + item_id = cloud_conf[CONF_EMAIL] if cloud_conf else CONFENTRY_ID_LOCAL_ONLY + + stored_cloud: dict[str, Any] | None = None + if not cloud_conf: + unique_id = CONFENTRY_ID_LOCAL_ONLY + else: + known_entry = next( + ( + entry + for entry in get_config_entries(self.hass) + if _matches_cloud_account(entry.data.get(CONF_CLOUD), cloud_conf) + and entry.unique_id + ), + None, + ) + + if known_entry and known_entry.unique_id: + # Reuse the stored token so there is no login on every restart + unique_id = known_entry.unique_id + stored_cloud = dict(known_entry.data[CONF_CLOUD]) + else: + cloud_api = GreeCloudApi( + region=GreeRegion(cloud_conf[CONF_REGION]), + username=cloud_conf[CONF_EMAIL], + password=cloud_conf[CONF_PASSWORD], + ) + try: + credentials = await cloud_api.login() + except Exception as err: # noqa: BLE001 + return self._abort_yaml_import(item_id, err) + finally: + await cloud_api.close() + + unique_id = str(credentials.user_id) + stored_cloud = { + **cloud_conf, + CONF_TOKEN: credentials.token, + CONF_UID: credentials.user_id, + } + + await self.async_set_unique_id(unique_id) + + self._drop_devices_of_other_entries(devices, unique_id) + + if not devices: + create_yaml_import_issue( + self.hass, + item_id, + "every device is already configured in another config entry", + ) + return self.async_abort(reason="import_failed") + + delete_yaml_import_issue(self.hass, item_id) + + data = { + CONF_CLOUD: stored_cloud, + CONF_DEVICES: devices, + } + cloud_data: dict[str, Any] = stored_cloud or {} + title = ( + f"Gree Account: {cloud_data.get(CONF_UID)} ({cloud_data.get(CONF_EMAIL)})" + if cloud_data.get(CONF_EMAIL) + else "Local-only Devices" + ) + + entry = self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, unique_id + ) + if entry: + # remove devices that are no longer provided by the YAML + # they will be re-added if they exist in another entry + device_registry = dr.async_get(self.hass) + for mac in entry.data.get(CONF_DEVICES, {}): + if mac not in devices: + dev = device_registry.async_get_device(identifiers={(DOMAIN, mac)}) + if dev: + device_registry.async_remove_device(dev.id) + + return self.async_update_reload_and_abort( + entry, + title=title, + data=data, + reason="reconfigure_successful", + reload_even_if_entry_is_unchanged=False, + ) + + _LOGGER.debug( + "YAML import: new entry with config: %s", + async_redact_data(data, ["encryption_key", "password", "token"]), + ) + return self.async_create_entry( + title=title, + data=data, + ) + + def _drop_devices_of_other_entries( + self, devices: dict[str, Any], unique_id: str + ) -> None: + """Drop the devices that already belong to another config entry.""" + other_macs = get_configured_macs_in_entries( + self.hass, ignore_entries=[unique_id] + ) + + for mac in list(devices): + if mac in other_macs: + _LOGGER.error( + "YAML import: device %s is already configured in entry '%s'" + " and is skipped", + mac, + other_macs[mac].title, + ) + devices.pop(mac) + + def _abort_yaml_import(self, item_id: str, err: Exception) -> ConfigFlowResult: + """Log a failed cloud login, raise a repair issue and stop the import.""" + _LOGGER.error("YAML import: cloud login failed for %s: %s", item_id, err) + create_yaml_import_issue(self.hass, item_id, f"cloud login failed: {err}") + return self.async_abort(reason="import_failed") @override async def async_step_dhcp( diff --git a/custom_components/gree_custom/config_schema.py b/custom_components/gree_custom/config_schema.py new file mode 100644 index 0000000..60f4575 --- /dev/null +++ b/custom_components/gree_custom/config_schema.py @@ -0,0 +1,370 @@ +"""YAML schema for the Gree integration. + +`CONFIG_SCHEMA` validates the `gree_custom:` block in `configuration.yaml`, +fills the defaults and hands one item per config entry to the import flow. +""" + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import probatio +else: + try: + import probatio + except ImportError: + import voluptuous as probatio + +from homeassistant.const import ( + CONF_EMAIL, + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_REGION, + CONF_SCAN_INTERVAL, + CONF_TIMEOUT, +) +from homeassistant.helpers import config_validation as cv + +from .aiogree.cipher import EncryptionVersion +from .aiogree.cloud_api import GreeRegion +from .aiogree.helpers import gree_extract_macs +from .const import ( + ATTR_EXTERNAL_HUMIDITY_SENSOR, + ATTR_EXTERNAL_TEMPERATURE_SENSOR, + ATTR_FEATURES_TO_PROP_MAP, + CONF_CLOUD, + CONF_DEVICE_CONNECTION, + CONF_DEVICE_CONNECTION_CLOUD, + CONF_DEVICE_CONNECTION_LOCAL, + CONF_DEVICE_OPTIONS, + CONF_DEVICES, + CONF_DISABLE_AVAILABLE_CHECK, + CONF_ENCRYPTION_KEY, + CONF_ENCRYPTION_VERSION, + CONF_FAN_MODES, + CONF_FEATURES, + CONF_HVAC_MODES, + CONF_MAC_CONTROLLER_CLOUD, + CONF_MAC_CONTROLLER_LOCAL, + CONF_MAX_ONLINE_ATTEMPTS, + CONF_PREFER_CLOUD, + 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_ENCRYPTION_KEY, + DEFAULT_ENCRYPTION_VERSION, + DEFAULT_FAN_MODES, + DEFAULT_HVAC_MODES, + DEFAULT_PREFER_CLOUD, + DEFAULT_RESTORE_STATES, + DEFAULT_SCAN_INTERVAL, + DEFAULT_SWING_HORIZONTAL_MODES, + DEFAULT_SWING_MODES, + DOMAIN, + ENCRYPTION_VERSION_AUTO, + GATTR_FEAT_QUIET_MODE, + GATTR_FEAT_TURBO, + MIN_SCAN_INTERVAL, +) + +HEX_CHARACTERS = "0123456789abcdef" +VALID_MAC_LENGTHS = (12, 14) + +VALID_ENCRYPTION_VERSIONS = [ + ENCRYPTION_VERSION_AUTO, + *(str(version.value) for version in EncryptionVersion), +] +VALID_HVAC_MODES = [str(mode) for mode in DEFAULT_HVAC_MODES] +VALID_FAN_MODES = [*DEFAULT_FAN_MODES, GATTR_FEAT_TURBO, GATTR_FEAT_QUIET_MODE] +VALID_FEATURES = list(ATTR_FEATURES_TO_PROP_MAP) + +MIN_TARGET_TEMP_STEP = 0.5 +MAX_TARGET_TEMP_STEP = 5 + + +def _clean_mac(value: Any, field: str) -> str: + """Strip the separators from a MAC address and check that it is valid.""" + mac = str(value).replace(":", "").replace("-", "").strip().lower() + + if len(mac) not in VALID_MAC_LENGTHS or any(c not in HEX_CHARACTERS for c in mac): + raise probatio.Invalid( + f"{field} must be a MAC address of 12 or 14 hex characters " + f"without separators, got '{value}'" + ) + + return mac + + +def _mac_controller_local(value: Any) -> str: + """Validate the MAC address of the local controller.""" + return _clean_mac(value, CONF_MAC_CONTROLLER_LOCAL) + + +def _mac_controller_cloud(value: Any) -> str: + """Validate the MAC address of the cloud controller.""" + return _clean_mac(value, CONF_MAC_CONTROLLER_CLOUD) + + +def _encryption_version(value: Any) -> str: + """Validate the encryption version and return it as a string.""" + version = str(value) + + if version not in VALID_ENCRYPTION_VERSIONS: + raise probatio.Invalid( + f"{CONF_ENCRYPTION_VERSION} must be one of " + f"{VALID_ENCRYPTION_VERSIONS}, got '{value}'" + ) + + return version + + +def _target_temp_step(value: Any) -> float: + """Validate the target temperature step and return it as a float.""" + step = probatio.Coerce(float)(value) + + if step < MIN_TARGET_TEMP_STEP or step > MAX_TARGET_TEMP_STEP: + raise probatio.Invalid( + f"{CONF_TEMPERATURE_STEP} must be between " + f"{MIN_TARGET_TEMP_STEP} and {MAX_TARGET_TEMP_STEP}, got {step}" + ) + + if step * 2 != int(step * 2): + raise probatio.Invalid( + f"{CONF_TEMPERATURE_STEP} must be a multiple of 0.5, got {step}" + ) + + return step + + +CLOUD_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_EMAIL): cv.string, + probatio.Required(CONF_PASSWORD): cv.string, + probatio.Required(CONF_REGION): probatio.In( + [region.value for region in GreeRegion] + ), + } +) + +CONNECTION_LOCAL_SCHEMA = probatio.Schema( + { + probatio.Optional(CONF_MAC_CONTROLLER_LOCAL): _mac_controller_local, + probatio.Required(CONF_HOST): cv.string, + probatio.Optional(CONF_PORT, default=DEFAULT_DEVICE_PORT): cv.port, + probatio.Optional( + CONF_TIMEOUT, default=DEFAULT_CONNECTION_TIMEOUT + ): cv.positive_int, + probatio.Optional( + CONF_ENCRYPTION_VERSION, default=DEFAULT_ENCRYPTION_VERSION + ): _encryption_version, + probatio.Optional( + CONF_MAX_ONLINE_ATTEMPTS, default=DEFAULT_CONNECTION_MAX_ATTEMPTS + ): cv.positive_int, + } +) + +CONNECTION_CLOUD_SCHEMA = probatio.Schema( + { + probatio.Optional(CONF_PREFER_CLOUD, default=DEFAULT_PREFER_CLOUD): cv.boolean, + probatio.Optional(CONF_MAC_CONTROLLER_CLOUD): _mac_controller_cloud, + } +) + +CONNECTION_SCHEMA = probatio.Schema( + { + probatio.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): probatio.All(probatio.Coerce(int), probatio.Range(min=MIN_SCAN_INTERVAL)), + probatio.Optional( + CONF_DISABLE_AVAILABLE_CHECK, default=DEFAULT_DISABLE_AVAILABLE_CHECK + ): cv.boolean, + probatio.Optional( + CONF_ENCRYPTION_KEY, default=DEFAULT_ENCRYPTION_KEY + ): cv.string, + probatio.Optional(CONF_UID, default=DEFAULT_DEVICE_UID): cv.positive_int, + probatio.Optional(CONF_DEVICE_CONNECTION_LOCAL): CONNECTION_LOCAL_SCHEMA, + probatio.Optional(CONF_DEVICE_CONNECTION_CLOUD): CONNECTION_CLOUD_SCHEMA, + } +) + +OPTIONS_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_NAME): cv.string, + probatio.Optional(CONF_HVAC_MODES): probatio.All( + cv.ensure_list, [probatio.In(VALID_HVAC_MODES)] + ), + probatio.Optional(CONF_FAN_MODES): probatio.All( + cv.ensure_list, [probatio.In(VALID_FAN_MODES)] + ), + probatio.Optional(CONF_SWING_MODES): probatio.All( + cv.ensure_list, [probatio.In(DEFAULT_SWING_MODES)] + ), + probatio.Optional(CONF_SWING_HORIZONTAL_MODES): probatio.All( + cv.ensure_list, [probatio.In(DEFAULT_SWING_HORIZONTAL_MODES)] + ), + probatio.Optional(CONF_FEATURES): probatio.All( + cv.ensure_list, [probatio.In(VALID_FEATURES)] + ), + probatio.Optional(CONF_TEMPERATURE_STEP): _target_temp_step, + probatio.Optional(ATTR_EXTERNAL_TEMPERATURE_SENSOR): cv.entity_id, + probatio.Optional(ATTR_EXTERNAL_HUMIDITY_SENSOR): cv.entity_id, + probatio.Optional( + CONF_RESTORE_STATES, default=DEFAULT_RESTORE_STATES + ): cv.boolean, + } +) + + +def _add_connection_block(value: Any) -> dict[str, Any]: + """Give a device an empty connection block when it has none.""" + if not isinstance(value, dict): + raise probatio.Invalid("a device must be a mapping") + + if value.get(CONF_DEVICE_CONNECTION) is None: + return {**value, CONF_DEVICE_CONNECTION: {}} + + return value + + +DEVICE_SCHEMA = probatio.All( + _add_connection_block, + probatio.Schema( + { + probatio.Required(CONF_DEVICE_CONNECTION): CONNECTION_SCHEMA, + probatio.Required(CONF_DEVICE_OPTIONS): OPTIONS_SCHEMA, + } + ), +) + + +def _normalize_device_macs(value: Any) -> dict[str, Any]: + """Re-key the devices mapping on the normalized device MAC address.""" + if not isinstance(value, dict): + raise probatio.Invalid(f"{CONF_DEVICES} must be a mapping keyed by MAC address") + + devices: dict[str, Any] = {} + for raw_mac, device in value.items(): + mac, _ = gree_extract_macs(_clean_mac(raw_mac, "device MAC address")) + + if mac in devices: + raise probatio.Invalid(f"device {mac} is listed more than once") + + devices[mac] = device + + return devices + + +def _fill_device_defaults(value: dict[str, Any]) -> dict[str, Any]: + """Fill the controller MAC addresses and the missing connection blocks.""" + devices: dict[str, Any] = {} + + for mac, device in value.items(): + _, mac_controller = gree_extract_macs(mac) + connection = dict(device[CONF_DEVICE_CONNECTION]) + + local = connection.get(CONF_DEVICE_CONNECTION_LOCAL) + if local is None: + connection[CONF_DEVICE_CONNECTION_LOCAL] = {} + else: + connection[CONF_DEVICE_CONNECTION_LOCAL] = { + CONF_MAC_CONTROLLER_LOCAL: mac_controller, + **local, + } + + cloud = connection.get(CONF_DEVICE_CONNECTION_CLOUD) + if cloud is None: + connection[CONF_DEVICE_CONNECTION_CLOUD] = { + CONF_PREFER_CLOUD: DEFAULT_PREFER_CLOUD, + CONF_MAC_CONTROLLER_CLOUD: "", + } + else: + connection[CONF_DEVICE_CONNECTION_CLOUD] = { + CONF_MAC_CONTROLLER_CLOUD: mac_controller, + **cloud, + } + + devices[mac] = {**device, CONF_DEVICE_CONNECTION: connection} + + return devices + + +def _validate_item(value: dict[str, Any]) -> dict[str, Any]: + """Check that every device of one item can be reached.""" + devices: dict[str, Any] = value[CONF_DEVICES] + + if not devices: + raise probatio.Invalid(f"{CONF_DEVICES} must hold at least one device") + + has_account = value.get(CONF_CLOUD) is not None + + for mac, device in devices.items(): + connection = device[CONF_DEVICE_CONNECTION] + has_local = bool(connection[CONF_DEVICE_CONNECTION_LOCAL].get(CONF_HOST)) + has_cloud = bool( + connection[CONF_DEVICE_CONNECTION_CLOUD].get(CONF_MAC_CONTROLLER_CLOUD) + ) + + if not has_local and not (has_account and has_cloud): + raise probatio.Invalid( + f"device {mac} cannot be reached: it needs a " + f"{CONF_DEVICE_CONNECTION}.{CONF_DEVICE_CONNECTION_LOCAL} block, " + f"or a top level {CONF_CLOUD} account plus a " + f"{CONF_DEVICE_CONNECTION}.{CONF_DEVICE_CONNECTION_CLOUD} block" + ) + + return value + + +def _validate_items(value: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Check that the items do not share an entry.""" + local_only_items = 0 + emails: set[str] = set() + + for item in value: + cloud = item.get(CONF_CLOUD) + + if cloud is None: + local_only_items += 1 + if local_only_items > 1: + raise probatio.Invalid( + f"only one item may leave out the {CONF_CLOUD} block, " + "because all local-only devices share one config entry" + ) + continue + + email = cloud[CONF_EMAIL] + if email in emails: + raise probatio.Invalid( + f"the {CONF_CLOUD} account {email} is used by more than one item" + ) + emails.add(email) + + return value + + +ITEM_SCHEMA = probatio.All( + probatio.Schema( + { + probatio.Optional(CONF_CLOUD): CLOUD_SCHEMA, + probatio.Required(CONF_DEVICES): probatio.All( + _normalize_device_macs, + {str: DEVICE_SCHEMA}, + _fill_device_defaults, + ), + } + ), + _validate_item, +) + +CONFIG_SCHEMA = probatio.Schema( + {DOMAIN: probatio.All(cv.ensure_list, [ITEM_SCHEMA], _validate_items)}, + extra=probatio.ALLOW_EXTRA, +) diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index a3835bb..490f0db 100644 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -17,7 +17,8 @@ "reconfigure_successful": "Devices reconfigured with success.", "already_configured": "A device with this MAC address is already configured.", "unique_id_mismatch": "The entry being configured does not match the configuration intent.", - "no_devices_to_add": "No new devices discovered. Adjust your discovery options and try again." + "no_devices_to_add": "No new devices discovered. Adjust your discovery options and try again.", + "import_failed": "The YAML configuration could not be imported. See the repair issue and the log for the reason." }, "step": { "user": { @@ -367,6 +368,10 @@ "device_connection_failed": { "title": "Device connection failed", "description": "Unable to connect to device {device}." + }, + "yaml_import_failed": { + "title": "YAML import failed", + "description": "The YAML configuration for {item} could not be imported: {reason}" } }, "services": { diff --git a/docs/architecture.md b/docs/architecture.md index 81eb88e..e55e4da 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,8 @@ No Home Assistant imports here. |---|---| | `__init__.py` | Entry setup. Builds transports and devices, binds them, starts one `GreeCoordinator` per device. | | `coordinator.py` | `GreeCoordinator`. Polls on `scan_interval` and listens for status pushed by the device. | -| `config_flow.py` | Setup, reconfigure and reauth flows. Local discovery, cloud login, device picker, per device options. | +| `config_flow.py` | Setup, reconfigure, reauth and YAML import flows. Local discovery, cloud login, device picker, per device options. `async_step_import` turns one validated YAML item into a config entry. | +| `config_schema.py` | `CONFIG_SCHEMA` for the `gree_custom:` block in `configuration.yaml`. Validates the YAML, normalizes the MAC addresses and fills the defaults. | | `climate.py`, `switch.py`, `sensor.py`, `binary_sensor.py`, `number.py`, `select.py` | Entity platforms. | | `entity.py`, `platform_helpers.py` | Base entity, availability logic, shared helpers. | | `services.py`, `services.yaml` | Services `get_prop_values` and `get_prop_values_all`. | diff --git a/docs/config-entry.md b/docs/config-entry.md index 51ab091..c758c02 100644 --- a/docs/config-entry.md +++ b/docs/config-entry.md @@ -53,6 +53,22 @@ For a cloud entry, `cloud` holds the account email, region, user id and token. T | `options.features` | Which optional switches to create. Only features the unit reported as supported are offered. | | `options.restore_states` | Restore the last known entity states after a restart. | +## YAML import + +An entry can also come from `configuration.yaml`. The parts: + +- `config_schema.py` holds `CONFIG_SCHEMA`. It validates the `gree_custom:` block, normalizes the MAC addresses and fills the defaults, so an imported item already has the shape above. `__init__.py` imports `CONFIG_SCHEMA` so Home Assistant and hassfest find it. +- `async_setup` in `__init__.py` starts one import flow per item in the list. +- `async_step_import` in `config_flow.py` resolves the target entry. Without a `cloud` block that is the local-only entry (`unique_id` `local_only`). With a `cloud` block it first looks for an entry that already stores the same email, region and password. If it finds one, it reuses that `unique_id` and the stored `uid` and `token`, so there is no cloud login on every restart. Only when there is no such entry does it log in and use the returned user id as `unique_id`. +- The step then creates the entry, or updates the existing one with `async_update_reload_and_abort`. Unchanged YAML changes nothing and causes no reload. Changed YAML updates the entry and reloads it once, after startup. A device that is already in another entry is skipped, with an error in the log and a repair issue (`yaml_import_failed`). +- Devices that are in the entry but not in the YAML are removed, together with their device registry rows. + +The YAML wins at every start, so values that change after the import are not preserved: + +- `connection.local.host` updated by discovery goes back to the `host` in the YAML at the next start. Keep the YAML current, or give the unit a fixed IP. +- The import does not bind the device, so it stores the `encryption_key` and `encryption_version` as written. With a blank key and version `"0"`, entry setup fetches the key and detects the version on every start. That is the normal path. +- Changes made in the UI to a YAML managed entry are replaced by the YAML values at the next start. + ## Older entries Releases 4.x used the domain `gree` and a flat entry: one device per entry, all fields at the top level. Those entries are not compatible with this shape and there is no migration. Users set the integration up again. diff --git a/manual-configuration.yaml b/manual-configuration.yaml index 0e5da79..3578033 100644 --- a/manual-configuration.yaml +++ b/manual-configuration.yaml @@ -8,7 +8,21 @@ # 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 is lowercase without separators +# How the YAML is applied +# +# - The YAML is read at every start of Home Assistant. +# - It creates the config entry when it is missing and updates it when the YAML +# changed. An unchanged YAML causes no reload. +# - A device you remove from the YAML is removed from the config entry. +# - Each item in the list is one config entry. There can be only one item +# without a "cloud" block, because all local-only devices share one entry. +# - Do not change a YAML managed entry in the UI. The next restart puts the +# YAML values back. +# - A device that is already in another config entry is skipped. You get an +# error in the log and a repair issue. +# +# MAC address Format is lowercase without separators. +# Upper case and ":" or "-" separators are accepted and cleaned up. # # For VRF units the MACs are: # MAC: xxxxxxxxxxxxyy <14 chars> @@ -21,29 +35,29 @@ # MAC Controller Cloud: xxxxxxxxxxxx <12 chars, same as MAC> gree_custom: - - cloud: # Info about a Gree cloud accont | optional + - cloud: # Info about a Gree cloud account | optional email: "user@server.com" # User email | required | str password: "my-password" # User password | required | str region: "Europe" # Gree account region | required | str | options = ["Australia", "China Mainland", "East South Asia", "Europe", "India", "Latin America", "Middle East" ,"North America", "Russia", "South America"] - devices: # List of configured gree devices | required + devices: # List of configured gree devices | required, at least one device # Example of single unit "20fabb123456": # MAC Address of the device | required | str - connection: - scan_interval: 60 # Device polling rate | int > 5 | default = 60 + connection: # optional, all keys below have a default + scan_interval: 60 # Device polling rate | int >= 5 | default = 60 disable_available_check: false # Change entity availability based on device connection | boolean | default = false encryption_key: "my_device_key" # Custom encryption key | str | default = "" uid: 0 # User identifier of device owner which is not needed for all devices, can be sniffed if required | positive int | default = 0 - local: # optional, if ommitted requires cloud - mac_controller_local: "20fabb123456" # MAC Address of the local controller (see above) | required | str + local: # optional, if omitted requires cloud + mac_controller_local: "20fabb123456" # MAC Address of the local controller (see above) | optional | str | default = the controller MAC derived from the device MAC host: "192.168.1.100" # IP Address of AC | required | str port: 7000 # Port number to connect to the device | int | default = 7000 timeout: 10 # Seconds before a connection attempt times out | positive int (seconds) | default = 10 - encryption_version: # The encryption version to use with the device | options = "0", "1", "2" | default = "0" + encryption_version: "0" # The encryption version to use with the device | options = "0", "1", "2" | default = "0" max_online_attempts: 3 # Number connection attempts made with device before it is marked as unavailable | positive int | default = 3 - cloud: # optional, if ommitted requires local + cloud: # optional, if omitted requires local prefer_cloud: false # When local is present, prefer to use cloud | optional | bool | default = false - mac_controller_cloud: "20fabb123456" # MAC Address of the local controller (see above) | required | str - options: + mac_controller_cloud: "20fabb123456" # MAC Address of the cloud controller (see above) | optional | str | default = the controller MAC derived from the device MAC + options: # required name: "Gree AC" # Name for the AC unit | required | str hvac_modes: # Standard Home Assistant HVAC Modes to enable | list | options = ["auto", "cool", "dry", "fan_only", "heat", "off"] | default = all options - "auto" @@ -82,20 +96,19 @@ gree_custom: - "center" - "right_center" - "right" - features: # Supported device features | list | options = ["beeper", "air", "xfan", "sleep", "eightdegheat", "lights", "health", "anti_direct_blow", "powersave", "light_sensor", "faults", "humidity_control"] | default = all options + features: # Supported device features | list | options = ["beeper", "air", "xfan", "sleep", "eightdegheat", "lights", "light_sensor", "health", "anti_direct_blow", "powersave", "humidity_control"] | default = all options - "beeper" - "air" - "xfan" - "sleep" - "eightdegheat" - "lights" + - "light_sensor" - "health" - "anti_direct_blow" - "powersave" - - "light_sensor" - - "faults" - "humidity_control" - 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" + target_temp_step: 1 # Number of degrees increase or decrease when changing the temperature | 0.5 <= number <= 5, 0.5 increments | default = 1 + external_temperature_sensor: "sensor.living_room_temperature" # Sets a given temperature sensor as the sensor for the AC | str (Entity ID) | omit the key to not use one + external_humidity_sensor: "sensor.living_room_humidity" # Sets a given humidity sensor as the sensor for the AC | str (Entity ID) | omit the key to not use one restore_states: true # Wether to restore the last HA state to device when HA starts | bool | default = true From e175daa0f40d5c8293fab08a7824dfdba5cdc376 Mon Sep 17 00:00:00 2001 From: Rob Hofmann Date: Sun, 20 Sep 2026 22:45:18 +0200 Subject: [PATCH 2/5] Add Dutch translation Complete nl.json with every key from en.json, including the new import_failed abort reason and the yaml_import_failed repair issue. --- .../gree_custom/translations/nl.json | 400 ++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 custom_components/gree_custom/translations/nl.json diff --git a/custom_components/gree_custom/translations/nl.json b/custom_components/gree_custom/translations/nl.json new file mode 100644 index 0000000..15d0fd8 --- /dev/null +++ b/custom_components/gree_custom/translations/nl.json @@ -0,0 +1,400 @@ +{ + "config": { + "error": { + "unknown": "Er is iets misgegaan, probeer het opnieuw. Blijft het probleem bestaan, controleer dan de logs.", + "cannot_connect": "Kan geen verbinding maken met het apparaat. Controleer de apparaatconfiguratie en de netwerkverbinding en probeer het opnieuw. Blijft het probleem bestaan, controleer dan de logs.", + "cannot_bind": "Kan het apparaat niet koppelen. De encryptieversie of -sleutel van het apparaat is niet gevonden. Blijft het probleem bestaan, controleer dan de logs.", + "invalid_network": "Ongeldige CIDR. Voorbeeld: 192.168.30.0/24", + "invalid_host": "Ongeldig IP-adres. Voorbeeld: 192.168.30.50", + "network_too_large": "Het netwerk is groter dan het maximum van 65536 hosts (een /16). Splits het in meerdere CIDR's of geef specifieke hosts op.", + "too_many_targets": "De opgegeven netwerken en hosts overschrijden het maximum van 65536 hosts (een /16). Splits ze in meerdere CIDR's of geef specifieke hosts op.", + "cloud_bad_login": "Inloggen mislukt. Controleer de gegevens en probeer het opnieuw.", + "cloud_unknown": "Fout tijdens het inloggen. Controleer de logs en neem contact op met de ontwikkelaars", + "no_devices_selected": "Selecteer minimaal één apparaat om verder te gaan", + "no_methods_selected": "Selecteer minimaal één ontdekkingsmethode" + }, + "abort": { + "reconfigure_successful": "Apparaten zijn opnieuw geconfigureerd.", + "already_configured": "Een apparaat met dit MAC-adres is al geconfigureerd.", + "unique_id_mismatch": "De entry die wordt geconfigureerd komt niet overeen met de bedoelde configuratie.", + "no_devices_to_add": "Geen nieuwe apparaten ontdekt. Pas de ontdekkingsopties aan en probeer het opnieuw.", + "import_failed": "De YAML-configuratie kon niet worden geïmporteerd. Zie het herstelprobleem en de log voor de reden." + }, + "step": { + "user": { + "title": "Gree Climate instellen", + "description": "Kies hoe je Gree-apparaat wordt ontdekt", + "data": { + "discovery": "Ontdekkingsmethode" + } + }, + "cloud_add": { + "title": "Gree-account", + "description": "Vul de gegevens in om verbinding te maken met je Gree-account", + "data": { + "email": "E-mail", + "password": "Wachtwoord", + "region": "Regio" + } + }, + "local_add": { + "title": "Lokale ontdekking", + "description": "Apparaten worden automatisch ontdekt door de beschikbare netwerken te scannen.\n\nHeb je apparaten in een ander subnet of VLAN, vul dan een of meer netwerken en/of specifieke IP-adressen in die via unicast worden benaderd. De routering tussen VLAN's en de firewallregels moeten UDP-poort 7000 van Home Assistant naar het doelsubnet toestaan.", + "data": { + "extra_scan_hosts": "Extra hosts", + "extra_scan_networks": "Extra netwerken" + }, + "data_description": { + "extra_scan_hosts": "(IP-adressen, bijv. 192.168.30.50, 192.168.30.51)", + "extra_scan_networks": "(CIDR's, bijv. 192.168.20.0/24, 192.168.30.0/24)" + } + }, + "device_picker": { + "title": "Ontdekte apparaten", + "description": "{devices_found} Gree-apparaat/apparaten gevonden. Selecteer de apparaten die je wilt configureren.\n\nLet op: als je apparaten opnieuw configureert, worden niet-geselecteerde apparaten verwijderd.", + "data": { + "devices": "Apparaten" + } + }, + "connection_options": { + "title": "Verbindingsopties {device_idx} van {device_cnt}", + "description": "Stel de verbindingsopties in voor het apparaat:\n\n{device_name}", + "data": { + "disable_available_check": "Beschikbaarheidscontrole uitschakelen", + "encryption_key": "Encryptiesleutel", + "scan_interval": "Scaninterval", + "uid": "Gebruikers-ID" + }, + "data_description": { + "encryption_key": "Leeg laten om de sleutel automatisch op te halen", + "scan_interval": "Hoe vaak de apparaatgegevens worden opgehaald (in seconden)", + "uid": "Het gebruikers-ID van de eigenaar van het apparaat. Mag 0 zijn als het onbekend is" + }, + "sections": { + "local": { + "name": "Instellingen lokale verbinding", + "data": { + "encryption_version": "Encryptieversie", + "host": "IP-adres (ontdekt: {discovered_ip})", + "mac_controller_local": "MAC van de lokale controller (ontdekt: {discovered_mac_local})", + "max_online_attempts": "Maximaal aantal verbindingspogingen", + "port": "Poort", + "timeout": "Verbindingstime-out" + }, + "data_description": { + "max_online_attempts": "Het aantal pogingen om met het apparaat te communiceren voordat het als niet beschikbaar wordt gemarkeerd", + "timeout": "De time-out voor elke verbindingspoging" + } + }, + "cloud": { + "name": "Instellingen cloudverbinding", + "data": { + "mac_controller_cloud": "MAC van de cloudcontroller (ontdekt: {discovered_mac_cloud})", + "prefer_cloud": "Voorkeur voor cloudverbinding" + }, + "data_description": { + "prefer_cloud": "Apparaten gebruiken bij voorkeur een lokale verbinding. Vink aan als dit apparaat via de cloud moet verbinden" + } + } + } + }, + "device_options": { + "title": "Apparaatfuncties {device_idx} van {device_cnt}", + "description": "De Gree API heeft geen betrouwbare manier om de ondersteunde functies van een apparaat op te vragen. Vul de opties hieronder zo goed mogelijk in op basis van wat je van je apparaat weet:\n\n {device_name}", + "data": { + "name": "Apparaatnaam", + "hvac_modes": "HVAC-modi", + "fan_modes": "Ventilatorsnelheden", + "swing_modes": "Verticale swingmodi", + "swing_horizontal_modes": "Horizontale swingmodi", + "features": "Apparaatfuncties en -modi", + "target_temp_step": "Temperatuurstap", + "external_temperature_sensor": "Externe temperatuursensor", + "external_humidity_sensor": "Externe vochtigheidssensor", + "restore_states": "Entiteiten herstellen" + }, + "data_description": { + "external_temperature_sensor": "Als deze is ingesteld, vervangt hij de ingebouwde temperatuursensor van de airco voor de gegevens van de climate-entiteit", + "external_humidity_sensor": "Als deze is ingesteld, vervangt hij de ingebouwde vochtigheidssensor van de airco voor de gegevens van de climate-entiteit", + "restore_states": "Als dit is aangevinkt, krijgt het apparaat bij het starten van de integratie de eerder opgeslagen toestand terug in plaats van de huidige toestand van het apparaat.", + "target_temp_step": "Stelt de stapgrootte in voor het aanpassen van de doeltemperatuur. Graden Fahrenheit worden afgerond op een geheel getal." + } + }, + "reconfigure": { + "title": "Apparaten opnieuw configureren", + "description": "Je configureert een entry met een cloudaccount opnieuw. Wil je zoeken naar lokale apparaten die overeenkomen met je cloudapparaten?", + "data": { + "include_local": "Lokale apparaten meenemen" + } + } + } + }, + "selector": { + "discovery": { + "options": { + "cloud": "Gree Cloud-account", + "local": "Lokaal netwerk" + } + }, + "encryption_version": { + "options": { + "0": "Automatisch detecteren", + "1": "V1", + "2": "V2" + } + }, + "hvac_modes": { + "options": { + "auto": "Automatisch", + "cool": "Koelen", + "dry": "Drogen", + "fan_only": "Alleen ventilator", + "heat": "Verwarmen", + "off": "Uit" + } + }, + "fan_modes": { + "options": { + "auto": "Automatisch", + "low": "Laag", + "medium_low": "Gemiddeld laag", + "medium": "Gemiddeld", + "medium_high": "Gemiddeld hoog", + "high": "Hoog", + "turbo": "Turbo", + "quiet": "Stil" + } + }, + "swing_modes": { + "options": { + "default": "Standaard", + "full_swing": "Volledig swingen", + "fixed_upper": "Vast in de hoogste stand", + "fixed_upper_middle": "Vast in de stand middenboven", + "fixed_middle": "Vast in de middenstand", + "fixed_lower_middle": "Vast in de stand middenonder", + "fixed_lower": "Vast in de laagste stand", + "swing_lower": "Swingen in het laagste bereik", + "swing_lower_middle": "Swingen in het bereik middenonder", + "swing_middle": "Swingen in het middenbereik", + "swing_upper_middle": "Swingen in het bereik middenboven", + "swing_upper": "Swingen in het hoogste bereik" + } + }, + "swing_horizontal_modes": { + "options": { + "default": "Standaard", + "full_swing": "Volledig swingen", + "left": "Vast in de meest linkse stand", + "left_center": "Vast in de stand middenlinks", + "center": "Vast in de middenstand", + "right_center": "Vast in de stand middenrechts", + "right": "Vast in de meest rechtse stand" + } + }, + "features": { + "options": { + "beeper": "Pieptoon", + "air": "Verse lucht", + "xfan": "X-Fan", + "sleep": "Slaapstand", + "eightdegheat": "8ºC Smart Heat", + "lights": "Displayverlichting", + "health": "Health", + "anti_direct_blow": "Anti Direct Blow", + "powersave": "Energiebesparing", + "light_sensor": "Automatische displayhelderheid", + "faults": "Storingsdetectie", + "humidity_control": "Vochtigheidsregeling" + } + } + }, + "entity": { + "sensor": { + "indoor_temperature": { + "name": "Binnentemperatuur" + }, + "outdoor_temperature": { + "name": "Buitentemperatuur" + }, + "room_humidity": { + "name": "Luchtvochtigheid binnen" + } + }, + "binary_sensor": { + "faults": { + "name": "Storingsdetectie" + } + }, + "climate": { + "hvac": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "Automatisch", + "low": "Laag", + "medium_low": "Gemiddeld laag", + "medium": "Gemiddeld", + "medium_high": "Gemiddeld hoog", + "high": "Hoog", + "turbo": "Turbo", + "quiet": "Stil" + } + }, + "swing_mode": { + "state": { + "default": "Standaard", + "full_swing": "Volledig swingen", + "fixed_upper": "Vast in de hoogste stand", + "fixed_upper_middle": "Vast in de stand middenboven", + "fixed_middle": "Vast in de middenstand", + "fixed_lower_middle": "Vast in de stand middenonder", + "fixed_lower": "Vast in de laagste stand", + "swing_lower": "Swingen in het laagste bereik", + "swing_lower_middle": "Swingen in het bereik middenonder", + "swing_middle": "Swingen in het middenbereik", + "swing_upper_middle": "Swingen in het bereik middenboven", + "swing_upper": "Swingen in het hoogste bereik" + } + }, + "swing_horizontal_mode": { + "state": { + "default": "Standaard", + "full_swing": "Volledig swingen", + "left": "Vast in de meest linkse stand", + "left_center": "Vast in de stand middenlinks", + "center": "Vast in de middenstand", + "right_center": "Vast in de stand middenrechts", + "right": "Vast in de meest rechtse stand" + } + } + } + } + }, + "number": { + "target_temp_step": { + "name": "Temperatuurstap" + }, + "humidity_control_target": { + "name": "Doel vochtigheidsregeling" + } + }, + "select": { + "temperature_units": { + "name": "Temperatuureenheid" + }, + "humidity_control": { + "name": "Vochtigheidsregeling", + "state": { + "disabled": "Uitgeschakeld", + "target_dry": "Normaal drogen", + "smart_dry": "Slim drogen", + "continuous_dry": "Continu drogen" + } + } + }, + "switch": { + "auto_light": { + "name": "Automatische displayverlichting" + }, + "auto_xfan": { + "name": "Automatische X-Fan" + }, + "lights": { + "name": "Displayverlichting" + }, + "xfan": { + "name": "X-Fan" + }, + "health": { + "name": "Health" + }, + "powersave": { + "name": "Energiebesparing" + }, + "eightdegheat": { + "name": "Smart Heat 8ºC" + }, + "sleep": { + "name": "Slaapstand" + }, + "air": { + "name": "Verse lucht" + }, + "anti_direct_blow": { + "name": "Anti Direct Blow" + }, + "light_sensor": { + "name": "Automatische displayhelderheid" + }, + "beeper": { + "name": "Pieptoon" + } + } + }, + "exceptions": { + "turbo_availability": { + "message": "De turbomodus is alleen beschikbaar in de modi Koelen en Verwarmen." + }, + "entity_unavailable": { + "message": "De entiteit is niet beschikbaar." + }, + "generic": { + "message": "Er is een probleem opgetreden bij het uitvoeren van de gevraagde wijziging. Raadpleeg de log van de integratie." + }, + "invalid_device_id": { + "message": "Er is een probleem opgetreden bij het uitvoeren van de actie. Er is een ongeldig apparaat geselecteerd." + }, + "entry_not_loaded": { + "message": "Er is een probleem opgetreden bij het uitvoeren van de actie. De configuratie-entry van het apparaat is niet geladen." + }, + "config_entry_not_found": { + "message": "Er is een probleem opgetreden bij het uitvoeren van de actie. De configuratie-entry van het apparaat is niet gevonden." + }, + "invalid_config_data": { + "message": "Er is een probleem opgetreden bij het uitvoeren van de actie. De configuratie-entry bevat ongeldige gegevens." + }, + "humidity_mode_unavailable": { + "message": "Vochtigheidsregeling is alleen beschikbaar in de modi Koelen en Drogen." + }, + "continuous_dry_unavailable": { + "message": "Continu drogen is alleen beschikbaar in de modus Drogen." + }, + "smart_dry_unavailable": { + "message": "Slim drogen is alleen beschikbaar in de modus Koelen." + } + }, + "issues": { + "device_connection_failed": { + "title": "Verbinding met apparaat mislukt", + "description": "Kan geen verbinding maken met apparaat {device}." + }, + "yaml_import_failed": { + "title": "YAML-import mislukt", + "description": "De YAML-configuratie voor {item} kon niet worden geïmporteerd: {reason}" + } + }, + "services": { + "get_prop_values_all": { + "name": "Alle eigenschappen opvragen", + "description": "Vraag alle eigenschappen van een Gree-apparaat op", + "fields": { + "device_id": { + "name": "Apparaat-ID" + } + } + }, + "get_prop_values": { + "name": "Eigenschappen opvragen", + "description": "Vraag eigenschappen van een Gree-apparaat op", + "fields": { + "device_id": { + "name": "Apparaat-ID" + }, + "prop_list": { + "name": "Lijst van eigenschappen om op te vragen" + } + } + } + } +} From e222e2c9c3033cf69c8b3a1d0ec4aa92ef621869 Mon Sep 17 00:00:00 2001 From: Rob Hofmann Date: Mon, 21 Sep 2026 02:13:29 +0200 Subject: [PATCH 3/5] Review fixes: VRF controller MAC and cloud login notes - A VRF sub-device (14 character MAC) has its local controller in another unit, so mac_controller_local cannot be derived from the device MAC. The schema now requires it for a 14 character MAC with a local block, and rejects the config with a clear message otherwise. For a normal unit the default stays the device MAC. The cloud controller defaults to the first 12 characters, as protocol.md says. - README, manual-configuration.yaml and docs/config-entry.md now say when a cloud login happens (first import, or a change of email, region or password) and that every login ends the other sessions of the account, so the Gree app logs out at that moment. Schema harness: 20/20 checks. --- README.md | 2 ++ .../gree_custom/config_schema.py | 24 ++++++++++++++----- docs/config-entry.md | 4 ++-- manual-configuration.yaml | 9 +++++-- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b4db202..c46037e 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Home Assistant reads the YAML at every start. It creates the config entry when i Every item in the list is one config entry. An item with a `cloud` block is the entry for that Gree account, and the one item without a `cloud` block is the entry for all local-only devices. A device that is already in another config entry is skipped, with an error in the log and a repair issue. +A `cloud` block logs in to the Gree account only on the first import, and again when you change the email, region or password. Every login ends the other sessions of that account, so the Gree app logs you out at that moment. An unchanged `cloud` block reuses the stored session. + See [`manual-configuration.yaml`](manual-configuration.yaml) for a complete configuration example with all available options and detailed comments. ## Connection Methods and Configuration diff --git a/custom_components/gree_custom/config_schema.py b/custom_components/gree_custom/config_schema.py index 60f4575..4704db7 100644 --- a/custom_components/gree_custom/config_schema.py +++ b/custom_components/gree_custom/config_schema.py @@ -28,7 +28,6 @@ from .aiogree.cipher import EncryptionVersion from .aiogree.cloud_api import GreeRegion -from .aiogree.helpers import gree_extract_macs from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, @@ -252,7 +251,7 @@ def _normalize_device_macs(value: Any) -> dict[str, Any]: devices: dict[str, Any] = {} for raw_mac, device in value.items(): - mac, _ = gree_extract_macs(_clean_mac(raw_mac, "device MAC address")) + mac = _clean_mac(raw_mac, "device MAC address") if mac in devices: raise probatio.Invalid(f"device {mac} is listed more than once") @@ -263,19 +262,32 @@ def _normalize_device_macs(value: Any) -> dict[str, Any]: def _fill_device_defaults(value: dict[str, Any]) -> dict[str, Any]: - """Fill the controller MAC addresses and the missing connection blocks.""" + """Fill the controller MAC addresses and the missing connection blocks. + + A normal unit has a 12 character MAC and is its own controller. A VRF + sub-device has a 14 character MAC. Its cloud controller is the first 12 + characters of that MAC, but its local controller is another unit, so + `mac_controller_local` cannot be derived and must be given. + """ devices: dict[str, Any] = {} for mac, device in value.items(): - _, mac_controller = gree_extract_macs(mac) + is_vrf = len(mac) > 12 connection = dict(device[CONF_DEVICE_CONNECTION]) local = connection.get(CONF_DEVICE_CONNECTION_LOCAL) if local is None: connection[CONF_DEVICE_CONNECTION_LOCAL] = {} + elif is_vrf and not local.get(CONF_MAC_CONTROLLER_LOCAL): + raise probatio.Invalid( + f"device {mac} is a VRF sub-device (14 character MAC), so " + f"{CONF_DEVICE_CONNECTION}.{CONF_DEVICE_CONNECTION_LOCAL}." + f"{CONF_MAC_CONTROLLER_LOCAL} must be the MAC of the unit that " + "holds the network connection" + ) else: connection[CONF_DEVICE_CONNECTION_LOCAL] = { - CONF_MAC_CONTROLLER_LOCAL: mac_controller, + CONF_MAC_CONTROLLER_LOCAL: mac, **local, } @@ -287,7 +299,7 @@ def _fill_device_defaults(value: dict[str, Any]) -> dict[str, Any]: } else: connection[CONF_DEVICE_CONNECTION_CLOUD] = { - CONF_MAC_CONTROLLER_CLOUD: mac_controller, + CONF_MAC_CONTROLLER_CLOUD: mac[:12], **cloud, } diff --git a/docs/config-entry.md b/docs/config-entry.md index c758c02..83b4c26 100644 --- a/docs/config-entry.md +++ b/docs/config-entry.md @@ -57,9 +57,9 @@ For a cloud entry, `cloud` holds the account email, region, user id and token. T An entry can also come from `configuration.yaml`. The parts: -- `config_schema.py` holds `CONFIG_SCHEMA`. It validates the `gree_custom:` block, normalizes the MAC addresses and fills the defaults, so an imported item already has the shape above. `__init__.py` imports `CONFIG_SCHEMA` so Home Assistant and hassfest find it. +- `config_schema.py` holds `CONFIG_SCHEMA`. It validates the `gree_custom:` block, normalizes the MAC addresses and fills the defaults, so an imported item already has the shape above. For a normal unit both controller MACs default to the device MAC. For a VRF sub-device (14 character MAC) the cloud controller defaults to the first 12 characters, and `mac_controller_local` must be given, because the local controller is another unit (see [protocol.md](protocol.md#mac-addresses)). `__init__.py` imports `CONFIG_SCHEMA` so Home Assistant and hassfest find it. - `async_setup` in `__init__.py` starts one import flow per item in the list. -- `async_step_import` in `config_flow.py` resolves the target entry. Without a `cloud` block that is the local-only entry (`unique_id` `local_only`). With a `cloud` block it first looks for an entry that already stores the same email, region and password. If it finds one, it reuses that `unique_id` and the stored `uid` and `token`, so there is no cloud login on every restart. Only when there is no such entry does it log in and use the returned user id as `unique_id`. +- `async_step_import` in `config_flow.py` resolves the target entry. Without a `cloud` block that is the local-only entry (`unique_id` `local_only`). With a `cloud` block it first looks for an entry that already stores the same email, region and password. If it finds one, it reuses that `unique_id` and the stored `uid` and `token`, so there is no cloud login on every restart. Only when there is no such entry does it log in and use the returned user id as `unique_id`. A login matters: Gree allows one session per account, so every login logs the Gree app out. It happens on the first import and after a change of email, region or password. A changed email still lands on the same entry, because the user id from the login is the `unique_id`. - The step then creates the entry, or updates the existing one with `async_update_reload_and_abort`. Unchanged YAML changes nothing and causes no reload. Changed YAML updates the entry and reloads it once, after startup. A device that is already in another entry is skipped, with an error in the log and a repair issue (`yaml_import_failed`). - Devices that are in the entry but not in the YAML are removed, together with their device registry rows. diff --git a/manual-configuration.yaml b/manual-configuration.yaml index 3578033..b75bf16 100644 --- a/manual-configuration.yaml +++ b/manual-configuration.yaml @@ -20,6 +20,11 @@ # YAML values back. # - A device that is already in another config entry is skipped. You get an # error in the log and a repair issue. +# - A "cloud" block logs in to the Gree account only when no config entry +# stores the same email, region and password yet: on the first import, and +# again after you change one of those three values. Every login ends the +# other sessions of that account, so the Gree app logs you out. An +# unchanged "cloud" block reuses the stored session and does not log in. # # MAC address Format is lowercase without separators. # Upper case and ":" or "-" separators are accepted and cleaned up. @@ -48,7 +53,7 @@ gree_custom: encryption_key: "my_device_key" # Custom encryption key | str | default = "" uid: 0 # User identifier of device owner which is not needed for all devices, can be sniffed if required | positive int | default = 0 local: # optional, if omitted requires cloud - mac_controller_local: "20fabb123456" # MAC Address of the local controller (see above) | optional | str | default = the controller MAC derived from the device MAC + mac_controller_local: "20fabb123456" # MAC Address of the local controller (see above) | single unit: optional, default = the device MAC | VRF sub-device (14 chars): required, the MAC of the unit that holds the IP; it cannot be derived host: "192.168.1.100" # IP Address of AC | required | str port: 7000 # Port number to connect to the device | int | default = 7000 timeout: 10 # Seconds before a connection attempt times out | positive int (seconds) | default = 10 @@ -56,7 +61,7 @@ gree_custom: max_online_attempts: 3 # Number connection attempts made with device before it is marked as unavailable | positive int | default = 3 cloud: # optional, if omitted requires local prefer_cloud: false # When local is present, prefer to use cloud | optional | bool | default = false - mac_controller_cloud: "20fabb123456" # MAC Address of the cloud controller (see above) | optional | str | default = the controller MAC derived from the device MAC + mac_controller_cloud: "20fabb123456" # MAC Address of the cloud controller (see above) | optional | str | default = the device MAC, or its first 12 characters for a VRF sub-device options: # required name: "Gree AC" # Name for the AC unit | required | str hvac_modes: # Standard Home Assistant HVAC Modes to enable | list | options = ["auto", "cool", "dry", "fan_only", "heat", "off"] | default = all options From 57ee11cb4bf7ca5359fea7141074d58c7e3fdec7 Mon Sep 17 00:00:00 2001 From: Rob Hofmann Date: Mon, 21 Sep 2026 02:29:03 +0200 Subject: [PATCH 4/5] Derive controller MACs with gree_extract_macs, like discovery does The device key now goes through gree_extract_macs(), the same function local and cloud discovery use. That brings back the two cases the last commit dropped: a VRF main device MAC that ends in 00 derives the first 12 characters as controller, and a key written as @ sets the controller for a VRF sub-device. A given mac_controller_local or mac_controller_cloud still wins over the derived value. A VRF sub-device key without @ has no known local controller, so the schema rejects it when a local block is given, with a message that names both ways to fix it. Schema harness: 22/22 checks. --- .../gree_custom/config_schema.py | 62 ++++++++----------- docs/config-entry.md | 2 +- manual-configuration.yaml | 6 +- 3 files changed, 29 insertions(+), 41 deletions(-) diff --git a/custom_components/gree_custom/config_schema.py b/custom_components/gree_custom/config_schema.py index 4704db7..633ef03 100644 --- a/custom_components/gree_custom/config_schema.py +++ b/custom_components/gree_custom/config_schema.py @@ -28,6 +28,7 @@ from .aiogree.cipher import EncryptionVersion from .aiogree.cloud_api import GreeRegion +from .aiogree.helpers import gree_extract_macs from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, @@ -244,52 +245,40 @@ def _add_connection_block(value: Any) -> dict[str, Any]: ) -def _normalize_device_macs(value: Any) -> dict[str, Any]: - """Re-key the devices mapping on the normalized device MAC address.""" - if not isinstance(value, dict): - raise probatio.Invalid(f"{CONF_DEVICES} must be a mapping keyed by MAC address") +def _finish_devices(value: dict[str, Any]) -> dict[str, Any]: + """Re-key the devices on the normalized MAC and fill the controller MACs. + The key goes through `gree_extract_macs()`, the same function discovery + uses, so it accepts separators, upper case, a VRF main device MAC that + ends in `00`, and the `@` form for a VRF sub-device. + A given `mac_controller_local` or `mac_controller_cloud` wins over the + derived value. + """ devices: dict[str, Any] = {} - for raw_mac, device in value.items(): - mac = _clean_mac(raw_mac, "device MAC address") + + for raw_key, device in value.items(): + mac, mac_controller = gree_extract_macs(str(raw_key)) + _clean_mac(mac, "device MAC address") if mac in devices: raise probatio.Invalid(f"device {mac} is listed more than once") - devices[mac] = device - - return devices - - -def _fill_device_defaults(value: dict[str, Any]) -> dict[str, Any]: - """Fill the controller MAC addresses and the missing connection blocks. - - A normal unit has a 12 character MAC and is its own controller. A VRF - sub-device has a 14 character MAC. Its cloud controller is the first 12 - characters of that MAC, but its local controller is another unit, so - `mac_controller_local` cannot be derived and must be given. - """ - devices: dict[str, Any] = {} - - for mac, device in value.items(): - is_vrf = len(mac) > 12 connection = dict(device[CONF_DEVICE_CONNECTION]) local = connection.get(CONF_DEVICE_CONNECTION_LOCAL) if local is None: connection[CONF_DEVICE_CONNECTION_LOCAL] = {} - elif is_vrf and not local.get(CONF_MAC_CONTROLLER_LOCAL): - raise probatio.Invalid( - f"device {mac} is a VRF sub-device (14 character MAC), so " - f"{CONF_DEVICE_CONNECTION}.{CONF_DEVICE_CONNECTION_LOCAL}." - f"{CONF_MAC_CONTROLLER_LOCAL} must be the MAC of the unit that " - "holds the network connection" - ) else: - connection[CONF_DEVICE_CONNECTION_LOCAL] = { - CONF_MAC_CONTROLLER_LOCAL: mac, - **local, - } + local = {CONF_MAC_CONTROLLER_LOCAL: mac_controller, **local} + if len(local[CONF_MAC_CONTROLLER_LOCAL]) != 12: + raise probatio.Invalid( + f"device {mac} is a VRF sub-device and its local controller " + "is another unit. Write the key as '@' " + f"or set {CONF_DEVICE_CONNECTION}.{CONF_DEVICE_CONNECTION_LOCAL}." + f"{CONF_MAC_CONTROLLER_LOCAL} to the 12 character MAC of the " + "unit that holds the network connection" + ) + connection[CONF_DEVICE_CONNECTION_LOCAL] = local cloud = connection.get(CONF_DEVICE_CONNECTION_CLOUD) if cloud is None: @@ -299,7 +288,7 @@ def _fill_device_defaults(value: dict[str, Any]) -> dict[str, Any]: } else: connection[CONF_DEVICE_CONNECTION_CLOUD] = { - CONF_MAC_CONTROLLER_CLOUD: mac[:12], + CONF_MAC_CONTROLLER_CLOUD: mac_controller, **cloud, } @@ -367,9 +356,8 @@ def _validate_items(value: list[dict[str, Any]]) -> list[dict[str, Any]]: { probatio.Optional(CONF_CLOUD): CLOUD_SCHEMA, probatio.Required(CONF_DEVICES): probatio.All( - _normalize_device_macs, {str: DEVICE_SCHEMA}, - _fill_device_defaults, + _finish_devices, ), } ), diff --git a/docs/config-entry.md b/docs/config-entry.md index 83b4c26..01ce8ed 100644 --- a/docs/config-entry.md +++ b/docs/config-entry.md @@ -57,7 +57,7 @@ For a cloud entry, `cloud` holds the account email, region, user id and token. T An entry can also come from `configuration.yaml`. The parts: -- `config_schema.py` holds `CONFIG_SCHEMA`. It validates the `gree_custom:` block, normalizes the MAC addresses and fills the defaults, so an imported item already has the shape above. For a normal unit both controller MACs default to the device MAC. For a VRF sub-device (14 character MAC) the cloud controller defaults to the first 12 characters, and `mac_controller_local` must be given, because the local controller is another unit (see [protocol.md](protocol.md#mac-addresses)). `__init__.py` imports `CONFIG_SCHEMA` so Home Assistant and hassfest find it. +- `config_schema.py` holds `CONFIG_SCHEMA`. It validates the `gree_custom:` block, normalizes the MAC addresses and fills the defaults, so an imported item already has the shape above. The device key goes through `gree_extract_macs()`, the same function discovery uses, so the controller MACs default the way discovery sets them: the device MAC for a normal unit, the first 12 characters for a VRF main device MAC that ends in `00`, and the part after `@` for a key written as `@`. A VRF sub-device key without `@` has no known local controller, so the schema requires `mac_controller_local` for it (see [protocol.md](protocol.md#mac-addresses)). `__init__.py` imports `CONFIG_SCHEMA` so Home Assistant and hassfest find it. - `async_setup` in `__init__.py` starts one import flow per item in the list. - `async_step_import` in `config_flow.py` resolves the target entry. Without a `cloud` block that is the local-only entry (`unique_id` `local_only`). With a `cloud` block it first looks for an entry that already stores the same email, region and password. If it finds one, it reuses that `unique_id` and the stored `uid` and `token`, so there is no cloud login on every restart. Only when there is no such entry does it log in and use the returned user id as `unique_id`. A login matters: Gree allows one session per account, so every login logs the Gree app out. It happens on the first import and after a change of email, region or password. A changed email still lands on the same entry, because the user id from the login is the `unique_id`. - The step then creates the entry, or updates the existing one with `async_update_reload_and_abort`. Unchanged YAML changes nothing and causes no reload. Changed YAML updates the entry and reloads it once, after startup. A device that is already in another entry is skipped, with an error in the log and a repair issue (`yaml_import_failed`). diff --git a/manual-configuration.yaml b/manual-configuration.yaml index b75bf16..45139e0 100644 --- a/manual-configuration.yaml +++ b/manual-configuration.yaml @@ -46,14 +46,14 @@ gree_custom: region: "Europe" # Gree account region | required | str | options = ["Australia", "China Mainland", "East South Asia", "Europe", "India", "Latin America", "Middle East" ,"North America", "Russia", "South America"] devices: # List of configured gree devices | required, at least one device # Example of single unit - "20fabb123456": # MAC Address of the device | required | str + "20fabb123456": # MAC Address of the device | required | str | for a VRF sub-device write "@" so the local controller is known connection: # optional, all keys below have a default scan_interval: 60 # Device polling rate | int >= 5 | default = 60 disable_available_check: false # Change entity availability based on device connection | boolean | default = false encryption_key: "my_device_key" # Custom encryption key | str | default = "" uid: 0 # User identifier of device owner which is not needed for all devices, can be sniffed if required | positive int | default = 0 local: # optional, if omitted requires cloud - mac_controller_local: "20fabb123456" # MAC Address of the local controller (see above) | single unit: optional, default = the device MAC | VRF sub-device (14 chars): required, the MAC of the unit that holds the IP; it cannot be derived + mac_controller_local: "20fabb123456" # MAC Address of the local controller (see above) | optional | str | default = the device MAC, the first 12 characters of a VRF main device MAC ending in 00, or the part after "@" in the key. A VRF sub-device without "@" must set it host: "192.168.1.100" # IP Address of AC | required | str port: 7000 # Port number to connect to the device | int | default = 7000 timeout: 10 # Seconds before a connection attempt times out | positive int (seconds) | default = 10 @@ -61,7 +61,7 @@ gree_custom: max_online_attempts: 3 # Number connection attempts made with device before it is marked as unavailable | positive int | default = 3 cloud: # optional, if omitted requires local prefer_cloud: false # When local is present, prefer to use cloud | optional | bool | default = false - mac_controller_cloud: "20fabb123456" # MAC Address of the cloud controller (see above) | optional | str | default = the device MAC, or its first 12 characters for a VRF sub-device + mac_controller_cloud: "20fabb123456" # MAC Address of the cloud controller (see above) | optional | str | default = derived from the key the same way as mac_controller_local options: # required name: "Gree AC" # Name for the AC unit | required | str hvac_modes: # Standard Home Assistant HVAC Modes to enable | list | options = ["auto", "cool", "dry", "fan_only", "heat", "off"] | default = all options From 6554d169eab2ae94a4cc1856d3f89c6140ed12e8 Mon Sep 17 00:00:00 2001 From: Rob Hofmann Date: Mon, 21 Sep 2026 14:55:58 +0200 Subject: [PATCH 5/5] Move the config flow form schemas to config_schema.py SETUP_SCHEMA and the five setup_*_schema() builders now live next to CONFIG_SCHEMA, without the leading underscore and with docstrings. config_flow.py imports them. The temperature step form uses MIN_TARGET_TEMP_STEP and MAX_TARGET_TEMP_STEP instead of the literals. No behaviour change. --- custom_components/gree_custom/config_flow.py | 439 +----------------- .../gree_custom/config_schema.py | 412 +++++++++++++++- docs/architecture.md | 2 +- 3 files changed, 425 insertions(+), 428 deletions(-) diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py index 27bc001..b854485 100755 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -16,7 +16,6 @@ import voluptuous as probatio from homeassistant.components.diagnostics import async_redact_data -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.config_entries import ( SOURCE_REAUTH, SOURCE_RECONFIGURE, @@ -29,38 +28,18 @@ CONF_DISCOVERY, CONF_EMAIL, CONF_HOST, - CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_REGION, - CONF_SCAN_INTERVAL, - CONF_TIMEOUT, CONF_TOKEN, ) -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import section from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.selector import ( - EntitySelector, - EntitySelectorConfig, - NumberSelector, - NumberSelectorConfig, - NumberSelectorMode, - SelectOptionDict, - SelectSelector, - SelectSelectorConfig, - SelectSelectorMode, - TextSelector, - TextSelectorConfig, - TextSelectorType, -) from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.storage import Store from . import create_yaml_import_issue, delete_yaml_import_issue from .aiogree.api import ( GreeDiscoveredDevice, - GreeProp, gree_discover_device_local, gree_discover_devices_cloud, gree_discover_devices_local, @@ -77,10 +56,15 @@ ) from .aiogree.transport_mqtt import GreeMqttTransport from .aiogree.transport_udp import GreeUdpTransport +from .config_schema import ( + SETUP_SCHEMA, + setup_cloud_schema, + setup_device_connection_options_schema, + setup_device_options_schema, + setup_local_schema, + setup_picker_schema, +) from .const import ( - ATTR_EXTERNAL_HUMIDITY_SENSOR, - ATTR_EXTERNAL_TEMPERATURE_SENSOR, - ATTR_FEATURES_TO_PROP_MAP, CONF_ALL_DEVICE_CONNECTIONS, CONF_ALL_DEVICE_OPTIONS, CONF_CLOUD, @@ -89,7 +73,6 @@ CONF_DEVICE_CONNECTION_LOCAL, CONF_DEVICE_OPTIONS, CONF_DEVICES, - CONF_DISABLE_AVAILABLE_CHECK, CONF_DISCOVERY_PREFS_KEY, CONF_DISCOVERY_PREFS_VERSION, CONF_ENCRYPTION_KEY, @@ -101,37 +84,19 @@ CONF_HVAC_MODES, CONF_MAC_CONTROLLER_CLOUD, CONF_MAC_CONTROLLER_LOCAL, - CONF_MAX_ONLINE_ATTEMPTS, CONF_PREFER_CLOUD, - CONF_RESTORE_STATES, CONF_SWING_HORIZONTAL_MODES, CONF_SWING_MODES, - CONF_TEMPERATURE_STEP, CONF_UID, CONFENTRY_ID_LOCAL_ONLY, CURRENT_CONF_VERSION, - DEFAULT_CONNECTION_MAX_ATTEMPTS, - DEFAULT_CONNECTION_TIMEOUT, - DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_UID, - DEFAULT_DISABLE_AVAILABLE_CHECK, DEFAULT_DISCOVERY_TIMEOUT, - DEFAULT_ENCRYPTION_KEY, DEFAULT_ENCRYPTION_VERSION, - DEFAULT_FAN_MODES, - DEFAULT_HVAC_MODES, DEFAULT_PREFER_CLOUD, - DEFAULT_RESTORE_STATES, - DEFAULT_SCAN_INTERVAL, - DEFAULT_SWING_HORIZONTAL_MODES, - DEFAULT_SWING_MODES, - DEFAULT_TARGET_TEMP_STEP, DOMAIN, ENCRYPTION_VERSION_AUTO, - GATTR_FEAT_QUIET_MODE, - GATTR_FEAT_TURBO, MAX_UNICAST_SCAN_HOSTS, - MIN_SCAN_INTERVAL, ) from .coordinator import GreeConfigEntry from .helpers import ( @@ -139,7 +104,6 @@ get_config_entries, get_configured_macs_in_entries, get_discovery_addresses, - get_entity_ids_from_unique_ids, get_entry_matching_mac, ) @@ -159,383 +123,6 @@ def _matches_cloud_account( ) -SETUP_SCHEMA = probatio.Schema( - { - probatio.Required(CONF_DISCOVERY, default=["cloud", "local"]): SelectSelector( - SelectSelectorConfig( - options=["cloud", "local"], - multiple=True, - translation_key=CONF_DISCOVERY, - ) - ) - } -) - - -def _setup_cloud_schema(defaults_values: dict | None = None) -> probatio.Schema: - defaults = defaults_values or {} - - return probatio.Schema( - { - probatio.Required( - CONF_EMAIL, - default=defaults.get(CONF_EMAIL, ""), - ): str, - probatio.Required( - CONF_PASSWORD, - default=defaults.get(CONF_PASSWORD, ""), - ): str, - probatio.Required( - CONF_REGION, - default=defaults.get(CONF_REGION), - ): SelectSelector( - SelectSelectorConfig( - options=[region.value for region in GreeRegion], - multiple=False, - ) - ), - } - ) - - -def _setup_local_schema(default_values: dict | None = None) -> probatio.Schema: - defaults = default_values or {} - - return probatio.Schema( - { - probatio.Optional( - CONF_EXTRA_SCAN_NETWORKS, - description={ - "suggested_value": defaults.get(CONF_EXTRA_SCAN_NETWORKS, []) - }, - ): TextSelector(TextSelectorConfig(multiple=True, multiline=False)), - probatio.Optional( - CONF_EXTRA_SCAN_HOSTS, - description={ - "suggested_value": defaults.get(CONF_EXTRA_SCAN_HOSTS, []) - }, - ): TextSelector(TextSelectorConfig(multiple=True, multiline=False)), - } - ) - - -def _setup_picker_schema( - default: list[str], options: dict[str, GreeDiscoveredDevice] -) -> probatio.Schema: - return probatio.Schema( - { - probatio.Required(CONF_DEVICES, default=default): SelectSelector( - SelectSelectorConfig( - options=[ - SelectOptionDict(value=m, label=d.friendly_name) - for m, d in options.items() - ], - multiple=True, - ) - ) - } - ) - - -def _setup_device_connection_options_schema( - device_info: GreeDiscoveredDevice, default_values: dict | None = None -) -> probatio.Schema: - defaults: dict = default_values or {} - defaults_local = defaults.get(CONF_DEVICE_CONNECTION_LOCAL, {}) - defaults_cloud = defaults.get(CONF_DEVICE_CONNECTION_CLOUD, {}) - - return probatio.Schema( - { - probatio.Required( - CONF_SCAN_INTERVAL, - default=defaults.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), - ): probatio.All( - probatio.Coerce(int), probatio.Range(min=MIN_SCAN_INTERVAL) - ), - probatio.Required( - CONF_DISABLE_AVAILABLE_CHECK, - default=defaults.get( - CONF_DISABLE_AVAILABLE_CHECK, - DEFAULT_DISABLE_AVAILABLE_CHECK, - ), - ): cv.boolean, - probatio.Optional( - CONF_ENCRYPTION_KEY, - default=( - defaults.get(CONF_ENCRYPTION_KEY) - or device_info.key - or DEFAULT_ENCRYPTION_KEY - ), - ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), - probatio.Required( - CONF_UID, - default=defaults.get(CONF_UID, device_info.user_id), - ): cv.positive_int, - probatio.Required(CONF_DEVICE_CONNECTION_LOCAL): section( - probatio.Schema( - { - probatio.Optional( - CONF_MAC_CONTROLLER_LOCAL, - default=( - defaults_local.get(CONF_MAC_CONTROLLER_LOCAL) - or device_info.mac_controller_local - ), - ): str, - probatio.Optional( - CONF_HOST, - default=( - defaults_local.get(CONF_HOST) or device_info.host or "" - ), - ): str, - probatio.Optional( - CONF_PORT, - default=( - defaults_local.get(CONF_PORT) - or device_info.port - or DEFAULT_DEVICE_PORT - ), - ): cv.port, - probatio.Required( - CONF_TIMEOUT, - default=defaults_local.get( - CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT - ), - ): cv.positive_int, - probatio.Required( - CONF_ENCRYPTION_VERSION, - default=defaults_local.get( - CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION - ), - ): SelectSelector( - SelectSelectorConfig( - translation_key=CONF_ENCRYPTION_VERSION, - options=[ - ENCRYPTION_VERSION_AUTO, - *( - str(version.value) - for version in EncryptionVersion - ), - ], - mode=SelectSelectorMode.DROPDOWN, - ) - ), - probatio.Required( - CONF_MAX_ONLINE_ATTEMPTS, - default=defaults_local.get( - CONF_MAX_ONLINE_ATTEMPTS, - DEFAULT_CONNECTION_MAX_ATTEMPTS, - ), - ): cv.positive_int, - } - ) - ), - probatio.Required(CONF_DEVICE_CONNECTION_CLOUD): section( - probatio.Schema( - { - probatio.Required( - CONF_PREFER_CLOUD, - default=defaults_cloud.get( - CONF_PREFER_CLOUD, - DEFAULT_PREFER_CLOUD, - ), - ): cv.boolean, - probatio.Optional( - CONF_MAC_CONTROLLER_CLOUD, - default=defaults_cloud.get(CONF_MAC_CONTROLLER_CLOUD) - or device_info.mac_controller_mqtt, - ): str, - } - ) - ), - } - ) - - -def _setup_device_options_schema( # noqa: C901 - hass: HomeAssistant, device: GreeDevice, default_values: Mapping | None -) -> probatio.Schema: - defaults = default_values or {} - - schema: dict = {} - schema.update( - { - probatio.Required( - CONF_NAME, - default=defaults.get(CONF_NAME, device.name), - ): str - } - ) - - if device.supports_property(GreeProp.OP_MODE): - schema.update( - { - probatio.Optional( - CONF_HVAC_MODES, - default=defaults.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), - ): SelectSelector( - config=SelectSelectorConfig( - options=DEFAULT_HVAC_MODES, - multiple=True, - translation_key=CONF_HVAC_MODES, - ) - ), - } - ) - - fan_mapping = { - GreeProp.FAN_SPEED: DEFAULT_FAN_MODES, - GreeProp.FEAT_TURBO_MODE: [GATTR_FEAT_TURBO], - GreeProp.FEAT_QUIET_MODE: [GATTR_FEAT_QUIET_MODE], - } - valid_fan_modes: list[str] = [] - for prop, modes in fan_mapping.items(): - if device.supports_property(prop): - valid_fan_modes.extend(modes) - - if valid_fan_modes: - schema.update( - { - probatio.Optional( - CONF_FAN_MODES, - default=defaults.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( - { - probatio.Optional( - CONF_SWING_MODES, - default=defaults.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( - { - probatio.Optional( - CONF_SWING_HORIZONTAL_MODES, - default=defaults.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 = [] - for feat, props in ATTR_FEATURES_TO_PROP_MAP.items(): - if all(device.supports_property(p) for p in props): - valid_features.append(feat) - - if valid_features: - schema.update( - { - probatio.Optional( - CONF_FEATURES, - default=defaults.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( - { - probatio.Required( - CONF_TEMPERATURE_STEP, - default=defaults.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( - { - probatio.Optional( - ATTR_EXTERNAL_TEMPERATURE_SENSOR, - description={ - "suggested_value": defaults.get( - ATTR_EXTERNAL_TEMPERATURE_SENSOR, "" - ) - }, - ): EntitySelector( - config=EntitySelectorConfig( - domain=SENSOR_DOMAIN, - device_class=SensorDeviceClass.TEMPERATURE, - multiple=False, - exclude_entities=get_entity_ids_from_unique_ids( - hass, - SENSOR_DOMAIN, - [ - f"{device.mac_address}_indoor_temperature", - f"{device.mac_address}_outdoor_temperature", - ], - ), - ) - ), - probatio.Optional( - ATTR_EXTERNAL_HUMIDITY_SENSOR, - description={ - "suggested_value": defaults.get(ATTR_EXTERNAL_HUMIDITY_SENSOR, "") - }, - ): EntitySelector( - config=EntitySelectorConfig( - domain=SENSOR_DOMAIN, - device_class=SensorDeviceClass.HUMIDITY, - multiple=False, - exclude_entities=get_entity_ids_from_unique_ids( - hass, - SENSOR_DOMAIN, - [ - f"{device.mac_address}_room_humidity", - ], - ), - ) - ), - probatio.Required( - CONF_RESTORE_STATES, - default=defaults.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), - ): cv.boolean, - } - ) - - return probatio.Schema(schema) - - class SetupConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the config flow for the integration.""" @@ -929,7 +516,7 @@ async def async_step_cloud_add( return self.async_show_form( step_id="cloud_add", - data_schema=_setup_cloud_schema(defaults), + data_schema=setup_cloud_schema(defaults), errors=errors, ) @@ -1059,7 +646,7 @@ async def async_step_local_add( return self.async_show_form( step_id="local_add", - data_schema=_setup_local_schema( + data_schema=setup_local_schema( { CONF_EXTRA_SCAN_NETWORKS: default_networks, CONF_EXTRA_SCAN_HOSTS: default_hosts, @@ -1110,7 +697,7 @@ async def async_step_device_picker( return self.async_show_form( step_id="device_picker", - data_schema=_setup_picker_schema(selected, self._discovered_devices), + data_schema=setup_picker_schema(selected, self._discovered_devices), description_placeholders={ "devices_found": str(len(self._discovered_devices)) }, @@ -1239,7 +826,7 @@ async def async_step_connection_options( # noqa: C901 return self.async_show_form( step_id="connection_options", - data_schema=_setup_device_connection_options_schema(d, defaults), + data_schema=setup_device_connection_options_schema(d, defaults), description_placeholders={ "device_name": str(d.friendly_name), "device_idx": str(self._current_setup_device_index + 1), @@ -1306,7 +893,7 @@ async def async_step_device_options( .get(CONF_DEVICE_OPTIONS, {}) ) - data_schema = _setup_device_options_schema( + data_schema = setup_device_options_schema( hass=self.hass, device=device, default_values=( diff --git a/custom_components/gree_custom/config_schema.py b/custom_components/gree_custom/config_schema.py index 633ef03..eb36e25 100644 --- a/custom_components/gree_custom/config_schema.py +++ b/custom_components/gree_custom/config_schema.py @@ -1,9 +1,11 @@ -"""YAML schema for the Gree integration. +"""Schemas for the Gree integration. `CONFIG_SCHEMA` validates the `gree_custom:` block in `configuration.yaml`, fills the defaults and hands one item per config entry to the import flow. +The rest of the module holds the form schemas that the config flow shows. """ +from collections.abc import Mapping from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -14,7 +16,9 @@ except ImportError: import voluptuous as probatio +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.const import ( + CONF_DISCOVERY, CONF_EMAIL, CONF_HOST, CONF_NAME, @@ -24,10 +28,28 @@ 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.selector import ( + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) +from .aiogree.api import GreeDiscoveredDevice, GreeProp from .aiogree.cipher import EncryptionVersion from .aiogree.cloud_api import GreeRegion +from .aiogree.device import GreeDevice from .aiogree.helpers import gree_extract_macs from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, @@ -42,6 +64,8 @@ CONF_DISABLE_AVAILABLE_CHECK, CONF_ENCRYPTION_KEY, CONF_ENCRYPTION_VERSION, + CONF_EXTRA_SCAN_HOSTS, + CONF_EXTRA_SCAN_NETWORKS, CONF_FAN_MODES, CONF_FEATURES, CONF_HVAC_MODES, @@ -68,12 +92,14 @@ DEFAULT_SCAN_INTERVAL, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, + DEFAULT_TARGET_TEMP_STEP, DOMAIN, ENCRYPTION_VERSION_AUTO, GATTR_FEAT_QUIET_MODE, GATTR_FEAT_TURBO, MIN_SCAN_INTERVAL, ) +from .helpers import get_entity_ids_from_unique_ids HEX_CHARACTERS = "0123456789abcdef" VALID_MAC_LENGTHS = (12, 14) @@ -368,3 +394,387 @@ def _validate_items(value: list[dict[str, Any]]) -> list[dict[str, Any]]: {DOMAIN: probatio.All(cv.ensure_list, [ITEM_SCHEMA], _validate_items)}, extra=probatio.ALLOW_EXTRA, ) + + +# Forms for the config flow + +SETUP_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_DISCOVERY, default=["cloud", "local"]): SelectSelector( + SelectSelectorConfig( + options=["cloud", "local"], + multiple=True, + translation_key=CONF_DISCOVERY, + ) + ) + } +) + + +def setup_cloud_schema(defaults_values: dict | None = None) -> probatio.Schema: + """Build the form that asks for the Gree cloud account.""" + defaults = defaults_values or {} + + return probatio.Schema( + { + probatio.Required( + CONF_EMAIL, + default=defaults.get(CONF_EMAIL, ""), + ): str, + probatio.Required( + CONF_PASSWORD, + default=defaults.get(CONF_PASSWORD, ""), + ): str, + probatio.Required( + CONF_REGION, + default=defaults.get(CONF_REGION), + ): SelectSelector( + SelectSelectorConfig( + options=[region.value for region in GreeRegion], + multiple=False, + ) + ), + } + ) + + +def setup_local_schema(default_values: dict | None = None) -> probatio.Schema: + """Build the form that asks for extra networks and hosts to scan.""" + defaults = default_values or {} + + return probatio.Schema( + { + probatio.Optional( + CONF_EXTRA_SCAN_NETWORKS, + description={ + "suggested_value": defaults.get(CONF_EXTRA_SCAN_NETWORKS, []) + }, + ): TextSelector(TextSelectorConfig(multiple=True, multiline=False)), + probatio.Optional( + CONF_EXTRA_SCAN_HOSTS, + description={ + "suggested_value": defaults.get(CONF_EXTRA_SCAN_HOSTS, []) + }, + ): TextSelector(TextSelectorConfig(multiple=True, multiline=False)), + } + ) + + +def setup_picker_schema( + default: list[str], options: dict[str, GreeDiscoveredDevice] +) -> probatio.Schema: + """Build the form that lets the user pick which devices to set up.""" + return probatio.Schema( + { + probatio.Required(CONF_DEVICES, default=default): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=m, label=d.friendly_name) + for m, d in options.items() + ], + multiple=True, + ) + ) + } + ) + + +def setup_device_connection_options_schema( + device_info: GreeDiscoveredDevice, default_values: dict | None = None +) -> probatio.Schema: + """Build the form that asks how to connect to one device.""" + defaults: dict = default_values or {} + defaults_local = defaults.get(CONF_DEVICE_CONNECTION_LOCAL, {}) + defaults_cloud = defaults.get(CONF_DEVICE_CONNECTION_CLOUD, {}) + + return probatio.Schema( + { + probatio.Required( + CONF_SCAN_INTERVAL, + default=defaults.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + ): probatio.All( + probatio.Coerce(int), probatio.Range(min=MIN_SCAN_INTERVAL) + ), + probatio.Required( + CONF_DISABLE_AVAILABLE_CHECK, + default=defaults.get( + CONF_DISABLE_AVAILABLE_CHECK, + DEFAULT_DISABLE_AVAILABLE_CHECK, + ), + ): cv.boolean, + probatio.Optional( + CONF_ENCRYPTION_KEY, + default=( + defaults.get(CONF_ENCRYPTION_KEY) + or device_info.key + or DEFAULT_ENCRYPTION_KEY + ), + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + probatio.Required( + CONF_UID, + default=defaults.get(CONF_UID, device_info.user_id), + ): cv.positive_int, + probatio.Required(CONF_DEVICE_CONNECTION_LOCAL): section( + probatio.Schema( + { + probatio.Optional( + CONF_MAC_CONTROLLER_LOCAL, + default=( + defaults_local.get(CONF_MAC_CONTROLLER_LOCAL) + or device_info.mac_controller_local + ), + ): str, + probatio.Optional( + CONF_HOST, + default=( + defaults_local.get(CONF_HOST) or device_info.host or "" + ), + ): str, + probatio.Optional( + CONF_PORT, + default=( + defaults_local.get(CONF_PORT) + or device_info.port + or DEFAULT_DEVICE_PORT + ), + ): cv.port, + probatio.Required( + CONF_TIMEOUT, + default=defaults_local.get( + CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT + ), + ): cv.positive_int, + probatio.Required( + CONF_ENCRYPTION_VERSION, + default=defaults_local.get( + CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION + ), + ): SelectSelector( + SelectSelectorConfig( + translation_key=CONF_ENCRYPTION_VERSION, + options=[ + ENCRYPTION_VERSION_AUTO, + *( + str(version.value) + for version in EncryptionVersion + ), + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + probatio.Required( + CONF_MAX_ONLINE_ATTEMPTS, + default=defaults_local.get( + CONF_MAX_ONLINE_ATTEMPTS, + DEFAULT_CONNECTION_MAX_ATTEMPTS, + ), + ): cv.positive_int, + } + ) + ), + probatio.Required(CONF_DEVICE_CONNECTION_CLOUD): section( + probatio.Schema( + { + probatio.Required( + CONF_PREFER_CLOUD, + default=defaults_cloud.get( + CONF_PREFER_CLOUD, + DEFAULT_PREFER_CLOUD, + ), + ): cv.boolean, + probatio.Optional( + CONF_MAC_CONTROLLER_CLOUD, + default=defaults_cloud.get(CONF_MAC_CONTROLLER_CLOUD) + or device_info.mac_controller_mqtt, + ): str, + } + ) + ), + } + ) + + +def setup_device_options_schema( # noqa: C901 + hass: HomeAssistant, device: GreeDevice, default_values: Mapping | None +) -> probatio.Schema: + """Build the form that asks for the options of one device.""" + defaults = default_values or {} + + schema: dict = {} + schema.update( + { + probatio.Required( + CONF_NAME, + default=defaults.get(CONF_NAME, device.name), + ): str + } + ) + + if device.supports_property(GreeProp.OP_MODE): + schema.update( + { + probatio.Optional( + CONF_HVAC_MODES, + default=defaults.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), + ): SelectSelector( + config=SelectSelectorConfig( + options=DEFAULT_HVAC_MODES, + multiple=True, + translation_key=CONF_HVAC_MODES, + ) + ), + } + ) + + fan_mapping = { + GreeProp.FAN_SPEED: DEFAULT_FAN_MODES, + GreeProp.FEAT_TURBO_MODE: [GATTR_FEAT_TURBO], + GreeProp.FEAT_QUIET_MODE: [GATTR_FEAT_QUIET_MODE], + } + valid_fan_modes: list[str] = [] + for prop, modes in fan_mapping.items(): + if device.supports_property(prop): + valid_fan_modes.extend(modes) + + if valid_fan_modes: + schema.update( + { + probatio.Optional( + CONF_FAN_MODES, + default=defaults.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( + { + probatio.Optional( + CONF_SWING_MODES, + default=defaults.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( + { + probatio.Optional( + CONF_SWING_HORIZONTAL_MODES, + default=defaults.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 = [] + for feat, props in ATTR_FEATURES_TO_PROP_MAP.items(): + if all(device.supports_property(p) for p in props): + valid_features.append(feat) + + if valid_features: + schema.update( + { + probatio.Optional( + CONF_FEATURES, + default=defaults.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( + { + probatio.Required( + CONF_TEMPERATURE_STEP, + default=defaults.get( + CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP + ), + ): NumberSelector( + NumberSelectorConfig( + min=MIN_TARGET_TEMP_STEP, + max=MAX_TARGET_TEMP_STEP, + step=0.5, + mode=NumberSelectorMode.BOX, + unit_of_measurement="ºC", + ) + ) + } + ) + + schema.update( + { + probatio.Optional( + ATTR_EXTERNAL_TEMPERATURE_SENSOR, + description={ + "suggested_value": defaults.get( + ATTR_EXTERNAL_TEMPERATURE_SENSOR, "" + ) + }, + ): EntitySelector( + config=EntitySelectorConfig( + domain=SENSOR_DOMAIN, + device_class=SensorDeviceClass.TEMPERATURE, + multiple=False, + exclude_entities=get_entity_ids_from_unique_ids( + hass, + SENSOR_DOMAIN, + [ + f"{device.mac_address}_indoor_temperature", + f"{device.mac_address}_outdoor_temperature", + ], + ), + ) + ), + probatio.Optional( + ATTR_EXTERNAL_HUMIDITY_SENSOR, + description={ + "suggested_value": defaults.get(ATTR_EXTERNAL_HUMIDITY_SENSOR, "") + }, + ): EntitySelector( + config=EntitySelectorConfig( + domain=SENSOR_DOMAIN, + device_class=SensorDeviceClass.HUMIDITY, + multiple=False, + exclude_entities=get_entity_ids_from_unique_ids( + hass, + SENSOR_DOMAIN, + [ + f"{device.mac_address}_room_humidity", + ], + ), + ) + ), + probatio.Required( + CONF_RESTORE_STATES, + default=defaults.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), + ): cv.boolean, + } + ) + + return probatio.Schema(schema) diff --git a/docs/architecture.md b/docs/architecture.md index e55e4da..c25de41 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ No Home Assistant imports here. | `__init__.py` | Entry setup. Builds transports and devices, binds them, starts one `GreeCoordinator` per device. | | `coordinator.py` | `GreeCoordinator`. Polls on `scan_interval` and listens for status pushed by the device. | | `config_flow.py` | Setup, reconfigure, reauth and YAML import flows. Local discovery, cloud login, device picker, per device options. `async_step_import` turns one validated YAML item into a config entry. | -| `config_schema.py` | `CONFIG_SCHEMA` for the `gree_custom:` block in `configuration.yaml`. Validates the YAML, normalizes the MAC addresses and fills the defaults. | +| `config_schema.py` | `CONFIG_SCHEMA` for the `gree_custom:` block in `configuration.yaml`. Validates the YAML, normalizes the MAC addresses and fills the defaults. It also holds the form schemas that the config flow shows. | | `climate.py`, `switch.py`, `sensor.py`, `binary_sensor.py`, `number.py`, `select.py` | Entity platforms. | | `entity.py`, `platform_helpers.py` | Base entity, availability logic, shared helpers. | | `services.py`, `services.yaml` | Services `get_prop_values` and `get_prop_values_all`. |