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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user