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:
@@ -72,15 +72,22 @@ Assistant. Убедитесь, что `pyfglair` установлен в python-
|
||||
|
||||
*Должно быть видно: список с одним устройством (имя, модель, host).*
|
||||
|
||||
6. **Проверка шаблона с превью**: форма-предпросмотр рассчитанных
|
||||
значений (режимы, диапазон температур, пример конверсии) и, при
|
||||
необходимости, ручные коэффициенты/диапазон уставки. Сверьте с
|
||||
приложением FGLair и подтвердите.
|
||||
6. **Три страницы настройки** (все значения уже подставлены по данным
|
||||
устройства — можно просто нажимать «Далее»):
|
||||
1. **Шаблон модуля** — A/B/F с пояснениями; определяется по модели
|
||||
модуля, а для неизвестных моделей — проверкой свойств. Рядом
|
||||
показаны прочитанные с устройства значения.
|
||||
2. **Возможности прибора** — тумблеры по «человекочитаемым» фичам
|
||||
(режимы, скорости, swing, пресеты, флаги, заслонки) с описаниями;
|
||||
включены/выключены по `device_capabilities` и ответам устройства.
|
||||
Сущности создаются только для включённых возможностей.
|
||||
3. **Уставка** — диапазон/шаг (у A/F 16–30 °C шаг 0.5, у B шаг 1.0) и,
|
||||
при необходимости, ручная конверсия коэффициентами.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: таблица превью — режимы, диапазон 16–30 °C, шаг,
|
||||
текущая температура, capabilities, кнопки Submit.*
|
||||
*Должно быть видно: страница «Возможности прибора» с тумблерами и
|
||||
описаниями, часть выключена по данным устройства.*
|
||||
|
||||
7. **Готово**: карточка устройства с сущностями climate/switch/select/
|
||||
sensor/binary_sensor.
|
||||
@@ -121,8 +128,11 @@ lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl
|
||||
| sensor | температура в помещении, код ошибки, диагностическое состояние связи |
|
||||
| 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 не
|
||||
поддерживает нативные тултипы, поэтому описание видно там.
|
||||
|
||||
## Диагностика и известные ситуации
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
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 .features import FeatureSet, LiveValues
|
||||
from .fglair_client import FglairClient
|
||||
from .overrides import ConversionSet
|
||||
|
||||
@@ -80,8 +81,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
finally:
|
||||
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
|
||||
entry.runtime_data = FglairRuntime(client, coordinator, conversions)
|
||||
entry.runtime_data = FglairRuntime(client, coordinator, conversions, features)
|
||||
if len(clients) == 1:
|
||||
pyfglair.set_log_handler(_log_handler)
|
||||
pyfglair.set_log_level(1)
|
||||
|
||||
@@ -16,10 +16,6 @@ from pyfglair import Prop
|
||||
from pyfglair.templates import prop_info
|
||||
|
||||
from .const import (
|
||||
CAP_FAN,
|
||||
CAP_HVAC,
|
||||
CAP_SWING_HORIZONTAL,
|
||||
CAP_SWING_VERTICAL,
|
||||
FAN_MODE_TO_VALUE,
|
||||
FAN_VALUE_TO_MODE,
|
||||
HVAC_MODE_TO_VALUE,
|
||||
@@ -27,7 +23,6 @@ from .const import (
|
||||
OPERATION_MODE_OFF,
|
||||
OPERATION_MODE_ON,
|
||||
PRESET_BOOST,
|
||||
PRESET_CAP,
|
||||
PRESET_ECO,
|
||||
PRESET_PROP,
|
||||
SWING_MODES,
|
||||
@@ -41,13 +36,24 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
|
||||
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):
|
||||
_attr_name = None
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(self, coordinator: FglairCoordinator) -> None:
|
||||
# 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
|
||||
|
||||
# -- свойства шаблона --------------------------------------------------
|
||||
@@ -56,37 +62,37 @@ class FglairClimate(FglairEntity, ClimateEntity):
|
||||
|
||||
def _hvac_modes(self) -> list[HVACMode]:
|
||||
modes: list[HVACMode] = [HVACMode.OFF]
|
||||
for mode, bit in CAP_HVAC.items():
|
||||
if self.supports_capability(bit):
|
||||
for mode, key in MODE_FEATURE.items():
|
||||
if self.feature_enabled(key):
|
||||
modes.append(mode)
|
||||
return modes
|
||||
|
||||
def _fan_modes(self) -> Optional[list[str]]:
|
||||
if not self._in_template(Prop.FAN_SPEED):
|
||||
return None
|
||||
return [
|
||||
mode
|
||||
for mode, bit in CAP_FAN.items()
|
||||
if self.supports_capability(bit)
|
||||
modes = [
|
||||
mode for mode, key in FAN_FEATURE.items()
|
||||
if self.feature_enabled(key)
|
||||
]
|
||||
return modes or None
|
||||
|
||||
def _preset_modes(self) -> Optional[list[str]]:
|
||||
presets = [
|
||||
preset
|
||||
for preset, bit in PRESET_CAP.items()
|
||||
for preset, key in PRESET_FEATURE.items()
|
||||
if self._in_template(PRESET_PROP[preset])
|
||||
and self.supports_capability(bit)
|
||||
and self.feature_enabled(key)
|
||||
]
|
||||
return presets or None
|
||||
|
||||
def _vertical_swing_supported(self) -> bool:
|
||||
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:
|
||||
return self._in_template(Prop.AF_HORIZONTAL_SWING) and (
|
||||
self.supports_capability(CAP_SWING_HORIZONTAL)
|
||||
self.feature_enabled("swing_horizontal")
|
||||
)
|
||||
|
||||
# -- ClimateEntity ----------------------------------------------------
|
||||
|
||||
@@ -22,14 +22,11 @@ from pyfglair.provision import Device
|
||||
from pyfglair.templates import detect
|
||||
|
||||
from .const import (
|
||||
CAP_FAN,
|
||||
CAP_HVAC,
|
||||
CAP_SWING_HORIZONTAL,
|
||||
CAP_SWING_VERTICAL,
|
||||
CONF_CONFIG_JSON,
|
||||
CONF_DEVICE_PORT,
|
||||
CONF_DSN,
|
||||
CONF_EMAIL,
|
||||
CONF_FEATURES,
|
||||
CONF_HOST,
|
||||
CONF_LANIP_KEY,
|
||||
CONF_LANIP_KEY_ID,
|
||||
@@ -49,6 +46,8 @@ from .const import (
|
||||
DEFAULT_DEVICE_PORT,
|
||||
DEFAULT_LISTEN_PORT,
|
||||
DOMAIN,
|
||||
FAN_VALUE_TO_MODE,
|
||||
FEATURES,
|
||||
REGIONS,
|
||||
TEMP_MAX_TENTHS,
|
||||
TEMP_MIN_TENTHS,
|
||||
@@ -56,8 +55,9 @@ from .const import (
|
||||
TRIAL_ERRORS,
|
||||
TRIAL_TIMEOUT,
|
||||
)
|
||||
from .features import resolve_features
|
||||
from .overrides import ConversionSet, LinearOverride
|
||||
from .trial import TrialResult, trial_connect
|
||||
from .trial import TrialResult, probe_template, trial_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -113,7 +113,24 @@ def _spec_int(spec: dict[str, Any], key: str, default: int) -> int:
|
||||
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"))]
|
||||
overrides = data.get(CONF_OVERRIDES)
|
||||
spec = (
|
||||
@@ -131,9 +148,6 @@ def _preview_schema(data: dict[str, Any]) -> vol.Schema:
|
||||
step = TEMP_STEP_TENTHS[template]
|
||||
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.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(
|
||||
template: Template, properties: dict[Any, Any]
|
||||
) -> str:
|
||||
def _device_values_text(properties: dict[Any, Any]) -> str:
|
||||
"""Что прочитано с устройства (человекочитаемо) для описаний шагов."""
|
||||
|
||||
def raw(prop) -> int | None:
|
||||
value = properties.get(prop)
|
||||
return value.int_value if value is not None else None
|
||||
|
||||
caps = raw(Prop.DEVICE_CAPABILITIES)
|
||||
|
||||
def supported(bit: int) -> bool:
|
||||
return caps is None or bool(caps & (1 << bit))
|
||||
|
||||
hvac = ["off"] + [
|
||||
mode.value for mode, bit in CAP_HVAC.items() if supported(bit)
|
||||
]
|
||||
fan = [mode for mode, bit in CAP_FAN.items() if supported(bit)]
|
||||
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",
|
||||
]
|
||||
lines: list[str] = []
|
||||
mode = raw(Prop.OPERATION_MODE)
|
||||
if mode is not None:
|
||||
names = {0: "off", 1: "on", 2: "auto", 3: "cool", 4: "dry",
|
||||
5: "fan", 6: "heat"}
|
||||
lines.append(f"- режим: {names.get(mode, '?')} ({mode})")
|
||||
fan = raw(Prop.FAN_SPEED)
|
||||
if fan is not None:
|
||||
lines.append(f"- скорость: {FAN_VALUE_TO_MODE.get(fan, '?')} ({fan})")
|
||||
current = raw(Prop.DISPLAY_TEMPERATURE)
|
||||
if current is not None:
|
||||
lines.append(f"- Current temperature: {current / 10:.1f} °C")
|
||||
swings = []
|
||||
if supported(CAP_SWING_VERTICAL):
|
||||
swings.append("vertical")
|
||||
if supported(CAP_SWING_HORIZONTAL):
|
||||
swings.append("horizontal")
|
||||
if swings:
|
||||
lines.append(f"- Swing: {', '.join(swings)}")
|
||||
lines.append(f"- температура в помещении: {current / 10:.1f} °C")
|
||||
caps = raw(Prop.DEVICE_CAPABILITIES)
|
||||
if caps is not None:
|
||||
lines.append(f"- Capabilities: 0x{caps:08X}")
|
||||
lines.append(f"- Template: {template.name}")
|
||||
return "\n".join(lines)
|
||||
lines.append(f"- device_capabilities: 0x{caps:08X}")
|
||||
vnum = raw(Prop.AF_VERTICAL_NUM_DIR)
|
||||
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:
|
||||
@@ -263,17 +266,38 @@ def _parse_import(source: str | dict[str, Any]) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
def _run_trial(data: dict[str, Any]) -> TrialResult:
|
||||
return trial_connect(
|
||||
def _run_trial(data: dict[str, Any]) -> tuple[TrialResult, Template, bool]:
|
||||
"""Пробная сессия; для неизвестной модели — предварительный пробинг 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],
|
||||
dsn=data[CONF_DSN],
|
||||
lanip_key=data[CONF_LANIP_KEY],
|
||||
lanip_key_id=int(data[CONF_LANIP_KEY_ID]),
|
||||
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)),
|
||||
template=Template[str(data.get(CONF_TEMPLATE, "A"))],
|
||||
template=template,
|
||||
listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
|
||||
timeout=TRIAL_TIMEOUT,
|
||||
)
|
||||
return result, template, probed
|
||||
|
||||
|
||||
class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -285,6 +309,10 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._devices: list[Device] = []
|
||||
self._pending_data: dict[str, 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
|
||||
|
||||
async def async_step_reconfigure(
|
||||
@@ -391,7 +419,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
error = await self._async_trial(data)
|
||||
if error is None:
|
||||
return await self.async_step_preview()
|
||||
return await self.async_step_template()
|
||||
errors["base"] = error
|
||||
schema = vol.Schema(
|
||||
{
|
||||
@@ -413,7 +441,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data = _manual_data(user_input)
|
||||
error = await self._async_trial(data)
|
||||
if error is None:
|
||||
return await self.async_step_preview()
|
||||
return await self.async_step_template()
|
||||
errors["base"] = error
|
||||
if user_input is None and self._reconfigure_entry is not None:
|
||||
user_input = self._reconfigure_suggestions()
|
||||
@@ -433,7 +461,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
error = await self._async_trial(data)
|
||||
if error is None:
|
||||
return await self.async_step_preview()
|
||||
return await self.async_step_template()
|
||||
errors["base"] = error
|
||||
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)
|
||||
if error is not None:
|
||||
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
|
||||
) -> 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)
|
||||
errors: dict[str, str] = {}
|
||||
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_max = int(round(float(user_input[CONF_TEMP_MAX]) * 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[0] >= bounds[1]
|
||||
):
|
||||
# Достижимый диапазон пуст: конверсия выводит таблицу
|
||||
# шаблона за пределы, которые не пересекаются с min/max.
|
||||
errors["base"] = "invalid_range"
|
||||
else:
|
||||
data[CONF_TEMPLATE] = template.name
|
||||
data[CONF_TEMP_STEP] = step
|
||||
data[CONF_OVERRIDES] = {
|
||||
Prop.ADJUST_TEMPERATURE.name.lower(): {
|
||||
@@ -497,28 +588,51 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"max": temp_max,
|
||||
}
|
||||
}
|
||||
data[CONF_FEATURES] = dict(self._selected_features)
|
||||
_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,
|
||||
len(self._selected_features),
|
||||
)
|
||||
return self._create_entry(data)
|
||||
selected = str(data.get(CONF_TEMPLATE, Template.A.name))
|
||||
if user_input and user_input.get(CONF_TEMPLATE) in Template.__members__:
|
||||
selected = str(user_input[CONF_TEMPLATE])
|
||||
if user_input is None:
|
||||
user_input = {CONF_TEMPLATE: selected}
|
||||
return self.async_show_form(
|
||||
step_id="preview",
|
||||
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
|
||||
if user_input is None and self._reconfigure_entry is not None:
|
||||
user_input = self._reconfigure_limits_suggestions()
|
||||
return self._show_form(
|
||||
"limits",
|
||||
_limits_schema(data),
|
||||
errors,
|
||||
user_input,
|
||||
)
|
||||
},
|
||||
|
||||
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:
|
||||
"""None — сессия поднялась; иначе ключ ошибки формы."""
|
||||
@@ -532,12 +646,16 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_mismatch()
|
||||
else:
|
||||
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)
|
||||
)
|
||||
if result.ok:
|
||||
self._pending_data = data
|
||||
self._pending_data[CONF_TEMPLATE] = template.name
|
||||
self._trial_properties = result.properties
|
||||
self._trial_answered = result.answered
|
||||
self._resolved_template = template
|
||||
self._probed = probed
|
||||
return None
|
||||
_LOGGER.debug(
|
||||
"пробное подключение не удалось: reason=%s state=%s error=%s",
|
||||
|
||||
@@ -24,6 +24,7 @@ CONF_PASSWORD = "password"
|
||||
CONF_CONFIG_JSON = "config_json"
|
||||
CONF_OVERRIDES = "overrides"
|
||||
CONF_TEMP_STEP = "temp_step"
|
||||
CONF_FEATURES = "features"
|
||||
CONF_TEMP_MIN = "temp_min"
|
||||
CONF_TEMP_MAX = "temp_max"
|
||||
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),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_BITS: dict[str, int] = {
|
||||
"defrost": 24,
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
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 (
|
||||
AVAILABLE_STATES,
|
||||
@@ -21,6 +21,7 @@ from .const import (
|
||||
CONF_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
from .features import FeatureSet, LiveValues
|
||||
from .fglair_client import FglairClient
|
||||
from .overrides import ConversionSet
|
||||
|
||||
@@ -40,6 +41,7 @@ class FglairRuntime:
|
||||
client: FglairClient
|
||||
coordinator: "FglairCoordinator"
|
||||
conversions: ConversionSet
|
||||
features: "FeatureSet"
|
||||
|
||||
|
||||
def key_issue_id(dsn: str) -> str:
|
||||
@@ -70,6 +72,9 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
|
||||
)
|
||||
self.client = client
|
||||
self.conversions = conversions
|
||||
self.features = FeatureSet(
|
||||
explicit=None, values=LiveValues(client.cached)
|
||||
)
|
||||
self.entry_id = entry_id
|
||||
self.key_issue_id = key_issue_id(client.data[CONF_DSN])
|
||||
self._synced = False
|
||||
@@ -118,8 +123,19 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
|
||||
# После разрыва при следующем ONLINE нужна повторная синхронизация.
|
||||
self._synced = False
|
||||
|
||||
async def async_sync_now(self) -> None:
|
||||
"""Гарантирует первичный GET-батч (если уже online)."""
|
||||
async def async_sync_now(self, timeout: float = 2.0) -> None:
|
||||
"""Гарантирует первичный 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:
|
||||
await self._sync_task
|
||||
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:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self._initial_sync)
|
||||
await self._async_wait_first_props()
|
||||
self._synced = True
|
||||
except Exception: # pragma: no cover - защитный путь
|
||||
_LOGGER.exception("первичная синхронизация свойств не удалась")
|
||||
finally:
|
||||
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:
|
||||
self.client.batch_begin()
|
||||
for prop in _writable_props(self.client.template):
|
||||
|
||||
@@ -8,7 +8,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -22,6 +28,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
|
||||
coordinator: FglairCoordinator,
|
||||
unique_suffix: str,
|
||||
translation_key: Optional[str],
|
||||
description_key: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
data = coordinator.client.data
|
||||
@@ -29,6 +36,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
|
||||
self._attr_unique_id = f"{dsn}_{unique_suffix}"
|
||||
if translation_key is not None:
|
||||
self._attr_translation_key = translation_key
|
||||
self._description_key = description_key or translation_key
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, dsn)},
|
||||
name=data.get(CONF_NAME) or dsn,
|
||||
@@ -40,6 +48,18 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
|
||||
def available(self) -> bool:
|
||||
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]:
|
||||
return self.coordinator.data.values.get(prop)
|
||||
|
||||
@@ -54,19 +74,4 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
|
||||
return None
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
@@ -48,7 +48,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
|
||||
|
||||
entities = []
|
||||
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
|
||||
):
|
||||
entities.append(
|
||||
@@ -60,7 +61,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
|
||||
)
|
||||
)
|
||||
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
|
||||
):
|
||||
entities.append(
|
||||
|
||||
@@ -71,7 +71,9 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
|
||||
props = {info.prop for info in template_info(template)}
|
||||
|
||||
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))
|
||||
if Prop.ERROR_CODE in props:
|
||||
entities.append(FglairErrorCode(coordinator))
|
||||
|
||||
@@ -52,11 +52,74 @@
|
||||
"config_json": "Paste a JSON object produced by fglair-discover or fglctl"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"title": "Template preview",
|
||||
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.",
|
||||
"template": {
|
||||
"title": "Module template",
|
||||
"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": {
|
||||
"template": "Template",
|
||||
"temp_min": "Minimum setpoint (°C)",
|
||||
"temp_max": "Maximum setpoint (°C)",
|
||||
"temp_step": "Setpoint step (°C)",
|
||||
@@ -66,8 +129,8 @@
|
||||
},
|
||||
"data_description": {
|
||||
"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_offset": "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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -40,21 +40,10 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
|
||||
template = runtime.client.template
|
||||
|
||||
entities = []
|
||||
for prop, (key, capability) in SWITCH_PROPS.items():
|
||||
for prop, (key, _capability) in SWITCH_PROPS.items():
|
||||
if prop_info(template, prop) is None:
|
||||
continue
|
||||
if not _capability_supported(coordinator, capability):
|
||||
if not coordinator.features.enabled(key):
|
||||
continue
|
||||
entities.append(FglairSwitch(coordinator, prop, key))
|
||||
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))
|
||||
|
||||
@@ -52,11 +52,74 @@
|
||||
"config_json": "Paste a JSON object produced by fglair-discover or fglctl"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"title": "Template preview",
|
||||
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.",
|
||||
"template": {
|
||||
"title": "Module template",
|
||||
"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": {
|
||||
"template": "Template",
|
||||
"temp_min": "Minimum setpoint (°C)",
|
||||
"temp_max": "Maximum setpoint (°C)",
|
||||
"temp_step": "Setpoint step (°C)",
|
||||
@@ -66,8 +129,8 @@
|
||||
},
|
||||
"data_description": {
|
||||
"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_offset": "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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -52,11 +52,74 @@
|
||||
"config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"title": "Превью шаблона",
|
||||
"description": "Рассчитанные значения (сверьте с приложением FGLair):\n\n{preview}\n\nДиапазон уставки и конверсию можно поправить ниже.",
|
||||
"template": {
|
||||
"title": "Шаблон модуля",
|
||||
"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": {
|
||||
"template": "Шаблон",
|
||||
"temp_min": "Минимум уставки (°C)",
|
||||
"temp_max": "Максимум уставки (°C)",
|
||||
"temp_step": "Шаг уставки (°C)",
|
||||
@@ -66,8 +129,8 @@
|
||||
},
|
||||
"data_description": {
|
||||
"temp_num": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)",
|
||||
"temp_den": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)",
|
||||
"temp_offset": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)"
|
||||
"temp_den": "display = raw × num / den + offset",
|
||||
"temp_offset": "display = raw × num / den + offset"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение
|
||||
базовых свойств. Выполняется в executor (блокирующие вызовы ядра).
|
||||
базовых свойств и зондирование необязательных (presence).
|
||||
|
||||
Выполняется в executor (блокирующие вызовы ядра). Модуль отвечает только на
|
||||
свойства из своей таблицы: отсутствие push = свойства нет (проверено на
|
||||
AP-WC1E). Поэтому необязательные свойства не валят trial, а фиксируются в
|
||||
``answered`` и используются как дефолты фич и для определения шаблона.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,12 +16,48 @@ from typing import Iterable
|
||||
from pyfglair import Config, Error, Prop, Session, State, Template, Value
|
||||
from pyfglair.templates import template_info
|
||||
|
||||
BASE_PROPS: tuple[Prop, ...] = (
|
||||
# Обязательные свойства: есть во всех шаблонах, должны ответить.
|
||||
REQUIRED_PROPS: tuple[Prop, ...] = (
|
||||
Prop.OPERATION_MODE,
|
||||
Prop.FAN_SPEED,
|
||||
Prop.DEVICE_CAPABILITIES,
|
||||
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
|
||||
class TrialResult:
|
||||
@@ -25,6 +66,7 @@ class TrialResult:
|
||||
state: State = State.IDLE
|
||||
error: Error = Error.NONE
|
||||
properties: dict[Prop, Value] = field(default_factory=dict)
|
||||
answered: set[Prop] = field(default_factory=set)
|
||||
|
||||
|
||||
def _failure_reason(state: State, error: Error) -> str:
|
||||
@@ -37,6 +79,24 @@ def _failure_reason(state: State, error: Error) -> str:
|
||||
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(
|
||||
host: str,
|
||||
dsn: str,
|
||||
@@ -48,9 +108,12 @@ def trial_connect(
|
||||
listen_port: int = 10275,
|
||||
keepalive_ms: int = 15000,
|
||||
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:
|
||||
"""Поднимает сессию, ждёт ONLINE и читает базовые свойства.
|
||||
"""Поднимает сессию, ждёт ONLINE, читает обязательные и зондирует
|
||||
необязательные свойства.
|
||||
|
||||
Никогда не бросает: любая неудача — ``TrialResult(ok=False, reason=...)``.
|
||||
"""
|
||||
@@ -59,6 +122,9 @@ def trial_connect(
|
||||
values: dict[Prop, Value] = {}
|
||||
valid = {info.prop for info in template_info(template)}
|
||||
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:
|
||||
with lock:
|
||||
@@ -115,12 +181,15 @@ def trial_connect(
|
||||
state=state, error=error,
|
||||
)
|
||||
|
||||
if wanted:
|
||||
requested = wanted + optional
|
||||
if requested:
|
||||
session.batch_begin()
|
||||
for prop in wanted:
|
||||
for prop in requested:
|
||||
session.get_prop(prop)
|
||||
session.batch_commit()
|
||||
prop_deadline = min(deadline, time.monotonic() + 2.0)
|
||||
prop_deadline = min(
|
||||
deadline, time.monotonic() + props_timeout
|
||||
)
|
||||
while time.monotonic() < prop_deadline:
|
||||
with lock:
|
||||
if all(prop in values for prop in wanted):
|
||||
@@ -136,7 +205,49 @@ def trial_connect(
|
||||
reason="properties_missing",
|
||||
state=State.ONLINE,
|
||||
properties=snapshot,
|
||||
answered=set(snapshot),
|
||||
)
|
||||
return TrialResult(
|
||||
ok=True,
|
||||
state=State.ONLINE,
|
||||
properties=snapshot,
|
||||
answered=set(snapshot),
|
||||
)
|
||||
return TrialResult(ok=True, state=State.ONLINE, properties=snapshot)
|
||||
finally:
|
||||
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
|
||||
|
||||
@@ -99,8 +99,12 @@ Repair (теоретический): `key_error` → «Ключ устройст
|
||||
display_temperature; sensors: room temp, error_code, op_status-флаги;
|
||||
switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/
|
||||
human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки,
|
||||
demand_control; binary_sensor: connectivity). capabilities фильтруют
|
||||
режимы/пресеты. Записи — один `batch_commit()` на действие пользователя.
|
||||
demand_control; binary_sensor: connectivity). capabilities и выбранные в мастере
|
||||
feature-тумблеры фильтруют сущности (`features.py`,
|
||||
`entry.data["features"]`): то, что прибор не умеет или отключено
|
||||
пользователем, не создаётся. Записи — один `batch_commit()` на действие
|
||||
пользователя. У сущностей есть атрибут `description` (HA не поддерживает
|
||||
тултипы).
|
||||
Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована
|
||||
(§10) — сущность не создаётся до уточнения; в H3 вместо неё
|
||||
диагностический sensor состояния связи.
|
||||
|
||||
@@ -14,7 +14,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
import pyfglair
|
||||
from custom_components.fglair.const import DOMAIN
|
||||
from custom_components.fglair.trial import TrialResult
|
||||
from pyfglair import Prop, Value, ValueKind
|
||||
from pyfglair import Prop, Template, Value, ValueKind
|
||||
from pyfglair.provision import Device
|
||||
|
||||
MANUAL_INPUT = {
|
||||
@@ -27,8 +27,7 @@ MANUAL_INPUT = {
|
||||
"model": "AP-WC1E",
|
||||
}
|
||||
|
||||
PREVIEW_INPUT = {
|
||||
"template": "A",
|
||||
LIMITS_INPUT = {
|
||||
"temp_min": 16.0,
|
||||
"temp_max": 30.0,
|
||||
"temp_step": 0.5,
|
||||
@@ -37,8 +36,20 @@ PREVIEW_INPUT = {
|
||||
"temp_offset": 0,
|
||||
}
|
||||
|
||||
TRIAL_OK = TrialResult(ok=True)
|
||||
TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable")
|
||||
TRIAL_OK = (TrialResult(ok=True), Template.A, False)
|
||||
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):
|
||||
@@ -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)
|
||||
|
||||
|
||||
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):
|
||||
for key in schema.schema:
|
||||
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")
|
||||
assert result["type"] == "form"
|
||||
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "preview"
|
||||
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["title"] == "Bedroom"
|
||||
assert result["result"].unique_id == DSN
|
||||
@@ -98,11 +124,13 @@ async def test_manual_success(hass: HomeAssistant):
|
||||
"min": 160,
|
||||
"max": 300,
|
||||
}
|
||||
assert isinstance(data["features"], dict)
|
||||
assert data["features"]["mode_heat"] is True
|
||||
trial.assert_called_once()
|
||||
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(
|
||||
"custom_components.fglair.config_flow._run_trial",
|
||||
return_value=TRIAL_OK,
|
||||
@@ -110,17 +138,20 @@ async def test_preview_template_change_and_conversion(hass: HomeAssistant):
|
||||
result = await _menu(hass)
|
||||
result = await _select(hass, result["flow_id"], "manual")
|
||||
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||
preview_input = {
|
||||
**PREVIEW_INPUT,
|
||||
"template": "B",
|
||||
result = await _pages(
|
||||
hass,
|
||||
result["flow_id"],
|
||||
template="B",
|
||||
limits={
|
||||
**LIMITS_INPUT,
|
||||
"temp_min": 17.0,
|
||||
"temp_max": 28.0,
|
||||
"temp_step": 1.0,
|
||||
"temp_num": 2,
|
||||
"temp_den": 4,
|
||||
"temp_offset": 5,
|
||||
}
|
||||
result = await _configure(hass, result["flow_id"], preview_input)
|
||||
},
|
||||
)
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"]["template"] == "B"
|
||||
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(
|
||||
"custom_components.fglair.config_flow._run_trial",
|
||||
return_value=TRIAL_OK,
|
||||
@@ -141,24 +172,23 @@ async def test_preview_invalid_range(hass: HomeAssistant):
|
||||
result = await _menu(hass)
|
||||
result = await _select(hass, result["flow_id"], "manual")
|
||||
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(
|
||||
hass,
|
||||
result["flow_id"],
|
||||
{
|
||||
**PREVIEW_INPUT,
|
||||
"template": "B",
|
||||
"temp_min": 30.0,
|
||||
"temp_max": 16.0,
|
||||
},
|
||||
{**LIMITS_INPUT, "temp_min": 30.0, "temp_max": 16.0},
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "invalid_range"}
|
||||
assert "Template: B" in result["description_placeholders"]["preview"]
|
||||
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
|
||||
result = await _configure(hass, result["flow_id"], LIMITS_INPUT)
|
||||
assert result["type"] == "create_entry"
|
||||
|
||||
|
||||
async def test_preview_unreachable_range(hass: HomeAssistant):
|
||||
async def test_limits_unreachable_range(hass: HomeAssistant):
|
||||
"""Конверсия выводит таблицу за min/max — пустой достижимый диапазон."""
|
||||
with patch(
|
||||
"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 _select(hass, result["flow_id"], "manual")
|
||||
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(
|
||||
hass,
|
||||
result["flow_id"],
|
||||
{
|
||||
**PREVIEW_INPUT,
|
||||
**LIMITS_INPUT,
|
||||
"temp_min": 30.0,
|
||||
"temp_max": 40.0,
|
||||
"temp_num": 1,
|
||||
@@ -182,22 +216,37 @@ async def test_preview_unreachable_range(hass: HomeAssistant):
|
||||
assert result["errors"] == {"base": "invalid_range"}
|
||||
|
||||
|
||||
async def test_preview_contains_trial_values(hass: HomeAssistant):
|
||||
async def test_template_device_values(hass: HomeAssistant):
|
||||
props = {
|
||||
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.AF_VERTICAL_NUM_DIR: Value(ValueKind.INT, 5),
|
||||
}
|
||||
with patch(
|
||||
"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 _select(hass, result["flow_id"], "manual")
|
||||
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
|
||||
preview = result["description_placeholders"]["preview"]
|
||||
assert "20.0" in preview
|
||||
assert "0x0000001B" in preview
|
||||
assert "cool" in preview and "heat" in preview
|
||||
assert result["step_id"] == "template"
|
||||
values = result["description_placeholders"]["device_values"]
|
||||
assert "20.0" in values
|
||||
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):
|
||||
@@ -215,7 +264,11 @@ async def test_manual_connect_error(hass: HomeAssistant):
|
||||
async def test_manual_key_mismatch_error(hass: HomeAssistant):
|
||||
with patch(
|
||||
"custom_components.fglair.config_flow._run_trial",
|
||||
return_value=TrialResult(ok=False, reason="key_mismatch"),
|
||||
return_value=(
|
||||
TrialResult(ok=False, reason="key_mismatch"),
|
||||
Template.A,
|
||||
False,
|
||||
),
|
||||
):
|
||||
result = await _menu(hass)
|
||||
result = await _select(hass, result["flow_id"], "manual")
|
||||
@@ -265,7 +318,7 @@ async def test_import_json_success(hass: HomeAssistant):
|
||||
)
|
||||
with patch(
|
||||
"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 _select(hass, result["flow_id"], "import_json")
|
||||
@@ -273,11 +326,9 @@ async def test_import_json_success(hass: HomeAssistant):
|
||||
result = await _configure(
|
||||
hass, result["flow_id"], {"config_json": payload}
|
||||
)
|
||||
assert result["step_id"] == "preview"
|
||||
assert result["step_id"] == "template"
|
||||
assert _suggested(result["data_schema"], "template") == "B"
|
||||
result = await _configure(
|
||||
hass, result["flow_id"], {**PREVIEW_INPUT, "template": "B"}
|
||||
)
|
||||
result = await _pages(hass, result["flow_id"], template="B")
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["data"]["host"] == "192.168.1.60"
|
||||
assert result["data"]["template"] == "B"
|
||||
@@ -319,8 +370,8 @@ async def test_cloud_flow_success(hass: HomeAssistant):
|
||||
)
|
||||
assert result["step_id"] == "cloud_device"
|
||||
result = await _configure(hass, result["flow_id"], {"dsn": DSN})
|
||||
assert result["step_id"] == "preview"
|
||||
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["data"]["host"] == "192.168.1.50"
|
||||
assert result["data"]["lanip_key_id"] == KEY_ID
|
||||
@@ -374,7 +425,7 @@ async def test_yaml_import(hass: HomeAssistant):
|
||||
}
|
||||
with patch(
|
||||
"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})
|
||||
await hass.async_block_till_done()
|
||||
@@ -382,6 +433,7 @@ async def test_yaml_import(hass: HomeAssistant):
|
||||
assert len(entries) == 1
|
||||
assert entries[0].data["template"] == "F"
|
||||
assert entries[0].data["host"] == "192.168.1.70"
|
||||
assert isinstance(entries[0].data["features"], dict)
|
||||
|
||||
|
||||
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(
|
||||
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["data"]["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(
|
||||
hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"}
|
||||
)
|
||||
assert result["step_id"] == "preview"
|
||||
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
|
||||
assert result["step_id"] == "template"
|
||||
result = await _pages(hass, result["flow_id"])
|
||||
assert result["type"] == "abort"
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
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")
|
||||
if entry.state.name == "LOADED":
|
||||
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()
|
||||
|
||||
@@ -77,6 +77,7 @@ async def test_entities_snapshot(hass: HomeAssistant, mock_ac, socket_enabled):
|
||||
)
|
||||
|
||||
climate = hass.states.get(climate_id)
|
||||
assert climate.attributes["description"]
|
||||
assert climate.attributes["current_temperature"] == 20.0
|
||||
assert climate.attributes["temperature"] == 22.0
|
||||
assert climate.attributes["fan_mode"] == "auto"
|
||||
|
||||
@@ -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
|
||||
@@ -58,7 +58,7 @@ async def test_reconfigure_fixes_key_error(
|
||||
):
|
||||
"""Repair-путь целиком: bad key_id → issue → reconfigure → online → issue снят."""
|
||||
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(
|
||||
["--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,
|
||||
},
|
||||
)
|
||||
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["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["reason"] == "reconfigure_successful"
|
||||
|
||||
Reference in New Issue
Block a user