Files
fgl-aircon/custom_components/fglair/trial.py
T
petr.polezhaev da01c607a6 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
2026-09-29 12:20:37 +03:00

143 lines
4.5 KiB
Python

"""Пробное подключение при настройке: старт сессии, 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
from pyfglair.templates import template_info
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] = {}
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:
box["state"] = state
box["error"] = error
def on_property(event) -> None:
with lock:
values[event.prop] = event.value
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()
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,
)
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()