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:
2026-09-29 12:03:44 +03:00
parent 397be6236d
commit 26d1d6d64f
21 changed files with 1400 additions and 20 deletions
+65
View File
@@ -0,0 +1,65 @@
"""FGLair (Fujitsu General) — локальное управление через LAN-протокол Ayla."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
import pyfglair
from .const import DOMAIN
from .fglair_client import FglairClient
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[str] = []
_LOG_LEVELS = {
0: logging.DEBUG,
1: logging.INFO,
2: logging.WARNING,
3: logging.ERROR,
}
DATA_CLIENTS = f"{DOMAIN}_clients"
def _log_handler(level: int, message: str) -> None:
_LOGGER.log(_LOG_LEVELS.get(level, logging.DEBUG), "pyfglair: %s", message)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
clients: dict[str, FglairClient] = hass.data.setdefault(DATA_CLIENTS, {})
if not clients:
pyfglair.set_log_handler(_log_handler)
pyfglair.set_log_level(1)
client = FglairClient(hass, entry.data)
try:
await client.async_start()
except Exception:
if not clients:
pyfglair.set_log_handler(None)
raise
clients[entry.entry_id] = client
entry.runtime_data = client
if PLATFORMS:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client: FglairClient = entry.runtime_data
await client.async_stop()
clients: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {})
clients.pop(entry.entry_id, None)
if not clients:
pyfglair.set_log_handler(None)
return True
+307
View File
@@ -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)
+39
View File
@@ -0,0 +1,39 @@
"""Константы интеграции fglair."""
from __future__ import annotations
from pyfglair import State
from pyfglair.provision import REGIONS as _PROVISION_REGIONS
DOMAIN = "fglair"
CONF_HOST = "host"
CONF_DEVICE_PORT = "device_port"
CONF_DSN = "dsn"
CONF_LANIP_KEY = "lanip_key"
CONF_LANIP_KEY_ID = "lanip_key_id"
CONF_MODEL = "model"
CONF_TEMPLATE = "template"
CONF_NAME = "name"
CONF_LISTEN_PORT = "listen_port"
CONF_KEEPALIVE_MS = "keepalive_ms"
CONF_REGION = "region"
CONF_EMAIL = "email"
CONF_PASSWORD = "password"
CONF_CONFIG_JSON = "config_json"
DEFAULT_DEVICE_PORT = 80
DEFAULT_LISTEN_PORT = 10275
DEFAULT_KEEPALIVE_MS = 15000
TRIAL_TIMEOUT = 10.0
REGIONS = sorted(_PROVISION_REGIONS)
TRIAL_ERRORS = {
"no_slots": "no_slots",
"key_mismatch": "key_mismatch",
"unreachable": "cannot_connect",
"timeout": "timeout",
"start_failed": "cannot_connect",
}
STATE_FAILED = {State.OFFLINE, State.KEY_ERROR}
+194
View File
@@ -0,0 +1,194 @@
"""Фоновый клиент FGLair: сессия pyfglair + доставка колбэков в event loop HA."""
from __future__ import annotations
import logging
from typing import Any, Callable, Mapping, Optional
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from pyfglair import (
Config,
Error,
Prop,
PropertyEvent,
Session,
State,
Template,
Value,
)
from pyfglair.templates import detect
from .const import (
CONF_DEVICE_PORT,
CONF_DSN,
CONF_HOST,
CONF_KEEPALIVE_MS,
CONF_LANIP_KEY,
CONF_LANIP_KEY_ID,
CONF_LISTEN_PORT,
CONF_MODEL,
CONF_TEMPLATE,
DEFAULT_DEVICE_PORT,
DEFAULT_KEEPALIVE_MS,
DEFAULT_LISTEN_PORT,
)
_LOGGER = logging.getLogger(__name__)
StateListener = Callable[[State, Error], None]
PropertyListener = Callable[[PropertyEvent], None]
def resolve_template(data: Mapping[str, Any]) -> Template:
"""Шаблон из данных записи: явный или по oem_model, иначе A."""
name = data.get(CONF_TEMPLATE)
if isinstance(name, str) and name in Template.__members__:
return Template[name]
model = data.get(CONF_MODEL)
if model:
found = detect(model)
if found is not None:
return found
return Template.A
class FglairClient:
"""Жизненный цикл сессии для одной ConfigEntry.
Создание/старт выполняются в executor (вызовы C-ядра), колбэки приходят
в event loop (Session создаётся с ``loop=hass.loop``).
"""
def __init__(self, hass: HomeAssistant, data: Mapping[str, Any]) -> None:
self.hass = hass
self._data = dict(data)
self._session: Optional[Session] = None
self._state = State.IDLE
self._last_error = Error.NONE
self._state_listeners: list[StateListener] = []
self._property_listeners: list[PropertyListener] = []
self._dropped_notified = 0
@property
def state(self) -> State:
return self._state
@property
def last_error(self) -> Error:
return self._last_error
@property
def template(self) -> Template:
return resolve_template(self._data)
@property
def data(self) -> Mapping[str, Any]:
return self._data
@callback
def add_state_listener(self, listener: StateListener) -> Callable[[], None]:
self._state_listeners.append(listener)
return lambda: self._state_listeners.remove(listener)
@callback
def add_property_listener(
self, listener: PropertyListener
) -> Callable[[], None]:
self._property_listeners.append(listener)
return lambda: self._property_listeners.remove(listener)
@callback
def _on_state(self, state: State, error: Error) -> None:
self._state = state
self._last_error = error
self._notify_state()
if self._session is not None:
dropped = self._session.events_dropped
if dropped > self._dropped_notified:
_LOGGER.warning(
"FGLair: потеряно событий устройства: %d",
dropped - self._dropped_notified,
)
self._dropped_notified = dropped
@callback
def _on_property(self, event: PropertyEvent) -> None:
for listener in list(self._property_listeners):
listener(event)
def _notify_state(self) -> None:
for listener in list(self._state_listeners):
listener(self._state, self._last_error)
def _build_session(self) -> Session:
return Session(
Config(
host=self._data[CONF_HOST],
device_port=int(
self._data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)
),
dsn=self._data[CONF_DSN],
lanip_key=self._data[CONF_LANIP_KEY],
lanip_key_id=int(self._data[CONF_LANIP_KEY_ID]),
template=self.template,
listen_port=int(
self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)
),
keepalive_ms=int(
self._data.get(CONF_KEEPALIVE_MS, DEFAULT_KEEPALIVE_MS)
),
),
loop=self.hass.loop,
on_state=self._on_state,
on_property=self._on_property,
)
async def async_start(self) -> None:
if self._session is not None:
return
self._session = await self.hass.async_add_executor_job(
self._build_session
)
started = await self.hass.async_add_executor_job(self._session.start)
if not started:
await self._session.async_stop()
self._session = None
raise ConfigEntryNotReady(
"не удалось запустить локальный HTTP-сервер FGLair "
f"(порт {self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)})"
)
self._state = self._session.state
async def async_stop(self) -> None:
session, self._session = self._session, None
if session is not None:
await session.async_stop()
def cached(self, prop: Prop) -> Optional[Value]:
if self._session is None:
return None
return self._session.cached(prop)
def set_int(self, prop: Prop, value: int) -> bool:
return self._session is not None and self._session.set_int(prop, value)
def set_bool(self, prop: Prop, value: bool) -> bool:
return self._session is not None and self._session.set_bool(prop, value)
def set_string(self, prop: Prop, value: str) -> bool:
return (
self._session is not None and self._session.set_string(prop, value)
)
def get_prop(self, prop: Prop) -> bool:
return self._session is not None and self._session.get_prop(prop)
def batch_begin(self) -> bool:
return self._session is not None and self._session.batch_begin()
def batch_commit(self) -> bool:
return self._session is not None and self._session.batch_commit()
def batch_abort(self) -> bool:
return self._session is not None and self._session.batch_abort()
+12
View File
@@ -0,0 +1,12 @@
{
"domain": "fglair",
"name": "FGLair (Fujitsu General)",
"config_flow": true,
"documentation": "https://git.ratigorsk-12.ru/esphome/fgl-aircon",
"integration_type": "device",
"iot_class": "local_push",
"issue_tracker": "https://git.ratigorsk-12.ru/esphome/fgl-aircon/-/issues",
"loggers": ["pyfglair"],
"requirements": ["pyfglair>=1.0.0"],
"version": "0.1.0"
}
+77
View File
@@ -0,0 +1,77 @@
{
"config": {
"step": {
"user": {
"title": "FGLair",
"menu_options": {
"cloud": "Cloud sign-in (FGLair account)",
"manual": "Manual setup",
"import_json": "Import config_*.json"
}
},
"cloud": {
"title": "FGLair cloud sign-in",
"description": "Used once for provisioning: fetches the static LAN key of the device.",
"data": {
"email": "Email",
"password": "Password",
"region": "Region"
},
"data_description": {
"region": "FGLair/Ayla account region"
}
},
"cloud_device": {
"title": "Select air conditioner",
"data": {
"dsn": "Device"
}
},
"manual": {
"title": "Device parameters",
"data": {
"host": "Host (IP or DNS name)",
"device_port": "Device port",
"dsn": "DSN",
"lanip_key": "LAN IP key",
"lanip_key_id": "LAN IP key ID",
"name": "Name (optional)",
"model": "Model / oem_model (optional)"
},
"data_description": {
"device_port": "Default 80; change only for port-forwarding setups",
"lanip_key": "From cloud discovery or ESPHome diagnostics"
}
},
"import_json": {
"title": "Import config_*.json",
"data": {
"config_json": "config JSON"
},
"data_description": {
"config_json": "Paste a JSON object produced by fglair-discover or fglctl"
}
}
},
"error": {
"cannot_connect": "Could not connect to the device",
"no_slots": "Both LAN slots are busy (FGLair app or ESPHome connected). Close one and retry",
"key_mismatch": "LAN key does not match the device; re-run discovery or re-import the config",
"timeout": "Timed out waiting for the device to come online",
"invalid_auth": "Invalid email or password",
"invalid_import": "Invalid config JSON",
"no_devices": "No devices found in the account",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "This device is already configured",
"invalid_import": "Invalid config JSON",
"cannot_connect": "Could not connect to the device",
"no_slots": "Both LAN slots are busy",
"key_mismatch": "LAN key does not match the device",
"timeout": "Timed out waiting for the device",
"start_failed": "Could not start the local FGLair server",
"unknown": "Unexpected error"
}
}
}
@@ -0,0 +1,77 @@
{
"config": {
"step": {
"user": {
"title": "FGLair",
"menu_options": {
"cloud": "Cloud sign-in (FGLair account)",
"manual": "Manual setup",
"import_json": "Import config_*.json"
}
},
"cloud": {
"title": "FGLair cloud sign-in",
"description": "Used once for provisioning: fetches the static LAN key of the device.",
"data": {
"email": "Email",
"password": "Password",
"region": "Region"
},
"data_description": {
"region": "FGLair/Ayla account region"
}
},
"cloud_device": {
"title": "Select air conditioner",
"data": {
"dsn": "Device"
}
},
"manual": {
"title": "Device parameters",
"data": {
"host": "Host (IP or DNS name)",
"device_port": "Device port",
"dsn": "DSN",
"lanip_key": "LAN IP key",
"lanip_key_id": "LAN IP key ID",
"name": "Name (optional)",
"model": "Model / oem_model (optional)"
},
"data_description": {
"device_port": "Default 80; change only for port-forwarding setups",
"lanip_key": "From cloud discovery or ESPHome diagnostics"
}
},
"import_json": {
"title": "Import config_*.json",
"data": {
"config_json": "config JSON"
},
"data_description": {
"config_json": "Paste a JSON object produced by fglair-discover or fglctl"
}
}
},
"error": {
"cannot_connect": "Could not connect to the device",
"no_slots": "Both LAN slots are busy (FGLair app or ESPHome connected). Close one and retry",
"key_mismatch": "LAN key does not match the device; re-run discovery or re-import the config",
"timeout": "Timed out waiting for the device to come online",
"invalid_auth": "Invalid email or password",
"invalid_import": "Invalid config JSON",
"no_devices": "No devices found in the account",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "This device is already configured",
"invalid_import": "Invalid config JSON",
"cannot_connect": "Could not connect to the device",
"no_slots": "Both LAN slots are busy",
"key_mismatch": "LAN key does not match the device",
"timeout": "Timed out waiting for the device",
"start_failed": "Could not start the local FGLair server",
"unknown": "Unexpected error"
}
}
}
@@ -0,0 +1,77 @@
{
"config": {
"step": {
"user": {
"title": "FGLair",
"menu_options": {
"cloud": "Вход в облако FGLair",
"manual": "Ввод параметров вручную",
"import_json": "Импорт config_*.json"
}
},
"cloud": {
"title": "Вход в облако FGLair",
"description": "Нужен однократно для провижинига: получает статический LAN-ключ устройства.",
"data": {
"email": "E-mail",
"password": "Пароль",
"region": "Регион"
},
"data_description": {
"region": "Регион аккаунта FGLair/Ayla"
}
},
"cloud_device": {
"title": "Выбор кондиционера",
"data": {
"dsn": "Устройство"
}
},
"manual": {
"title": "Параметры устройства",
"data": {
"host": "Хост (IP или DNS-имя)",
"device_port": "Порт модуля",
"dsn": "DSN",
"lanip_key": "LAN IP key",
"lanip_key_id": "LAN IP key ID",
"name": "Имя (необязательно)",
"model": "Модель / oem_model (необязательно)"
},
"data_description": {
"device_port": "По умолчанию 80; меняйте только для проброса портов",
"lanip_key": "Из облачного discovery или диагностики ESPHome"
}
},
"import_json": {
"title": "Импорт config_*.json",
"data": {
"config_json": "JSON конфигурации"
},
"data_description": {
"config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться к устройству",
"no_slots": "Оба LAN-слота заняты (приложение FGLair или ESPHome). Освободите один и повторите",
"key_mismatch": "LAN-ключ не совпадает с устройством; повторите discovery или импортируйте config заново",
"timeout": "Устройство не вышло на связь за отведённое время",
"invalid_auth": "Неверный e-mail или пароль",
"invalid_import": "Некорректный config JSON",
"no_devices": "В аккаунте не найдено устройств",
"unknown": "Неожиданная ошибка"
},
"abort": {
"already_configured": "Устройство уже настроено",
"invalid_import": "Некорректный config JSON",
"cannot_connect": "Не удалось подключиться к устройству",
"no_slots": "Оба LAN-слота заняты",
"key_mismatch": "LAN-ключ не совпадает с устройством",
"timeout": "Устройство не вышло на связь за отведённое время",
"start_failed": "Не удалось запустить локальный сервер FGLair",
"unknown": "Неожиданная ошибка"
}
}
}
+129
View File
@@ -0,0 +1,129 @@
"""Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение
базовых свойств. Выполняется в executor (блокирующие вызовы ядра).
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Iterable
from pyfglair import Config, Error, Prop, Session, State, Template, Value
BASE_PROPS: tuple[Prop, ...] = (
Prop.OPERATION_MODE,
Prop.FAN_SPEED,
Prop.DISPLAY_TEMPERATURE,
)
@dataclass
class TrialResult:
ok: bool
reason: str = ""
state: State = State.IDLE
error: Error = Error.NONE
properties: dict[Prop, Value] = field(default_factory=dict)
def _failure_reason(state: State, error: Error) -> str:
if state == State.KEY_ERROR or error == Error.KEY_MISMATCH:
return "key_mismatch"
if error == Error.NO_SLOT:
return "no_slots"
if error in (Error.UNREACHABLE, Error.ACTIVATION_TIMEOUT):
return "unreachable"
return "timeout"
def trial_connect(
host: str,
dsn: str,
lanip_key: str,
lanip_key_id: int,
*,
device_port: int = 80,
template: Template = Template.A,
listen_port: int = 10275,
keepalive_ms: int = 15000,
timeout: float = 10.0,
props: Iterable[Prop] = BASE_PROPS,
) -> TrialResult:
"""Поднимает сессию, ждёт ONLINE и читает базовые свойства.
Никогда не бросает: любая неудача — ``TrialResult(ok=False, reason=...)``.
"""
lock = threading.Lock()
box = {"state": State.IDLE, "error": Error.NONE}
values: dict[Prop, Value] = {}
wanted = tuple(props)
def on_state(state: State, error: Error) -> None:
with lock:
box["state"] = state
box["error"] = error
def on_property(event) -> None:
with lock:
values[event.prop] = event.value
session = Session(
Config(
host=host,
device_port=device_port,
dsn=dsn,
lanip_key=lanip_key,
lanip_key_id=lanip_key_id,
template=template,
listen_port=listen_port,
keepalive_ms=keepalive_ms,
),
loop=None,
on_state=on_state,
on_property=on_property,
)
try:
try:
started = session.start()
except Exception: # pragma: no cover - защитный путь
return TrialResult(ok=False, reason="start_failed")
if not started:
return TrialResult(ok=False, reason="start_failed")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with lock:
state, error = box["state"], box["error"]
if state == State.ONLINE:
break
if state == State.KEY_ERROR:
return TrialResult(
ok=False, reason=_failure_reason(state, error),
state=state, error=error,
)
time.sleep(0.05)
else:
with lock:
state, error = box["state"], box["error"]
return TrialResult(
ok=False, reason=_failure_reason(state, error),
state=state, error=error,
)
session.batch_begin()
for prop in wanted:
session.get_prop(prop)
session.batch_commit()
prop_deadline = min(deadline, time.monotonic() + 2.0)
while time.monotonic() < prop_deadline:
with lock:
if all(prop in values for prop in wanted):
break
time.sleep(0.05)
with lock:
snapshot = dict(values)
return TrialResult(ok=True, state=State.ONLINE, properties=snapshot)
finally:
session.close()