ha(H3): превью шаблона с конверсиями; climate и сущности

- config flow: шаг preview (шаблон A/B/F, диапазон/шаг уставки, линейные
  коэффициенты num/den/offset, текст-превью по данным trial: hvac/fan/
  swing/capabilities/текущая температура)
- coordinator: push-driven DataUpdateCoordinator (снимок кэша, доступность
  online/recovering, первичный GET-батч, один batch_commit на действие)
- overrides.py: ConversionSet (ручные линейные конверсии и диапазон поверх
  таблиц ядра, применяются в HA)
- entity.py: DeviceInfo/unique_id/доступность, capabilities-фильтры
- climate: hvac/fan/swing/vertical+horizontal swing/preset ECO-BOOST,
  уставка 16-30 °C (шаг по шаблону), current_temperature
- sensor: room_temperature, error_code, connection_state (диагностика)
- binary_sensor: connectivity + флаги op_status (PROTOCOL §8.4)
- switch: 8 флагов-свойств; select: положения заслонок 0..N-1
- тесты: превью/конверсии/ошибки диапазона, сущности против mock_ac
  (снимок, команды, capabilities-фильтр, ручная конверсия), ConversionSet
- mock_ac: --set NAME=VALUE (0b/0x) для сценариев
- план: demand_control отложен (семантика не зафиксирована)
This commit is contained in:
2026-09-29 12:44:34 +03:00
parent 29f62b639c
commit 3e0b6b3653
21 changed files with 1974 additions and 87 deletions
+140 -4
View File
@@ -12,11 +12,15 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import selector
import pyfglair
from pyfglair import Template
from pyfglair import Prop, Template
from pyfglair.provision import Device
from pyfglair.templates import detect
from .const import (
CAP_FAN,
CAP_HVAC,
CAP_SWING_HORIZONTAL,
CAP_SWING_VERTICAL,
CONF_CONFIG_JSON,
CONF_DEVICE_PORT,
CONF_DSN,
@@ -27,13 +31,23 @@ from .const import (
CONF_LISTEN_PORT,
CONF_MODEL,
CONF_NAME,
CONF_OVERRIDES,
CONF_PASSWORD,
CONF_REGION,
CONF_TEMP_DEN,
CONF_TEMP_MAX,
CONF_TEMP_MIN,
CONF_TEMP_NUM,
CONF_TEMP_OFFSET,
CONF_TEMP_STEP,
CONF_TEMPLATE,
DEFAULT_DEVICE_PORT,
DEFAULT_LISTEN_PORT,
DOMAIN,
REGIONS,
TEMP_MAX_TENTHS,
TEMP_MIN_TENTHS,
TEMP_STEP_TENTHS,
TRIAL_ERRORS,
TRIAL_TIMEOUT,
)
@@ -86,6 +100,75 @@ def _import_schema() -> vol.Schema:
)
def _preview_schema(data: dict[str, Any]) -> vol.Schema:
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
return vol.Schema(
{
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
["A", "B", "F"]
),
vol.Required(CONF_TEMP_MIN, default=16.0): vol.All(
vol.Coerce(float), vol.Range(min=-10.0, max=45.0)
),
vol.Required(CONF_TEMP_MAX, default=30.0): vol.All(
vol.Coerce(float), vol.Range(min=-10.0, max=45.0)
),
vol.Required(
CONF_TEMP_STEP, default=TEMP_STEP_TENTHS[template] / 10
): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=5.0)),
vol.Required(CONF_TEMP_NUM, default=1): vol.All(
int, vol.Range(min=1, max=1000)
),
vol.Required(CONF_TEMP_DEN, default=1): vol.All(
int, vol.Range(min=1, max=1000)
),
vol.Required(CONF_TEMP_OFFSET, default=0): vol.All(
int, vol.Range(min=-1000, max=1000)
),
}
)
def _preview_text(
template: Template, properties: dict[Any, Any]
) -> str:
def raw(prop) -> int | None:
value = properties.get(prop)
return value.int_value if value is not None else None
caps = raw(Prop.DEVICE_CAPABILITIES)
def supported(bit: int) -> bool:
return caps is None or bool(caps & (1 << bit))
hvac = ["off"] + [
mode.value for mode, bit in CAP_HVAC.items() if supported(bit)
]
fan = [mode for mode, bit in CAP_FAN.items() if supported(bit)]
tmin, tmax = TEMP_MIN_TENTHS / 10, TEMP_MAX_TENTHS / 10
step = TEMP_STEP_TENTHS[template] / 10
lines = [
f"- HVAC: {', '.join(hvac)}",
f"- Fan: {', '.join(fan) or '-'}",
f"- Setpoint: {tmin:.1f}-{tmax:.1f} °C, step {step:.1f} °C",
]
current = raw(Prop.DISPLAY_TEMPERATURE)
if current is not None:
lines.append(f"- Current temperature: {current / 10:.1f} °C")
swings = []
if supported(CAP_SWING_VERTICAL):
swings.append("vertical")
if supported(CAP_SWING_HORIZONTAL):
swings.append("horizontal")
if swings:
lines.append(f"- Swing: {', '.join(swings)}")
if caps is not None:
lines.append(f"- Capabilities: 0x{caps:08X}")
lines.append(f"- Template: {template.name}")
return "\n".join(lines)
def _template_name(model: str | None) -> str:
if model:
found = detect(model)
@@ -173,6 +256,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
self._devices: list[Device] = []
self._pending_data: dict[str, Any] = {}
self._trial_properties: dict[Any, Any] = {}
def _show_form(
self,
@@ -256,7 +341,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
}
error = await self._async_trial(data)
if error is None:
return self._create_entry(data)
return await self.async_step_preview()
errors["base"] = error
schema = vol.Schema(
{
@@ -278,7 +363,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
data = _manual_data(user_input)
error = await self._async_trial(data)
if error is None:
return self._create_entry(data)
return await self.async_step_preview()
errors["base"] = error
return self._show_form(
"manual", _manual_schema(), errors, user_input,
@@ -296,7 +381,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
else:
error = await self._async_trial(data)
if error is None:
return self._create_entry(data)
return await self.async_step_preview()
errors["base"] = error
return self._show_form("import_json", _import_schema(), errors, user_input)
@@ -312,6 +397,55 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason=error)
return self._create_entry(data)
async def async_step_preview(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Шаг 3: превью шаблона и ручные конверсии/диапазон уставки."""
data = dict(self._pending_data)
errors: dict[str, str] = {}
if user_input is not None:
template = Template[user_input[CONF_TEMPLATE]]
temp_min = int(round(float(user_input[CONF_TEMP_MIN]) * 10))
temp_max = int(round(float(user_input[CONF_TEMP_MAX]) * 10))
step = int(round(float(user_input[CONF_TEMP_STEP]) * 10))
if temp_min >= temp_max:
errors["base"] = "invalid_range"
elif step <= 0:
errors["base"] = "invalid_range"
else:
data[CONF_TEMPLATE] = template.name
data[CONF_TEMP_STEP] = step
data[CONF_OVERRIDES] = {
Prop.ADJUST_TEMPERATURE.name.lower(): {
"num": int(user_input[CONF_TEMP_NUM]),
"den": int(user_input[CONF_TEMP_DEN]),
"offset": int(user_input[CONF_TEMP_OFFSET]),
"min": temp_min,
"max": temp_max,
}
}
_LOGGER.debug(
"превью: template=%s range=%d..%d step=%d",
template.name, temp_min, temp_max, step,
)
return self._create_entry(data)
schema = _preview_schema(data)
if user_input is None:
user_input = {
CONF_TEMPLATE: data.get(CONF_TEMPLATE, Template.A.name),
}
return self.async_show_form(
step_id="preview",
data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors,
description_placeholders={
"preview": _preview_text(
Template[str(data.get(CONF_TEMPLATE, "A"))],
self._trial_properties,
)
},
)
async def _async_trial(self, data: dict[str, Any]) -> str | None:
"""None — сессия поднялась; иначе ключ ошибки формы."""
await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False)
@@ -320,6 +454,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
partial(_run_trial, data)
)
if result.ok:
self._pending_data = data
self._trial_properties = result.properties
return None
_LOGGER.debug(
"пробное подключение не удалось: reason=%s state=%s error=%s",