Files
fgl-aircon/custom_components/fglair/config_flow.py
T
petr.polezhaev a03458f9bc ha(H5): исправления по ревью — GitHub/HACS, рабочий ESPHome-канал, restore, manylinux
- critical: README честно описывает HACS (только публичный GitHub-зеркало)
  + ручная установка копированием; RELEASE_HA — шаг зеркала и codeowners
- critical: ESPHome-канал приёмки реализован реально (aioesphomeapi:
  connect/list_entities/climate_command для режима и уставки; fan/swing —
  только HA); unit-тест на фейковом модуле
- major: quick/long восстанавливают исходное состояние в finally даже при
  сбое; quick ждёт восстановления; ошибки шага пишутся в результат (rc 1),
  а не фаталят (rc 2)
- major: RELEASE_HA — manylinux через auditwheel repair (PyPI отклоняет
  linux_x86_64), build/twine, aarch64, корректные проверки .so, тег/версия
- major: полный ключ для ESPHome теперь реально виден в options flow
  («Настройка» на карточке интеграции, поле LAN IP key, можно заменить) —
  README/diagnostics синхронизированы
- minor: non-JSON ответ → rc 2; CSV quick+long и инкрементальная запись;
  допуск _matches в long; --settle-timeout; hacs.json homeassistant=2025.1;
  brand/{icon,logo}.png заглушки; план §6/§7 уточнён
- тесты: acceptance self-test 11 (restore-after-failure, long fail, non-JSON,
  esphome channel), options flow (2), всего 28+11+49
2026-09-29 14:54:50 +03:00

592 lines
21 KiB
Python

"""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 (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
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 .overrides import ConversionSet, LinearOverride
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 _spec_int(spec: dict[str, Any], key: str, default: int) -> int:
try:
return int(spec.get(key, default))
except (TypeError, ValueError):
return default
def _preview_schema(data: dict[str, Any]) -> vol.Schema:
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
overrides = data.get(CONF_OVERRIDES)
spec = (
overrides.get(Prop.ADJUST_TEMPERATURE.name.lower(), {})
if isinstance(overrides, dict)
else {}
)
if not isinstance(spec, dict):
spec = {}
temp_min = _spec_int(spec, "min", TEMP_MIN_TENTHS)
temp_max = _spec_int(spec, "max", TEMP_MAX_TENTHS)
try:
step = int(data.get(CONF_TEMP_STEP) or TEMP_STEP_TENTHS[template])
except (TypeError, ValueError):
step = TEMP_STEP_TENTHS[template]
return vol.Schema(
{
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
["A", "B", "F"]
),
vol.Required(CONF_TEMP_MIN, default=temp_min / 10): vol.All(
vol.Coerce(float), vol.Range(min=-10.0, max=45.0)
),
vol.Required(CONF_TEMP_MAX, default=temp_max / 10): vol.All(
vol.Coerce(float), vol.Range(min=-10.0, max=45.0)
),
vol.Required(CONF_TEMP_STEP, default=step / 10): vol.All(
vol.Coerce(float), vol.Range(min=0.1, max=5.0)
),
vol.Required(
CONF_TEMP_NUM, default=_spec_int(spec, "num", 1)
): vol.All(int, vol.Range(min=1, max=1000)),
vol.Required(
CONF_TEMP_DEN, default=_spec_int(spec, "den", 1)
): vol.All(int, vol.Range(min=1, max=1000)),
vol.Required(
CONF_TEMP_OFFSET, default=_spec_int(spec, "offset", 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] = {}
self._reconfigure_entry: ConfigEntry | None = None
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Повторная настройка (в т.ч. смена LAN-ключа после key_error)."""
self._reconfigure_entry = self._get_reconfigure_entry()
return await self.async_step_user()
def _reconfigure_suggestions(self) -> dict[str, Any] | None:
entry = self._reconfigure_entry
if entry is None:
return None
fields = (
CONF_HOST,
CONF_DEVICE_PORT,
CONF_DSN,
CONF_LANIP_KEY,
CONF_LANIP_KEY_ID,
CONF_NAME,
CONF_MODEL,
)
return {key: entry.data[key] for key in fields if key in entry.data}
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
if user_input is None and self._reconfigure_entry is not None:
user_input = self._reconfigure_suggestions()
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))
num = int(user_input[CONF_TEMP_NUM])
den = int(user_input[CONF_TEMP_DEN])
offset = int(user_input[CONF_TEMP_OFFSET])
conversions = ConversionSet(
template,
{
Prop.ADJUST_TEMPERATURE: LinearOverride(
num=num,
den=den,
offset=offset,
min_value=temp_min,
max_value=temp_max,
)
},
step,
)
bounds = conversions.range(Prop.ADJUST_TEMPERATURE)
if (
temp_min >= temp_max
or bounds is None
or bounds[0] >= bounds[1]
):
# Достижимый диапазон пуст: конверсия выводит таблицу
# шаблона за пределы, которые не пересекаются с min/max.
errors["base"] = "invalid_range"
else:
data[CONF_TEMPLATE] = template.name
data[CONF_TEMP_STEP] = step
data[CONF_OVERRIDES] = {
Prop.ADJUST_TEMPERATURE.name.lower(): {
"num": num,
"den": den,
"offset": 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)
selected = str(data.get(CONF_TEMPLATE, Template.A.name))
if user_input and user_input.get(CONF_TEMPLATE) in Template.__members__:
selected = str(user_input[CONF_TEMPLATE])
if user_input is None:
user_input = {CONF_TEMPLATE: selected}
return self.async_show_form(
step_id="preview",
data_schema=self.add_suggested_values_to_schema(
_preview_schema(data), user_input
),
errors=errors,
description_placeholders={
"preview": _preview_text(
Template[selected], self._trial_properties
)
},
)
async def _async_trial(self, data: dict[str, Any]) -> str | None:
"""None — сессия поднялась; иначе ключ ошибки формы."""
if self._reconfigure_entry is not None:
# Сохраняем служебные поля записи (listen_port/keepalive_ms) и
# текущие конверсии, если путь их не задаёт.
for key, value in self._reconfigure_entry.data.items():
data.setdefault(key, value)
await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False)
if self._reconfigure_entry is not None:
self._abort_if_unique_id_mismatch()
else:
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]
if self._reconfigure_entry is not None:
return self.async_update_reload_and_abort(
self._reconfigure_entry, title=title, data=data
)
return self.async_create_entry(title=title, data=data)
@staticmethod
def async_get_options_flow(config_entry: ConfigEntry) -> "FglairOptionsFlow":
return FglairOptionsFlow()
class FglairOptionsFlow(OptionsFlow):
"""Показывает полный LAN-ключ (для secrets.yaml ESPHome) и позволяет
обновить его без облака."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
entry = self.config_entry
if user_input is not None:
key = str(user_input[CONF_LANIP_KEY]).strip()
if key and key != entry.data.get(CONF_LANIP_KEY):
self.hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_LANIP_KEY: key}
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_create_entry(title="", data={})
schema = vol.Schema(
{vol.Required(CONF_LANIP_KEY): selector.TextSelector()}
)
schema = self.add_suggested_values_to_schema(
schema, {CONF_LANIP_KEY: entry.data.get(CONF_LANIP_KEY, "")}
)
return self.async_show_form(
step_id="init",
data_schema=schema,
description_placeholders={
"dsn": str(entry.data.get(CONF_DSN, "")),
"lanip_key_id": str(entry.data.get(CONF_LANIP_KEY_ID, "")),
"host": str(entry.data.get(CONF_HOST, "")),
},
)