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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user