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 hass.data[DATA_PENDING] = hass.data.get(DATA_PENDING, 0) + 1
client = FglairClient(hass, entry.data) client = FglairClient(hass, entry.data)
try:
await client.async_start()
finally:
hass.data[DATA_PENDING] -= 1
conversions = ConversionSet.from_entry(entry.data) conversions = ConversionSet.from_entry(entry.data)
# Координатор создаётся (и подписывается) до старта сессии, чтобы не
# пропустить первое ONLINE.
coordinator = FglairCoordinator(hass, client, conversions) coordinator = FglairCoordinator(hass, client, conversions)
try: try:
await client.async_start()
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
await coordinator.async_sync_now()
except Exception: except Exception:
await coordinator.async_shutdown() await coordinator.async_shutdown()
await client.async_stop() await client.async_stop()
raise raise
finally:
hass.data[DATA_PENDING] -= 1
clients[entry.entry_id] = client clients[entry.entry_id] = client
entry.runtime_data = FglairRuntime(client, coordinator, conversions) 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( unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS entry, PLATFORMS
) )
if not unloaded:
return False
await runtime.coordinator.async_shutdown() await runtime.coordinator.async_shutdown()
await runtime.client.async_stop() 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): class FglairClimate(FglairEntity, ClimateEntity):
_attr_translation_key = "climate"
_attr_name = None _attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_enable_turn_on_off_backwards_compat = False
def __init__(self, coordinator: FglairCoordinator) -> None: def __init__(self, coordinator: FglairCoordinator) -> None:
super().__init__(coordinator, "climate", "climate") super().__init__(coordinator, "climate", "climate")
@@ -105,6 +103,7 @@ class FglairClimate(FglairEntity, ClimateEntity):
features |= ClimateEntityFeature.SWING_MODE features |= ClimateEntityFeature.SWING_MODE
if self._horizontal_swing_supported(): if self._horizontal_swing_supported():
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
return features return features
@property @property
@@ -119,10 +118,9 @@ class FglairClimate(FglairEntity, ClimateEntity):
if value == OPERATION_MODE_OFF: if value == OPERATION_MODE_OFF:
return HVACMode.OFF return HVACMode.OFF
if value == OPERATION_MODE_ON: 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 self._last_hvac_mode
return available[0] if available else HVACMode.AUTO
return HVAC_VALUE_TO_MODE.get(value) return HVAC_VALUE_TO_MODE.get(value)
@property @property
@@ -212,6 +210,11 @@ class FglairClimate(FglairEntity, ClimateEntity):
[(Prop.OPERATION_MODE, OPERATION_MODE_ON)] [(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: async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE) temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None: 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)) step = int(round(float(user_input[CONF_TEMP_STEP]) * 10))
if temp_min >= temp_max: if temp_min >= temp_max:
errors["base"] = "invalid_range" errors["base"] = "invalid_range"
elif step <= 0:
errors["base"] = "invalid_range"
else: else:
data[CONF_TEMPLATE] = template.name data[CONF_TEMPLATE] = template.name
data[CONF_TEMP_STEP] = step data[CONF_TEMP_STEP] = step
@@ -429,19 +427,20 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
template.name, temp_min, temp_max, step, template.name, temp_min, temp_max, step,
) )
return self._create_entry(data) 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: if user_input is None:
user_input = { user_input = {CONF_TEMPLATE: selected}
CONF_TEMPLATE: data.get(CONF_TEMPLATE, Template.A.name),
}
return self.async_show_form( return self.async_show_form(
step_id="preview", 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, errors=errors,
description_placeholders={ description_placeholders={
"preview": _preview_text( "preview": _preview_text(
Template[str(data.get(CONF_TEMPLATE, "A"))], Template[selected], self._trial_properties
self._trial_properties,
) )
}, },
) )
+1
View File
@@ -121,6 +121,7 @@ OP_STATUS_BITS: dict[str, int] = {
"pump_down": 29, "pump_down": 29,
"check_operation": 30, "check_operation": 30,
"simultaneous": 25, "simultaneous": 25,
"different_modes": 21,
} }
# Уставка: фактические ограничения бытового прибора (PROTOCOL §8.3 [LEGACY]); # Уставка: фактические ограничения бытового прибора (PROTOCOL §8.3 [LEGACY]);
+40 -6
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field, replace
from typing import Optional from typing import Optional
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
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
@@ -93,10 +94,34 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
available=state in AVAILABLE_STATES, available=state in AVAILABLE_STATES,
) )
self.async_set_updated_data(self.data) 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._sync_task = self.hass.async_create_task(
self._async_initial_sync() 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 @callback
def _on_property(self, event: PropertyEvent) -> None: def _on_property(self, event: PropertyEvent) -> None:
@@ -127,7 +152,11 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
"""Одно действие пользователя — один batch_commit().""" """Одно действие пользователя — один batch_commit()."""
if not updates: if not updates:
return 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) values = dict(self.data.values)
for prop, _ in updates: for prop, _ in updates:
value = self.client.cached(prop) value = self.client.cached(prop)
@@ -136,11 +165,16 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
self.data = replace(self.data, values=values) self.data = replace(self.data, values=values)
self.async_set_updated_data(self.data) self.async_set_updated_data(self.data)
def _write(self, updates: list[tuple[Prop, int]]) -> None: def _write(self, updates: list[tuple[Prop, int]]) -> bool:
self.client.batch_begin() if not self.client.batch_begin():
return False
ok = True
for prop, value in updates: for prop, value in updates:
self.client.set_int(prop, value) if not self.client.set_int(prop, value):
self.client.batch_commit() ok = False
if not self.client.batch_commit():
ok = False
return ok
async def async_shutdown(self) -> None: async def async_shutdown(self) -> None:
for unsub in self._unsubs: for unsub in self._unsubs:
+2 -1
View File
@@ -58,7 +58,8 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
def capability(self, bit: int) -> Optional[bool]: def capability(self, bit: int) -> Optional[bool]:
caps = self.capabilities() caps = self.capabilities()
if caps is None: if not caps:
# 0/None: возможностей ещё не сообщили — не фильтруем.
return None return None
return bool(caps & (1 << bit)) return bool(caps & (1 << bit))
+47 -8
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
from typing import Any, Mapping, Optional from typing import Any, Mapping, Optional
from pyfglair import Prop, Template 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 ( from .const import (
CONF_OVERRIDES, CONF_OVERRIDES,
@@ -112,22 +112,61 @@ class ConversionSet:
raw = convert_from_input(self.template, prop, api_value) raw = convert_from_input(self.template, prop, api_value)
return override.to_display(raw) return override.to_display(raw)
def from_display(self, prop: Prop, display: int) -> int: def raw_range(self, prop: Prop) -> Optional[tuple[int, int]]:
"""Отображаемое значение → API-значение для set_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) override = self.override(prop)
if override.identity: if override.identity:
return display return convert_to_display(self.template, prop, raw)
raw = override.from_display(display) 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) return convert_to_display(self.template, prop, raw)
def clamp(self, prop: Prop, display: int) -> int: 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]]: def range(self, prop: Prop) -> Optional[tuple[int, int]]:
"""Достижимый диапазон display: override ∩ таблица шаблона."""
override = self.override(prop) 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 None
return (override.min_value, override.max_value) return (low, high)
def temperature_range(self) -> tuple[int, int]: def temperature_range(self) -> tuple[int, int]:
return self.range(Prop.ADJUST_TEMPERATURE) or ( return self.range(Prop.ADJUST_TEMPERATURE) or (
+7 -7
View File
@@ -65,9 +65,9 @@
"temp_offset": "Conversion offset (0.1 °C)" "temp_offset": "Conversion offset (0.1 °C)"
}, },
"data_description": { "data_description": {
"temp_num": "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 in API units, 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 in API units, 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", "no_devices": "No devices found in the account",
"unknown": "Unexpected error", "unknown": "Unexpected error",
"properties_missing": "The device connected but did not report its base properties", "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": { "abort": {
"already_configured": "This device is already configured", "already_configured": "This device is already configured",
@@ -94,9 +94,6 @@
} }
}, },
"entity": { "entity": {
"climate": {
"name": "Air conditioner"
},
"sensor": { "sensor": {
"room_temperature": { "room_temperature": {
"name": "Room temperature" "name": "Room temperature"
@@ -137,6 +134,9 @@
}, },
"simultaneous": { "simultaneous": {
"name": "Simultaneous operation" "name": "Simultaneous operation"
},
"different_modes": {
"name": "Freeze/oil/other modes (template B)"
} }
}, },
"switch": { "switch": {
+1 -1
View File
@@ -55,6 +55,6 @@ def _capability_supported(
if bit is None: if bit is None:
return True return True
caps = coordinator.data.values.get(Prop.DEVICE_CAPABILITIES) caps = coordinator.data.values.get(Prop.DEVICE_CAPABILITIES)
if caps is None: if caps is None or not caps.int_value:
return True return True
return bool(caps.int_value & (1 << bit)) return bool(caps.int_value & (1 << bit))
@@ -65,9 +65,9 @@
"temp_offset": "Conversion offset (0.1 °C)" "temp_offset": "Conversion offset (0.1 °C)"
}, },
"data_description": { "data_description": {
"temp_num": "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 in API units, 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 in API units, 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", "no_devices": "No devices found in the account",
"unknown": "Unexpected error", "unknown": "Unexpected error",
"properties_missing": "The device connected but did not report its base properties", "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": { "abort": {
"already_configured": "This device is already configured", "already_configured": "This device is already configured",
@@ -94,9 +94,6 @@
} }
}, },
"entity": { "entity": {
"climate": {
"name": "Air conditioner"
},
"sensor": { "sensor": {
"room_temperature": { "room_temperature": {
"name": "Room temperature" "name": "Room temperature"
@@ -137,6 +134,9 @@
}, },
"simultaneous": { "simultaneous": {
"name": "Simultaneous operation" "name": "Simultaneous operation"
},
"different_modes": {
"name": "Freeze/oil/other modes (template B)"
} }
}, },
"switch": { "switch": {
@@ -65,9 +65,9 @@
"temp_offset": "Смещение конверсии (0,1 °C)" "temp_offset": "Смещение конверсии (0,1 °C)"
}, },
"data_description": { "data_description": {
"temp_num": "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 в единицах API, 0,1 °C)", "temp_den": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)",
"temp_offset": "display = raw × num / den + offset (raw в единицах API, 0,1 °C)" "temp_offset": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)"
} }
} }
}, },
@@ -81,7 +81,7 @@
"no_devices": "В аккаунте не найдено устройств", "no_devices": "В аккаунте не найдено устройств",
"unknown": "Неожиданная ошибка", "unknown": "Неожиданная ошибка",
"properties_missing": "Устройство подключилось, но не отдало базовые свойства", "properties_missing": "Устройство подключилось, но не отдало базовые свойства",
"invalid_range": "Минимум должен быть меньше максимума, шаг — положительным" "invalid_range": "Минимум должен быть меньше максимума"
}, },
"abort": { "abort": {
"already_configured": "Устройство уже настроено", "already_configured": "Устройство уже настроено",
@@ -94,9 +94,6 @@
} }
}, },
"entity": { "entity": {
"climate": {
"name": "Кондиционер"
},
"sensor": { "sensor": {
"room_temperature": { "room_temperature": {
"name": "Температура в помещении" "name": "Температура в помещении"
@@ -137,6 +134,9 @@
}, },
"simultaneous": { "simultaneous": {
"name": "Одновременные операции" "name": "Одновременные операции"
},
"different_modes": {
"name": "Разморозка/масло/прочие режимы (шаблон B)"
} }
}, },
"switch": { "switch": {
+3
View File
@@ -3,6 +3,7 @@ src/aircon/tables.cpp; дублей в Python нет)."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from typing import Optional, Tuple from typing import Optional, Tuple
from ._cffi import ffi, lib from ._cffi import ffi, lib
@@ -36,6 +37,7 @@ def detect(oem_model: Optional[str]) -> Optional[Template]:
return Template(result) return Template(result)
@lru_cache(maxsize=None)
def template_info(template: Template) -> Tuple[PropertyInfo, ...]: def template_info(template: Template) -> Tuple[PropertyInfo, ...]:
"""Все свойства шаблона (порядок констант Prop).""" """Все свойства шаблона (порядок констант Prop)."""
out = [] out = []
@@ -67,6 +69,7 @@ def template_info(template: Template) -> Tuple[PropertyInfo, ...]:
return tuple(out) return tuple(out)
@lru_cache(maxsize=None)
def prop_info(template: Template, prop: Prop) -> Optional[PropertyInfo]: def prop_info(template: Template, prop: Prop) -> Optional[PropertyInfo]:
for info in template_info(template): for info in template_info(template):
if info.prop == prop: if info.prop == prop:
+3
View File
@@ -292,6 +292,9 @@ class Mock:
cmd = c.get("cmd", {}) cmd = c.get("cmd", {})
if cmd.get("method") == "DELETE": if cmd.get("method") == "DELETE":
print("DELETE", flush=True) print("DELETE", flush=True)
# Слот освобождён: следующая сессия должна получить KE.
self.crypto = None
self.active = False
return return
res = cmd.get("resource", "") res = cmd.get("resource", "")
name = res.split("name=")[-1] 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 _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( 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["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"], PREVIEW_INPUT) result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["type"] == "create_entry" 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) 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) 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 = [ RESTRICTED_SETTINGS = [
"--set", "operation_mode=3", "--set", "operation_mode=3",
"--set", "fan_speed=4", "--set", "fan_speed=4",
@@ -258,6 +282,7 @@ async def test_manual_conversion_applied(
await _wait_for( await _wait_for(
lambda: climate_id and hass.states.get(climate_id) is not None 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).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["temperature"] == 22.5
assert hass.states.get(climate_id).attributes["min_temp"] == 17.0 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 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(): def test_from_entry_parses_spec():
conversions = ConversionSet.from_entry( conversions = ConversionSet.from_entry(
{ {
@@ -54,7 +68,9 @@ def test_from_entry_parses_spec():
) )
assert conversions.template == Template.B assert conversions.template == Template.B
assert conversions.step_tenths == 10 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) override = conversions.override(Prop.ADJUST_TEMPERATURE)
assert (override.num, override.den, override.offset) == (2, 4, 5) assert (override.num, override.den, override.offset) == (2, 4, 5)
assert conversions.override(Prop.FAN_SPEED).identity assert conversions.override(Prop.FAN_SPEED).identity
+8
View File
@@ -9,6 +9,7 @@ from pyfglair import (
convert_from_input, convert_from_input,
convert_to_display, convert_to_display,
detect, detect,
prop_info,
template_info, template_info,
) )
from pyfglair._cffi import ffi, lib from pyfglair._cffi import ffi, lib
@@ -61,6 +62,13 @@ def test_conversions():
assert convert_from_input(Template.A, Prop.ADJUST_TEMPERATURE, 220) == 220 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(): def test_api_range():
assert api_range(Template.A, Prop.DISPLAY_TEMPERATURE) == (-100, 450) assert api_range(Template.A, Prop.DISPLAY_TEMPERATURE) == (-100, 450)
assert api_range(Template.A, Prop.OPERATION_MODE) == (0, 6) assert api_range(Template.A, Prop.OPERATION_MODE) == (0, 6)