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
+42
View File
@@ -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)
+16
View File
@@ -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
+199
View File
@@ -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"}
+41
View File
@@ -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()
+76
View File
@@ -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"