"""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 Template from pyfglair.provision import Device from pyfglair.templates import detect from .const import ( 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_PASSWORD, CONF_REGION, CONF_TEMPLATE, DEFAULT_DEVICE_PORT, DEFAULT_LISTEN_PORT, DOMAIN, REGIONS, 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 _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 try: key_id = int(lanip_key_id) except (TypeError, ValueError): return None model = str(obj.get("model") or "").strip() return { CONF_HOST: str(host).strip(), CONF_DEVICE_PORT: int(obj.get("device_port", DEFAULT_DEVICE_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] = [] 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) text = str(err) if "вход" in text: errors["base"] = "invalid_auth" elif "нет устройств" in text or "Не найдено" in text: errors["base"] = "no_devices" else: errors["base"] = "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.async_show_form( step_id="cloud", data_schema=_cloud_schema(), errors=errors ) 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 self._create_entry(data) 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.async_show_form( step_id="cloud_device", data_schema=schema, errors=errors ) 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 self._create_entry(data) errors["base"] = error return self.async_show_form( step_id="manual", data_schema=_manual_schema(), errors=errors ) 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 self._create_entry(data) errors["base"] = error return self.async_show_form( step_id="import_json", data_schema=_import_schema(), errors=errors ) 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_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: 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)