"""Config flow fglair: облако / ручной ввод / импорт config_*.json + пробная сессия (start → ONLINE ≤ 10 c → базовые свойства).""" from __future__ import annotations import json import logging from functools import partial from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.helpers import selector import pyfglair from pyfglair import Prop, Template from pyfglair.provision import Device from pyfglair.templates import detect from .const import ( CAP_FAN, CAP_HVAC, CAP_SWING_HORIZONTAL, CAP_SWING_VERTICAL, CONF_CONFIG_JSON, CONF_DEVICE_PORT, CONF_DSN, CONF_EMAIL, CONF_HOST, CONF_LANIP_KEY, CONF_LANIP_KEY_ID, CONF_LISTEN_PORT, CONF_MODEL, CONF_NAME, CONF_OVERRIDES, CONF_PASSWORD, CONF_REGION, CONF_TEMP_DEN, CONF_TEMP_MAX, CONF_TEMP_MIN, CONF_TEMP_NUM, CONF_TEMP_OFFSET, CONF_TEMP_STEP, CONF_TEMPLATE, DEFAULT_DEVICE_PORT, DEFAULT_LISTEN_PORT, DOMAIN, REGIONS, TEMP_MAX_TENTHS, TEMP_MIN_TENTHS, TEMP_STEP_TENTHS, TRIAL_ERRORS, TRIAL_TIMEOUT, ) from .trial import TrialResult, trial_connect _LOGGER = logging.getLogger(__name__) def _password_selector() -> selector.TextSelector: return selector.TextSelector( selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) ) def _cloud_schema() -> vol.Schema: return vol.Schema( { vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): _password_selector(), vol.Required(CONF_REGION, default="eu"): vol.In(REGIONS), } ) def _manual_schema() -> vol.Schema: return vol.Schema( { vol.Required(CONF_HOST): str, vol.Optional( CONF_DEVICE_PORT, default=DEFAULT_DEVICE_PORT ): vol.All(int, vol.Range(min=1, max=65535)), vol.Required(CONF_DSN): str, vol.Required(CONF_LANIP_KEY): _password_selector(), vol.Required(CONF_LANIP_KEY_ID): vol.All(int, vol.Range(min=0)), vol.Optional(CONF_NAME): str, vol.Optional(CONF_MODEL): str, } ) def _import_schema() -> vol.Schema: return vol.Schema( { vol.Required(CONF_CONFIG_JSON): selector.TextSelector( selector.TextSelectorConfig( type=selector.TextSelectorType.TEXT, multiline=True ) ) } ) def _preview_schema(data: dict[str, Any]) -> vol.Schema: template = Template[str(data.get(CONF_TEMPLATE, "A"))] return vol.Schema( { vol.Required(CONF_TEMPLATE, default=template.name): vol.In( ["A", "B", "F"] ), vol.Required(CONF_TEMP_MIN, default=16.0): vol.All( vol.Coerce(float), vol.Range(min=-10.0, max=45.0) ), vol.Required(CONF_TEMP_MAX, default=30.0): vol.All( vol.Coerce(float), vol.Range(min=-10.0, max=45.0) ), vol.Required( CONF_TEMP_STEP, default=TEMP_STEP_TENTHS[template] / 10 ): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=5.0)), vol.Required(CONF_TEMP_NUM, default=1): vol.All( int, vol.Range(min=1, max=1000) ), vol.Required(CONF_TEMP_DEN, default=1): vol.All( int, vol.Range(min=1, max=1000) ), vol.Required(CONF_TEMP_OFFSET, default=0): vol.All( int, vol.Range(min=-1000, max=1000) ), } ) def _preview_text( template: Template, properties: dict[Any, Any] ) -> str: def raw(prop) -> int | None: value = properties.get(prop) return value.int_value if value is not None else None caps = raw(Prop.DEVICE_CAPABILITIES) def supported(bit: int) -> bool: return caps is None or bool(caps & (1 << bit)) hvac = ["off"] + [ mode.value for mode, bit in CAP_HVAC.items() if supported(bit) ] fan = [mode for mode, bit in CAP_FAN.items() if supported(bit)] tmin, tmax = TEMP_MIN_TENTHS / 10, TEMP_MAX_TENTHS / 10 step = TEMP_STEP_TENTHS[template] / 10 lines = [ f"- HVAC: {', '.join(hvac)}", f"- Fan: {', '.join(fan) or '-'}", f"- Setpoint: {tmin:.1f}-{tmax:.1f} °C, step {step:.1f} °C", ] current = raw(Prop.DISPLAY_TEMPERATURE) if current is not None: lines.append(f"- Current temperature: {current / 10:.1f} °C") swings = [] if supported(CAP_SWING_VERTICAL): swings.append("vertical") if supported(CAP_SWING_HORIZONTAL): swings.append("horizontal") if swings: lines.append(f"- Swing: {', '.join(swings)}") if caps is not None: lines.append(f"- Capabilities: 0x{caps:08X}") lines.append(f"- Template: {template.name}") return "\n".join(lines) def _template_name(model: str | None) -> str: if model: found = detect(model) if found is not None: return found.name return Template.A.name def _manual_data(user_input: dict[str, Any]) -> dict[str, Any]: model = str(user_input.get(CONF_MODEL) or "").strip() return { CONF_HOST: str(user_input[CONF_HOST]).strip(), CONF_DEVICE_PORT: int( user_input.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT) ), CONF_DSN: str(user_input[CONF_DSN]).strip(), CONF_LANIP_KEY: str(user_input[CONF_LANIP_KEY]).strip(), CONF_LANIP_KEY_ID: int(user_input[CONF_LANIP_KEY_ID]), CONF_NAME: str(user_input.get(CONF_NAME) or "").strip(), CONF_MODEL: model, CONF_TEMPLATE: _template_name(model), } def _parse_import(source: str | dict[str, Any]) -> dict[str, Any] | None: """config_*.json (fglair-discover/fglctl) → данные записи.""" if isinstance(source, str): try: obj = json.loads(source) except ValueError: return None else: obj = source if not isinstance(obj, dict): return None host = obj.get("ip_address") or obj.get("host") dsn = obj.get("dsn") lanip_key = obj.get("lanip_key") lanip_key_id = obj.get("lanip_key_id") if not host or not dsn or not lanip_key or lanip_key_id is None: return None def to_int(value: Any) -> int | None: try: return int(value) except (TypeError, ValueError): return None key_id = to_int(lanip_key_id) if key_id is None or key_id < 0: return None port = to_int(obj.get("device_port", DEFAULT_DEVICE_PORT)) if port is None or not 1 <= port <= 65535: port = DEFAULT_DEVICE_PORT model = str(obj.get("model") or "").strip() return { CONF_HOST: str(host).strip(), CONF_DEVICE_PORT: port, CONF_DSN: str(dsn).strip(), CONF_LANIP_KEY: str(lanip_key).strip(), CONF_LANIP_KEY_ID: key_id, CONF_NAME: str(obj.get("name") or "").strip(), CONF_MODEL: model, CONF_TEMPLATE: _template_name(model), } def _run_trial(data: dict[str, Any]) -> TrialResult: return trial_connect( host=data[CONF_HOST], dsn=data[CONF_DSN], lanip_key=data[CONF_LANIP_KEY], lanip_key_id=int(data[CONF_LANIP_KEY_ID]), device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)), template=Template[str(data.get(CONF_TEMPLATE, "A"))], listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)), timeout=TRIAL_TIMEOUT, ) class FglairConfigFlow(ConfigFlow, domain=DOMAIN): """Настройка интеграции: облако, ручной ввод или импорт.""" VERSION = 1 def __init__(self) -> None: self._devices: list[Device] = [] self._pending_data: dict[str, Any] = {} self._trial_properties: dict[Any, Any] = {} def _show_form( self, step_id: str, schema: vol.Schema, errors: dict[str, str], user_input: dict[str, Any] | None = None, *, exclude: tuple[str, ...] = (), ) -> ConfigFlowResult: """Показывает форму, возвращая введённые значения (кроме секретов).""" if user_input: suggested = { key: value for key, value in user_input.items() if key not in exclude } schema = self.add_suggested_values_to_schema(schema, suggested) return self.async_show_form( step_id=step_id, data_schema=schema, errors=errors ) async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: return self.async_show_menu( step_id="user", menu_options=["cloud", "manual", "import_json"] ) async def async_step_cloud( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: errors: dict[str, str] = {} if user_input is not None: try: self._devices = await pyfglair.discover( user_input[CONF_EMAIL], user_input[CONF_PASSWORD], user_input[CONF_REGION], ) except pyfglair.ProvisionError as err: _LOGGER.debug("discover не удался: %s", err) errors["base"] = { "auth": "invalid_auth", "no_devices": "no_devices", }.get(err.kind, "cannot_connect") except Exception: # pragma: no cover - защитный путь _LOGGER.exception("discover: неожиданная ошибка") errors["base"] = "cannot_connect" else: if not self._devices: errors["base"] = "no_devices" else: return await self.async_step_cloud_device() return self._show_form( "cloud", _cloud_schema(), errors, user_input, exclude=(CONF_PASSWORD,), ) async def async_step_cloud_device( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: errors: dict[str, str] = {} if user_input is not None: device = next( (d for d in self._devices if d.dsn == user_input[CONF_DSN]), None ) if device is None: errors["base"] = "unknown" else: data = { CONF_HOST: device.lan_ip or device.dsn, CONF_DEVICE_PORT: DEFAULT_DEVICE_PORT, CONF_DSN: device.dsn, CONF_LANIP_KEY: device.lanip_key, CONF_LANIP_KEY_ID: device.lanip_key_id, CONF_NAME: device.name, CONF_MODEL: device.model, CONF_TEMPLATE: _template_name(device.model), CONF_REGION: device.region, } error = await self._async_trial(data) if error is None: return await self.async_step_preview() errors["base"] = error schema = vol.Schema( { vol.Required(CONF_DSN): vol.In( { d.dsn: f"{d.name} ({d.model or '?'}, {d.lan_ip or '?'})" for d in self._devices } ) } ) return self._show_form("cloud_device", schema, errors, user_input) async def async_step_manual( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: errors: dict[str, str] = {} if user_input is not None: data = _manual_data(user_input) error = await self._async_trial(data) if error is None: return await self.async_step_preview() errors["base"] = error return self._show_form( "manual", _manual_schema(), errors, user_input, exclude=(CONF_LANIP_KEY,), ) async def async_step_import_json( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: errors: dict[str, str] = {} if user_input is not None: data = _parse_import(user_input[CONF_CONFIG_JSON]) if data is None: errors["base"] = "invalid_import" else: error = await self._async_trial(data) if error is None: return await self.async_step_preview() errors["base"] = error return self._show_form("import_json", _import_schema(), errors, user_input) async def async_step_import( self, import_data: dict[str, Any] ) -> ConfigFlowResult: """Импорт из configuration.yaml / discovery-конфига.""" data = _parse_import(import_data) if isinstance(import_data, dict) else None if data is None: return self.async_abort(reason="invalid_import") error = await self._async_trial(data) if error is not None: return self.async_abort(reason=error) return self._create_entry(data) async def async_step_preview( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Шаг 3: превью шаблона и ручные конверсии/диапазон уставки.""" data = dict(self._pending_data) errors: dict[str, str] = {} if user_input is not None: template = Template[user_input[CONF_TEMPLATE]] temp_min = int(round(float(user_input[CONF_TEMP_MIN]) * 10)) temp_max = int(round(float(user_input[CONF_TEMP_MAX]) * 10)) step = int(round(float(user_input[CONF_TEMP_STEP]) * 10)) if temp_min >= temp_max: errors["base"] = "invalid_range" elif step <= 0: errors["base"] = "invalid_range" else: data[CONF_TEMPLATE] = template.name data[CONF_TEMP_STEP] = step data[CONF_OVERRIDES] = { Prop.ADJUST_TEMPERATURE.name.lower(): { "num": int(user_input[CONF_TEMP_NUM]), "den": int(user_input[CONF_TEMP_DEN]), "offset": int(user_input[CONF_TEMP_OFFSET]), "min": temp_min, "max": temp_max, } } _LOGGER.debug( "превью: template=%s range=%d..%d step=%d", template.name, temp_min, temp_max, step, ) return self._create_entry(data) schema = _preview_schema(data) if user_input is None: user_input = { CONF_TEMPLATE: data.get(CONF_TEMPLATE, Template.A.name), } return self.async_show_form( step_id="preview", data_schema=self.add_suggested_values_to_schema(schema, user_input), errors=errors, description_placeholders={ "preview": _preview_text( Template[str(data.get(CONF_TEMPLATE, "A"))], self._trial_properties, ) }, ) async def _async_trial(self, data: dict[str, Any]) -> str | None: """None — сессия поднялась; иначе ключ ошибки формы.""" await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False) self._abort_if_unique_id_configured() result: TrialResult = await self.hass.async_add_executor_job( partial(_run_trial, data) ) if result.ok: self._pending_data = data self._trial_properties = result.properties return None _LOGGER.debug( "пробное подключение не удалось: reason=%s state=%s error=%s", result.reason, result.state, result.error, ) return TRIAL_ERRORS.get(result.reason, "cannot_connect") def _create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: title = data.get(CONF_NAME) or data[CONF_DSN] return self.async_create_entry(title=title, data=data)