Compare commits
5
Commits
f9d917c66d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f09716dce | ||
|
|
976a2dcbb8 | ||
|
|
d1c02d7c4b | ||
|
|
4ee428a6fd | ||
|
|
90b9e2783b |
@@ -83,7 +83,7 @@ jobs:
|
|||||||
TOKEN: ${{ secrets.PYPI_TOKEN }}
|
TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||||
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
PYPI_USER: ${{ secrets.PYPI_USER }}
|
PYPI_USER: ${{ secrets.PYPI_USER }}
|
||||||
run: bash scripts/publish-gitea-pypi.sh
|
run: sh scripts/publish-gitea-pypi.sh
|
||||||
|
|
||||||
build-musllinux:
|
build-musllinux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -132,4 +132,4 @@ jobs:
|
|||||||
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
PYPI_USER: ${{ secrets.PYPI_USER }}
|
PYPI_USER: ${{ secrets.PYPI_USER }}
|
||||||
DIST_DIR: /src/dist
|
DIST_DIR: /src/dist
|
||||||
run: cd /src && bash scripts/publish-gitea-pypi.sh
|
run: cd /src && sh scripts/publish-gitea-pypi.sh
|
||||||
|
|||||||
@@ -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) и,
|
||||||
|
при необходимости, ручная конверсия коэффициентами.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*Должно быть видно: таблица превью — режимы, диапазон 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 не
|
||||||
|
поддерживает нативные тултипы, поэтому описание видно там.
|
||||||
|
|
||||||
## Диагностика и известные ситуации
|
## Диагностика и известные ситуации
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ----------------------------------------------------
|
||||||
|
|||||||
@@ -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,41 @@ 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, fatal = 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 fatal is not None:
|
||||||
|
# Модуль недоступен/нет слотов/ключ — вторую сессию не открываем.
|
||||||
|
return TrialResult(ok=False, reason=fatal), template, 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 +312,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 +422,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 +444,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 +464,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 +478,80 @@ 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),
|
||||||
|
}
|
||||||
|
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 +577,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,28 +589,51 @@ 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 — сессия поднялась; иначе ключ ошибки формы."""
|
||||||
@@ -527,17 +642,31 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
# текущие конверсии, если путь их не задаёт.
|
# текущие конверсии, если путь их не задаёт.
|
||||||
for key, value in self._reconfigure_entry.data.items():
|
for key, value in self._reconfigure_entry.data.items():
|
||||||
data.setdefault(key, value)
|
data.setdefault(key, value)
|
||||||
|
# Сохранённый шаблон важнее пересчёта по модели: пользователь
|
||||||
|
# мог выбрать его вручную (страница подтвердит/изменит).
|
||||||
|
if self._reconfigure_entry.data.get(CONF_TEMPLATE):
|
||||||
|
data[CONF_TEMPLATE] = self._reconfigure_entry.data[
|
||||||
|
CONF_TEMPLATE
|
||||||
|
]
|
||||||
await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False)
|
await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False)
|
||||||
if self._reconfigure_entry is not None:
|
if self._reconfigure_entry is not None:
|
||||||
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
|
||||||
|
if (
|
||||||
|
self._reconfigure_entry is None
|
||||||
|
or not self._reconfigure_entry.data.get(CONF_TEMPLATE)
|
||||||
|
):
|
||||||
|
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",
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -93,25 +94,145 @@ PRESET_PROP = {
|
|||||||
PRESET_ECO: Prop.ECONOMY_MODE,
|
PRESET_ECO: Prop.ECONOMY_MODE,
|
||||||
PRESET_BOOST: Prop.POWERFUL_MODE,
|
PRESET_BOOST: Prop.POWERFUL_MODE,
|
||||||
}
|
}
|
||||||
PRESET_CAP = {
|
|
||||||
PRESET_ECO: CAP_ECONOMY,
|
|
||||||
PRESET_BOOST: CAP_POWERFUL,
|
|
||||||
}
|
|
||||||
|
|
||||||
SWING_MODES = ["off", "on"]
|
SWING_MODES = ["off", "on"]
|
||||||
|
|
||||||
# switch-свойства: prop -> (translation_key, capability bit | None)
|
# switch-свойства: prop -> translation_key (filtracija po FEATURES)
|
||||||
SWITCH_PROPS: dict[Prop, tuple[str, int | None]] = {
|
SWITCH_PROPS: dict[Prop, str] = {
|
||||||
Prop.ECONOMY_MODE: ("economy_mode", CAP_ECONOMY),
|
Prop.ECONOMY_MODE: "economy_mode",
|
||||||
Prop.POWERFUL_MODE: ("powerful_mode", CAP_POWERFUL),
|
Prop.POWERFUL_MODE: "powerful_mode",
|
||||||
Prop.COIL_DRY_MODE: ("coil_dry_mode", CAP_COIL_DRY),
|
Prop.COIL_DRY_MODE: "coil_dry_mode",
|
||||||
Prop.MIN_HEAT: ("min_heat", CAP_MIN_HEAT),
|
Prop.MIN_HEAT: "min_heat",
|
||||||
Prop.OUTDOOR_LOW_NOISE: ("outdoor_low_noise", CAP_OUTDOOR_LOW_NOISE),
|
Prop.OUTDOOR_LOW_NOISE: "outdoor_low_noise",
|
||||||
Prop.HUMAN_DET_AUTO_SAVE: ("human_det_auto_save", None),
|
Prop.HUMAN_DET_AUTO_SAVE: "human_det_auto_save",
|
||||||
Prop.WIFI_LED_ENABLE: ("wifi_led_enable", None),
|
Prop.WIFI_LED_ENABLE: "wifi_led_enable",
|
||||||
Prop.INDOOR_FAN_CONTROL: ("indoor_fan_control", CAP_INDOOR_FAN_CONTROL),
|
Prop.INDOOR_FAN_CONTROL: "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,
|
||||||
|
|||||||
@@ -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,20 @@ 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, online_timeout: float = 2.0) -> None:
|
||||||
"""Гарантирует первичный GET-батч (если уже online)."""
|
"""Гарантирует первичный GET-батч и ожидание ключевых свойств.
|
||||||
|
|
||||||
|
Ждёт ONLINE не дольше ``online_timeout``, затем — завершения
|
||||||
|
синхронизации (сама синхронизация ограничена своим таймаутом),
|
||||||
|
чтобы сущности создавались с корректными feature-дефолтами.
|
||||||
|
"""
|
||||||
|
deadline = self.hass.loop.time() + online_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 +186,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):
|
||||||
|
|||||||
@@ -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 or self.hass 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
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""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": Prop.AF_VERTICAL_NUM_DIR,
|
||||||
|
"af_horizontal_direction": 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])
|
||||||
|
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)
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 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))
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.DISPLAY_TEMPERATURE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Зондируемые свойства (в пределах выбранного шаблона): наличие в ответе —
|
||||||
|
# информация для дефолтов фич и распознавания шаблона.
|
||||||
|
OPTIONAL_PROPS: tuple[Prop, ...] = (
|
||||||
|
Prop.DEVICE_CAPABILITIES,
|
||||||
|
Prop.DISPLAY_TEMPERATURE,
|
||||||
|
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,54 @@ 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,
|
||||||
|
answered=set(snapshot),
|
||||||
)
|
)
|
||||||
return TrialResult(ok=True, state=State.ONLINE, properties=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], str | None]:
|
||||||
|
"""Определяет шаблон по ответам, перебирая A/B/F короткими сессиями.
|
||||||
|
|
||||||
|
Возвращает (template, properties, fatal_reason): если модуль недоступен
|
||||||
|
(или нет слотов/ключ не тот), перебор бессмыслен и причина возвращается
|
||||||
|
третьим элементом, чтобы вызывающий не открывал ещё одну сессию.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
if result.reason == "properties_missing":
|
||||||
|
# Модуль доступен, но свойств шаблона нет — пробуем дальше.
|
||||||
|
continue
|
||||||
|
# Недоступен/нет слотов/ключ — перебор шаблонов бессмыслен.
|
||||||
|
return None, properties, result.reason
|
||||||
|
answered |= result.answered
|
||||||
|
properties.update(result.properties)
|
||||||
|
detected = classify_template(answered)
|
||||||
|
if detected is not None:
|
||||||
|
return detected, properties, None
|
||||||
|
return classify_template(answered), properties, None
|
||||||
|
|||||||
@@ -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 состояния связи.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/bin/sh
|
||||||
# Публикация файлов из DIST_DIR в PyPI-реестр пакетов Gitea.
|
# Публикация файлов из DIST_DIR в PyPI-реестр пакетов Gitea.
|
||||||
# Уже загруженные (по имени файла в simple-индексе) пропускаются: twine 7 не
|
# Уже загруженные (по имени файла в simple-индексе) пропускаются: twine 7 не
|
||||||
# поддерживает --skip-existing для не-PyPI репозиториев.
|
# поддерживает --skip-existing для не-PyPI репозиториев.
|
||||||
|
# POSIX sh (Alpine busybox/dash) — внешний bash не требуется.
|
||||||
#
|
#
|
||||||
# Переменные:
|
# Переменные:
|
||||||
# TOKEN (или GITEA_JOB_TOKEN) — обязательный токен (PAT write:package или
|
# TOKEN (или GITEA_JOB_TOKEN) — обязательный токен (PAT write:package или
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
# SERVER, OWNER — Gitea и владелец; если не заданы, берутся
|
# SERVER, OWNER — Gitea и владелец; если не заданы, берутся
|
||||||
# из git remote (origin)
|
# из git remote (origin)
|
||||||
# PYPI_USER — имя пользователя (по умолчанию актор)
|
# PYPI_USER — имя пользователя (по умолчанию актор)
|
||||||
set -euo pipefail
|
set -eu
|
||||||
|
|
||||||
DIST_DIR="${DIST_DIR:-dist}"
|
DIST_DIR="${DIST_DIR:-dist}"
|
||||||
TOKEN="${TOKEN:-${GITEA_JOB_TOKEN:-}}"
|
TOKEN="${TOKEN:-${GITEA_JOB_TOKEN:-}}"
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
template="B",
|
||||||
|
limits={
|
||||||
|
**LIMITS_INPUT,
|
||||||
"temp_min": 17.0,
|
"temp_min": 17.0,
|
||||||
"temp_max": 28.0,
|
"temp_max": 28.0,
|
||||||
"temp_step": 1.0,
|
"temp_step": 1.0,
|
||||||
"temp_num": 2,
|
"temp_num": 2,
|
||||||
"temp_den": 4,
|
"temp_den": 4,
|
||||||
"temp_offset": 5,
|
"temp_offset": 5,
|
||||||
}
|
},
|
||||||
result = await _configure(hass, result["flow_id"], preview_input)
|
)
|
||||||
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,59 @@ 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, {}, None)
|
||||||
|
) 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()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconfigure_prefills_stored_template(
|
||||||
|
hass: HomeAssistant, mock_ac, socket_enabled
|
||||||
|
):
|
||||||
|
"""При reconfigure дефолт — сохранённый шаблон, а не пересчитанный."""
|
||||||
|
from test_entities import _setup_entry
|
||||||
|
|
||||||
|
proc = mock_ac(["--set", "operation_mode=6"])
|
||||||
|
entry = await _setup_entry(hass, proc.port)
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry, data={**entry.data, "template": "B"}
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"custom_components.fglair.config_flow._run_trial",
|
||||||
|
return_value=(TrialResult(ok=True), Template.A, False),
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
|
||||||
|
)
|
||||||
|
result = await _select(hass, result["flow_id"], "manual")
|
||||||
|
result = await _configure(
|
||||||
|
hass, result["flow_id"], {**MANUAL_INPUT, "host": "127.0.0.1",
|
||||||
|
"device_port": proc.port}
|
||||||
|
)
|
||||||
|
assert result["step_id"] == "template"
|
||||||
|
assert _suggested(result["data_schema"], "template") == "B"
|
||||||
|
await hass.config_entries.async_unload(entry.entry_id)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Дефолты 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
|
||||||
|
from custom_components.fglair.const import FEATURES
|
||||||
|
assert len(resolved) == len(FEATURES)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_stops_on_unreachable(monkeypatch):
|
||||||
|
"""Недоступный модуль не перебираем трижды — выходим после первой сессии."""
|
||||||
|
from custom_components.fglair import trial as tr
|
||||||
|
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
def fake_trial(*args, **kwargs):
|
||||||
|
calls.append(kwargs.get("template"))
|
||||||
|
return tr.TrialResult(ok=False, reason="unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tr, "trial_connect", fake_trial)
|
||||||
|
template, props, fatal = tr.probe_template("host", "dsn", "key", 1)
|
||||||
|
assert template is None
|
||||||
|
assert props == {}
|
||||||
|
assert fatal == "unreachable"
|
||||||
|
assert calls == [Template.A]
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user