ha(ux): мастер из трёх страниц, feature-модель и capabilities-фильтрация

- config flow: template (A/B/F с пояснениями, значения с устройства,
  определение по oem_model; для неизвестных моделей — пробинг A/B/F тремя
  короткими сессиями) -> capabilities (23 тумблера с описаниями, дефолты
  из device_capabilities/num_dir/ответов) -> limits (диапазон/шаг, ручная
  конверсия); каждая страница с пометкой «значения определены
  автоматически, можно пропустить»
- features.py: FeatureSet/LiveValues/device_default/resolve_features;
  entry.data["features"] хранит выбор пользователя; reconfigure
  предзаполняет; YAML-import получает дефолты
- сущности создаются только для включённых фич: climate (режимы/скорости/
  swing/пресеты), switch, select заслонок (caps+num_dir), датчик комнаты;
  исправлено появление select без ламелей
- trial: caps/num_dir и presence-зонды (необязательные свойства), answered;
  координатор ждёт первые свойства перед созданием сущностей (feature-
  дефолты без гонки)
- атрибут description у сущностей (HA не поддерживает тултипы) + описания
  фич в мастере (en/ru)
- тесты: features, probing, обновлённые flow/entities/repairs; 57 components
This commit is contained in:
2026-09-29 17:39:34 +03:00
parent 4ee428a6fd
commit d1c02d7c4b
20 changed files with 1131 additions and 212 deletions
+18 -8
View File
@@ -72,15 +72,22 @@ Assistant. Убедитесь, что `pyfglair` установлен в python-
*Должно быть видно: список с одним устройством (имя, модель, host).* *Должно быть видно: список с одним устройством (имя, модель, host).*
6. **Проверка шаблона с превью**: форма-предпросмотр рассчитанных 6. **Три страницы настройки** (все значения уже подставлены по данным
значений (режимы, диапазон температур, пример конверсии) и, при устройства — можно просто нажимать «Далее»):
необходимости, ручные коэффициенты/диапазон уставки. Сверьте с 1. **Шаблон модуля** — A/B/F с пояснениями; определяется по модели
приложением FGLair и подтвердите. модуля, а для неизвестных моделей — проверкой свойств. Рядом
показаны прочитанные с устройства значения.
2. **Возможности прибора** — тумблеры по «человекочитаемым» фичам
(режимы, скорости, swing, пресеты, флаги, заслонки) с описаниями;
включены/выключены по `device_capabilities` и ответам устройства.
Сущности создаются только для включённых возможностей.
3. **Уставка** — диапазон/шаг (у A/F 16–30 °C шаг 0.5, у B шаг 1.0) и,
при необходимости, ручная конверсия коэффициентами.
![шаг 6](screenshots/step-6.png) ![шаг 6](screenshots/step-6.png)
*Должно быть видно: таблица превью — режимы, диапазон 16–30 °C, шаг, *Должно быть видно: страница «Возможности прибора» с тумблерами и
текущая температура, capabilities, кнопки Submit.* описаниями, часть выключена по данным устройства.*
7. **Готово**: карточка устройства с сущностями climate/switch/select/ 7. **Готово**: карточка устройства с сущностями climate/switch/select/
sensor/binary_sensor. sensor/binary_sensor.
@@ -121,8 +128,11 @@ lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl
| sensor | температура в помещении, код ошибки, диагностическое состояние связи | | sensor | температура в помещении, код ошибки, диагностическое состояние связи |
| binary_sensor | connectivity + флаги op_status (defrost, maintenance, oil recovery, pump down, check operation, …) | | binary_sensor | connectivity + флаги op_status (defrost, maintenance, oil recovery, pump down, check operation, …) |
Список зависит от шаблона устройства (A/B/F) и маски `device_capabilities`; Список зависит от шаблона устройства (A/B/F), маски `device_capabilities`
режимы, которыми прибор не управляет, не создаются. и выбранных в мастере возможностей: то, что прибор не умеет или что
отключено, не создаётся. У каждой сущности есть краткое описание в
атрибуте `description` (карточка сущности → «Атрибуты»): HA не
поддерживает нативные тултипы, поэтому описание видно там.
## Диагностика и известные ситуации ## Диагностика и известные ситуации
+9 -2
View File
@@ -11,8 +11,9 @@ from homeassistant.helpers import issue_registry as ir
import pyfglair import pyfglair
from .const import CONF_DSN, DOMAIN from .const import CONF_DSN, CONF_FEATURES, DOMAIN
from .coordinator import FglairCoordinator, FglairRuntime, key_issue_id from .coordinator import FglairCoordinator, FglairRuntime, key_issue_id
from .features import FeatureSet, LiveValues
from .fglair_client import FglairClient from .fglair_client import FglairClient
from .overrides import ConversionSet from .overrides import ConversionSet
@@ -80,8 +81,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
finally: finally:
hass.data[DATA_PENDING] -= 1 hass.data[DATA_PENDING] -= 1
features = FeatureSet(
explicit=entry.data.get(CONF_FEATURES),
values=LiveValues(coordinator.client.cached),
)
coordinator.features = features
clients[entry.entry_id] = client clients[entry.entry_id] = client
entry.runtime_data = FglairRuntime(client, coordinator, conversions) entry.runtime_data = FglairRuntime(client, coordinator, conversions, features)
if len(clients) == 1: if len(clients) == 1:
pyfglair.set_log_handler(_log_handler) pyfglair.set_log_handler(_log_handler)
pyfglair.set_log_level(1) pyfglair.set_log_level(1)
+22 -16
View File
@@ -16,10 +16,6 @@ from pyfglair import Prop
from pyfglair.templates import prop_info from pyfglair.templates import prop_info
from .const import ( from .const import (
CAP_FAN,
CAP_HVAC,
CAP_SWING_HORIZONTAL,
CAP_SWING_VERTICAL,
FAN_MODE_TO_VALUE, FAN_MODE_TO_VALUE,
FAN_VALUE_TO_MODE, FAN_VALUE_TO_MODE,
HVAC_MODE_TO_VALUE, HVAC_MODE_TO_VALUE,
@@ -27,7 +23,6 @@ from .const import (
OPERATION_MODE_OFF, OPERATION_MODE_OFF,
OPERATION_MODE_ON, OPERATION_MODE_ON,
PRESET_BOOST, PRESET_BOOST,
PRESET_CAP,
PRESET_ECO, PRESET_ECO,
PRESET_PROP, PRESET_PROP,
SWING_MODES, SWING_MODES,
@@ -41,13 +36,24 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
async_add_entities([FglairClimate(runtime.coordinator)]) async_add_entities([FglairClimate(runtime.coordinator)])
MODE_FEATURE = {
HVACMode.AUTO: "mode_auto",
HVACMode.COOL: "mode_cool",
HVACMode.DRY: "mode_dry",
HVACMode.FAN_ONLY: "mode_fan",
HVACMode.HEAT: "mode_heat",
}
FAN_FEATURE = {mode: f"fan_{mode}" for mode in FAN_VALUE_TO_MODE.values()}
PRESET_FEATURE = {PRESET_ECO: "economy_mode", PRESET_BOOST: "powerful_mode"}
class FglairClimate(FglairEntity, ClimateEntity): class FglairClimate(FglairEntity, ClimateEntity):
_attr_name = None _attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(self, coordinator: FglairCoordinator) -> None: def __init__(self, coordinator: FglairCoordinator) -> None:
# translation_key не нужен: имя = имя устройства (has_entity_name). # translation_key не нужен: имя = имя устройства (has_entity_name).
super().__init__(coordinator, "climate", None) super().__init__(coordinator, "climate", None, description_key="climate")
self._last_hvac_mode: Optional[HVACMode] = None self._last_hvac_mode: Optional[HVACMode] = None
# -- свойства шаблона -------------------------------------------------- # -- свойства шаблона --------------------------------------------------
@@ -56,37 +62,37 @@ class FglairClimate(FglairEntity, ClimateEntity):
def _hvac_modes(self) -> list[HVACMode]: def _hvac_modes(self) -> list[HVACMode]:
modes: list[HVACMode] = [HVACMode.OFF] modes: list[HVACMode] = [HVACMode.OFF]
for mode, bit in CAP_HVAC.items(): for mode, key in MODE_FEATURE.items():
if self.supports_capability(bit): if self.feature_enabled(key):
modes.append(mode) modes.append(mode)
return modes return modes
def _fan_modes(self) -> Optional[list[str]]: def _fan_modes(self) -> Optional[list[str]]:
if not self._in_template(Prop.FAN_SPEED): if not self._in_template(Prop.FAN_SPEED):
return None return None
return [ modes = [
mode mode for mode, key in FAN_FEATURE.items()
for mode, bit in CAP_FAN.items() if self.feature_enabled(key)
if self.supports_capability(bit)
] ]
return modes or None
def _preset_modes(self) -> Optional[list[str]]: def _preset_modes(self) -> Optional[list[str]]:
presets = [ presets = [
preset preset
for preset, bit in PRESET_CAP.items() for preset, key in PRESET_FEATURE.items()
if self._in_template(PRESET_PROP[preset]) if self._in_template(PRESET_PROP[preset])
and self.supports_capability(bit) and self.feature_enabled(key)
] ]
return presets or None return presets or None
def _vertical_swing_supported(self) -> bool: def _vertical_swing_supported(self) -> bool:
return self._in_template(Prop.AF_VERTICAL_SWING) and ( return self._in_template(Prop.AF_VERTICAL_SWING) and (
self.supports_capability(CAP_SWING_VERTICAL) self.feature_enabled("swing_vertical")
) )
def _horizontal_swing_supported(self) -> bool: def _horizontal_swing_supported(self) -> bool:
return self._in_template(Prop.AF_HORIZONTAL_SWING) and ( return self._in_template(Prop.AF_HORIZONTAL_SWING) and (
self.supports_capability(CAP_SWING_HORIZONTAL) self.feature_enabled("swing_horizontal")
) )
# -- ClimateEntity ---------------------------------------------------- # -- ClimateEntity ----------------------------------------------------
+189 -71
View File
@@ -22,14 +22,11 @@ from pyfglair.provision import Device
from pyfglair.templates import detect from pyfglair.templates import detect
from .const import ( from .const import (
CAP_FAN,
CAP_HVAC,
CAP_SWING_HORIZONTAL,
CAP_SWING_VERTICAL,
CONF_CONFIG_JSON, CONF_CONFIG_JSON,
CONF_DEVICE_PORT, CONF_DEVICE_PORT,
CONF_DSN, CONF_DSN,
CONF_EMAIL, CONF_EMAIL,
CONF_FEATURES,
CONF_HOST, CONF_HOST,
CONF_LANIP_KEY, CONF_LANIP_KEY,
CONF_LANIP_KEY_ID, CONF_LANIP_KEY_ID,
@@ -49,6 +46,8 @@ from .const import (
DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_PORT,
DEFAULT_LISTEN_PORT, DEFAULT_LISTEN_PORT,
DOMAIN, DOMAIN,
FAN_VALUE_TO_MODE,
FEATURES,
REGIONS, REGIONS,
TEMP_MAX_TENTHS, TEMP_MAX_TENTHS,
TEMP_MIN_TENTHS, TEMP_MIN_TENTHS,
@@ -56,8 +55,9 @@ from .const import (
TRIAL_ERRORS, TRIAL_ERRORS,
TRIAL_TIMEOUT, TRIAL_TIMEOUT,
) )
from .features import resolve_features
from .overrides import ConversionSet, LinearOverride from .overrides import ConversionSet, LinearOverride
from .trial import TrialResult, trial_connect from .trial import TrialResult, probe_template, trial_connect
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -113,7 +113,24 @@ def _spec_int(spec: dict[str, Any], key: str, default: int) -> int:
return default return default
def _preview_schema(data: dict[str, Any]) -> vol.Schema: def _template_schema(template: Template) -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
["A", "B", "F"]
)
}
)
def _capabilities_schema(defaults: dict[str, bool]) -> vol.Schema:
fields = {}
for key in FEATURES:
fields[vol.Optional(key, default=bool(defaults.get(key)))] = bool
return vol.Schema(fields)
def _limits_schema(data: dict[str, Any]) -> vol.Schema:
template = Template[str(data.get(CONF_TEMPLATE, "A"))] template = Template[str(data.get(CONF_TEMPLATE, "A"))]
overrides = data.get(CONF_OVERRIDES) overrides = data.get(CONF_OVERRIDES)
spec = ( spec = (
@@ -131,9 +148,6 @@ def _preview_schema(data: dict[str, Any]) -> vol.Schema:
step = TEMP_STEP_TENTHS[template] step = TEMP_STEP_TENTHS[template]
return vol.Schema( return vol.Schema(
{ {
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
["A", "B", "F"]
),
vol.Required(CONF_TEMP_MIN, default=temp_min / 10): vol.All( vol.Required(CONF_TEMP_MIN, default=temp_min / 10): vol.All(
vol.Coerce(float), vol.Range(min=-10.0, max=45.0) vol.Coerce(float), vol.Range(min=-10.0, max=45.0)
), ),
@@ -156,44 +170,33 @@ def _preview_schema(data: dict[str, Any]) -> vol.Schema:
) )
def _preview_text( def _device_values_text(properties: dict[Any, Any]) -> str:
template: Template, properties: dict[Any, Any] """Что прочитано с устройства (человекочитаемо) для описаний шагов."""
) -> str:
def raw(prop) -> int | None: def raw(prop) -> int | None:
value = properties.get(prop) value = properties.get(prop)
return value.int_value if value is not None else None return value.int_value if value is not None else None
caps = raw(Prop.DEVICE_CAPABILITIES) lines: list[str] = []
mode = raw(Prop.OPERATION_MODE)
def supported(bit: int) -> bool: if mode is not None:
return caps is None or bool(caps & (1 << bit)) names = {0: "off", 1: "on", 2: "auto", 3: "cool", 4: "dry",
5: "fan", 6: "heat"}
hvac = ["off"] + [ lines.append(f"- режим: {names.get(mode, '?')} ({mode})")
mode.value for mode, bit in CAP_HVAC.items() if supported(bit) fan = raw(Prop.FAN_SPEED)
] if fan is not None:
fan = [mode for mode, bit in CAP_FAN.items() if supported(bit)] lines.append(f"- скорость: {FAN_VALUE_TO_MODE.get(fan, '?')} ({fan})")
tmin, tmax = TEMP_MIN_TENTHS / 10, TEMP_MAX_TENTHS / 10
step = TEMP_STEP_TENTHS[template] / 10
lines = [
f"- HVAC: {', '.join(hvac)}",
f"- Fan: {', '.join(fan) or '-'}",
f"- Setpoint: {tmin:.1f}-{tmax:.1f} °C, step {step:.1f} °C",
]
current = raw(Prop.DISPLAY_TEMPERATURE) current = raw(Prop.DISPLAY_TEMPERATURE)
if current is not None: if current is not None:
lines.append(f"- Current temperature: {current / 10:.1f} °C") lines.append(f"- температура в помещении: {current / 10:.1f} °C")
swings = [] caps = raw(Prop.DEVICE_CAPABILITIES)
if supported(CAP_SWING_VERTICAL):
swings.append("vertical")
if supported(CAP_SWING_HORIZONTAL):
swings.append("horizontal")
if swings:
lines.append(f"- Swing: {', '.join(swings)}")
if caps is not None: if caps is not None:
lines.append(f"- Capabilities: 0x{caps:08X}") lines.append(f"- device_capabilities: 0x{caps:08X}")
lines.append(f"- Template: {template.name}") vnum = raw(Prop.AF_VERTICAL_NUM_DIR)
return "\n".join(lines) hnum = raw(Prop.AF_HORIZONTAL_NUM_DIR)
if vnum is not None or hnum is not None:
lines.append(f"- направлений заслонок: верт. {vnum or 0}, гориз. {hnum or 0}")
return "\n".join(lines) or "- данные не получены"
def _template_name(model: str | None) -> str: def _template_name(model: str | None) -> str:
@@ -263,17 +266,38 @@ def _parse_import(source: str | dict[str, Any]) -> dict[str, Any] | None:
} }
def _run_trial(data: dict[str, Any]) -> TrialResult: def _run_trial(data: dict[str, Any]) -> tuple[TrialResult, Template, bool]:
return trial_connect( """Пробная сессия; для неизвестной модели — предварительный пробинг A/B/F.
Возвращает (результат, шаблон, был_ли_пробинг).
"""
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
model = str(data.get(CONF_MODEL) or "")
probed = False
if not model or detect(model) is None:
detected, _probe_props = probe_template(
data[CONF_HOST],
data[CONF_DSN],
data[CONF_LANIP_KEY],
int(data[CONF_LANIP_KEY_ID]),
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)),
listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
timeout=TRIAL_TIMEOUT,
)
probed = True
if detected is not None:
template = detected
result = trial_connect(
host=data[CONF_HOST], host=data[CONF_HOST],
dsn=data[CONF_DSN], dsn=data[CONF_DSN],
lanip_key=data[CONF_LANIP_KEY], lanip_key=data[CONF_LANIP_KEY],
lanip_key_id=int(data[CONF_LANIP_KEY_ID]), lanip_key_id=int(data[CONF_LANIP_KEY_ID]),
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)), device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)),
template=Template[str(data.get(CONF_TEMPLATE, "A"))], template=template,
listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)), listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
timeout=TRIAL_TIMEOUT, timeout=TRIAL_TIMEOUT,
) )
return result, template, probed
class FglairConfigFlow(ConfigFlow, domain=DOMAIN): class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -285,6 +309,10 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
self._devices: list[Device] = [] self._devices: list[Device] = []
self._pending_data: dict[str, Any] = {} self._pending_data: dict[str, Any] = {}
self._trial_properties: dict[Any, Any] = {} self._trial_properties: dict[Any, Any] = {}
self._trial_answered: set[Prop] = set()
self._resolved_template: Template = Template.A
self._probed: bool = False
self._selected_features: dict[str, bool] = {}
self._reconfigure_entry: ConfigEntry | None = None self._reconfigure_entry: ConfigEntry | None = None
async def async_step_reconfigure( async def async_step_reconfigure(
@@ -391,7 +419,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
} }
error = await self._async_trial(data) error = await self._async_trial(data)
if error is None: if error is None:
return await self.async_step_preview() return await self.async_step_template()
errors["base"] = error errors["base"] = error
schema = vol.Schema( schema = vol.Schema(
{ {
@@ -413,7 +441,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
data = _manual_data(user_input) data = _manual_data(user_input)
error = await self._async_trial(data) error = await self._async_trial(data)
if error is None: if error is None:
return await self.async_step_preview() return await self.async_step_template()
errors["base"] = error errors["base"] = error
if user_input is None and self._reconfigure_entry is not None: if user_input is None and self._reconfigure_entry is not None:
user_input = self._reconfigure_suggestions() user_input = self._reconfigure_suggestions()
@@ -433,7 +461,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
else: else:
error = await self._async_trial(data) error = await self._async_trial(data)
if error is None: if error is None:
return await self.async_step_preview() return await self.async_step_template()
errors["base"] = error errors["base"] = error
return self._show_form("import_json", _import_schema(), errors, user_input) return self._show_form("import_json", _import_schema(), errors, user_input)
@@ -447,16 +475,82 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
error = await self._async_trial(data) error = await self._async_trial(data)
if error is not None: if error is not None:
return self.async_abort(reason=error) return self.async_abort(reason=error)
return self._create_entry(data) entry_data = dict(self._pending_data)
entry_data[CONF_FEATURES] = resolve_features(
self._trial_properties, self._trial_answered
)
return self._create_entry(entry_data)
async def async_step_preview( async def async_step_template(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Шаг 3: превью шаблона и ручные конверсии/диапазон уставки.""" """Страница 1: шаблон (таблица свойств модуля) с пояснениями."""
data = dict(self._pending_data)
if user_input is not None:
template = Template[str(user_input[CONF_TEMPLATE])]
data[CONF_TEMPLATE] = template.name
self._pending_data = data
return await self.async_step_capabilities()
default = self._resolved_template.name
if self._reconfigure_entry is not None:
default = str(data.get(CONF_TEMPLATE, default))
placeholders = {
"device_values": _device_values_text(self._trial_properties),
"detected": default,
"model": str(data.get(CONF_MODEL) or "-"),
}
if self._probed:
placeholders["probe_note"] = (
"Модель модуля неизвестна — шаблон определён проверкой "
"свойств."
)
else:
placeholders["probe_note"] = (
"Шаблон определён по модели модуля."
)
return self.async_show_form(
step_id="template",
data_schema=self.add_suggested_values_to_schema(
_template_schema(Template[default]),
{CONF_TEMPLATE: default},
),
description_placeholders=placeholders,
)
async def async_step_capabilities(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Страница 2: возможности прибора (тумблеры, дефолты с устройства)."""
if user_input is not None:
self._selected_features = {
key: bool(user_input.get(key, False)) for key in FEATURES
}
return await self.async_step_limits()
defaults = resolve_features(
self._trial_properties,
self._trial_answered,
self._reconfigure_defaults(),
)
schema = self.add_suggested_values_to_schema(
_capabilities_schema(defaults),
{key: value for key, value in defaults.items()},
)
return self.async_show_form(
step_id="capabilities",
data_schema=schema,
description_placeholders={
"device_values": _device_values_text(self._trial_properties),
},
)
async def async_step_limits(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Страница 3: диапазон и шаг уставки (при необходимости — конверсия)."""
data = dict(self._pending_data) data = dict(self._pending_data)
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
template = Template[user_input[CONF_TEMPLATE]] template = Template[str(data.get(CONF_TEMPLATE, "A"))]
temp_min = int(round(float(user_input[CONF_TEMP_MIN]) * 10)) temp_min = int(round(float(user_input[CONF_TEMP_MIN]) * 10))
temp_max = int(round(float(user_input[CONF_TEMP_MAX]) * 10)) temp_max = int(round(float(user_input[CONF_TEMP_MAX]) * 10))
step = int(round(float(user_input[CONF_TEMP_STEP]) * 10)) step = int(round(float(user_input[CONF_TEMP_STEP]) * 10))
@@ -482,11 +576,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
or bounds is None or bounds is None
or bounds[0] >= bounds[1] or bounds[0] >= bounds[1]
): ):
# Достижимый диапазон пуст: конверсия выводит таблицу
# шаблона за пределы, которые не пересекаются с min/max.
errors["base"] = "invalid_range" errors["base"] = "invalid_range"
else: else:
data[CONF_TEMPLATE] = template.name
data[CONF_TEMP_STEP] = step data[CONF_TEMP_STEP] = step
data[CONF_OVERRIDES] = { data[CONF_OVERRIDES] = {
Prop.ADJUST_TEMPERATURE.name.lower(): { Prop.ADJUST_TEMPERATURE.name.lower(): {
@@ -497,29 +588,52 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
"max": temp_max, "max": temp_max,
} }
} }
data[CONF_FEATURES] = dict(self._selected_features)
_LOGGER.debug( _LOGGER.debug(
"превью: template=%s range=%d..%d step=%d", "limits: template=%s range=%d..%d step=%d features=%d",
template.name, temp_min, temp_max, step, template.name, temp_min, temp_max, step,
len(self._selected_features),
) )
return self._create_entry(data) return self._create_entry(data)
selected = str(data.get(CONF_TEMPLATE, Template.A.name)) if user_input is None and self._reconfigure_entry is not None:
if user_input and user_input.get(CONF_TEMPLATE) in Template.__members__: user_input = self._reconfigure_limits_suggestions()
selected = str(user_input[CONF_TEMPLATE]) return self._show_form(
if user_input is None: "limits",
user_input = {CONF_TEMPLATE: selected} _limits_schema(data),
return self.async_show_form( errors,
step_id="preview", user_input,
data_schema=self.add_suggested_values_to_schema(
_preview_schema(data), user_input
),
errors=errors,
description_placeholders={
"preview": _preview_text(
Template[selected], self._trial_properties
)
},
) )
def _reconfigure_defaults(self) -> dict[str, bool] | None:
entry = self._reconfigure_entry
if entry is None:
return None
features = entry.data.get(CONF_FEATURES)
return features if isinstance(features, dict) else None
def _reconfigure_limits_suggestions(self) -> dict[str, Any] | None:
entry = self._reconfigure_entry
if entry is None:
return None
spec = (entry.data.get(CONF_OVERRIDES) or {}).get(
Prop.ADJUST_TEMPERATURE.name.lower(), {}
)
if not isinstance(spec, dict):
return None
return {
CONF_TEMP_MIN: _spec_int(spec, "min", TEMP_MIN_TENTHS) / 10,
CONF_TEMP_MAX: _spec_int(spec, "max", TEMP_MAX_TENTHS) / 10,
CONF_TEMP_STEP: int(
entry.data.get(CONF_TEMP_STEP) or TEMP_STEP_TENTHS[
Template[str(entry.data.get(CONF_TEMPLATE, "A"))]
]
)
/ 10,
CONF_TEMP_NUM: _spec_int(spec, "num", 1),
CONF_TEMP_DEN: _spec_int(spec, "den", 1),
CONF_TEMP_OFFSET: _spec_int(spec, "offset", 0),
}
async def _async_trial(self, data: dict[str, Any]) -> str | None: async def _async_trial(self, data: dict[str, Any]) -> str | None:
"""None — сессия поднялась; иначе ключ ошибки формы.""" """None — сессия поднялась; иначе ключ ошибки формы."""
if self._reconfigure_entry is not None: if self._reconfigure_entry is not None:
@@ -532,12 +646,16 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_mismatch() self._abort_if_unique_id_mismatch()
else: else:
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
result: TrialResult = await self.hass.async_add_executor_job( result, template, probed = await self.hass.async_add_executor_job(
partial(_run_trial, data) partial(_run_trial, data)
) )
if result.ok: if result.ok:
self._pending_data = data self._pending_data = data
self._pending_data[CONF_TEMPLATE] = template.name
self._trial_properties = result.properties self._trial_properties = result.properties
self._trial_answered = result.answered
self._resolved_template = template
self._probed = probed
return None return None
_LOGGER.debug( _LOGGER.debug(
"пробное подключение не удалось: reason=%s state=%s error=%s", "пробное подключение не удалось: reason=%s state=%s error=%s",
+126
View File
@@ -24,6 +24,7 @@ CONF_PASSWORD = "password"
CONF_CONFIG_JSON = "config_json" CONF_CONFIG_JSON = "config_json"
CONF_OVERRIDES = "overrides" CONF_OVERRIDES = "overrides"
CONF_TEMP_STEP = "temp_step" CONF_TEMP_STEP = "temp_step"
CONF_FEATURES = "features"
CONF_TEMP_MIN = "temp_min" CONF_TEMP_MIN = "temp_min"
CONF_TEMP_MAX = "temp_max" CONF_TEMP_MAX = "temp_max"
CONF_TEMP_NUM = "temp_num" CONF_TEMP_NUM = "temp_num"
@@ -112,6 +113,131 @@ SWITCH_PROPS: dict[Prop, tuple[str, int | None]] = {
Prop.INDOOR_FAN_CONTROL: ("indoor_fan_control", CAP_INDOOR_FAN_CONTROL), Prop.INDOOR_FAN_CONTROL: ("indoor_fan_control", CAP_INDOOR_FAN_CONTROL),
} }
# ---------------------------------------------------------------------------
# Feature-модель (план: страница capabilities в мастере + фильтрация сущностей).
# key -> (capability bit | None, свойство-источник | None).
# Для битовых фич дефолт = бит в device_capabilities; для небитовых —
# наличие свойства в ответах устройства; для заслонок — num_dir > 1.
# ---------------------------------------------------------------------------
FEATURES: dict[str, tuple[int | None, Prop | None]] = {
"mode_cool": (CAP_HVAC[HVACMode.COOL], Prop.OPERATION_MODE),
"mode_dry": (CAP_HVAC[HVACMode.DRY], Prop.OPERATION_MODE),
"mode_fan": (CAP_HVAC[HVACMode.FAN_ONLY], Prop.OPERATION_MODE),
"mode_heat": (CAP_HVAC[HVACMode.HEAT], Prop.OPERATION_MODE),
"mode_auto": (CAP_HVAC[HVACMode.AUTO], Prop.OPERATION_MODE),
"fan_quiet": (CAP_FAN["quiet"], Prop.FAN_SPEED),
"fan_low": (CAP_FAN["low"], Prop.FAN_SPEED),
"fan_medium": (CAP_FAN["medium"], Prop.FAN_SPEED),
"fan_high": (CAP_FAN["high"], Prop.FAN_SPEED),
"fan_auto": (CAP_FAN["auto"], Prop.FAN_SPEED),
"swing_vertical": (CAP_SWING_VERTICAL, Prop.AF_VERTICAL_SWING),
"swing_horizontal": (CAP_SWING_HORIZONTAL, Prop.AF_HORIZONTAL_SWING),
"economy_mode": (CAP_ECONOMY, Prop.ECONOMY_MODE),
"min_heat": (CAP_MIN_HEAT, Prop.MIN_HEAT),
"indoor_fan_control": (CAP_INDOOR_FAN_CONTROL, Prop.INDOOR_FAN_CONTROL),
"powerful_mode": (CAP_POWERFUL, Prop.POWERFUL_MODE),
"outdoor_low_noise": (CAP_OUTDOOR_LOW_NOISE, Prop.OUTDOOR_LOW_NOISE),
"coil_dry_mode": (CAP_COIL_DRY, Prop.COIL_DRY_MODE),
"display_temperature": (None, Prop.DISPLAY_TEMPERATURE),
"human_det_auto_save": (None, Prop.HUMAN_DET_AUTO_SAVE),
"wifi_led_enable": (None, Prop.WIFI_LED_ENABLE),
"af_vertical_direction": (None, Prop.AF_VERTICAL_DIRECTION),
"af_horizontal_direction": (None, Prop.AF_HORIZONTAL_DIRECTION),
}
# Описания для мастера и для атрибута description сущностей.
FEATURE_DESCRIPTIONS = {
"ru": {
"mode_cool": "Охлаждение",
"mode_dry": "Осушение",
"mode_fan": "Вентиляция (без нагрева/охлаждения)",
"mode_heat": "Обогрев",
"mode_auto": "Автоматический режим",
"fan_auto": "Автоскорость вентилятора",
"fan_quiet": "Тихая скорость",
"fan_low": "Низкая скорость",
"fan_medium": "Средняя скорость",
"fan_high": "Высокая скорость",
"swing_vertical": "Качание заслонки по вертикали",
"swing_horizontal": "Качание заслонки по горизонтали",
"economy_mode": "Экономичный режим",
"min_heat": "Минимальный обогрев (поддержание +10 °C)",
"indoor_fan_control": "Управление вентилятором при работе наружного блока",
"powerful_mode": "Мощный режим (ускоренный выход на режим)",
"outdoor_low_noise": "Тихий наружный блок",
"coil_dry_mode": "Сушка испарителя",
"display_temperature": "Датчик температуры помещения",
"human_det_auto_save": "Автосохранение при отсутствии людей (датчик присутствия)",
"wifi_led_enable": "Индикатор Wi-Fi на модуле",
"af_vertical_direction": "Выбор положения вертикальной заслонки",
"af_horizontal_direction": "Выбор положения горизонтальной заслонки",
},
"en": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic mode",
"fan_auto": "Auto fan speed",
"fan_quiet": "Quiet fan speed",
"fan_low": "Low fan speed",
"fan_medium": "Medium fan speed",
"fan_high": "High fan speed",
"swing_vertical": "Vertical louver swing",
"swing_horizontal": "Horizontal louver swing",
"economy_mode": "Economy mode",
"min_heat": "Minimum heat (keep +10 °C)",
"indoor_fan_control": "Indoor fan control while outdoor unit runs",
"powerful_mode": "Powerful mode",
"outdoor_low_noise": "Outdoor unit low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED indicator",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position",
},
}
ENTITY_DESCRIPTIONS = {
"ru": {
"climate": "Управление кондиционером: режим, скорость, уставка, "
"заслонки, пресеты",
"room_temperature": "Температура воздуха в помещении",
"error_code": "Код ошибки кондиционера (0 — ошибок нет)",
"connection_state": "Состояние связи с модулем кондиционера",
"connectivity": "Есть ли связь с модулем",
"defrost": "Идёт разморозка наружного блока",
"maintenance": "Требуется обслуживание",
"oil_recovery": "Возврат масла в компрессор",
"pump_down": "Откачка хладагента (pump down)",
"check_operation": "Проверочный прогон",
"simultaneous": "Одновременные операции",
"different_modes": "Разморозка/масло/прочие режимы (шаблон B)",
},
"en": {
"climate": "Air conditioner control: mode, fan, setpoint, louvers, presets",
"room_temperature": "Room air temperature",
"error_code": "Air conditioner error code (0 — no errors)",
"connection_state": "Connection state with the AC module",
"connectivity": "Whether the module is reachable",
"defrost": "Outdoor unit defrost in progress",
"maintenance": "Maintenance required",
"oil_recovery": "Compressor oil recovery",
"pump_down": "Refrigerant pump down",
"check_operation": "Check operation",
"simultaneous": "Simultaneous operations",
"different_modes": "Defrost/oil/other modes (template B)",
},
}
def entity_description(language: str, key: str) -> str | None:
lang = "ru" if str(language).lower().startswith("ru") else "en"
return ENTITY_DESCRIPTIONS[lang].get(key) or FEATURE_DESCRIPTIONS[lang].get(
key
)
# op_status, биты (PROTOCOL §8.4): translation_key -> bit # op_status, биты (PROTOCOL §8.4): translation_key -> bit
OP_STATUS_BITS: dict[str, int] = { OP_STATUS_BITS: dict[str, int] = {
"defrost": 24, "defrost": 24,
+42 -3
View File
@@ -12,7 +12,7 @@ from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind
from pyfglair.templates import template_info from pyfglair.templates import prop_info, template_info
from .const import ( from .const import (
AVAILABLE_STATES, AVAILABLE_STATES,
@@ -21,6 +21,7 @@ from .const import (
CONF_NAME, CONF_NAME,
DOMAIN, DOMAIN,
) )
from .features import FeatureSet, LiveValues
from .fglair_client import FglairClient from .fglair_client import FglairClient
from .overrides import ConversionSet from .overrides import ConversionSet
@@ -40,6 +41,7 @@ class FglairRuntime:
client: FglairClient client: FglairClient
coordinator: "FglairCoordinator" coordinator: "FglairCoordinator"
conversions: ConversionSet conversions: ConversionSet
features: "FeatureSet"
def key_issue_id(dsn: str) -> str: def key_issue_id(dsn: str) -> str:
@@ -70,6 +72,9 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
) )
self.client = client self.client = client
self.conversions = conversions self.conversions = conversions
self.features = FeatureSet(
explicit=None, values=LiveValues(client.cached)
)
self.entry_id = entry_id self.entry_id = entry_id
self.key_issue_id = key_issue_id(client.data[CONF_DSN]) self.key_issue_id = key_issue_id(client.data[CONF_DSN])
self._synced = False self._synced = False
@@ -118,8 +123,19 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
# После разрыва при следующем ONLINE нужна повторная синхронизация. # После разрыва при следующем ONLINE нужна повторная синхронизация.
self._synced = False self._synced = False
async def async_sync_now(self) -> None: async def async_sync_now(self, timeout: float = 2.0) -> None:
"""Гарантирует первичный GET-батч (если уже online).""" """Гарантирует первичный GET-батч и ожидание ключевых свойств.
Ждёт ONLINE (ограниченно), затем — завершения синхронизации, чтобы
сущности создавались с корректными feature-дефолтами.
"""
deadline = self.hass.loop.time() + timeout
while (
self._sync_task is None
and self.client.state != State.ONLINE
and self.hass.loop.time() < deadline
):
await asyncio.sleep(0.05)
if self._sync_task is not None: if self._sync_task is not None:
await self._sync_task await self._sync_task
elif self.client.state == State.ONLINE and not self._synced: elif self.client.state == State.ONLINE and not self._synced:
@@ -169,12 +185,35 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
async def _async_initial_sync(self) -> None: async def _async_initial_sync(self) -> None:
try: try:
await self.hass.async_add_executor_job(self._initial_sync) await self.hass.async_add_executor_job(self._initial_sync)
await self._async_wait_first_props()
self._synced = True self._synced = True
except Exception: # pragma: no cover - защитный путь except Exception: # pragma: no cover - защитный путь
_LOGGER.exception("первичная синхронизация свойств не удалась") _LOGGER.exception("первичная синхронизация свойств не удалась")
finally: finally:
self._sync_task = None self._sync_task = None
async def _async_wait_first_props(self, timeout: float = 3.0) -> None:
"""Ждёт ключевые свойства (caps/режим/num_dir), чтобы сущности
создавались с корректными feature-дефолтами."""
template = self.client.template
wanted = [
Prop.OPERATION_MODE,
Prop.DEVICE_CAPABILITIES,
Prop.AF_VERTICAL_NUM_DIR,
Prop.AF_HORIZONTAL_NUM_DIR,
Prop.DISPLAY_TEMPERATURE,
Prop.HUMAN_DET_AUTO_SAVE,
Prop.WIFI_LED_ENABLE,
]
wanted = [
prop for prop in wanted if prop_info(template, prop) is not None
]
deadline = self.hass.loop.time() + timeout
while self.hass.loop.time() < deadline:
if all(self.client.cached(prop) is not None for prop in wanted):
return
await asyncio.sleep(0.05)
def _initial_sync(self) -> None: def _initial_sync(self) -> None:
self.client.batch_begin() self.client.batch_begin()
for prop in _writable_props(self.client.template): for prop in _writable_props(self.client.template):
+21 -16
View File
@@ -8,7 +8,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from pyfglair import Prop, Value from pyfglair import Prop, Value
from .const import CONF_DSN, CONF_MODEL, CONF_NAME, DOMAIN from .const import (
CONF_DSN,
CONF_MODEL,
CONF_NAME,
DOMAIN,
entity_description,
)
from .coordinator import FglairCoordinator from .coordinator import FglairCoordinator
@@ -22,6 +28,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
coordinator: FglairCoordinator, coordinator: FglairCoordinator,
unique_suffix: str, unique_suffix: str,
translation_key: Optional[str], translation_key: Optional[str],
description_key: Optional[str] = None,
) -> None: ) -> None:
super().__init__(coordinator) super().__init__(coordinator)
data = coordinator.client.data data = coordinator.client.data
@@ -29,6 +36,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
self._attr_unique_id = f"{dsn}_{unique_suffix}" self._attr_unique_id = f"{dsn}_{unique_suffix}"
if translation_key is not None: if translation_key is not None:
self._attr_translation_key = translation_key self._attr_translation_key = translation_key
self._description_key = description_key or translation_key
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, dsn)}, identifiers={(DOMAIN, dsn)},
name=data.get(CONF_NAME) or dsn, name=data.get(CONF_NAME) or dsn,
@@ -40,6 +48,18 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
def available(self) -> bool: def available(self) -> bool:
return self.coordinator.data.available and super().available return self.coordinator.data.available and super().available
@property
def extra_state_attributes(self) -> Optional[dict]:
"""HA не поддерживает тултипы сущностей: краткое описание видно в
атрибутах (More info → Атрибуты)."""
if self._description_key is None:
return None
text = entity_description(self.hass.config.language, self._description_key)
return {"description": text} if text else None
def feature_enabled(self, key: str) -> bool:
return self.coordinator.features.enabled(key)
def value(self, prop: Prop) -> Optional[Value]: def value(self, prop: Prop) -> Optional[Value]:
return self.coordinator.data.values.get(prop) return self.coordinator.data.values.get(prop)
@@ -54,19 +74,4 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
return None return None
return self.coordinator.conversions.to_display(prop, raw) return self.coordinator.conversions.to_display(prop, raw)
def capabilities(self) -> Optional[int]:
return self.raw_value(Prop.DEVICE_CAPABILITIES)
def capability(self, bit: int) -> Optional[bool]:
caps = self.capabilities()
if not caps:
# 0/None: возможностей ещё не сообщили — не фильтруем.
return None
return bool(caps & (1 << bit))
def supports_capability(self, bit: Optional[int]) -> bool:
"""None — считать поддержанным (capabilities ещё не пришли)."""
if bit is None:
return True
cap = self.capability(bit)
return True if cap is None else cap
+118
View File
@@ -0,0 +1,118 @@
"""Feature-модель: какие возможности реально есть у прибора.
Дефолты берутся из `device_capabilities` и фактических ответов устройства,
пользователь может переопределить их в мастере (и в reconfigure); результат
хранится в entry.data[CONF_FEATURES] и фильтрует создание сущностей.
"""
from __future__ import annotations
from typing import Any, Mapping, Optional
from pyfglair import Prop, Value
from .const import FEATURES
DIRECTION_KEYS = {
"af_vertical_direction": ("af_vertical_num_dir", Prop.AF_VERTICAL_NUM_DIR),
"af_horizontal_direction": (
"af_horizontal_num_dir",
Prop.AF_HORIZONTAL_NUM_DIR,
),
}
def _int_value(values: Mapping[Prop, Value], prop: Prop) -> Optional[int]:
value = values.get(prop)
return value.int_value if value is not None else None
def _caps(values: Mapping[Prop, Value]) -> Optional[int]:
raw = _int_value(values, Prop.DEVICE_CAPABILITIES)
return raw if raw else None
def device_default(
key: str,
values: Mapping[Prop, Value],
answered: Optional[set[Prop]] = None,
) -> bool:
"""Дефолт фичи по данным устройства (без пользовательских override)."""
bit, prop = FEATURES[key]
if bit is not None:
caps = _caps(values)
if caps is None:
return True # возможностей не сообщили — не режем
return bool(caps & (1 << bit))
if key in DIRECTION_KEYS:
num = _int_value(values, DIRECTION_KEYS[key][1])
return bool(num and num > 1)
if prop is not None:
if answered is not None:
return prop in answered
return prop in values
return True
def resolve_features(
values: Mapping[Prop, Value],
answered: Optional[set[Prop]] = None,
explicit: Optional[Mapping[str, Any]] = None,
) -> dict[str, bool]:
"""Полный набор фич: дефолт с устройства + пользовательские значения."""
resolved = {
key: bool(value)
for key, value in (
explicit or {}
).items()
if key in FEATURES
}
for key in FEATURES:
resolved.setdefault(key, device_default(key, values, answered))
return resolved
class LiveValues(Mapping):
"""Mapping поверх client.cached(prop) — значения кэша ядра «на сейчас».
Используется вместо снимка coordinator.data, чтобы фильтрация сущностей
не зависела от гонки с обработкой push-событий в loop.
"""
def __init__(self, getter) -> None:
self._getter = getter
def __getitem__(self, prop: Prop) -> Value:
value = self._getter(prop)
if value is None:
raise KeyError(prop)
return value
def __iter__(self):
return iter(())
def __len__(self) -> int:
return 0
class FeatureSet:
"""Фильтр создания сущностей для runtime (entry + live-значения ядра)."""
def __init__(
self,
explicit: Optional[Mapping[str, Any]],
values: Mapping[Prop, Value],
) -> None:
self._explicit = {
key: bool(value)
for key, value in (explicit or {}).items()
if key in FEATURES
}
self._values = values
def enabled(self, key: str) -> bool:
if key in self._explicit:
return self._explicit[key]
return device_default(key, self._values)
def as_dict(self) -> dict[str, bool]:
return dict(self._explicit)
+4 -2
View File
@@ -48,7 +48,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
entities = [] entities = []
if ( if (
prop_info(template, Prop.AF_VERTICAL_DIRECTION) is not None coordinator.features.enabled("af_vertical_direction")
and prop_info(template, Prop.AF_VERTICAL_DIRECTION) is not None
and prop_info(template, Prop.AF_VERTICAL_NUM_DIR) is not None and prop_info(template, Prop.AF_VERTICAL_NUM_DIR) is not None
): ):
entities.append( entities.append(
@@ -60,7 +61,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
) )
) )
if ( if (
prop_info(template, Prop.AF_HORIZONTAL_DIRECTION) is not None coordinator.features.enabled("af_horizontal_direction")
and prop_info(template, Prop.AF_HORIZONTAL_DIRECTION) is not None
and prop_info(template, Prop.AF_HORIZONTAL_NUM_DIR) is not None and prop_info(template, Prop.AF_HORIZONTAL_NUM_DIR) is not None
): ):
entities.append( entities.append(
+3 -1
View File
@@ -71,7 +71,9 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
props = {info.prop for info in template_info(template)} props = {info.prop for info in template_info(template)}
entities = [FglairConnectionState(coordinator)] entities = [FglairConnectionState(coordinator)]
if Prop.DISPLAY_TEMPERATURE in props: if Prop.DISPLAY_TEMPERATURE in props and runtime.features.enabled(
"display_temperature"
):
entities.append(FglairRoomTemperature(coordinator)) entities.append(FglairRoomTemperature(coordinator))
if Prop.ERROR_CODE in props: if Prop.ERROR_CODE in props:
entities.append(FglairErrorCode(coordinator)) entities.append(FglairErrorCode(coordinator))
+69 -6
View File
@@ -52,11 +52,74 @@
"config_json": "Paste a JSON object produced by fglair-discover or fglctl" "config_json": "Paste a JSON object produced by fglair-discover or fglctl"
} }
}, },
"preview": { "template": {
"title": "Template preview", "title": "Module template",
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.", "description": "Read from the device:\n\n{device_values}\n\n{probe_note} The template defines the module's property table (names, types, conversions); actual features are tuned on the next step.\n\nIf unsure, keep the default.",
"data": {
"template": "Template"
},
"data_description": {
"template": "A — AP-WA/AP-WC/AP-WD modules: room temperature sensor, louver directions and swing, all flags. B — AP-WB modules: no room temperature sensor, no louver directions, 1.0 °C setpoint step. F — like A but for AP-WF modules (monitor1, filter_sign_reset)."
}
},
"capabilities": {
"title": "Device capabilities",
"description": "Toggles are pre-set from the device:\n\n{device_values}\n\nDisable what the device cannot do or what you do not want to expose. Entities are created only for enabled features.\n\nIf unsure, keep as is.",
"data": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic",
"fan_quiet": "Quiet fan",
"fan_low": "Low fan",
"fan_medium": "Medium fan",
"fan_high": "High fan",
"fan_auto": "Auto fan",
"swing_vertical": "Vertical swing",
"swing_horizontal": "Horizontal swing",
"economy_mode": "Economy",
"min_heat": "Minimum heat",
"indoor_fan_control": "Indoor fan control",
"powerful_mode": "Powerful",
"outdoor_low_noise": "Outdoor low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
},
"data_description": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic mode",
"fan_auto": "Auto fan speed",
"fan_quiet": "Quiet fan speed",
"fan_low": "Low fan speed",
"fan_medium": "Medium fan speed",
"fan_high": "High fan speed",
"swing_vertical": "Vertical louver swing",
"swing_horizontal": "Horizontal louver swing",
"economy_mode": "Economy mode",
"min_heat": "Minimum heat (keep +10 °C)",
"indoor_fan_control": "Indoor fan control while outdoor unit runs",
"powerful_mode": "Powerful mode",
"outdoor_low_noise": "Outdoor unit low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED indicator",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
}
},
"limits": {
"title": "Setpoint (range and step)",
"description": "Range and step are auto-detected (A/F step 0.5 °C, B — 1.0). Optionally enable a custom linear conversion (API units are 0.1 °C).\n\nIf unsure, keep as is.",
"data": { "data": {
"template": "Template",
"temp_min": "Minimum setpoint (°C)", "temp_min": "Minimum setpoint (°C)",
"temp_max": "Maximum setpoint (°C)", "temp_max": "Maximum setpoint (°C)",
"temp_step": "Setpoint step (°C)", "temp_step": "Setpoint step (°C)",
@@ -66,8 +129,8 @@
}, },
"data_description": { "data_description": {
"temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)", "temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)",
"temp_den": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)", "temp_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)" "temp_offset": "display = raw × num / den + offset"
} }
} }
}, },
+2 -13
View File
@@ -40,21 +40,10 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
template = runtime.client.template template = runtime.client.template
entities = [] entities = []
for prop, (key, capability) in SWITCH_PROPS.items(): for prop, (key, _capability) in SWITCH_PROPS.items():
if prop_info(template, prop) is None: if prop_info(template, prop) is None:
continue continue
if not _capability_supported(coordinator, capability): if not coordinator.features.enabled(key):
continue continue
entities.append(FglairSwitch(coordinator, prop, key)) entities.append(FglairSwitch(coordinator, prop, key))
async_add_entities(entities) async_add_entities(entities)
def _capability_supported(
coordinator: FglairCoordinator, bit: int | None
) -> bool:
if bit is None:
return True
caps = coordinator.data.values.get(Prop.DEVICE_CAPABILITIES)
if caps is None or not caps.int_value:
return True
return bool(caps.int_value & (1 << bit))
+69 -6
View File
@@ -52,11 +52,74 @@
"config_json": "Paste a JSON object produced by fglair-discover or fglctl" "config_json": "Paste a JSON object produced by fglair-discover or fglctl"
} }
}, },
"preview": { "template": {
"title": "Template preview", "title": "Module template",
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.", "description": "Read from the device:\n\n{device_values}\n\n{probe_note} The template defines the module's property table (names, types, conversions); actual features are tuned on the next step.\n\nIf unsure, keep the default.",
"data": {
"template": "Template"
},
"data_description": {
"template": "A — AP-WA/AP-WC/AP-WD modules: room temperature sensor, louver directions and swing, all flags. B — AP-WB modules: no room temperature sensor, no louver directions, 1.0 °C setpoint step. F — like A but for AP-WF modules (monitor1, filter_sign_reset)."
}
},
"capabilities": {
"title": "Device capabilities",
"description": "Toggles are pre-set from the device:\n\n{device_values}\n\nDisable what the device cannot do or what you do not want to expose. Entities are created only for enabled features.\n\nIf unsure, keep as is.",
"data": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic",
"fan_quiet": "Quiet fan",
"fan_low": "Low fan",
"fan_medium": "Medium fan",
"fan_high": "High fan",
"fan_auto": "Auto fan",
"swing_vertical": "Vertical swing",
"swing_horizontal": "Horizontal swing",
"economy_mode": "Economy",
"min_heat": "Minimum heat",
"indoor_fan_control": "Indoor fan control",
"powerful_mode": "Powerful",
"outdoor_low_noise": "Outdoor low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
},
"data_description": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic mode",
"fan_auto": "Auto fan speed",
"fan_quiet": "Quiet fan speed",
"fan_low": "Low fan speed",
"fan_medium": "Medium fan speed",
"fan_high": "High fan speed",
"swing_vertical": "Vertical louver swing",
"swing_horizontal": "Horizontal louver swing",
"economy_mode": "Economy mode",
"min_heat": "Minimum heat (keep +10 °C)",
"indoor_fan_control": "Indoor fan control while outdoor unit runs",
"powerful_mode": "Powerful mode",
"outdoor_low_noise": "Outdoor unit low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED indicator",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
}
},
"limits": {
"title": "Setpoint (range and step)",
"description": "Range and step are auto-detected (A/F step 0.5 °C, B — 1.0). Optionally enable a custom linear conversion (API units are 0.1 °C).\n\nIf unsure, keep as is.",
"data": { "data": {
"template": "Template",
"temp_min": "Minimum setpoint (°C)", "temp_min": "Minimum setpoint (°C)",
"temp_max": "Maximum setpoint (°C)", "temp_max": "Maximum setpoint (°C)",
"temp_step": "Setpoint step (°C)", "temp_step": "Setpoint step (°C)",
@@ -66,8 +129,8 @@
}, },
"data_description": { "data_description": {
"temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)", "temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)",
"temp_den": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)", "temp_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)" "temp_offset": "display = raw × num / den + offset"
} }
} }
}, },
+69 -6
View File
@@ -52,11 +52,74 @@
"config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl" "config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl"
} }
}, },
"preview": { "template": {
"title": "Превью шаблона", "title": "Шаблон модуля",
"description": "Рассчитанные значения (сверьте с приложением FGLair):\n\n{preview}\n\nДиапазон уставки и конверсию можно поправить ниже.", "description": "С устройства прочитано:\n\n{device_values}\n\n{probe_note} Шаблон задаёт таблицу свойств модуля (имена, типы, конверсии); фактические возможности прибора уточняются на следующем шаге.\n\nЕсли не уверены — оставьте значение по умолчанию.",
"data": {
"template": "Шаблон"
},
"data_description": {
"template": "A — модули AP-WA/AP-WC/AP-WD: есть датчик температуры помещения, направления и swing заслонок, все флаги. B — модули AP-WB: без датчика температуры помещения и без направлений заслонок, шаг уставки 1,0 °C. F — как A, но для модулей AP-WF (monitor1, filter_sign_reset)."
}
},
"capabilities": {
"title": "Возможности прибора",
"description": "Тумблеры выставлены по данным устройства:\n\n{device_values}\n\nОтключите то, что прибор не умеет или что не нужно показывать. Сущности создаются только для включённых возможностей.\n\nЕсли не уверены — оставьте как есть.",
"data": {
"mode_cool": "Охлаждение",
"mode_dry": "Осушение",
"mode_fan": "Вентиляция",
"mode_heat": "Обогрев",
"mode_auto": "Автоматический режим",
"fan_quiet": "Тихая скорость",
"fan_low": "Низкая скорость",
"fan_medium": "Средняя скорость",
"fan_high": "Высокая скорость",
"fan_auto": "Автоскорость",
"swing_vertical": "Swing вертикальный",
"swing_horizontal": "Swing горизонтальный",
"economy_mode": "Экономичный режим",
"min_heat": "Минимальный обогрев",
"indoor_fan_control": "Управление вентилятором",
"powerful_mode": "Мощный режим",
"outdoor_low_noise": "Тихий наружный блок",
"coil_dry_mode": "Сушка испарителя",
"display_temperature": "Датчик температуры помещения",
"human_det_auto_save": "Автосохранение (датчик присутствия)",
"wifi_led_enable": "Wi-Fi индикатор",
"af_vertical_direction": "Заслонка вертикальная",
"af_horizontal_direction": "Заслонка горизонтальная"
},
"data_description": {
"mode_cool": "Охлаждение",
"mode_dry": "Осушение",
"mode_fan": "Вентиляция (без нагрева/охлаждения)",
"mode_heat": "Обогрев",
"mode_auto": "Автоматический режим",
"fan_auto": "Автоскорость вентилятора",
"fan_quiet": "Тихая скорость",
"fan_low": "Низкая скорость",
"fan_medium": "Средняя скорость",
"fan_high": "Высокая скорость",
"swing_vertical": "Качание заслонки по вертикали",
"swing_horizontal": "Качание заслонки по горизонтали",
"economy_mode": "Экономичный режим",
"min_heat": "Минимальный обогрев (поддержание +10 °C)",
"indoor_fan_control": "Управление вентилятором при работе наружного блока",
"powerful_mode": "Мощный режим (ускоренный выход на режим)",
"outdoor_low_noise": "Тихий наружный блок",
"coil_dry_mode": "Сушка испарителя",
"display_temperature": "Датчик температуры помещения",
"human_det_auto_save": "Автосохранение при отсутствии людей (датчик присутствия)",
"wifi_led_enable": "Индикатор Wi-Fi на модуле",
"af_vertical_direction": "Выбор положения вертикальной заслонки",
"af_horizontal_direction": "Выбор положения горизонтальной заслонки"
}
},
"limits": {
"title": "Уставка (диапазон и шаг)",
"description": "Диапазон/шаг заданы автоматически (у A/F шаг 0,5 °C, у B — 1,0). При необходимости включите ручную конверсию коэффициентами (единицы API — 0,1 °C).\n\nЕсли не уверены — оставьте как есть.",
"data": { "data": {
"template": "Шаблон",
"temp_min": "Минимум уставки (°C)", "temp_min": "Минимум уставки (°C)",
"temp_max": "Максимум уставки (°C)", "temp_max": "Максимум уставки (°C)",
"temp_step": "Шаг уставки (°C)", "temp_step": "Шаг уставки (°C)",
@@ -66,8 +129,8 @@
}, },
"data_description": { "data_description": {
"temp_num": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)", "temp_num": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)",
"temp_den": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)", "temp_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)" "temp_offset": "display = raw × num / den + offset"
} }
} }
}, },
+119 -8
View File
@@ -1,5 +1,10 @@
"""Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение """Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение
базовых свойств. Выполняется в executor (блокирующие вызовы ядра). базовых свойств и зондирование необязательных (presence).
Выполняется в executor (блокирующие вызовы ядра). Модуль отвечает только на
свойства из своей таблицы: отсутствие push = свойства нет (проверено на
AP-WC1E). Поэтому необязательные свойства не валят trial, а фиксируются в
``answered`` и используются как дефолты фич и для определения шаблона.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,12 +16,48 @@ from typing import Iterable
from pyfglair import Config, Error, Prop, Session, State, Template, Value from pyfglair import Config, Error, Prop, Session, State, Template, Value
from pyfglair.templates import template_info from pyfglair.templates import template_info
BASE_PROPS: tuple[Prop, ...] = ( # Обязательные свойства: есть во всех шаблонах, должны ответить.
REQUIRED_PROPS: tuple[Prop, ...] = (
Prop.OPERATION_MODE, Prop.OPERATION_MODE,
Prop.FAN_SPEED, Prop.FAN_SPEED,
Prop.DEVICE_CAPABILITIES,
Prop.DISPLAY_TEMPERATURE, Prop.DISPLAY_TEMPERATURE,
) )
# Зондируемые свойства (в пределах выбранного шаблона): наличие в ответе —
# информация для дефолтов фич и распознавания шаблона.
OPTIONAL_PROPS: tuple[Prop, ...] = (
Prop.AF_VERTICAL_DIRECTION,
Prop.AF_HORIZONTAL_DIRECTION,
Prop.AF_VERTICAL_NUM_DIR,
Prop.AF_HORIZONTAL_NUM_DIR,
Prop.AF_VERTICAL_SWING,
Prop.AF_HORIZONTAL_SWING,
Prop.HUMAN_DET_AUTO_SAVE,
Prop.WIFI_LED_ENABLE,
Prop.FILTER_SIGN_RESET_DISPLAY,
Prop.MONITOR1,
)
# Подписи, по которым распознаётся шаблон при неизвестной oem_model:
# B отвечает move_step1, F — monitor1/filter_sign_reset (у A их нет).
PROBE_PROPS: dict[Template, tuple[Prop, ...]] = {
Template.A: (
Prop.AF_VERTICAL_DIRECTION,
Prop.DISPLAY_TEMPERATURE,
Prop.FILTER_SIGN_RESET_DISPLAY,
),
Template.B: (
Prop.AF_VERTICAL_MOVE_STEP1,
Prop.AF_HORIZONTAL_MOVE_STEP1,
),
Template.F: (
Prop.MONITOR1,
Prop.FILTER_SIGN_RESET,
Prop.AF_VERTICAL_DIRECTION,
),
}
@dataclass @dataclass
class TrialResult: class TrialResult:
@@ -25,6 +66,7 @@ class TrialResult:
state: State = State.IDLE state: State = State.IDLE
error: Error = Error.NONE error: Error = Error.NONE
properties: dict[Prop, Value] = field(default_factory=dict) properties: dict[Prop, Value] = field(default_factory=dict)
answered: set[Prop] = field(default_factory=set)
def _failure_reason(state: State, error: Error) -> str: def _failure_reason(state: State, error: Error) -> str:
@@ -37,6 +79,24 @@ def _failure_reason(state: State, error: Error) -> str:
return "timeout" return "timeout"
def classify_template(answered: set[Prop]) -> Template | None:
"""Шаблон по ответам зондов; None — недостаточно данных."""
if Prop.AF_VERTICAL_MOVE_STEP1 in answered and (
Prop.AF_VERTICAL_DIRECTION not in answered
):
return Template.B
if Prop.MONITOR1 in answered or Prop.FILTER_SIGN_RESET in answered:
if Prop.AF_VERTICAL_DIRECTION in answered:
return Template.F
if (
Prop.AF_VERTICAL_DIRECTION in answered
or Prop.DISPLAY_TEMPERATURE in answered
or Prop.FILTER_SIGN_RESET_DISPLAY in answered
):
return Template.A
return None
def trial_connect( def trial_connect(
host: str, host: str,
dsn: str, dsn: str,
@@ -48,9 +108,12 @@ def trial_connect(
listen_port: int = 10275, listen_port: int = 10275,
keepalive_ms: int = 15000, keepalive_ms: int = 15000,
timeout: float = 10.0, timeout: float = 10.0,
props: Iterable[Prop] = BASE_PROPS, props: Iterable[Prop] = REQUIRED_PROPS,
optional_props: Iterable[Prop] = OPTIONAL_PROPS,
props_timeout: float = 3.0,
) -> TrialResult: ) -> TrialResult:
"""Поднимает сессию, ждёт ONLINE и читает базовые свойства. """Поднимает сессию, ждёт ONLINE, читает обязательные и зондирует
необязательные свойства.
Никогда не бросает: любая неудача — ``TrialResult(ok=False, reason=...)``. Никогда не бросает: любая неудача — ``TrialResult(ok=False, reason=...)``.
""" """
@@ -59,6 +122,9 @@ def trial_connect(
values: dict[Prop, Value] = {} values: dict[Prop, Value] = {}
valid = {info.prop for info in template_info(template)} valid = {info.prop for info in template_info(template)}
wanted = tuple(prop for prop in props if prop in valid) wanted = tuple(prop for prop in props if prop in valid)
optional = tuple(
prop for prop in optional_props if prop in valid and prop not in wanted
)
def on_state(state: State, error: Error) -> None: def on_state(state: State, error: Error) -> None:
with lock: with lock:
@@ -115,12 +181,15 @@ def trial_connect(
state=state, error=error, state=state, error=error,
) )
if wanted: requested = wanted + optional
if requested:
session.batch_begin() session.batch_begin()
for prop in wanted: for prop in requested:
session.get_prop(prop) session.get_prop(prop)
session.batch_commit() session.batch_commit()
prop_deadline = min(deadline, time.monotonic() + 2.0) prop_deadline = min(
deadline, time.monotonic() + props_timeout
)
while time.monotonic() < prop_deadline: while time.monotonic() < prop_deadline:
with lock: with lock:
if all(prop in values for prop in wanted): if all(prop in values for prop in wanted):
@@ -136,7 +205,49 @@ def trial_connect(
reason="properties_missing", reason="properties_missing",
state=State.ONLINE, state=State.ONLINE,
properties=snapshot, properties=snapshot,
answered=set(snapshot),
) )
return TrialResult(ok=True, state=State.ONLINE, properties=snapshot) return TrialResult(
ok=True,
state=State.ONLINE,
properties=snapshot,
answered=set(snapshot),
)
finally: finally:
session.close() session.close()
def probe_template(
host: str,
dsn: str,
lanip_key: str,
lanip_key_id: int,
*,
device_port: int = 80,
listen_port: int = 10275,
timeout: float = 10.0,
) -> tuple[Template | None, dict[Prop, Value]]:
"""Определяет шаблон по ответам, перебирая A/B/F короткими сессиями.
Используется только когда oem_model неизвестна ядру: для известных
моделей шаблон берётся из C-таблиц без лишних подключений.
"""
answered: set[Prop] = set()
properties: dict[Prop, Value] = {}
for template in (Template.A, Template.B, Template.F):
result = trial_connect(
host, dsn, lanip_key, lanip_key_id,
device_port=device_port, template=template,
listen_port=listen_port, timeout=timeout,
props=PROBE_PROPS[template] + (Prop.OPERATION_MODE,),
optional_props=(),
props_timeout=2.0,
)
if not result.ok:
continue
answered |= result.answered
properties.update(result.properties)
detected = classify_template(answered)
if detected is not None:
return detected, properties
return classify_template(answered), properties
+6 -2
View File
@@ -99,8 +99,12 @@ Repair (теоретический): `key_error` → «Ключ устройст
display_temperature; sensors: room temp, error_code, op_status-флаги; display_temperature; sensors: room temp, error_code, op_status-флаги;
switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/ switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/
human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки, human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки,
demand_control; binary_sensor: connectivity). capabilities фильтруют demand_control; binary_sensor: connectivity). capabilities и выбранные в мастере
режимы/пресеты. Записи — один `batch_commit()` на действие пользователя. feature-тумблеры фильтруют сущности (`features.py`,
`entry.data["features"]`): то, что прибор не умеет или отключено
пользователем, не создаётся. Записи — один `batch_commit()` на действие
пользователя. У сущностей есть атрибут `description` (HA не поддерживает
тултипы).
Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована
(§10) — сущность не создаётся до уточнения; в H3 вместо неё (§10) — сущность не создаётся до уточнения; в H3 вместо неё
диагностический sensor состояния связи. диагностический sensor состояния связи.
+129 -49
View File
@@ -14,7 +14,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
import pyfglair import pyfglair
from custom_components.fglair.const import DOMAIN from custom_components.fglair.const import DOMAIN
from custom_components.fglair.trial import TrialResult from custom_components.fglair.trial import TrialResult
from pyfglair import Prop, Value, ValueKind from pyfglair import Prop, Template, Value, ValueKind
from pyfglair.provision import Device from pyfglair.provision import Device
MANUAL_INPUT = { MANUAL_INPUT = {
@@ -27,8 +27,7 @@ MANUAL_INPUT = {
"model": "AP-WC1E", "model": "AP-WC1E",
} }
PREVIEW_INPUT = { LIMITS_INPUT = {
"template": "A",
"temp_min": 16.0, "temp_min": 16.0,
"temp_max": 30.0, "temp_max": 30.0,
"temp_step": 0.5, "temp_step": 0.5,
@@ -37,8 +36,20 @@ PREVIEW_INPUT = {
"temp_offset": 0, "temp_offset": 0,
} }
TRIAL_OK = TrialResult(ok=True) TRIAL_OK = (TrialResult(ok=True), Template.A, False)
TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable") TRIAL_NETWORK_FAIL = (
TrialResult(ok=False, reason="unreachable"),
Template.A,
False,
)
def _trial_ok(properties=None):
return (
TrialResult(ok=True, properties=properties or {}),
Template.A,
False,
)
async def _menu(hass: HomeAssistant): async def _menu(hass: HomeAssistant):
@@ -57,6 +68,22 @@ async def _configure(hass: HomeAssistant, flow_id: str, user_input: dict):
return await hass.config_entries.flow.async_configure(flow_id, user_input) return await hass.config_entries.flow.async_configure(flow_id, user_input)
async def _pages(
hass: HomeAssistant,
flow_id: str,
*,
template: str = "A",
caps: dict | None = None,
limits: dict | None = None,
):
"""Проходит страницы template → capabilities → limits."""
result = await _configure(hass, flow_id, {"template": template})
assert result["step_id"] == "capabilities", result
result = await _configure(hass, flow_id, caps or {})
assert result["step_id"] == "limits", result
return await _configure(hass, flow_id, limits or LIMITS_INPUT)
def _suggested(schema, field: str): def _suggested(schema, field: str):
for key in schema.schema: for key in schema.schema:
if getattr(key, "schema", None) == field: if getattr(key, "schema", None) == field:
@@ -81,9 +108,8 @@ async def test_manual_success(hass: HomeAssistant):
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
assert result["type"] == "form" assert result["type"] == "form"
result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["type"] == "form" assert result["step_id"] == "template"
assert result["step_id"] == "preview" result = await _pages(hass, result["flow_id"])
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["title"] == "Bedroom" assert result["title"] == "Bedroom"
assert result["result"].unique_id == DSN assert result["result"].unique_id == DSN
@@ -98,11 +124,13 @@ async def test_manual_success(hass: HomeAssistant):
"min": 160, "min": 160,
"max": 300, "max": 300,
} }
assert isinstance(data["features"], dict)
assert data["features"]["mode_heat"] is True
trial.assert_called_once() trial.assert_called_once()
assert trial.call_args.args[0]["host"] == "192.168.1.50" assert trial.call_args.args[0]["host"] == "192.168.1.50"
async def test_preview_template_change_and_conversion(hass: HomeAssistant): async def test_template_change_and_conversion(hass: HomeAssistant):
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK, return_value=TRIAL_OK,
@@ -110,17 +138,20 @@ async def test_preview_template_change_and_conversion(hass: HomeAssistant):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
preview_input = { result = await _pages(
**PREVIEW_INPUT, hass,
"template": "B", result["flow_id"],
"temp_min": 17.0, template="B",
"temp_max": 28.0, limits={
"temp_step": 1.0, **LIMITS_INPUT,
"temp_num": 2, "temp_min": 17.0,
"temp_den": 4, "temp_max": 28.0,
"temp_offset": 5, "temp_step": 1.0,
} "temp_num": 2,
result = await _configure(hass, result["flow_id"], preview_input) "temp_den": 4,
"temp_offset": 5,
},
)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["data"]["template"] == "B" assert result["data"]["template"] == "B"
assert result["data"]["temp_step"] == 10 assert result["data"]["temp_step"] == 10
@@ -133,7 +164,7 @@ async def test_preview_template_change_and_conversion(hass: HomeAssistant):
} }
async def test_preview_invalid_range(hass: HomeAssistant): async def test_limits_invalid_range(hass: HomeAssistant):
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK, return_value=TRIAL_OK,
@@ -141,24 +172,23 @@ async def test_preview_invalid_range(hass: HomeAssistant):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {"template": "B"}
)
result = await _configure(hass, result["flow_id"], {})
assert result["step_id"] == "limits"
result = await _configure( result = await _configure(
hass, hass,
result["flow_id"], result["flow_id"],
{ {**LIMITS_INPUT, "temp_min": 30.0, "temp_max": 16.0},
**PREVIEW_INPUT,
"template": "B",
"temp_min": 30.0,
"temp_max": 16.0,
},
) )
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_range"} assert result["errors"] == {"base": "invalid_range"}
assert "Template: B" in result["description_placeholders"]["preview"] result = await _configure(hass, result["flow_id"], LIMITS_INPUT)
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
async def test_preview_unreachable_range(hass: HomeAssistant): async def test_limits_unreachable_range(hass: HomeAssistant):
"""Конверсия выводит таблицу за min/max — пустой достижимый диапазон.""" """Конверсия выводит таблицу за min/max — пустой достижимый диапазон."""
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "custom_components.fglair.config_flow._run_trial",
@@ -167,11 +197,15 @@ async def test_preview_unreachable_range(hass: HomeAssistant):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {"template": "A"}
)
result = await _configure(hass, result["flow_id"], {})
result = await _configure( result = await _configure(
hass, hass,
result["flow_id"], result["flow_id"],
{ {
**PREVIEW_INPUT, **LIMITS_INPUT,
"temp_min": 30.0, "temp_min": 30.0,
"temp_max": 40.0, "temp_max": 40.0,
"temp_num": 1, "temp_num": 1,
@@ -182,22 +216,37 @@ async def test_preview_unreachable_range(hass: HomeAssistant):
assert result["errors"] == {"base": "invalid_range"} assert result["errors"] == {"base": "invalid_range"}
async def test_preview_contains_trial_values(hass: HomeAssistant): async def test_template_device_values(hass: HomeAssistant):
props = { props = {
Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200), Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200),
Prop.OPERATION_MODE: Value(ValueKind.INT, 3),
Prop.FAN_SPEED: Value(ValueKind.INT, 4),
Prop.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011), Prop.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011),
Prop.AF_VERTICAL_NUM_DIR: Value(ValueKind.INT, 5),
} }
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "custom_components.fglair.config_flow._run_trial",
return_value=TrialResult(ok=True, properties=props), return_value=(
TrialResult(ok=True, properties=props),
Template.A,
False,
),
): ):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
preview = result["description_placeholders"]["preview"] assert result["step_id"] == "template"
assert "20.0" in preview values = result["description_placeholders"]["device_values"]
assert "0x0000001B" in preview assert "20.0" in values
assert "cool" in preview and "heat" in preview assert "0x0000001B" in values
assert "cool" in values
assert "направлений заслонок: верт. 5" in values
# capabilities: дефолты из маски, swing выключен
result = await _configure(hass, result["flow_id"], {"template": "A"})
assert result["step_id"] == "capabilities"
assert _suggested(result["data_schema"], "swing_vertical") is False
assert _suggested(result["data_schema"], "mode_cool") is True
assert _suggested(result["data_schema"], "af_vertical_direction") is True
async def test_manual_connect_error(hass: HomeAssistant): async def test_manual_connect_error(hass: HomeAssistant):
@@ -215,7 +264,11 @@ async def test_manual_connect_error(hass: HomeAssistant):
async def test_manual_key_mismatch_error(hass: HomeAssistant): async def test_manual_key_mismatch_error(hass: HomeAssistant):
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "custom_components.fglair.config_flow._run_trial",
return_value=TrialResult(ok=False, reason="key_mismatch"), return_value=(
TrialResult(ok=False, reason="key_mismatch"),
Template.A,
False,
),
): ):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
@@ -265,7 +318,7 @@ async def test_import_json_success(hass: HomeAssistant):
) )
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK, return_value=(TrialResult(ok=True), Template.B, False),
): ):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json") result = await _select(hass, result["flow_id"], "import_json")
@@ -273,11 +326,9 @@ async def test_import_json_success(hass: HomeAssistant):
result = await _configure( result = await _configure(
hass, result["flow_id"], {"config_json": payload} hass, result["flow_id"], {"config_json": payload}
) )
assert result["step_id"] == "preview" assert result["step_id"] == "template"
assert _suggested(result["data_schema"], "template") == "B" assert _suggested(result["data_schema"], "template") == "B"
result = await _configure( result = await _pages(hass, result["flow_id"], template="B")
hass, result["flow_id"], {**PREVIEW_INPUT, "template": "B"}
)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.60" assert result["data"]["host"] == "192.168.1.60"
assert result["data"]["template"] == "B" assert result["data"]["template"] == "B"
@@ -319,8 +370,8 @@ async def test_cloud_flow_success(hass: HomeAssistant):
) )
assert result["step_id"] == "cloud_device" assert result["step_id"] == "cloud_device"
result = await _configure(hass, result["flow_id"], {"dsn": DSN}) result = await _configure(hass, result["flow_id"], {"dsn": DSN})
assert result["step_id"] == "preview" assert result["step_id"] == "template"
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT) result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.50" assert result["data"]["host"] == "192.168.1.50"
assert result["data"]["lanip_key_id"] == KEY_ID assert result["data"]["lanip_key_id"] == KEY_ID
@@ -374,7 +425,7 @@ async def test_yaml_import(hass: HomeAssistant):
} }
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK, return_value=(TrialResult(ok=True), Template.F, False),
): ):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload})
await hass.async_block_till_done() await hass.async_block_till_done()
@@ -382,6 +433,7 @@ async def test_yaml_import(hass: HomeAssistant):
assert len(entries) == 1 assert len(entries) == 1
assert entries[0].data["template"] == "F" assert entries[0].data["template"] == "F"
assert entries[0].data["host"] == "192.168.1.70" assert entries[0].data["host"] == "192.168.1.70"
assert isinstance(entries[0].data["features"], dict)
async def test_yaml_import_invalid(hass: HomeAssistant): async def test_yaml_import_invalid(hass: HomeAssistant):
@@ -410,7 +462,8 @@ async def test_import_json_null_device_port(hass: HomeAssistant):
result = await _configure( result = await _configure(
hass, result["flow_id"], {"config_json": payload} hass, result["flow_id"], {"config_json": payload}
) )
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT) assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["data"]["device_port"] == 80 assert result["data"]["device_port"] == 80
assert trial.call_args.args[0]["device_port"] == 80 assert trial.call_args.args[0]["device_port"] == 80
@@ -448,8 +501,8 @@ async def test_reconfigure_success(hass: HomeAssistant, mock_ac, socket_enabled)
result = await _configure( result = await _configure(
hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"} hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"}
) )
assert result["step_id"] == "preview" assert result["step_id"] == "template"
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT) result = await _pages(hass, result["flow_id"])
assert result["type"] == "abort" assert result["type"] == "abort"
assert result["reason"] == "reconfigure_successful" assert result["reason"] == "reconfigure_successful"
await hass.async_block_till_done() await hass.async_block_till_done()
@@ -520,3 +573,30 @@ async def test_options_flow_new_key_reloads(
assert entry.state.name in ("LOADED", "SETUP_RETRY") assert entry.state.name in ("LOADED", "SETUP_RETRY")
if entry.state.name == "LOADED": if entry.state.name == "LOADED":
assert await hass.config_entries.async_unload(entry.entry_id) assert await hass.config_entries.async_unload(entry.entry_id)
async def test_run_trial_probes_unknown_model(hass: HomeAssistant):
"""Без oem_model шаблон определяется пробингом A/B/F."""
from custom_components.fglair import config_flow as cf
with patch.object(
cf, "probe_template", return_value=(Template.B, {})
) as probe, patch.object(
cf, "trial_connect", return_value=TrialResult(ok=True)
):
result = await hass.async_add_executor_job(
cf._run_trial,
{
"host": "127.0.0.1",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"model": "",
"template": "A",
},
)
trial_result, template, probed = result
assert trial_result.ok
assert template is Template.B
assert probed is True
probe.assert_called_once()
+1
View File
@@ -77,6 +77,7 @@ async def test_entities_snapshot(hass: HomeAssistant, mock_ac, socket_enabled):
) )
climate = hass.states.get(climate_id) climate = hass.states.get(climate_id)
assert climate.attributes["description"]
assert climate.attributes["current_temperature"] == 20.0 assert climate.attributes["current_temperature"] == 20.0
assert climate.attributes["temperature"] == 22.0 assert climate.attributes["temperature"] == 22.0
assert climate.attributes["fan_mode"] == "auto" assert climate.attributes["fan_mode"] == "auto"
+105
View File
@@ -0,0 +1,105 @@
"""Дефолты feature-модели: device_capabilities, num_dir, presence."""
from __future__ import annotations
from custom_components.fglair.features import (
FeatureSet,
device_default,
resolve_features,
)
from pyfglair import Prop, Template, Value, ValueKind
def _value(value: int) -> Value:
return Value(ValueKind.INT, value)
def test_caps_defaults_ap_wc1e():
values = {Prop.DEVICE_CAPABILITIES: _value(5119)} # 0x13FF
assert device_default("mode_cool", values) is True
assert device_default("mode_heat", values) is True
assert device_default("fan_auto", values) is True
assert device_default("swing_vertical", values) is False
assert device_default("swing_horizontal", values) is False
assert device_default("economy_mode", values) is True
assert device_default("powerful_mode", values) is False
assert device_default("min_heat", values) is False
assert device_default("coil_dry_mode", values) is False
def test_caps_unknown_keeps_everything():
assert device_default("swing_vertical", {}) is True
assert device_default(
"min_heat", {Prop.DEVICE_CAPABILITIES: _value(0)}
) is True
def test_directions_need_num_dir():
zero = {Prop.AF_VERTICAL_NUM_DIR: _value(0)}
five = {Prop.AF_VERTICAL_NUM_DIR: _value(5)}
assert device_default("af_vertical_direction", zero) is False
assert device_default("af_vertical_direction", five) is True
assert device_default("af_vertical_direction", {}) is False
assert device_default("af_horizontal_direction", zero) is False
def test_presence_defaults():
values = {Prop.DISPLAY_TEMPERATURE: _value(200)}
assert device_default("display_temperature", values) is True
assert device_default("wifi_led_enable", values) is False
assert (
device_default(
"display_temperature", {}, {Prop.DISPLAY_TEMPERATURE}
)
is True
)
assert device_default("wifi_led_enable", {}, set()) is False
def test_resolve_and_featureset_override():
values = {Prop.DEVICE_CAPABILITIES: _value(5119)}
resolved = resolve_features(values, set(), {"swing_vertical": True})
assert resolved["swing_vertical"] is True
assert resolved["powerful_mode"] is False
assert len(resolved) == len(
{key for key in resolved}
)
feature_set = FeatureSet({"swing_vertical": True}, values)
assert feature_set.enabled("swing_vertical") is True
assert feature_set.enabled("powerful_mode") is False
default_set = FeatureSet(None, values)
assert default_set.enabled("economy_mode") is True
assert default_set.enabled("swing_vertical") is False
def test_classify_template():
from custom_components.fglair.trial import classify_template
assert classify_template(
{Prop.AF_VERTICAL_MOVE_STEP1}
) is Template.B
assert classify_template(
{Prop.MONITOR1, Prop.AF_VERTICAL_DIRECTION}
) is Template.F
assert classify_template(
{Prop.AF_VERTICAL_DIRECTION}
) is Template.A
assert classify_template({Prop.DISPLAY_TEMPERATURE}) is Template.A
assert classify_template(set()) is None
def test_resolved_features_for_ap_wc1e_without_sensor():
"""Канальный блок: датчик комнаты есть, ламелей/swing нет."""
values = {
Prop.DEVICE_CAPABILITIES: _value(5119),
Prop.AF_VERTICAL_NUM_DIR: _value(0),
Prop.AF_HORIZONTAL_NUM_DIR: _value(0),
Prop.DISPLAY_TEMPERATURE: _value(205),
}
resolved = resolve_features(values, set(values))
assert resolved["display_temperature"] is True
assert resolved["swing_vertical"] is False
assert resolved["af_vertical_direction"] is False
assert resolved["af_horizontal_direction"] is False
assert resolved["mode_cool"] is True
assert resolved["economy_mode"] is True
assert resolved["powerful_mode"] is False
+10 -3
View File
@@ -58,7 +58,7 @@ async def test_reconfigure_fixes_key_error(
): ):
"""Repair-путь целиком: bad key_id → issue → reconfigure → online → issue снят.""" """Repair-путь целиком: bad key_id → issue → reconfigure → online → issue снят."""
from homeassistant.config_entries import SOURCE_RECONFIGURE from homeassistant.config_entries import SOURCE_RECONFIGURE
from test_config_flow import MANUAL_INPUT, PREVIEW_INPUT from test_config_flow import LIMITS_INPUT, MANUAL_INPUT
proc = mock_ac( proc = mock_ac(
["--set", "operation_mode=6"], key_id=KEY_ID + 1 ["--set", "operation_mode=6"], key_id=KEY_ID + 1
@@ -91,9 +91,16 @@ async def test_reconfigure_fixes_key_error(
"lanip_key_id": KEY_ID + 1, "lanip_key_id": KEY_ID + 1,
}, },
) )
assert result["step_id"] == "preview", result.get("errors") assert result["step_id"] == "template", result.get("errors")
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], PREVIEW_INPUT result["flow_id"], {"template": "A"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {}
)
assert result["step_id"] == "limits"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], LIMITS_INPUT
) )
assert result["type"] == "abort" assert result["type"] == "abort"
assert result["reason"] == "reconfigure_successful" assert result["reason"] == "reconfigure_successful"