ha(H3): исправления по ревью — turn_on/off, кламп конверсий, reload, гонка ONLINE

- M1: supported_features += TURN_ON/TURN_OFF (сервисы в HA 2026.9),
  async_turn_off; удалён мёртвый _enable_turn_on_off_backwards_compat;
  тест turn_on/turn_off → operation_mode 1/0
- M2: достижимый диапазон = override ∩ raw-таблица шаблона; raw клампится
  в from_display, clamp() по достижимому диапазону (молчаливый кламп ядра
  устранён); тесты scaled override
- M3: mock_ac сбрасывает crypto/active на DELETE; тест reload → снова ONLINE
- гонка: async_sync_now больше не перезаписывает данные устаревшим снимком
  (ONLINE мог потеряться при быстром старте) — стабильность 4×37 тестов
- m1: превью-текст учитывает выбранный в форме шаблон; m2: убрана
  недостижимая проверка шага; m3: удалён мёртвый translation_key climate
- m4: бит 21 op_status (шаблон B); m5: lru_cache template_info/prop_info;
  m6: operation_mode=1 без истории → hvac_mode unknown; m7: ошибка команды
  → HomeAssistantError; m8: unload возвращает False, не разбирая runtime
- n2: уточнены описания data_description в переводах
This commit is contained in:
2026-09-29 13:41:20 +03:00
parent 3e0b6b3653
commit d86ed60b51
17 changed files with 202 additions and 59 deletions
+8 -5
View File
@@ -64,19 +64,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DATA_PENDING] = hass.data.get(DATA_PENDING, 0) + 1
client = FglairClient(hass, entry.data)
try:
await client.async_start()
finally:
hass.data[DATA_PENDING] -= 1
conversions = ConversionSet.from_entry(entry.data)
# Координатор создаётся (и подписывается) до старта сессии, чтобы не
# пропустить первое ONLINE.
coordinator = FglairCoordinator(hass, client, conversions)
try:
await client.async_start()
await coordinator.async_config_entry_first_refresh()
await coordinator.async_sync_now()
except Exception:
await coordinator.async_shutdown()
await client.async_stop()
raise
finally:
hass.data[DATA_PENDING] -= 1
clients[entry.entry_id] = client
entry.runtime_data = FglairRuntime(client, coordinator, conversions)
@@ -93,6 +94,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
if not unloaded:
return False
await runtime.coordinator.async_shutdown()
await runtime.client.async_stop()
+8 -5
View File
@@ -43,10 +43,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
class FglairClimate(FglairEntity, ClimateEntity):
_attr_translation_key = "climate"
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_enable_turn_on_off_backwards_compat = False
def __init__(self, coordinator: FglairCoordinator) -> None:
super().__init__(coordinator, "climate", "climate")
@@ -105,6 +103,7 @@ class FglairClimate(FglairEntity, ClimateEntity):
features |= ClimateEntityFeature.SWING_MODE
if self._horizontal_swing_supported():
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
return features
@property
@@ -119,10 +118,9 @@ class FglairClimate(FglairEntity, ClimateEntity):
if value == OPERATION_MODE_OFF:
return HVACMode.OFF
if value == OPERATION_MODE_ON:
if self._last_hvac_mode is not None:
# Режим включения (1) не несёт информации о режиме: до первого
# явного режима состояние неизвестно.
return self._last_hvac_mode
available = [mode for mode in self._hvac_modes() if mode != HVACMode.OFF]
return available[0] if available else HVACMode.AUTO
return HVAC_VALUE_TO_MODE.get(value)
@property
@@ -212,6 +210,11 @@ class FglairClimate(FglairEntity, ClimateEntity):
[(Prop.OPERATION_MODE, OPERATION_MODE_ON)]
)
async def async_turn_off(self) -> None:
await self.coordinator.async_write(
[(Prop.OPERATION_MODE, OPERATION_MODE_OFF)]
)
async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
+8 -9
View File
@@ -410,8 +410,6 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
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
@@ -429,19 +427,20 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
template.name, temp_min, temp_max, step,
)
return self._create_entry(data)
schema = _preview_schema(data)
selected = str(data.get(CONF_TEMPLATE, Template.A.name))
if user_input and user_input.get(CONF_TEMPLATE) in Template.__members__:
selected = str(user_input[CONF_TEMPLATE])
if user_input is None:
user_input = {
CONF_TEMPLATE: data.get(CONF_TEMPLATE, Template.A.name),
}
user_input = {CONF_TEMPLATE: selected}
return self.async_show_form(
step_id="preview",
data_schema=self.add_suggested_values_to_schema(schema, user_input),
data_schema=self.add_suggested_values_to_schema(
_preview_schema(data), user_input
),
errors=errors,
description_placeholders={
"preview": _preview_text(
Template[str(data.get(CONF_TEMPLATE, "A"))],
self._trial_properties,
Template[selected], self._trial_properties
)
},
)
+1
View File
@@ -121,6 +121,7 @@ OP_STATUS_BITS: dict[str, int] = {
"pump_down": 29,
"check_operation": 30,
"simultaneous": 25,
"different_modes": 21,
}
# Уставка: фактические ограничения бытового прибора (PROTOCOL §8.3 [LEGACY]);
+40 -6
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field, replace
from typing import Optional
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind
@@ -93,10 +94,34 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
available=state in AVAILABLE_STATES,
)
self.async_set_updated_data(self.data)
if state == State.ONLINE and not self._synced and self._sync_task is None:
if state == State.ONLINE:
if not self._synced and self._sync_task is None:
self._sync_task = self.hass.async_create_task(
self._async_initial_sync()
)
else:
# После разрыва при следующем ONLINE нужна повторная синхронизация.
self._synced = False
async def async_sync_now(self) -> None:
"""Гарантирует первичный GET-батч (если уже online)."""
if self._sync_task is not None:
await self._sync_task
elif self.client.state == State.ONLINE and not self._synced:
self._sync_task = self.hass.async_create_task(
self._async_initial_sync()
)
await self._sync_task
# Снимок создавался до ONLINE: обновляем состояние/доступность из
# актуального состояния клиента (значения приходят событиями).
state = self.client.state
self.data = replace(
self.data,
state=state,
last_error=self.client.last_error,
available=state in AVAILABLE_STATES,
)
self.async_set_updated_data(self.data)
@callback
def _on_property(self, event: PropertyEvent) -> None:
@@ -127,7 +152,11 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
"""Одно действие пользователя — один batch_commit()."""
if not updates:
return
await self.hass.async_add_executor_job(self._write, updates)
ok = await self.hass.async_add_executor_job(self._write, updates)
if not ok:
raise HomeAssistantError(
"FGLair: команда не отправлена (сессия недоступна)"
)
values = dict(self.data.values)
for prop, _ in updates:
value = self.client.cached(prop)
@@ -136,11 +165,16 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
self.data = replace(self.data, values=values)
self.async_set_updated_data(self.data)
def _write(self, updates: list[tuple[Prop, int]]) -> None:
self.client.batch_begin()
def _write(self, updates: list[tuple[Prop, int]]) -> bool:
if not self.client.batch_begin():
return False
ok = True
for prop, value in updates:
self.client.set_int(prop, value)
self.client.batch_commit()
if not self.client.set_int(prop, value):
ok = False
if not self.client.batch_commit():
ok = False
return ok
async def async_shutdown(self) -> None:
for unsub in self._unsubs:
+2 -1
View File
@@ -58,7 +58,8 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
def capability(self, bit: int) -> Optional[bool]:
caps = self.capabilities()
if caps is None:
if not caps:
# 0/None: возможностей ещё не сообщили — не фильтруем.
return None
return bool(caps & (1 << bit))
+47 -8
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
from typing import Any, Mapping, Optional
from pyfglair import Prop, Template
from pyfglair.templates import convert_from_input, convert_to_display
from pyfglair.templates import convert_from_input, convert_to_display, prop_info
from .const import (
CONF_OVERRIDES,
@@ -112,22 +112,61 @@ class ConversionSet:
raw = convert_from_input(self.template, prop, api_value)
return override.to_display(raw)
def from_display(self, prop: Prop, display: int) -> int:
"""Отображаемое значение → API-значение для set_int()."""
def raw_range(self, prop: Prop) -> Optional[tuple[int, int]]:
"""raw-диапазон протокольной таблицы шаблона (None — не задан)."""
info = prop_info(self.template, prop)
if info is None or info.raw_min is None or info.raw_max is None:
return None
return (min(info.raw_min, info.raw_max), max(info.raw_min, info.raw_max))
def _display_of_raw(self, prop: Prop, raw: int) -> int:
override = self.override(prop)
if override.identity:
return display
raw = override.from_display(display)
return convert_to_display(self.template, prop, raw)
return override.to_display(raw)
def from_display(self, prop: Prop, display: int) -> int:
"""Отображаемое значение → API-значение для set_int().
raw клампится по таблице шаблона (ядро тоже клампит — не отдаём
молча команду за пределами достижимого).
"""
override = self.override(prop)
raw = display if override.identity else override.from_display(display)
raw_range = self.raw_range(prop)
if raw_range is not None:
raw = max(raw_range[0], min(raw_range[1], raw))
if override.identity:
return raw
return convert_to_display(self.template, prop, raw)
def clamp(self, prop: Prop, display: int) -> int:
return self.override(prop).clamp(display)
bounds = self.range(prop)
if bounds is None:
return display
return max(bounds[0], min(bounds[1], display))
def range(self, prop: Prop) -> Optional[tuple[int, int]]:
"""Достижимый диапазон display: override ∩ таблица шаблона."""
override = self.override(prop)
if override.min_value is None or override.max_value is None:
low = override.min_value
high = override.max_value
if prop == Prop.ADJUST_TEMPERATURE:
# Фактические ограничения прибора (PROTOCOL §8.3), если
# пользователь не задал свои.
low = TEMP_MIN_TENTHS if low is None else low
high = TEMP_MAX_TENTHS if high is None else high
raw_range = self.raw_range(prop)
if raw_range is not None:
raw_low = self._display_of_raw(prop, raw_range[0])
raw_high = self._display_of_raw(prop, raw_range[1])
if raw_low > raw_high:
raw_low, raw_high = raw_high, raw_low
low = raw_low if low is None else max(low, raw_low)
high = raw_high if high is None else min(high, raw_high)
if low is None or high is None:
return None
return (override.min_value, override.max_value)
return (low, high)
def temperature_range(self) -> tuple[int, int]:
return self.range(Prop.ADJUST_TEMPERATURE) or (
+7 -7
View File
@@ -65,9 +65,9 @@
"temp_offset": "Conversion offset (0.1 °C)"
},
"data_description": {
"temp_num": "display = raw × num / den + offset (raw in API units, 0.1 °C)",
"temp_den": "display = raw × num / den + offset (raw in API units, 0.1 °C)",
"temp_offset": "display = raw × num / den + offset (raw in API units, 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_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)"
}
}
},
@@ -81,7 +81,7 @@
"no_devices": "No devices found in the account",
"unknown": "Unexpected error",
"properties_missing": "The device connected but did not report its base properties",
"invalid_range": "Minimum must be less than maximum, step must be positive"
"invalid_range": "Minimum must be less than maximum"
},
"abort": {
"already_configured": "This device is already configured",
@@ -94,9 +94,6 @@
}
},
"entity": {
"climate": {
"name": "Air conditioner"
},
"sensor": {
"room_temperature": {
"name": "Room temperature"
@@ -137,6 +134,9 @@
},
"simultaneous": {
"name": "Simultaneous operation"
},
"different_modes": {
"name": "Freeze/oil/other modes (template B)"
}
},
"switch": {
+1 -1
View File
@@ -55,6 +55,6 @@ def _capability_supported(
if bit is None:
return True
caps = coordinator.data.values.get(Prop.DEVICE_CAPABILITIES)
if caps is None:
if caps is None or not caps.int_value:
return True
return bool(caps.int_value & (1 << bit))
@@ -65,9 +65,9 @@
"temp_offset": "Conversion offset (0.1 °C)"
},
"data_description": {
"temp_num": "display = raw × num / den + offset (raw in API units, 0.1 °C)",
"temp_den": "display = raw × num / den + offset (raw in API units, 0.1 °C)",
"temp_offset": "display = raw × num / den + offset (raw in API units, 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_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)"
}
}
},
@@ -81,7 +81,7 @@
"no_devices": "No devices found in the account",
"unknown": "Unexpected error",
"properties_missing": "The device connected but did not report its base properties",
"invalid_range": "Minimum must be less than maximum, step must be positive"
"invalid_range": "Minimum must be less than maximum"
},
"abort": {
"already_configured": "This device is already configured",
@@ -94,9 +94,6 @@
}
},
"entity": {
"climate": {
"name": "Air conditioner"
},
"sensor": {
"room_temperature": {
"name": "Room temperature"
@@ -137,6 +134,9 @@
},
"simultaneous": {
"name": "Simultaneous operation"
},
"different_modes": {
"name": "Freeze/oil/other modes (template B)"
}
},
"switch": {
@@ -65,9 +65,9 @@
"temp_offset": "Смещение конверсии (0,1 °C)"
},
"data_description": {
"temp_num": "display = raw × num / den + offset (raw в единицах API, 0,1 °C)",
"temp_den": "display = raw × num / den + offset (raw в единицах API, 0,1 °C)",
"temp_offset": "display = raw × num / den + offset (raw в единицах API, 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_offset": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)"
}
}
},
@@ -81,7 +81,7 @@
"no_devices": "В аккаунте не найдено устройств",
"unknown": "Неожиданная ошибка",
"properties_missing": "Устройство подключилось, но не отдало базовые свойства",
"invalid_range": "Минимум должен быть меньше максимума, шаг — положительным"
"invalid_range": "Минимум должен быть меньше максимума"
},
"abort": {
"already_configured": "Устройство уже настроено",
@@ -94,9 +94,6 @@
}
},
"entity": {
"climate": {
"name": "Кондиционер"
},
"sensor": {
"room_temperature": {
"name": "Температура в помещении"
@@ -137,6 +134,9 @@
},
"simultaneous": {
"name": "Одновременные операции"
},
"different_modes": {
"name": "Разморозка/масло/прочие режимы (шаблон B)"
}
},
"switch": {
+3
View File
@@ -3,6 +3,7 @@ src/aircon/tables.cpp; дублей в Python нет)."""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from typing import Optional, Tuple
from ._cffi import ffi, lib
@@ -36,6 +37,7 @@ def detect(oem_model: Optional[str]) -> Optional[Template]:
return Template(result)
@lru_cache(maxsize=None)
def template_info(template: Template) -> Tuple[PropertyInfo, ...]:
"""Все свойства шаблона (порядок констант Prop)."""
out = []
@@ -67,6 +69,7 @@ def template_info(template: Template) -> Tuple[PropertyInfo, ...]:
return tuple(out)
@lru_cache(maxsize=None)
def prop_info(template: Template, prop: Prop) -> Optional[PropertyInfo]:
for info in template_info(template):
if info.prop == prop:
+3
View File
@@ -292,6 +292,9 @@ class Mock:
cmd = c.get("cmd", {})
if cmd.get("method") == "DELETE":
print("DELETE", flush=True)
# Слот освобождён: следующая сессия должна получить KE.
self.crypto = None
self.active = False
return
res = cmd.get("resource", "")
name = res.split("name=")[-1]
+9 -1
View File
@@ -141,10 +141,18 @@ async def test_preview_invalid_range(hass: HomeAssistant):
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {**PREVIEW_INPUT, "temp_min": 30.0, "temp_max": 16.0}
hass,
result["flow_id"],
{
**PREVIEW_INPUT,
"template": "B",
"temp_min": 30.0,
"temp_max": 16.0,
},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_range"}
assert "Template: B" in result["description_placeholders"]["preview"]
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["type"] == "create_entry"
+25
View File
@@ -185,9 +185,33 @@ async def test_entity_commands(hass: HomeAssistant, mock_ac, socket_enabled):
)
assert proc.wait_line("CMD SET economy_mode=True", 10)
await hass.services.async_call(
"climate", "turn_off", {"entity_id": climate_id}, blocking=True
)
assert proc.wait_line("CMD SET operation_mode=0", 10)
await hass.services.async_call(
"climate", "turn_on", {"entity_id": climate_id}, blocking=True
)
assert proc.wait_line("CMD SET operation_mode=1", 10)
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_reload_online(hass: HomeAssistant, mock_ac, socket_enabled):
"""После unload тот же прибор снова поднимается до ONLINE."""
proc = mock_ac(MOCK_SETTINGS)
entry = await _setup_entry(hass, proc.port)
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
RESTRICTED_SETTINGS = [
"--set", "operation_mode=3",
"--set", "fan_speed=4",
@@ -258,6 +282,7 @@ async def test_manual_conversion_applied(
await _wait_for(
lambda: climate_id and hass.states.get(climate_id) is not None
and hass.states.get(climate_id).state == "heat"
and hass.states.get(climate_id).attributes["temperature"] == 22.5
)
assert hass.states.get(climate_id).attributes["temperature"] == 22.5
assert hass.states.get(climate_id).attributes["min_temp"] == 17.0
+17 -1
View File
@@ -36,6 +36,20 @@ def test_linear_override_scaled():
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 100) == 200
def test_scaled_override_clamped_to_table():
"""display = raw/2: таблица A -100..450 → достижимо -50..225."""
override = LinearOverride(
num=1, den=2, offset=0, min_value=160, max_value=300
)
conversions = ConversionSet(
Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5
)
assert conversions.temperature_range() == (160, 225)
assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 250) == 225
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 250) == 450
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 225) == 450
def test_from_entry_parses_spec():
conversions = ConversionSet.from_entry(
{
@@ -54,7 +68,9 @@ def test_from_entry_parses_spec():
)
assert conversions.template == Template.B
assert conversions.step_tenths == 10
assert conversions.temperature_range() == (170, 280)
# display = raw/2 + 5: таблица -100..450 → -45..230, override 170..280
# → достижимо 170..230.
assert conversions.temperature_range() == (170, 230)
override = conversions.override(Prop.ADJUST_TEMPERATURE)
assert (override.num, override.den, override.offset) == (2, 4, 5)
assert conversions.override(Prop.FAN_SPEED).identity
+8
View File
@@ -9,6 +9,7 @@ from pyfglair import (
convert_from_input,
convert_to_display,
detect,
prop_info,
template_info,
)
from pyfglair._cffi import ffi, lib
@@ -61,6 +62,13 @@ def test_conversions():
assert convert_from_input(Template.A, Prop.ADJUST_TEMPERATURE, 220) == 220
def test_template_info_is_cached():
assert template_info(Template.A) is template_info(Template.A)
assert prop_info(Template.A, Prop.FAN_SPEED) is prop_info(
Template.A, Prop.FAN_SPEED
)
def test_api_range():
assert api_range(Template.A, Prop.DISPLAY_TEMPERATURE) == (-100, 450)
assert api_range(Template.A, Prop.OPERATION_MODE) == (0, 6)