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()
+9 -6
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:
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
# Режим включения (1) не несёт информации о режиме: до первого
# явного режима состояние неизвестно.
return self._last_hvac_mode
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": {