ha(H2): исправления по ревью — несколько устройств, suggested values, YAML-import, типы ошибок
- critical: listen_port по умолчанию 0 (эфемерный) — несколько ConfigEntry не конфликтуют; тест двух записей на дефолтном порту - manifest: добавлен обязательный ключ codeowners - unload: set_log_handler(None) вынесен в executor (не блокирует loop); рефкаунт лог-пампа без гонки (старт после успешного setup) - config flow: возврат введённого в форму при ошибке (add_suggested_values_to_schema; секреты исключены), YAML-import подключён через async_setup + SOURCE_IMPORT, ProvisionError.kind вместо разбора русского текста, валидация device_port/key_id из JSON - trial: требует получения всех базовых свойств шаблона (не пустой ok), безопасное создание Session - py-ci.sh: отсутствие .venv-ha — ошибка (PY_CI_SKIP_HA=1 для пропуска); pytest.ini testpaths
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -30,24 +31,27 @@ def _log_handler(level: int, message: str) -> None:
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
if isinstance(config.get(DOMAIN), dict):
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data=config[DOMAIN],
|
||||
)
|
||||
)
|
||||
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
|
||||
await client.async_start()
|
||||
clients[entry.entry_id] = client
|
||||
entry.runtime_data = client
|
||||
if len(clients) == 1:
|
||||
pyfglair.set_log_handler(_log_handler)
|
||||
pyfglair.set_log_level(1)
|
||||
|
||||
if PLATFORMS:
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
@@ -61,5 +65,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
clients: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {})
|
||||
clients.pop(entry.entry_id, None)
|
||||
if not clients:
|
||||
pyfglair.set_log_handler(None)
|
||||
await hass.async_add_executor_job(pyfglair.set_log_handler, None)
|
||||
return True
|
||||
|
||||
@@ -127,14 +127,23 @@ def _parse_import(source: str | dict[str, Any]) -> dict[str, Any] | None:
|
||||
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):
|
||||
|
||||
def to_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
key_id = to_int(lanip_key_id)
|
||||
if key_id is None or key_id < 0:
|
||||
return None
|
||||
port = to_int(obj.get("device_port", DEFAULT_DEVICE_PORT))
|
||||
if port is None or not 1 <= port <= 65535:
|
||||
port = DEFAULT_DEVICE_PORT
|
||||
model = str(obj.get("model") or "").strip()
|
||||
return {
|
||||
CONF_HOST: str(host).strip(),
|
||||
CONF_DEVICE_PORT: int(obj.get("device_port", DEFAULT_DEVICE_PORT)),
|
||||
CONF_DEVICE_PORT: port,
|
||||
CONF_DSN: str(dsn).strip(),
|
||||
CONF_LANIP_KEY: str(lanip_key).strip(),
|
||||
CONF_LANIP_KEY_ID: key_id,
|
||||
@@ -165,6 +174,27 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
self._devices: list[Device] = []
|
||||
|
||||
def _show_form(
|
||||
self,
|
||||
step_id: str,
|
||||
schema: vol.Schema,
|
||||
errors: dict[str, str],
|
||||
user_input: dict[str, Any] | None = None,
|
||||
*,
|
||||
exclude: tuple[str, ...] = (),
|
||||
) -> ConfigFlowResult:
|
||||
"""Показывает форму, возвращая введённые значения (кроме секретов)."""
|
||||
if user_input:
|
||||
suggested = {
|
||||
key: value
|
||||
for key, value in user_input.items()
|
||||
if key not in exclude
|
||||
}
|
||||
schema = self.add_suggested_values_to_schema(schema, suggested)
|
||||
return self.async_show_form(
|
||||
step_id=step_id, data_schema=schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -185,13 +215,10 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
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"
|
||||
errors["base"] = {
|
||||
"auth": "invalid_auth",
|
||||
"no_devices": "no_devices",
|
||||
}.get(err.kind, "cannot_connect")
|
||||
except Exception: # pragma: no cover - защитный путь
|
||||
_LOGGER.exception("discover: неожиданная ошибка")
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -200,8 +227,9 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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
|
||||
return self._show_form(
|
||||
"cloud", _cloud_schema(), errors, user_input,
|
||||
exclude=(CONF_PASSWORD,),
|
||||
)
|
||||
|
||||
async def async_step_cloud_device(
|
||||
@@ -240,9 +268,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="cloud_device", data_schema=schema, errors=errors
|
||||
)
|
||||
return self._show_form("cloud_device", schema, errors, user_input)
|
||||
|
||||
async def async_step_manual(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -254,8 +280,9 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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
|
||||
return self._show_form(
|
||||
"manual", _manual_schema(), errors, user_input,
|
||||
exclude=(CONF_LANIP_KEY,),
|
||||
)
|
||||
|
||||
async def async_step_import_json(
|
||||
@@ -271,9 +298,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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
|
||||
)
|
||||
return self._show_form("import_json", _import_schema(), errors, user_input)
|
||||
|
||||
async def async_step_import(
|
||||
self, import_data: dict[str, Any]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Константы интеграции fglair."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pyfglair import State
|
||||
from pyfglair.provision import REGIONS as _PROVISION_REGIONS
|
||||
|
||||
DOMAIN = "fglair"
|
||||
@@ -22,7 +21,10 @@ CONF_PASSWORD = "password"
|
||||
CONF_CONFIG_JSON = "config_json"
|
||||
|
||||
DEFAULT_DEVICE_PORT = 80
|
||||
DEFAULT_LISTEN_PORT = 10275
|
||||
# 0 => свободный эфемерный порт: несколько ConfigEntry (несколько
|
||||
# кондиционеров) не конфликтуют; модулю фактический порт сообщается в
|
||||
# local_reg (PROTOCOL §1).
|
||||
DEFAULT_LISTEN_PORT = 0
|
||||
DEFAULT_KEEPALIVE_MS = 15000
|
||||
TRIAL_TIMEOUT = 10.0
|
||||
|
||||
@@ -34,6 +36,5 @@ TRIAL_ERRORS = {
|
||||
"unreachable": "cannot_connect",
|
||||
"timeout": "timeout",
|
||||
"start_failed": "cannot_connect",
|
||||
"properties_missing": "cannot_connect",
|
||||
}
|
||||
|
||||
STATE_FAILED = {State.OFFLINE, State.KEY_ERROR}
|
||||
|
||||
@@ -147,18 +147,20 @@ class FglairClient:
|
||||
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)
|
||||
session = await self.hass.async_add_executor_job(self._build_session)
|
||||
try:
|
||||
started = await self.hass.async_add_executor_job(session.start)
|
||||
except Exception:
|
||||
await session.async_stop()
|
||||
raise
|
||||
if not started:
|
||||
await self._session.async_stop()
|
||||
self._session = None
|
||||
await session.async_stop()
|
||||
raise ConfigEntryNotReady(
|
||||
"не удалось запустить локальный HTTP-сервер FGLair "
|
||||
f"(порт {self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)})"
|
||||
)
|
||||
self._state = self._session.state
|
||||
self._session = session
|
||||
self._state = session.state
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
session, self._session = self._session, None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"domain": "fglair",
|
||||
"name": "FGLair (Fujitsu General)",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://git.ratigorsk-12.ru/esphome/fgl-aircon",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -69,9 +69,7 @@
|
||||
"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"
|
||||
"timeout": "Timed out waiting for the device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +69,7 @@
|
||||
"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"
|
||||
"timeout": "Timed out waiting for the device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +69,7 @@
|
||||
"cannot_connect": "Не удалось подключиться к устройству",
|
||||
"no_slots": "Оба LAN-слота заняты",
|
||||
"key_mismatch": "LAN-ключ не совпадает с устройством",
|
||||
"timeout": "Устройство не вышло на связь за отведённое время",
|
||||
"start_failed": "Не удалось запустить локальный сервер FGLair",
|
||||
"unknown": "Неожиданная ошибка"
|
||||
"timeout": "Устройство не вышло на связь за отведённое время"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Iterable
|
||||
|
||||
from pyfglair import Config, Error, Prop, Session, State, Template, Value
|
||||
from pyfglair.templates import template_info
|
||||
|
||||
BASE_PROPS: tuple[Prop, ...] = (
|
||||
Prop.OPERATION_MODE,
|
||||
@@ -56,7 +57,8 @@ def trial_connect(
|
||||
lock = threading.Lock()
|
||||
box = {"state": State.IDLE, "error": Error.NONE}
|
||||
values: dict[Prop, Value] = {}
|
||||
wanted = tuple(props)
|
||||
valid = {info.prop for info in template_info(template)}
|
||||
wanted = tuple(prop for prop in props if prop in valid)
|
||||
|
||||
def on_state(state: State, error: Error) -> None:
|
||||
with lock:
|
||||
@@ -67,21 +69,24 @@ def trial_connect(
|
||||
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:
|
||||
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,
|
||||
)
|
||||
except Exception: # pragma: no cover - защитный путь
|
||||
return TrialResult(ok=False, reason="start_failed")
|
||||
try:
|
||||
try:
|
||||
started = session.start()
|
||||
@@ -110,20 +115,28 @@ def trial_connect(
|
||||
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)
|
||||
if wanted:
|
||||
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)
|
||||
missing = [prop for prop in wanted if prop not in snapshot]
|
||||
if missing:
|
||||
return TrialResult(
|
||||
ok=False,
|
||||
reason="properties_missing",
|
||||
state=State.ONLINE,
|
||||
properties=snapshot,
|
||||
)
|
||||
return TrialResult(ok=True, state=State.ONLINE, properties=snapshot)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
Reference in New Issue
Block a user