diff --git a/README.md b/README.md index b14c7ff..c46037e 100644 --- a/README.md +++ b/README.md @@ -51,17 +51,28 @@ 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. + +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 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..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,37 +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, @@ -76,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, @@ -88,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, @@ -100,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 ( @@ -138,389 +104,24 @@ get_config_entries, get_configured_macs_in_entries, get_discovery_addresses, - get_entity_ids_from_unique_ids, get_entry_matching_mac, ) _LOGGER = logging.getLogger(__name__) -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", - ) - ) - } - ) +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 - 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 all( + stored.get(key) == cloud_conf.get(key) + for key in (CONF_EMAIL, CONF_REGION, CONF_PASSWORD) ) - return probatio.Schema(schema) - class SetupConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the config flow for the integration.""" @@ -557,8 +158,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( @@ -795,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, ) @@ -925,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, @@ -976,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)) }, @@ -1105,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), @@ -1172,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 new file mode 100644 index 0000000..eb36e25 --- /dev/null +++ b/custom_components/gree_custom/config_schema.py @@ -0,0 +1,780 @@ +"""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: + import probatio +else: + try: + import probatio + 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, + CONF_PASSWORD, + CONF_PORT, + CONF_REGION, + 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, + 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_EXTRA_SCAN_HOSTS, + CONF_EXTRA_SCAN_NETWORKS, + 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, + 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) + +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 _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_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") + + connection = dict(device[CONF_DEVICE_CONNECTION]) + + local = connection.get(CONF_DEVICE_CONNECTION_LOCAL) + if local is None: + connection[CONF_DEVICE_CONNECTION_LOCAL] = {} + else: + 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: + 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( + {str: DEVICE_SCHEMA}, + _finish_devices, + ), + } + ), + _validate_item, +) + +CONFIG_SCHEMA = probatio.Schema( + {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/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/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" + } + } + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 81eb88e..c25de41 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. 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`. | diff --git a/docs/config-entry.md b/docs/config-entry.md index 51ab091..01ce8ed 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. 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`). +- 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..45139e0 100644 --- a/manual-configuration.yaml +++ b/manual-configuration.yaml @@ -8,7 +8,26 @@ # 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. +# - 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. # # For VRF units the MACs are: # MAC: xxxxxxxxxxxxyy <14 chars> @@ -21,29 +40,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 + "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 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 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 - 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 = 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 - "auto" @@ -82,20 +101,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