ha(H2): компонент fglair — manifest, config flow (облако/ручной/импорт), пробная сессия
- custom_components/fglair: manifest (config_flow, local_push, requirements),
const, trial (start -> ONLINE <=10 c -> GET базовых свойств), FglairClient
(сессия pyfglair в executor, колбэки в loop, --runtime_data),
__init__ (setup/unload, рефкаунт глобального лог-пампа)
- config flow: меню cloud/manual/import_json + import-source; discover через
pyfglair.provision; пробное подключение с диагностикой (cannot_connect,
no_slots, key_mismatch, timeout, invalid_auth, invalid_import); unique_id=dsn
- strings.json + translations/{en,ru}.json
- тесты HA (pytest-homeassistant-custom-component): меню/шаги/ошибки/импорт/
облако (discover замокан), setup/unload, занятый порт; trial против
mock_ac.py (online/key_mismatch/unreachable/no_slots)
- pytest.ini (общий), tests/conftest перенесён на уровень tests/,
scripts/py-ci.sh: --setup-ha и прогон tests/components
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user