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,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
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": "Неожиданная ошибка"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -155,7 +155,7 @@ Security → Long-lived access tokens) и REST API: вызов сервисов
|
|||||||
| # | Содержимое |
|
| # | Содержимое |
|
||||||
|---|-----------|
|
|---|-----------|
|
||||||
| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
||||||
| H2 | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
||||||
| H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
| H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
||||||
| H4 | repair, диагностика (ключ для ESPHome), translations |
|
| H4 | repair, диагностика (ключ для ESPHome), translations |
|
||||||
| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз |
|
| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз |
|
||||||
|
|||||||
+17
-4
@@ -65,12 +65,13 @@ class _LogPump(threading.Thread):
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__(name="pyfglair-log", daemon=True)
|
super().__init__(name="pyfglair-log", daemon=True)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
out = ffi.new("fgl_log_event_t[]", 8)
|
out = ffi.new("fgl_log_event_t[]", 8)
|
||||||
dropped = 0
|
dropped = 0
|
||||||
warned_at = 0.0
|
warned_at = 0.0
|
||||||
while True:
|
while not self._stop.is_set():
|
||||||
total = int(lib.fgl_log_events_dropped())
|
total = int(lib.fgl_log_events_dropped())
|
||||||
if total > dropped:
|
if total > dropped:
|
||||||
if time.monotonic() - warned_at > 30.0:
|
if time.monotonic() - warned_at > 30.0:
|
||||||
@@ -80,7 +81,7 @@ class _LogPump(threading.Thread):
|
|||||||
)
|
)
|
||||||
warned_at = time.monotonic()
|
warned_at = time.monotonic()
|
||||||
dropped = total
|
dropped = total
|
||||||
count = lib.fgl_log_wait_events(out, 8, 500)
|
count = lib.fgl_log_wait_events(out, 8, 250)
|
||||||
handler = _GLOBAL_LOG
|
handler = _GLOBAL_LOG
|
||||||
if handler is None:
|
if handler is None:
|
||||||
continue
|
continue
|
||||||
@@ -93,13 +94,25 @@ class _LogPump(threading.Thread):
|
|||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.exception("log handler failed")
|
_LOGGER.exception("log handler failed")
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
self.join(timeout=2.0)
|
||||||
|
|
||||||
|
|
||||||
def set_log_handler(handler: Optional[LogHandler]) -> None:
|
def set_log_handler(handler: Optional[LogHandler]) -> None:
|
||||||
"""Глобальный приёмник логов ядра (0=debug..3=error)."""
|
"""Глобальный приёмник логов ядра (0=debug..3=error).
|
||||||
|
|
||||||
|
``None`` останавливает pump-поток (если он запущен).
|
||||||
|
"""
|
||||||
global _GLOBAL_LOG, _LOG_PUMP
|
global _GLOBAL_LOG, _LOG_PUMP
|
||||||
with _LOG_PUMP_LOCK:
|
with _LOG_PUMP_LOCK:
|
||||||
_GLOBAL_LOG = handler
|
_GLOBAL_LOG = handler
|
||||||
if handler is not None and _LOG_PUMP is None:
|
if handler is None:
|
||||||
|
pump, _LOG_PUMP = _LOG_PUMP, None
|
||||||
|
if pump is not None:
|
||||||
|
pump.stop()
|
||||||
|
return
|
||||||
|
if _LOG_PUMP is None:
|
||||||
_LOG_PUMP = _LogPump()
|
_LOG_PUMP = _LogPump()
|
||||||
_LOG_PUMP.start()
|
_LOG_PUMP.start()
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,3 @@ zip-safe = false
|
|||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
pyfglair = ["py.typed"]
|
pyfglair = ["py.typed"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
testpaths = ["tests/pyfglair"]
|
|
||||||
asyncio_mode = "auto"
|
|
||||||
filterwarnings = ["error::ResourceWarning"]
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
filterwarnings =
|
||||||
|
error::ResourceWarning
|
||||||
+20
-5
@@ -1,17 +1,26 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# CI python-части репозитория (pyfglair; позже — тесты HA-компонента).
|
# CI python-части репозитория: pyfglair + HA-компонент.
|
||||||
# scripts/py-ci.sh --setup # создать venv и поставить зависимости
|
# scripts/py-ci.sh --setup # venv для pyfglair-тестов
|
||||||
# scripts/py-ci.sh # прогнать pytest (ядро соберётся в conftest)
|
# scripts/py-ci.sh --setup-ha # venv с homeassistant/pytest-плагином (тяжёлый)
|
||||||
|
# scripts/py-ci.sh # pytest: tests/pyfglair + tests/components (если есть venv)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
VENV="${PY_VENV:-.venv}"
|
VENV="${PY_VENV:-.venv}"
|
||||||
|
HA_VENV="${HA_VENV:-.venv-ha}"
|
||||||
|
|
||||||
if [ "${1:-}" = "--setup" ]; then
|
case "${1:-}" in
|
||||||
|
--setup)
|
||||||
python3 -m venv "$VENV"
|
python3 -m venv "$VENV"
|
||||||
"$VENV/bin/pip" install -q --upgrade pip
|
"$VENV/bin/pip" install -q --upgrade pip
|
||||||
"$VENV/bin/pip" install -q cffi aiohttp pytest pytest-asyncio
|
"$VENV/bin/pip" install -q cffi aiohttp pytest pytest-asyncio
|
||||||
fi
|
;;
|
||||||
|
--setup-ha)
|
||||||
|
python3 -m venv "$HA_VENV"
|
||||||
|
"$HA_VENV/bin/pip" install -q --upgrade pip
|
||||||
|
"$HA_VENV/bin/pip" install -q -r tests/components/requirements.txt
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
if [ ! -x "$VENV/bin/python" ]; then
|
if [ ! -x "$VENV/bin/python" ]; then
|
||||||
echo "py-ci: нет $VENV (запустите: scripts/py-ci.sh --setup)" >&2
|
echo "py-ci: нет $VENV (запустите: scripts/py-ci.sh --setup)" >&2
|
||||||
@@ -19,3 +28,9 @@ if [ ! -x "$VENV/bin/python" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
"$VENV/bin/python" -m pytest tests/pyfglair -q
|
"$VENV/bin/python" -m pytest tests/pyfglair -q
|
||||||
|
|
||||||
|
if [ -x "$HA_VENV/bin/python" ]; then
|
||||||
|
"$HA_VENV/bin/python" -m pytest tests/components -q
|
||||||
|
else
|
||||||
|
echo "py-ci: нет $HA_VENV — HA-тесты пропущены (scripts/py-ci.sh --setup-ha)"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Фикстуры тестов HA-компонента fglair."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from helpers import DSN, KEY_ID, LANIP_KEY
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
from custom_components.fglair.const import (
|
||||||
|
CONF_DEVICE_PORT,
|
||||||
|
CONF_DSN,
|
||||||
|
CONF_HOST,
|
||||||
|
CONF_LANIP_KEY,
|
||||||
|
CONF_LANIP_KEY_ID,
|
||||||
|
CONF_LISTEN_PORT,
|
||||||
|
CONF_TEMPLATE,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def auto_enable_custom_integrations(enable_custom_integrations):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def entry_data() -> dict:
|
||||||
|
return {
|
||||||
|
CONF_HOST: "127.0.0.1",
|
||||||
|
CONF_DEVICE_PORT: 9,
|
||||||
|
CONF_DSN: DSN,
|
||||||
|
CONF_LANIP_KEY: LANIP_KEY,
|
||||||
|
CONF_LANIP_KEY_ID: KEY_ID,
|
||||||
|
CONF_TEMPLATE: "A",
|
||||||
|
CONF_LISTEN_PORT: 20000 + (os.getpid() % 20000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_entry(entry_data) -> MockConfigEntry:
|
||||||
|
return MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id=DSN)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Общие данные HA-тестов fglair (импортируется как top-level модуль)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
DSN = "AC000W00MOCK0001"
|
||||||
|
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
|
||||||
|
KEY_ID = 64201
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
return port
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Config flow fglair: меню, ручной ввод, импорт, облако (discover замокан)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from helpers import DSN, KEY_ID, LANIP_KEY
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
import pyfglair
|
||||||
|
from custom_components.fglair.const import DOMAIN
|
||||||
|
from custom_components.fglair.trial import TrialResult
|
||||||
|
from pyfglair.provision import Device
|
||||||
|
|
||||||
|
MANUAL_INPUT = {
|
||||||
|
"host": "192.168.1.50",
|
||||||
|
"device_port": 80,
|
||||||
|
"dsn": DSN,
|
||||||
|
"lanip_key": LANIP_KEY,
|
||||||
|
"lanip_key_id": KEY_ID,
|
||||||
|
"name": "Bedroom",
|
||||||
|
"model": "AP-WC1E",
|
||||||
|
}
|
||||||
|
|
||||||
|
TRIAL_OK = TrialResult(ok=True)
|
||||||
|
TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
async def _menu(hass: HomeAssistant):
|
||||||
|
return await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _select(hass: HomeAssistant, flow_id: str, option: str):
|
||||||
|
return await hass.config_entries.flow.async_configure(
|
||||||
|
flow_id, {"next_step_id": option}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_menu(hass: HomeAssistant):
|
||||||
|
result = await _menu(hass)
|
||||||
|
assert result["type"] == "menu"
|
||||||
|
assert set(result["menu_options"]) == {"cloud", "manual", "import_json"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_success(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
) as trial:
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
assert result["type"] == "form"
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], MANUAL_INPUT
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["title"] == "Bedroom"
|
||||||
|
assert result["result"].unique_id == DSN
|
||||||
|
data = result["data"]
|
||||||
|
assert data["lanip_key_id"] == KEY_ID
|
||||||
|
assert data["template"] == "A"
|
||||||
|
trial.assert_called_once()
|
||||||
|
assert trial.call_args.args[0]["host"] == "192.168.1.50"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_connect_error(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_NETWORK_FAIL,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], MANUAL_INPUT
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_key_mismatch_error(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TrialResult(ok=False, reason="key_mismatch"),
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], MANUAL_INPUT
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "key_mismatch"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_already_configured(hass: HomeAssistant):
|
||||||
|
existing = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DSN)
|
||||||
|
existing.add_to_hass(hass)
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], MANUAL_INPUT
|
||||||
|
)
|
||||||
|
assert result["type"] == "abort"
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_json_success(hass: HomeAssistant):
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"ip_address": "192.168.1.60",
|
||||||
|
"dsn": DSN,
|
||||||
|
"lanip_key": LANIP_KEY,
|
||||||
|
"lanip_key_id": KEY_ID,
|
||||||
|
"model": "AP-WB2E",
|
||||||
|
"name": "Kitchen",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "import_json")
|
||||||
|
assert result["type"] == "form"
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"config_json": payload}
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"]["host"] == "192.168.1.60"
|
||||||
|
assert result["data"]["template"] == "B"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_import_json_invalid(hass: HomeAssistant):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "import_json")
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"config_json": "not json"}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_import"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloud_flow_success(hass: HomeAssistant):
|
||||||
|
device = Device(
|
||||||
|
name="Bedroom",
|
||||||
|
model="AP-WC1E",
|
||||||
|
dsn=DSN,
|
||||||
|
lan_ip="192.168.1.50",
|
||||||
|
lanip_key=LANIP_KEY,
|
||||||
|
lanip_key_id=KEY_ID,
|
||||||
|
region="eu",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"pyfglair.discover", AsyncMock(return_value=[device])
|
||||||
|
), patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "cloud")
|
||||||
|
assert result["type"] == "form"
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{"email": "user@example.com", "password": "secret", "region": "eu"},
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["step_id"] == "cloud_device"
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=TRIAL_OK,
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"], {"dsn": DSN}
|
||||||
|
)
|
||||||
|
assert result["type"] == "create_entry"
|
||||||
|
assert result["data"]["host"] == "192.168.1.50"
|
||||||
|
assert result["data"]["lanip_key_id"] == KEY_ID
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cloud_auth_error(hass: HomeAssistant):
|
||||||
|
with patch(
|
||||||
|
"pyfglair.discover",
|
||||||
|
AsyncMock(side_effect=pyfglair.ProvisionError("Ошибка входа (401)")),
|
||||||
|
):
|
||||||
|
result = await _menu(hass)
|
||||||
|
result = await _select(hass, result["flow_id"], "cloud")
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{"email": "user@example.com", "password": "bad", "region": "eu"},
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {"base": "invalid_auth"}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Setup/unload ConfigEntry: клиент стартует, останавливается, ошибки порта."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||||
|
|
||||||
|
from custom_components.fglair.const import CONF_LISTEN_PORT, DOMAIN
|
||||||
|
from pyfglair import Prop, State
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_unload(hass: HomeAssistant, config_entry, socket_enabled):
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
client = config_entry.runtime_data
|
||||||
|
assert client is not None
|
||||||
|
assert isinstance(client.state, State)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert client.cached(Prop.OPERATION_MODE) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_port_busy(hass: HomeAssistant, entry_data, socket_enabled):
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
||||||
|
sock.bind(("127.0.0.1", entry_data[CONF_LISTEN_PORT]))
|
||||||
|
sock.listen(1)
|
||||||
|
try:
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN, data=entry_data, unique_id=entry_data["dsn"]
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert entry.state.name == "SETUP_RETRY"
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Пробное подключение (trial_connect) против mock_ac.py — без HA-рантайма."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from helpers import free_port
|
||||||
|
|
||||||
|
from custom_components.fglair.trial import trial_connect
|
||||||
|
from pyfglair import Prop, State
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trial_online(mock_ac, ac_env, socket_enabled):
|
||||||
|
proc = mock_ac()
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
trial_connect,
|
||||||
|
"127.0.0.1",
|
||||||
|
ac_env["dsn"],
|
||||||
|
ac_env["key"],
|
||||||
|
ac_env["key_id"],
|
||||||
|
device_port=proc.port,
|
||||||
|
listen_port=free_port(),
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
assert result.ok, result.reason
|
||||||
|
assert result.state == State.ONLINE
|
||||||
|
assert result.properties[Prop.OPERATION_MODE].int_value == 6
|
||||||
|
assert result.properties[Prop.FAN_SPEED].int_value == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trial_key_mismatch(mock_ac, ac_env, socket_enabled):
|
||||||
|
proc = mock_ac()
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
trial_connect,
|
||||||
|
"127.0.0.1",
|
||||||
|
ac_env["dsn"],
|
||||||
|
ac_env["key"],
|
||||||
|
ac_env["key_id"] + 1,
|
||||||
|
device_port=proc.port,
|
||||||
|
listen_port=free_port(),
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
assert not result.ok
|
||||||
|
assert result.reason == "key_mismatch"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trial_unreachable(ac_env, socket_enabled):
|
||||||
|
dead_port = free_port()
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
trial_connect,
|
||||||
|
"127.0.0.1",
|
||||||
|
ac_env["dsn"],
|
||||||
|
ac_env["key"],
|
||||||
|
ac_env["key_id"],
|
||||||
|
device_port=dead_port,
|
||||||
|
listen_port=free_port(),
|
||||||
|
timeout=1.0,
|
||||||
|
)
|
||||||
|
assert not result.ok
|
||||||
|
assert result.reason in ("unreachable", "timeout")
|
||||||
|
assert result.state != State.ONLINE
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trial_no_slots(mock_ac, ac_env, socket_enabled):
|
||||||
|
proc = mock_ac(["--503"])
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
trial_connect,
|
||||||
|
"127.0.0.1",
|
||||||
|
ac_env["dsn"],
|
||||||
|
ac_env["key"],
|
||||||
|
ac_env["key_id"],
|
||||||
|
device_port=proc.port,
|
||||||
|
listen_port=free_port(),
|
||||||
|
timeout=3.0,
|
||||||
|
)
|
||||||
|
assert not result.ok
|
||||||
|
assert result.reason == "no_slots"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pytest-homeassistant-custom-component==0.13.367
|
||||||
|
aiohttp>=3.9
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Общие фикстуры pyfglair-тестов: сборка ядра, mock-облако, mock-модуль."""
|
"""Общие фикстуры тестов: сборка ядра, mock-облако, mock-модуль."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -12,7 +12,7 @@ import time
|
|||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
if str(ROOT) not in sys.path:
|
if str(ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
Reference in New Issue
Block a user