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:
2026-09-29 12:44:34 +03:00
parent 29f62b639c
commit 3e0b6b3653
21 changed files with 1974 additions and 87 deletions
+27 -6
View File
@@ -5,16 +5,25 @@ import logging
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
import pyfglair import pyfglair
from .const import DOMAIN from .const import DOMAIN
from .coordinator import FglairCoordinator, FglairRuntime
from .fglair_client import FglairClient from .fglair_client import FglairClient
from .overrides import ConversionSet
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
PLATFORMS: list[str] = [] PLATFORMS = [
Platform.CLIMATE,
Platform.SENSOR,
Platform.BINARY_SENSOR,
Platform.SWITCH,
Platform.SELECT,
]
_LOG_LEVELS = { _LOG_LEVELS = {
0: logging.DEBUG, 0: logging.DEBUG,
@@ -60,23 +69,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
finally: finally:
hass.data[DATA_PENDING] -= 1 hass.data[DATA_PENDING] -= 1
conversions = ConversionSet.from_entry(entry.data)
coordinator = FglairCoordinator(hass, client, conversions)
try:
await coordinator.async_config_entry_first_refresh()
except Exception:
await coordinator.async_shutdown()
await client.async_stop()
raise
clients[entry.entry_id] = client clients[entry.entry_id] = client
entry.runtime_data = client entry.runtime_data = FglairRuntime(client, coordinator, conversions)
if len(clients) == 1: if len(clients) == 1:
pyfglair.set_log_handler(_log_handler) pyfglair.set_log_handler(_log_handler)
pyfglair.set_log_level(1) pyfglair.set_log_level(1)
if PLATFORMS:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
client: FglairClient = entry.runtime_data runtime: FglairRuntime = entry.runtime_data
await client.async_stop() unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
await runtime.coordinator.async_shutdown()
await runtime.client.async_stop()
clients: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {}) clients: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {})
clients.pop(entry.entry_id, None) clients.pop(entry.entry_id, None)
if not clients and not hass.data.get(DATA_PENDING, 0): if not clients and not hass.data.get(DATA_PENDING, 0):
await hass.async_add_executor_job(pyfglair.set_log_handler, None) await hass.async_add_executor_job(pyfglair.set_log_handler, None)
return True return unloaded
+64
View File
@@ -0,0 +1,64 @@
"""Binary sensors: connectivity и флаги op_status (PROTOCOL §8.4)."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import EntityCategory
from pyfglair import Prop, State
from pyfglair.templates import template_info
from .const import OP_STATUS_BITS
from .coordinator import FglairCoordinator, FglairRuntime
from .entity import FglairEntity
class FglairConnectivity(FglairEntity, BinarySensorEntity):
_attr_translation_key = "connectivity"
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
def __init__(self, coordinator: FglairCoordinator) -> None:
super().__init__(coordinator, "connectivity", "connectivity")
@property
def available(self) -> bool:
return True
@property
def is_on(self) -> bool:
return self.coordinator.data.state == State.ONLINE
class FglairStatusFlag(FglairEntity, BinarySensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self, coordinator: FglairCoordinator, key: str, bit: int
) -> None:
super().__init__(coordinator, f"op_status_{key}", key)
self._bit = bit
@property
def is_on(self) -> bool:
status = self.raw_value(Prop.OP_STATUS)
if status is None:
return False
return bool(status & (1 << self._bit))
async def async_setup_entry(hass, entry, async_add_entities) -> None:
runtime: FglairRuntime = entry.runtime_data
coordinator = runtime.coordinator
props = {
info.prop for info in template_info(runtime.client.template)
}
entities = [FglairConnectivity(coordinator)]
if Prop.OP_STATUS in props:
entities.extend(
FglairStatusFlag(coordinator, key, bit)
for key, bit in OP_STATUS_BITS.items()
)
async_add_entities(entities)
+263
View File
@@ -0,0 +1,263 @@
"""Climate-сущность FGLair: режим, уставка, fan, swing, preset ECO/BOOST."""
from __future__ import annotations
from typing import Any, Optional
from homeassistant.components.climate import (
ATTR_TEMPERATURE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import callback
from pyfglair import Prop
from pyfglair.templates import prop_info
from .const import (
CAP_FAN,
CAP_HVAC,
CAP_SWING_HORIZONTAL,
CAP_SWING_VERTICAL,
FAN_MODE_TO_VALUE,
FAN_VALUE_TO_MODE,
HVAC_MODE_TO_VALUE,
HVAC_VALUE_TO_MODE,
OPERATION_MODE_OFF,
OPERATION_MODE_ON,
PRESET_BOOST,
PRESET_CAP,
PRESET_ECO,
PRESET_PROP,
SWING_HORIZONTAL_MODES,
SWING_MODES,
)
from .coordinator import FglairCoordinator, FglairRuntime
from .entity import FglairEntity
async def async_setup_entry(hass, entry, async_add_entities) -> None:
runtime: FglairRuntime = entry.runtime_data
async_add_entities([FglairClimate(runtime.coordinator)])
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")
self._last_hvac_mode: Optional[HVACMode] = None
# -- свойства шаблона --------------------------------------------------
def _in_template(self, prop: Prop) -> bool:
return prop_info(self.coordinator.client.template, prop) is not None
def _hvac_modes(self) -> list[HVACMode]:
modes: list[HVACMode] = [HVACMode.OFF]
for mode, bit in CAP_HVAC.items():
if self.supports_capability(bit):
modes.append(mode)
return modes
def _fan_modes(self) -> Optional[list[str]]:
if not self._in_template(Prop.FAN_SPEED):
return None
return [
mode
for mode, bit in CAP_FAN.items()
if self.supports_capability(bit)
]
def _preset_modes(self) -> Optional[list[str]]:
presets = [
preset
for preset, bit in PRESET_CAP.items()
if self._in_template(PRESET_PROP[preset])
and self.supports_capability(bit)
]
return presets or None
def _vertical_swing_supported(self) -> bool:
return self._in_template(Prop.AF_VERTICAL_SWING) and (
self.supports_capability(CAP_SWING_VERTICAL)
)
def _horizontal_swing_supported(self) -> bool:
return self._in_template(Prop.AF_HORIZONTAL_SWING) and (
self.supports_capability(CAP_SWING_HORIZONTAL)
)
# -- ClimateEntity ----------------------------------------------------
@property
def supported_features(self) -> ClimateEntityFeature:
features = ClimateEntityFeature(0)
if self._in_template(Prop.ADJUST_TEMPERATURE):
features |= ClimateEntityFeature.TARGET_TEMPERATURE
if self._fan_modes():
features |= ClimateEntityFeature.FAN_MODE
if self._preset_modes():
features |= ClimateEntityFeature.PRESET_MODE
if self._vertical_swing_supported():
features |= ClimateEntityFeature.SWING_MODE
if self._horizontal_swing_supported():
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
return features
@property
def hvac_modes(self) -> list[HVACMode]:
return self._hvac_modes()
@property
def hvac_mode(self) -> Optional[HVACMode]:
value = self.raw_value(Prop.OPERATION_MODE)
if value is None:
return None
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
return HVAC_VALUE_TO_MODE.get(value)
@property
def target_temperature(self) -> Optional[float]:
display = self.display_value(Prop.ADJUST_TEMPERATURE)
return display / 10 if display is not None else None
@property
def target_temperature_step(self) -> Optional[float]:
return self.coordinator.conversions.step_tenths / 10
@property
def min_temp(self) -> float:
return self.coordinator.conversions.temperature_range()[0] / 10
@property
def max_temp(self) -> float:
return self.coordinator.conversions.temperature_range()[1] / 10
@property
def current_temperature(self) -> Optional[float]:
display = self.display_value(Prop.DISPLAY_TEMPERATURE)
return display / 10 if display is not None else None
@property
def fan_mode(self) -> Optional[str]:
value = self.raw_value(Prop.FAN_SPEED)
return FAN_VALUE_TO_MODE.get(value) if value is not None else None
@property
def fan_modes(self) -> Optional[list[str]]:
return self._fan_modes()
@property
def preset_mode(self) -> Optional[str]:
if self.raw_value(PRESET_PROP[PRESET_ECO]) == 1:
return PRESET_ECO
if self.raw_value(PRESET_PROP[PRESET_BOOST]) == 1:
return PRESET_BOOST
return None
@property
def preset_modes(self) -> Optional[list[str]]:
return self._preset_modes()
@property
def swing_mode(self) -> Optional[str]:
if not self._vertical_swing_supported():
return None
value = self.raw_value(Prop.AF_VERTICAL_SWING)
return None if value is None else ("on" if value else "off")
@property
def swing_modes(self) -> Optional[list[str]]:
return SWING_MODES if self._vertical_swing_supported() else None
@property
def swing_horizontal_mode(self) -> Optional[str]:
if not self._horizontal_swing_supported():
return None
value = self.raw_value(Prop.AF_HORIZONTAL_SWING)
return None if value is None else ("on" if value else "off")
@property
def swing_horizontal_modes(self) -> Optional[list[str]]:
return (
SWING_HORIZONTAL_MODES
if self._horizontal_swing_supported()
else None
)
# -- команды ----------------------------------------------------------
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
await self.coordinator.async_write(
[(Prop.OPERATION_MODE, OPERATION_MODE_OFF)]
)
return
value = HVAC_MODE_TO_VALUE.get(hvac_mode)
if value is None:
return
self._last_hvac_mode = hvac_mode
await self.coordinator.async_write([(Prop.OPERATION_MODE, value)])
async def async_turn_on(self) -> None:
await self.coordinator.async_write(
[(Prop.OPERATION_MODE, OPERATION_MODE_ON)]
)
async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
display = int(round(temperature * 10))
display = self.coordinator.conversions.clamp(
Prop.ADJUST_TEMPERATURE, display
)
api_value = self.coordinator.conversions.from_display(
Prop.ADJUST_TEMPERATURE, display
)
await self.coordinator.async_write(
[(Prop.ADJUST_TEMPERATURE, api_value)]
)
async def async_set_fan_mode(self, fan_mode: str) -> None:
value = FAN_MODE_TO_VALUE.get(fan_mode)
if value is None:
return
await self.coordinator.async_write([(Prop.FAN_SPEED, value)])
async def async_set_preset_mode(self, preset_mode: str) -> None:
eco = 1 if preset_mode == PRESET_ECO else 0
boost = 1 if preset_mode == PRESET_BOOST else 0
updates = [(prop, value) for prop, value in (
(PRESET_PROP[PRESET_ECO], eco),
(PRESET_PROP[PRESET_BOOST], boost),
) if self._in_template(prop)]
await self.coordinator.async_write(updates)
async def async_set_swing_mode(self, swing_mode: str) -> None:
await self.coordinator.async_write(
[(Prop.AF_VERTICAL_SWING, 1 if swing_mode == "on" else 0)]
)
async def async_set_swing_horizontal_mode(
self, swing_mode: str
) -> None:
await self.coordinator.async_write(
[(Prop.AF_HORIZONTAL_SWING, 1 if swing_mode == "on" else 0)]
)
@callback
def _handle_coordinator_update(self) -> None:
value = self.raw_value(Prop.OPERATION_MODE)
mode = HVAC_VALUE_TO_MODE.get(value) if value is not None else None
if mode is not None:
self._last_hvac_mode = mode
super()._handle_coordinator_update()
+140 -4
View File
@@ -12,11 +12,15 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import selector from homeassistant.helpers import selector
import pyfglair import pyfglair
from pyfglair import Template from pyfglair import Prop, Template
from pyfglair.provision import Device from pyfglair.provision import Device
from pyfglair.templates import detect from pyfglair.templates import detect
from .const import ( from .const import (
CAP_FAN,
CAP_HVAC,
CAP_SWING_HORIZONTAL,
CAP_SWING_VERTICAL,
CONF_CONFIG_JSON, CONF_CONFIG_JSON,
CONF_DEVICE_PORT, CONF_DEVICE_PORT,
CONF_DSN, CONF_DSN,
@@ -27,13 +31,23 @@ from .const import (
CONF_LISTEN_PORT, CONF_LISTEN_PORT,
CONF_MODEL, CONF_MODEL,
CONF_NAME, CONF_NAME,
CONF_OVERRIDES,
CONF_PASSWORD, CONF_PASSWORD,
CONF_REGION, CONF_REGION,
CONF_TEMP_DEN,
CONF_TEMP_MAX,
CONF_TEMP_MIN,
CONF_TEMP_NUM,
CONF_TEMP_OFFSET,
CONF_TEMP_STEP,
CONF_TEMPLATE, CONF_TEMPLATE,
DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_PORT,
DEFAULT_LISTEN_PORT, DEFAULT_LISTEN_PORT,
DOMAIN, DOMAIN,
REGIONS, REGIONS,
TEMP_MAX_TENTHS,
TEMP_MIN_TENTHS,
TEMP_STEP_TENTHS,
TRIAL_ERRORS, TRIAL_ERRORS,
TRIAL_TIMEOUT, 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: def _template_name(model: str | None) -> str:
if model: if model:
found = detect(model) found = detect(model)
@@ -173,6 +256,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
self._devices: list[Device] = [] self._devices: list[Device] = []
self._pending_data: dict[str, Any] = {}
self._trial_properties: dict[Any, Any] = {}
def _show_form( def _show_form(
self, self,
@@ -256,7 +341,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
} }
error = await self._async_trial(data) error = await self._async_trial(data)
if error is None: if error is None:
return self._create_entry(data) return await self.async_step_preview()
errors["base"] = error errors["base"] = error
schema = vol.Schema( schema = vol.Schema(
{ {
@@ -278,7 +363,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
data = _manual_data(user_input) data = _manual_data(user_input)
error = await self._async_trial(data) error = await self._async_trial(data)
if error is None: if error is None:
return self._create_entry(data) return await self.async_step_preview()
errors["base"] = error errors["base"] = error
return self._show_form( return self._show_form(
"manual", _manual_schema(), errors, user_input, "manual", _manual_schema(), errors, user_input,
@@ -296,7 +381,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
else: else:
error = await self._async_trial(data) error = await self._async_trial(data)
if error is None: if error is None:
return self._create_entry(data) return await self.async_step_preview()
errors["base"] = error errors["base"] = error
return self._show_form("import_json", _import_schema(), errors, user_input) 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.async_abort(reason=error)
return self._create_entry(data) 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: async def _async_trial(self, data: dict[str, Any]) -> str | None:
"""None — сессия поднялась; иначе ключ ошибки формы.""" """None — сессия поднялась; иначе ключ ошибки формы."""
await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False) 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) partial(_run_trial, data)
) )
if result.ok: if result.ok:
self._pending_data = data
self._trial_properties = result.properties
return None return None
_LOGGER.debug( _LOGGER.debug(
"пробное подключение не удалось: reason=%s state=%s error=%s", "пробное подключение не удалось: reason=%s state=%s error=%s",
+98 -1
View File
@@ -1,6 +1,9 @@
"""Константы интеграции fglair.""" """Константы и словари значений интеграции fglair (PROTOCOL §8)."""
from __future__ import annotations from __future__ import annotations
from homeassistant.components.climate import HVACMode
from pyfglair import Prop, State, Template
from pyfglair.provision import REGIONS as _PROVISION_REGIONS from pyfglair.provision import REGIONS as _PROVISION_REGIONS
DOMAIN = "fglair" DOMAIN = "fglair"
@@ -19,6 +22,13 @@ CONF_REGION = "region"
CONF_EMAIL = "email" CONF_EMAIL = "email"
CONF_PASSWORD = "password" CONF_PASSWORD = "password"
CONF_CONFIG_JSON = "config_json" CONF_CONFIG_JSON = "config_json"
CONF_OVERRIDES = "overrides"
CONF_TEMP_STEP = "temp_step"
CONF_TEMP_MIN = "temp_min"
CONF_TEMP_MAX = "temp_max"
CONF_TEMP_NUM = "temp_num"
CONF_TEMP_DEN = "temp_den"
CONF_TEMP_OFFSET = "temp_offset"
DEFAULT_DEVICE_PORT = 80 DEFAULT_DEVICE_PORT = 80
# 0 => свободный эфемерный порт: несколько ConfigEntry (несколько # 0 => свободный эфемерный порт: несколько ConfigEntry (несколько
@@ -38,3 +48,90 @@ TRIAL_ERRORS = {
"start_failed": "cannot_connect", "start_failed": "cannot_connect",
"properties_missing": "properties_missing", "properties_missing": "properties_missing",
} }
# ---------------------------------------------------------------------------
# operation_mode (PROTOCOL §8.3): 0=OFF, 1=ON, 2=AUTO, 3=COOL, 4=DRY,
# 5=FAN, 6=HEAT
# ---------------------------------------------------------------------------
OPERATION_MODE_OFF = 0
OPERATION_MODE_ON = 1
HVAC_MODE_TO_VALUE = {
HVACMode.AUTO: 2,
HVACMode.COOL: 3,
HVACMode.DRY: 4,
HVACMode.FAN_ONLY: 5,
HVACMode.HEAT: 6,
}
HVAC_VALUE_TO_MODE = {value: mode for mode, value in HVAC_MODE_TO_VALUE.items()}
# device_capabilities, биты (PROTOCOL §8.5)
CAP_HVAC = {
HVACMode.AUTO: 4,
HVACMode.COOL: 0,
HVACMode.DRY: 1,
HVACMode.FAN_ONLY: 2,
HVACMode.HEAT: 3,
}
CAP_FAN = {"auto": 5, "high": 6, "medium": 7, "low": 8, "quiet": 9}
CAP_SWING_VERTICAL = 10
CAP_SWING_HORIZONTAL = 11
CAP_ECONOMY = 12
CAP_MIN_HEAT = 13
CAP_INDOOR_FAN_CONTROL = 14
CAP_POWERFUL = 16
CAP_OUTDOOR_LOW_NOISE = 17
CAP_COIL_DRY = 18
# fan_speed (PROTOCOL §8.3)
FAN_VALUE_TO_MODE = {0: "quiet", 1: "low", 2: "medium", 3: "high", 4: "auto"}
FAN_MODE_TO_VALUE = {mode: value for value, mode in FAN_VALUE_TO_MODE.items()}
# preset: ECO = economy_mode, BOOST = powerful_mode
PRESET_ECO = "eco"
PRESET_BOOST = "boost"
PRESET_PROP = {
PRESET_ECO: Prop.ECONOMY_MODE,
PRESET_BOOST: Prop.POWERFUL_MODE,
}
PRESET_CAP = {
PRESET_ECO: CAP_ECONOMY,
PRESET_BOOST: CAP_POWERFUL,
}
SWING_MODES = ["off", "on"]
SWING_HORIZONTAL_MODES = ["off", "on"]
# switch-свойства: prop -> (translation_key, capability bit | None)
SWITCH_PROPS: dict[Prop, tuple[str, int | None]] = {
Prop.ECONOMY_MODE: ("economy_mode", CAP_ECONOMY),
Prop.POWERFUL_MODE: ("powerful_mode", CAP_POWERFUL),
Prop.COIL_DRY_MODE: ("coil_dry_mode", CAP_COIL_DRY),
Prop.MIN_HEAT: ("min_heat", CAP_MIN_HEAT),
Prop.OUTDOOR_LOW_NOISE: ("outdoor_low_noise", CAP_OUTDOOR_LOW_NOISE),
Prop.HUMAN_DET_AUTO_SAVE: ("human_det_auto_save", None),
Prop.WIFI_LED_ENABLE: ("wifi_led_enable", None),
Prop.INDOOR_FAN_CONTROL: ("indoor_fan_control", CAP_INDOOR_FAN_CONTROL),
}
# op_status, биты (PROTOCOL §8.4): translation_key -> bit
OP_STATUS_BITS: dict[str, int] = {
"defrost": 24,
"maintenance": 22,
"oil_recovery": 28,
"pump_down": 29,
"check_operation": 30,
"simultaneous": 25,
}
# Уставка: фактические ограничения бытового прибора (PROTOCOL §8.3 [LEGACY]);
# единицы API = 0.1 °C.
TEMP_MIN_TENTHS = 160
TEMP_MAX_TENTHS = 300
TEMP_STEP_TENTHS = {
Template.A: 5,
Template.B: 10,
Template.F: 5,
}
# Состояния, в которых сущности доступны (PROTOCOL/план §5).
AVAILABLE_STATES = (State.ONLINE, State.RECOVERING)
+152
View File
@@ -0,0 +1,152 @@
"""Push-driven координатор: кэш свойств + доступность по состоянию сессии."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field, replace
from typing import Optional
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind
from pyfglair.templates import template_info
from .const import AVAILABLE_STATES, CONF_DSN, DOMAIN
from .fglair_client import FglairClient
from .overrides import ConversionSet
_LOGGER = logging.getLogger(__name__)
@dataclass
class FglairData:
values: dict[Prop, Value] = field(default_factory=dict)
state: State = State.IDLE
last_error: Error = Error.NONE
available: bool = False
@dataclass
class FglairRuntime:
client: FglairClient
coordinator: "FglairCoordinator"
conversions: ConversionSet
def _writable_props(template: Template):
for info in template_info(template):
if info.kind != ValueKind.STRING:
yield info.prop
class FglairCoordinator(DataUpdateCoordinator[FglairData]):
"""Держит актуальный снимок свойств и рассылает его сущностям."""
def __init__(
self,
hass: HomeAssistant,
client: FglairClient,
conversions: ConversionSet,
) -> None:
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN}-{client.data[CONF_DSN]}",
update_interval=None,
)
self.client = client
self.conversions = conversions
self._synced = False
self._sync_task: Optional[asyncio.Task] = None
self._unsubs = [
client.add_state_listener(self._on_state),
client.add_property_listener(self._on_property),
]
self.data = self._snapshot()
# -- снимок -----------------------------------------------------------
def _snapshot(self) -> FglairData:
values: dict[Prop, Value] = {}
for prop in _writable_props(self.client.template):
value = self.client.cached(prop)
if value is not None:
values[prop] = value
state = self.client.state
return FglairData(
values=values,
state=state,
last_error=self.client.last_error,
available=state in AVAILABLE_STATES,
)
async def _async_update_data(self) -> FglairData:
return await self.hass.async_add_executor_job(self._snapshot)
# -- события ----------------------------------------------------------
@callback
def _on_state(self, state: State, error: Error) -> None:
self.data = replace(
self.data,
state=state,
last_error=error,
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:
self._sync_task = self.hass.async_create_task(
self._async_initial_sync()
)
@callback
def _on_property(self, event: PropertyEvent) -> None:
values = dict(self.data.values)
values[event.prop] = event.value
self.data = replace(self.data, values=values)
self.async_set_updated_data(self.data)
async def _async_initial_sync(self) -> None:
try:
await self.hass.async_add_executor_job(self._initial_sync)
self._synced = True
except Exception: # pragma: no cover - защитный путь
_LOGGER.exception("первичная синхронизация свойств не удалась")
finally:
self._sync_task = None
def _initial_sync(self) -> None:
self.client.batch_begin()
for prop in _writable_props(self.client.template):
self.client.get_prop(prop)
self.client.batch_commit()
# -- записи -----------------------------------------------------------
async def async_write(
self, updates: list[tuple[Prop, int]]
) -> None:
"""Одно действие пользователя — один batch_commit()."""
if not updates:
return
await self.hass.async_add_executor_job(self._write, updates)
values = dict(self.data.values)
for prop, _ in updates:
value = self.client.cached(prop)
if value is not None:
values[prop] = value
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()
for prop, value in updates:
self.client.set_int(prop, value)
self.client.batch_commit()
async def async_shutdown(self) -> None:
for unsub in self._unsubs:
unsub()
self._unsubs.clear()
if self._sync_task is not None:
self._sync_task.cancel()
self._sync_task = None
await super().async_shutdown()
+70
View File
@@ -0,0 +1,70 @@
"""Базовая сущность fglair: DeviceInfo, значения из координатора."""
from __future__ import annotations
from typing import Optional
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from pyfglair import Prop, Value
from .const import CONF_DSN, CONF_MODEL, CONF_NAME, DOMAIN
from .coordinator import FglairCoordinator
class FglairEntity(CoordinatorEntity[FglairCoordinator]):
"""Общий базис: unique_id = <dsn>_<key>, device_info — один прибор."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FglairCoordinator,
unique_suffix: str,
translation_key: str,
) -> None:
super().__init__(coordinator)
data = coordinator.client.data
dsn = data[CONF_DSN]
self._attr_unique_id = f"{dsn}_{unique_suffix}"
self._attr_translation_key = translation_key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, dsn)},
name=data.get(CONF_NAME) or dsn,
manufacturer="Fujitsu General",
model=data.get(CONF_MODEL) or None,
)
@property
def available(self) -> bool:
return self.coordinator.data.available and super().available
def value(self, prop: Prop) -> Optional[Value]:
return self.coordinator.data.values.get(prop)
def raw_value(self, prop: Prop) -> Optional[int]:
value = self.value(prop)
return value.int_value if value is not None else None
def display_value(self, prop: Prop) -> Optional[int]:
"""Значение свойства в единицах отображения (с ручной конверсией)."""
raw = self.raw_value(prop)
if raw is None:
return None
return self.coordinator.conversions.to_display(prop, raw)
def capabilities(self) -> Optional[int]:
return self.raw_value(Prop.DEVICE_CAPABILITIES)
def capability(self, bit: int) -> Optional[bool]:
caps = self.capabilities()
if caps is None:
return None
return bool(caps & (1 << bit))
def supports_capability(self, bit: Optional[int]) -> bool:
"""None — считать поддержанным (capabilities ещё не пришли)."""
if bit is None:
return True
cap = self.capability(bit)
return True if cap is None else cap
+136
View File
@@ -0,0 +1,136 @@
"""Ручные конверсии/диапазоны свойств (план §3 шаг 3, §4).
Ядро конвертирует raw→API по таблице шаблона; ручная конверсия задаётся
коэффициентами ``display = raw*num/den + offset`` (в единицах API, для
температуры это 0.1 °C) и диапазоном. Применение — на стороне HA:
display = linear(raw), raw = convert_from_input(template, api)
api = convert_to_display(template, linear_inverse(display))
"""
from __future__ import annotations
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 .const import (
CONF_OVERRIDES,
CONF_TEMP_STEP,
TEMP_MAX_TENTHS,
TEMP_MIN_TENTHS,
TEMP_STEP_TENTHS,
)
def _div_trunc(numerator: int, denominator: int) -> int:
quotient = abs(numerator) // abs(denominator)
return quotient if (numerator < 0) == (denominator < 0) else -quotient
@dataclass(frozen=True)
class LinearOverride:
num: int = 1
den: int = 1
offset: int = 0
min_value: Optional[int] = None
max_value: Optional[int] = None
@property
def identity(self) -> bool:
return self.num == 1 and self.den == 1 and self.offset == 0
def to_display(self, raw: int) -> int:
return _div_trunc(raw * self.num, self.den) + self.offset
def from_display(self, display: int) -> int:
return _div_trunc((display - self.offset) * self.den, self.num)
def clamp(self, display: int) -> int:
if self.min_value is not None and display < self.min_value:
return self.min_value
if self.max_value is not None and display > self.max_value:
return self.max_value
return display
class ConversionSet:
"""Конверсии записи: ручные override поверх шаблонных конверсий ядра."""
def __init__(
self,
template: Template,
overrides: Mapping[Prop, LinearOverride],
step_tenths: int,
) -> None:
self.template = template
self.overrides = dict(overrides)
self.step_tenths = step_tenths
@classmethod
def from_entry(cls, data: Mapping[str, Any]) -> "ConversionSet":
template = Template[str(data.get("template", "A"))]
raw_overrides = data.get(CONF_OVERRIDES) or {}
overrides: dict[Prop, LinearOverride] = {}
if isinstance(raw_overrides, dict):
for name, spec in raw_overrides.items():
try:
prop = Prop[str(name).upper()]
except KeyError:
continue
if not isinstance(spec, dict):
continue
num = int(spec.get("num", 1)) or 1
den = int(spec.get("den", 1)) or 1
overrides[prop] = LinearOverride(
num=num,
den=den,
offset=int(spec.get("offset", 0)),
min_value=(
int(spec["min"]) if spec.get("min") is not None else None
),
max_value=(
int(spec["max"]) if spec.get("max") is not None else None
),
)
step = int(data.get(CONF_TEMP_STEP) or TEMP_STEP_TENTHS[template])
return cls(template, overrides, step)
def override(self, prop: Prop) -> LinearOverride:
return self.overrides.get(prop, LinearOverride())
def to_display(self, prop: Prop, api_value: int) -> int:
"""API-значение из ядра → отображаемое (при override — единицы API).
Без override отображаемое значение равно API-значению ядра
(например, display_temperature API 200 = 20.0 °C). С линейным
override: display = raw*num/den + offset.
"""
override = self.override(prop)
if override.identity:
return api_value
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()."""
override = self.override(prop)
if override.identity:
return display
raw = override.from_display(display)
return convert_to_display(self.template, prop, raw)
def clamp(self, prop: Prop, display: int) -> int:
return self.override(prop).clamp(display)
def range(self, prop: Prop) -> Optional[tuple[int, int]]:
override = self.override(prop)
if override.min_value is None or override.max_value is None:
return None
return (override.min_value, override.max_value)
def temperature_range(self) -> tuple[int, int]:
return self.range(Prop.ADJUST_TEMPERATURE) or (
TEMP_MIN_TENTHS,
TEMP_MAX_TENTHS,
)
+74
View File
@@ -0,0 +1,74 @@
"""Selects: положения заслонок (af_*_direction, число позиций из num_dir)."""
from __future__ import annotations
from homeassistant.components.select import SelectEntity
from pyfglair import Prop
from pyfglair.templates import prop_info
from .coordinator import FglairCoordinator, FglairRuntime
from .entity import FglairEntity
class FglairDirectionSelect(FglairEntity, SelectEntity):
def __init__(
self,
coordinator: FglairCoordinator,
prop: Prop,
num_dir_prop: Prop,
key: str,
) -> None:
super().__init__(coordinator, prop.name.lower(), key)
self._prop = prop
self._num_dir_prop = num_dir_prop
@property
def options(self) -> list[str]:
num = self.raw_value(self._num_dir_prop)
if num is None or num <= 0 or num > 15:
return ["0"]
return [str(value) for value in range(num)]
@property
def current_option(self) -> str | None:
value = self.raw_value(self._prop)
if value is None:
return None
text = str(value)
return text if text in self.options else self.options[0]
async def async_select_option(self, option: str) -> None:
await self.coordinator.async_write([(self._prop, int(option))])
async def async_setup_entry(hass, entry, async_add_entities) -> None:
runtime: FglairRuntime = entry.runtime_data
coordinator = runtime.coordinator
template = runtime.client.template
entities = []
if (
prop_info(template, Prop.AF_VERTICAL_DIRECTION) is not None
and prop_info(template, Prop.AF_VERTICAL_NUM_DIR) is not None
):
entities.append(
FglairDirectionSelect(
coordinator,
Prop.AF_VERTICAL_DIRECTION,
Prop.AF_VERTICAL_NUM_DIR,
"af_vertical_direction",
)
)
if (
prop_info(template, Prop.AF_HORIZONTAL_DIRECTION) is not None
and prop_info(template, Prop.AF_HORIZONTAL_NUM_DIR) is not None
):
entities.append(
FglairDirectionSelect(
coordinator,
Prop.AF_HORIZONTAL_DIRECTION,
Prop.AF_HORIZONTAL_NUM_DIR,
"af_horizontal_direction",
)
)
async_add_entities(entities)
+78
View File
@@ -0,0 +1,78 @@
"""Sensors: температура помещения, код ошибки, состояние связи."""
from __future__ import annotations
from typing import Optional
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfTemperature
from pyfglair import Prop, State
from pyfglair.templates import template_info
from .coordinator import FglairCoordinator, FglairRuntime
from .entity import FglairEntity
class FglairRoomTemperature(FglairEntity, SensorEntity):
_attr_translation_key = "room_temperature"
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_suggested_display_precision = 1
def __init__(self, coordinator: FglairCoordinator) -> None:
super().__init__(coordinator, "display_temperature", "room_temperature")
@property
def native_value(self) -> Optional[float]:
display = self.display_value(Prop.DISPLAY_TEMPERATURE)
return display / 10 if display is not None else None
class FglairErrorCode(FglairEntity, SensorEntity):
_attr_translation_key = "error_code"
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: FglairCoordinator) -> None:
super().__init__(coordinator, "error_code", "error_code")
@property
def native_value(self) -> Optional[int]:
return self.raw_value(Prop.ERROR_CODE)
class FglairConnectionState(FglairEntity, SensorEntity):
_attr_translation_key = "connection_state"
_attr_device_class = SensorDeviceClass.ENUM
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_options = [state.name.lower() for state in State]
def __init__(self, coordinator: FglairCoordinator) -> None:
super().__init__(coordinator, "connection_state", "connection_state")
@property
def available(self) -> bool:
# Диагностика связи должна быть видна и при offline.
return True
@property
def native_value(self) -> str:
return self.coordinator.data.state.name.lower()
async def async_setup_entry(hass, entry, async_add_entities) -> None:
runtime: FglairRuntime = entry.runtime_data
coordinator = runtime.coordinator
template = runtime.client.template
props = {info.prop for info in template_info(template)}
entities = [FglairConnectionState(coordinator)]
if Prop.DISPLAY_TEMPERATURE in props:
entities.append(FglairRoomTemperature(coordinator))
if Prop.ERROR_CODE in props:
entities.append(FglairErrorCode(coordinator))
async_add_entities(entities)
+101 -1
View File
@@ -51,6 +51,24 @@
"data_description": { "data_description": {
"config_json": "Paste a JSON object produced by fglair-discover or fglctl" "config_json": "Paste a JSON object produced by fglair-discover or fglctl"
} }
},
"preview": {
"title": "Template preview",
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.",
"data": {
"template": "Template",
"temp_min": "Minimum setpoint (°C)",
"temp_max": "Maximum setpoint (°C)",
"temp_step": "Setpoint step (°C)",
"temp_num": "Conversion numerator",
"temp_den": "Conversion denominator",
"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)"
}
} }
}, },
"error": { "error": {
@@ -62,7 +80,8 @@
"invalid_import": "Invalid config JSON", "invalid_import": "Invalid config JSON",
"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"
}, },
"abort": { "abort": {
"already_configured": "This device is already configured", "already_configured": "This device is already configured",
@@ -73,5 +92,86 @@
"timeout": "Timed out waiting for the device", "timeout": "Timed out waiting for the device",
"properties_missing": "The device connected but did not report its base properties" "properties_missing": "The device connected but did not report its base properties"
} }
},
"entity": {
"climate": {
"name": "Air conditioner"
},
"sensor": {
"room_temperature": {
"name": "Room temperature"
},
"error_code": {
"name": "Error code"
},
"connection_state": {
"name": "Connection state",
"state": {
"idle": "Idle",
"registering": "Registering",
"online": "Online",
"recovering": "Recovering",
"offline": "Offline",
"key_error": "Key error"
}
}
},
"binary_sensor": {
"connectivity": {
"name": "Connectivity"
},
"defrost": {
"name": "Defrost"
},
"maintenance": {
"name": "Maintenance"
},
"oil_recovery": {
"name": "Oil recovery"
},
"pump_down": {
"name": "Pump down"
},
"check_operation": {
"name": "Check operation"
},
"simultaneous": {
"name": "Simultaneous operation"
}
},
"switch": {
"economy_mode": {
"name": "Economy"
},
"powerful_mode": {
"name": "Powerful"
},
"coil_dry_mode": {
"name": "Coil dry"
},
"min_heat": {
"name": "Minimum heat"
},
"outdoor_low_noise": {
"name": "Outdoor low noise"
},
"human_det_auto_save": {
"name": "Human detection auto save"
},
"wifi_led_enable": {
"name": "Wi-Fi LED"
},
"indoor_fan_control": {
"name": "Indoor fan control"
}
},
"select": {
"af_vertical_direction": {
"name": "Vertical airflow direction"
},
"af_horizontal_direction": {
"name": "Horizontal airflow direction"
}
}
} }
} }
+60
View File
@@ -0,0 +1,60 @@
"""Switches: флаги-свойства (economy/powerful/min_heat/...)."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from pyfglair import Prop
from pyfglair.templates import prop_info
from .const import SWITCH_PROPS
from .coordinator import FglairCoordinator, FglairRuntime
from .entity import FglairEntity
class FglairSwitch(FglairEntity, SwitchEntity):
def __init__(
self,
coordinator: FglairCoordinator,
prop: Prop,
key: str,
) -> None:
super().__init__(coordinator, prop.name.lower(), key)
self._prop = prop
@property
def is_on(self) -> bool:
return self.raw_value(self._prop) == 1
async def async_turn_on(self, **kwargs: Any) -> None:
await self.coordinator.async_write([(self._prop, 1)])
async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.async_write([(self._prop, 0)])
async def async_setup_entry(hass, entry, async_add_entities) -> None:
runtime: FglairRuntime = entry.runtime_data
coordinator = runtime.coordinator
template = runtime.client.template
entities = []
for prop, (key, capability) in SWITCH_PROPS.items():
if prop_info(template, prop) is None:
continue
if not _capability_supported(coordinator, capability):
continue
entities.append(FglairSwitch(coordinator, prop, key))
async_add_entities(entities)
def _capability_supported(
coordinator: FglairCoordinator, bit: int | None
) -> bool:
if bit is None:
return True
caps = coordinator.data.values.get(Prop.DEVICE_CAPABILITIES)
if caps is None:
return True
return bool(caps.int_value & (1 << bit))
+101 -1
View File
@@ -51,6 +51,24 @@
"data_description": { "data_description": {
"config_json": "Paste a JSON object produced by fglair-discover or fglctl" "config_json": "Paste a JSON object produced by fglair-discover or fglctl"
} }
},
"preview": {
"title": "Template preview",
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.",
"data": {
"template": "Template",
"temp_min": "Minimum setpoint (°C)",
"temp_max": "Maximum setpoint (°C)",
"temp_step": "Setpoint step (°C)",
"temp_num": "Conversion numerator",
"temp_den": "Conversion denominator",
"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)"
}
} }
}, },
"error": { "error": {
@@ -62,7 +80,8 @@
"invalid_import": "Invalid config JSON", "invalid_import": "Invalid config JSON",
"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"
}, },
"abort": { "abort": {
"already_configured": "This device is already configured", "already_configured": "This device is already configured",
@@ -73,5 +92,86 @@
"timeout": "Timed out waiting for the device", "timeout": "Timed out waiting for the device",
"properties_missing": "The device connected but did not report its base properties" "properties_missing": "The device connected but did not report its base properties"
} }
},
"entity": {
"climate": {
"name": "Air conditioner"
},
"sensor": {
"room_temperature": {
"name": "Room temperature"
},
"error_code": {
"name": "Error code"
},
"connection_state": {
"name": "Connection state",
"state": {
"idle": "Idle",
"registering": "Registering",
"online": "Online",
"recovering": "Recovering",
"offline": "Offline",
"key_error": "Key error"
}
}
},
"binary_sensor": {
"connectivity": {
"name": "Connectivity"
},
"defrost": {
"name": "Defrost"
},
"maintenance": {
"name": "Maintenance"
},
"oil_recovery": {
"name": "Oil recovery"
},
"pump_down": {
"name": "Pump down"
},
"check_operation": {
"name": "Check operation"
},
"simultaneous": {
"name": "Simultaneous operation"
}
},
"switch": {
"economy_mode": {
"name": "Economy"
},
"powerful_mode": {
"name": "Powerful"
},
"coil_dry_mode": {
"name": "Coil dry"
},
"min_heat": {
"name": "Minimum heat"
},
"outdoor_low_noise": {
"name": "Outdoor low noise"
},
"human_det_auto_save": {
"name": "Human detection auto save"
},
"wifi_led_enable": {
"name": "Wi-Fi LED"
},
"indoor_fan_control": {
"name": "Indoor fan control"
}
},
"select": {
"af_vertical_direction": {
"name": "Vertical airflow direction"
},
"af_horizontal_direction": {
"name": "Horizontal airflow direction"
}
}
} }
} }
+101 -1
View File
@@ -51,6 +51,24 @@
"data_description": { "data_description": {
"config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl" "config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl"
} }
},
"preview": {
"title": "Превью шаблона",
"description": "Рассчитанные значения (сверьте с приложением FGLair):\n\n{preview}\n\nДиапазон уставки и конверсию можно поправить ниже.",
"data": {
"template": "Шаблон",
"temp_min": "Минимум уставки (°C)",
"temp_max": "Максимум уставки (°C)",
"temp_step": "Шаг уставки (°C)",
"temp_num": "Числитель конверсии",
"temp_den": "Знаменатель конверсии",
"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)"
}
} }
}, },
"error": { "error": {
@@ -62,7 +80,8 @@
"invalid_import": "Некорректный config JSON", "invalid_import": "Некорректный config JSON",
"no_devices": "В аккаунте не найдено устройств", "no_devices": "В аккаунте не найдено устройств",
"unknown": "Неожиданная ошибка", "unknown": "Неожиданная ошибка",
"properties_missing": "Устройство подключилось, но не отдало базовые свойства" "properties_missing": "Устройство подключилось, но не отдало базовые свойства",
"invalid_range": "Минимум должен быть меньше максимума, шаг — положительным"
}, },
"abort": { "abort": {
"already_configured": "Устройство уже настроено", "already_configured": "Устройство уже настроено",
@@ -73,5 +92,86 @@
"timeout": "Устройство не вышло на связь за отведённое время", "timeout": "Устройство не вышло на связь за отведённое время",
"properties_missing": "Устройство подключилось, но не отдало базовые свойства" "properties_missing": "Устройство подключилось, но не отдало базовые свойства"
} }
},
"entity": {
"climate": {
"name": "Кондиционер"
},
"sensor": {
"room_temperature": {
"name": "Температура в помещении"
},
"error_code": {
"name": "Код ошибки"
},
"connection_state": {
"name": "Состояние связи",
"state": {
"idle": "Покой",
"registering": "Регистрация",
"online": "На связи",
"recovering": "Восстановление",
"offline": "Не в сети",
"key_error": "Ошибка ключа"
}
}
},
"binary_sensor": {
"connectivity": {
"name": "Связь"
},
"defrost": {
"name": "Разморозка"
},
"maintenance": {
"name": "Обслуживание"
},
"oil_recovery": {
"name": "Возврат масла"
},
"pump_down": {
"name": "Pump down"
},
"check_operation": {
"name": "Проверка работы"
},
"simultaneous": {
"name": "Одновременные операции"
}
},
"switch": {
"economy_mode": {
"name": "Экономичный режим"
},
"powerful_mode": {
"name": "Мощный режим"
},
"coil_dry_mode": {
"name": "Сушка испарителя"
},
"min_heat": {
"name": "Минимальный обогрев"
},
"outdoor_low_noise": {
"name": "Тихий наружный блок"
},
"human_det_auto_save": {
"name": "Автосохранение при отсутствии людей"
},
"wifi_led_enable": {
"name": "Wi-Fi индикатор"
},
"indoor_fan_control": {
"name": "Управление вентилятором"
}
},
"select": {
"af_vertical_direction": {
"name": "Положение заслонки (верт.)"
},
"af_horizontal_direction": {
"name": "Положение заслонки (гориз.)"
}
}
} }
} }
+4 -1
View File
@@ -99,6 +99,9 @@ switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/
human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки, human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки,
demand_control; binary_sensor: connectivity). capabilities фильтруют demand_control; binary_sensor: connectivity). capabilities фильтруют
режимы/пресеты. Записи — один `batch_commit()` на действие пользователя. режимы/пресеты. Записи — один `batch_commit()` на действие пользователя.
Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована
(§10) — сущность не создаётся до уточнения; в H3 вместо неё
диагностический sensor состояния связи.
## 5. Runtime ## 5. Runtime
@@ -156,6 +159,6 @@ Security → Long-lived access tokens) и REST API: вызов сервисов
|---|-----------| |---|-----------|
| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor | | H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение | | H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
| H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности | | H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
| H4 | repair, диагностика (ключ для ESPHome), translations | | H4 | repair, диагностика (ключ для ESPHome), translations |
| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64, cibuildwheel) — штатный installer HA резолвит `requirements` только оттуда | | H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64, cibuildwheel) — штатный installer HA резолвит `requirements` только оттуда |
+4 -2
View File
@@ -276,10 +276,12 @@ class Session:
if handler is None: if handler is None:
return return
loop = self._loop loop = self._loop
if loop is not None and loop.is_running(): if loop is not None:
if not loop.is_running():
_LOGGER.debug("event loop остановлен, колбэк отброшен")
return
try: try:
loop.call_soon_threadsafe(handler, *args) loop.call_soon_threadsafe(handler, *args)
return
except RuntimeError: except RuntimeError:
_LOGGER.debug("event loop закрыт, колбэк отброшен") _LOGGER.debug("event loop закрыт, колбэк отброшен")
return return
+5
View File
@@ -382,9 +382,14 @@ def main():
ap.add_argument("--stale-gap", type=float, default=44.0) ap.add_argument("--stale-gap", type=float, default=44.0)
ap.add_argument("--push-every", type=float, default=0) ap.add_argument("--push-every", type=float, default=0)
ap.add_argument("--fail-first-ke", action="store_true") ap.add_argument("--fail-first-ke", action="store_true")
ap.add_argument("--set", action="append", default=[], metavar="NAME=VALUE",
help="предустановить свойство mock-модуля (повторяемо)")
args = ap.parse_args() args = ap.parse_args()
mock = Mock(args) mock = Mock(args)
for item in args.set:
name, _, value = item.partition("=")
mock.props[name] = int(value, 0) # принимает 0b/0x/десятичное
lock = mock.lock lock = mock.lock
class H(http.server.BaseHTTPRequestHandler): class H(http.server.BaseHTTPRequestHandler):
+147 -61
View File
@@ -13,6 +13,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
import pyfglair import pyfglair
from custom_components.fglair.const import DOMAIN from custom_components.fglair.const import DOMAIN
from custom_components.fglair.trial import TrialResult from custom_components.fglair.trial import TrialResult
from pyfglair import Prop, Value, ValueKind
from pyfglair.provision import Device from pyfglair.provision import Device
MANUAL_INPUT = { MANUAL_INPUT = {
@@ -25,6 +26,16 @@ MANUAL_INPUT = {
"model": "AP-WC1E", "model": "AP-WC1E",
} }
PREVIEW_INPUT = {
"template": "A",
"temp_min": 16.0,
"temp_max": 30.0,
"temp_step": 0.5,
"temp_num": 1,
"temp_den": 1,
"temp_offset": 0,
}
TRIAL_OK = TrialResult(ok=True) TRIAL_OK = TrialResult(ok=True)
TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable") TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable")
@@ -41,6 +52,10 @@ async def _select(hass: HomeAssistant, flow_id: str, option: str):
) )
async def _configure(hass: HomeAssistant, flow_id: str, user_input: dict):
return await hass.config_entries.flow.async_configure(flow_id, user_input)
def _suggested(schema, field: str): def _suggested(schema, field: str):
for key in schema.schema: for key in schema.schema:
if getattr(key, "schema", None) == field: if getattr(key, "schema", None) == field:
@@ -64,19 +79,94 @@ async def test_manual_success(hass: HomeAssistant):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
assert result["type"] == "form" assert result["type"] == "form"
result = await hass.config_entries.flow.async_configure( result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result["flow_id"], MANUAL_INPUT assert result["type"] == "form"
) assert result["step_id"] == "preview"
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["title"] == "Bedroom" assert result["title"] == "Bedroom"
assert result["result"].unique_id == DSN assert result["result"].unique_id == DSN
data = result["data"] data = result["data"]
assert data["lanip_key_id"] == KEY_ID assert data["lanip_key_id"] == KEY_ID
assert data["template"] == "A" assert data["template"] == "A"
assert data["temp_step"] == 5
assert data["overrides"]["adjust_temperature"] == {
"num": 1,
"den": 1,
"offset": 0,
"min": 160,
"max": 300,
}
trial.assert_called_once() trial.assert_called_once()
assert trial.call_args.args[0]["host"] == "192.168.1.50" assert trial.call_args.args[0]["host"] == "192.168.1.50"
async def test_preview_template_change_and_conversion(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
preview_input = {
**PREVIEW_INPUT,
"template": "B",
"temp_min": 17.0,
"temp_max": 28.0,
"temp_step": 1.0,
"temp_num": 2,
"temp_den": 4,
"temp_offset": 5,
}
result = await _configure(hass, result["flow_id"], preview_input)
assert result["type"] == "create_entry"
assert result["data"]["template"] == "B"
assert result["data"]["temp_step"] == 10
assert result["data"]["overrides"]["adjust_temperature"] == {
"num": 2,
"den": 4,
"offset": 5,
"min": 170,
"max": 280,
}
async def test_preview_invalid_range(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
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}
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_range"}
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["type"] == "create_entry"
async def test_preview_contains_trial_values(hass: HomeAssistant):
props = {
Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200),
Prop.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011),
}
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TrialResult(ok=True, properties=props),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
preview = result["description_placeholders"]["preview"]
assert "20.0" in preview
assert "0x0000001B" in preview
assert "cool" in preview and "heat" in preview
async def test_manual_connect_error(hass: HomeAssistant): async def test_manual_connect_error(hass: HomeAssistant):
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "custom_components.fglair.config_flow._run_trial",
@@ -84,9 +174,7 @@ async def test_manual_connect_error(hass: HomeAssistant):
): ):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await hass.config_entries.flow.async_configure( result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result["flow_id"], MANUAL_INPUT
)
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "cannot_connect"} assert result["errors"] == {"base": "cannot_connect"}
@@ -98,9 +186,7 @@ async def test_manual_key_mismatch_error(hass: HomeAssistant):
): ):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await hass.config_entries.flow.async_configure( result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result["flow_id"], MANUAL_INPUT
)
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "key_mismatch"} assert result["errors"] == {"base": "key_mismatch"}
@@ -114,13 +200,25 @@ async def test_manual_already_configured(hass: HomeAssistant):
): ):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await hass.config_entries.flow.async_configure( result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result["flow_id"], MANUAL_INPUT
)
assert result["type"] == "abort" assert result["type"] == "abort"
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
async def test_manual_error_keeps_input(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_NETWORK_FAIL,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["errors"] == {"base": "cannot_connect"}
assert _suggested(result["data_schema"], "host") == MANUAL_INPUT["host"]
assert _suggested(result["data_schema"], "dsn") == DSN
assert _suggested(result["data_schema"], "lanip_key") is None
async def test_import_json_success(hass: HomeAssistant): async def test_import_json_success(hass: HomeAssistant):
payload = json.dumps( payload = json.dumps(
{ {
@@ -139,8 +237,13 @@ async def test_import_json_success(hass: HomeAssistant):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json") result = await _select(hass, result["flow_id"], "import_json")
assert result["type"] == "form" assert result["type"] == "form"
result = await hass.config_entries.flow.async_configure( result = await _configure(
result["flow_id"], {"config_json": payload} hass, result["flow_id"], {"config_json": payload}
)
assert result["step_id"] == "preview"
assert _suggested(result["data_schema"], "template") == "B"
result = await _configure(
hass, result["flow_id"], {**PREVIEW_INPUT, "template": "B"}
) )
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.60" assert result["data"]["host"] == "192.168.1.60"
@@ -150,8 +253,8 @@ async def test_import_json_success(hass: HomeAssistant):
async def test_import_json_invalid(hass: HomeAssistant): async def test_import_json_invalid(hass: HomeAssistant):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json") result = await _select(hass, result["flow_id"], "import_json")
result = await hass.config_entries.flow.async_configure( result = await _configure(
result["flow_id"], {"config_json": "not json"} hass, result["flow_id"], {"config_json": "not json"}
) )
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_import"} assert result["errors"] == {"base": "invalid_import"}
@@ -176,57 +279,20 @@ async def test_cloud_flow_success(hass: HomeAssistant):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud") result = await _select(hass, result["flow_id"], "cloud")
assert result["type"] == "form" assert result["type"] == "form"
result = await hass.config_entries.flow.async_configure( result = await _configure(
hass,
result["flow_id"], result["flow_id"],
{"email": "user@example.com", "password": "secret", "region": "eu"}, {"email": "user@example.com", "password": "secret", "region": "eu"},
) )
assert result["type"] == "form"
assert result["step_id"] == "cloud_device" assert result["step_id"] == "cloud_device"
with patch( result = await _configure(hass, result["flow_id"], {"dsn": DSN})
"custom_components.fglair.config_flow._run_trial", assert result["step_id"] == "preview"
return_value=TRIAL_OK, result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"dsn": DSN}
)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.50" assert result["data"]["host"] == "192.168.1.50"
assert result["data"]["lanip_key_id"] == KEY_ID assert result["data"]["lanip_key_id"] == KEY_ID
async def test_cloud_auth_error(hass: HomeAssistant):
with patch(
"pyfglair.discover",
AsyncMock(
side_effect=pyfglair.ProvisionError("401 unauthorized", "auth")
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud")
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"email": "user@example.com", "password": "bad", "region": "eu"},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_auth"}
async def test_manual_error_keeps_input(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_NETWORK_FAIL,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MANUAL_INPUT
)
assert result["errors"] == {"base": "cannot_connect"}
assert _suggested(result["data_schema"], "host") == MANUAL_INPUT["host"]
assert _suggested(result["data_schema"], "dsn") == DSN
assert _suggested(result["data_schema"], "lanip_key") is None
async def test_cloud_no_devices(hass: HomeAssistant): async def test_cloud_no_devices(hass: HomeAssistant):
with patch( with patch(
"pyfglair.discover", "pyfglair.discover",
@@ -238,7 +304,8 @@ async def test_cloud_no_devices(hass: HomeAssistant):
): ):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud") result = await _select(hass, result["flow_id"], "cloud")
result = await hass.config_entries.flow.async_configure( result = await _configure(
hass,
result["flow_id"], result["flow_id"],
{"email": "user@example.com", "password": "secret", "region": "eu"}, {"email": "user@example.com", "password": "secret", "region": "eu"},
) )
@@ -246,6 +313,24 @@ async def test_cloud_no_devices(hass: HomeAssistant):
assert result["errors"] == {"base": "no_devices"} assert result["errors"] == {"base": "no_devices"}
async def test_cloud_auth_error(hass: HomeAssistant):
with patch(
"pyfglair.discover",
AsyncMock(
side_effect=pyfglair.ProvisionError("401 unauthorized", "auth")
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud")
result = await _configure(
hass,
result["flow_id"],
{"email": "user@example.com", "password": "bad", "region": "eu"},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_auth"}
async def test_yaml_import(hass: HomeAssistant): async def test_yaml_import(hass: HomeAssistant):
payload = { payload = {
"ip_address": "192.168.1.70", "ip_address": "192.168.1.70",
@@ -289,9 +374,10 @@ async def test_import_json_null_device_port(hass: HomeAssistant):
) as trial: ) as trial:
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json") result = await _select(hass, result["flow_id"], "import_json")
result = await hass.config_entries.flow.async_configure( result = await _configure(
result["flow_id"], {"config_json": payload} hass, result["flow_id"], {"config_json": payload}
) )
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["data"]["device_port"] == 80 assert result["data"]["device_port"] == 80
assert trial.call_args.args[0]["device_port"] == 80 assert trial.call_args.args[0]["device_port"] == 80
+273
View File
@@ -0,0 +1,273 @@
"""Сущности fglair против mock_ac.py: climate/switch/select/sensor/binary."""
from __future__ import annotations
import asyncio
import time
from helpers import DSN, KEY_ID, LANIP_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.fglair.const import DOMAIN
from pyfglair import State
MOCK_SETTINGS = [
"--set", "operation_mode=6",
"--set", "fan_speed=4",
"--set", "adjust_temperature=220",
"--set", "display_temperature=7000",
"--set", "device_capabilities=0b1111111111111111111",
"--set", "af_vertical_num_dir=5",
"--set", "af_horizontal_num_dir=7",
"--set", "af_vertical_direction=2",
"--set", "af_horizontal_direction=3",
"--set", "op_status=0b1000000010000000000000000",
"--set", "economy_mode=0",
"--set", "powerful_mode=0",
]
async def _wait_for(predicate, timeout: float = 20.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = predicate()
if result:
return result
await asyncio.sleep(0.1)
raise AssertionError("условие не выполнено за отведённое время")
def _entity_id(hass: HomeAssistant, platform: str, unique_id: str):
return er.async_get(hass).async_get_entity_id(platform, DOMAIN, unique_id)
async def _setup_entry(hass: HomeAssistant, port: int) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={
"host": "127.0.0.1",
"device_port": port,
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"template": "A",
"name": "Mock AC",
"listen_port": 0,
},
unique_id=DSN,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
async def test_entities_snapshot(hass: HomeAssistant, mock_ac, socket_enabled):
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)
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
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["swing_mode"] == "off"
)
climate = hass.states.get(climate_id)
assert climate.attributes["current_temperature"] == 20.0
assert climate.attributes["temperature"] == 22.0
assert climate.attributes["fan_mode"] == "auto"
assert climate.attributes["preset_mode"] is None
assert "heat" in climate.attributes["hvac_modes"]
assert "cool" in climate.attributes["hvac_modes"]
assert climate.attributes["swing_mode"] == "off"
room = _entity_id(hass, "sensor", f"{DSN}_display_temperature")
await _wait_for(
lambda: room and hass.states.get(room) is not None
and hass.states.get(room).state == "20.0"
)
connectivity = _entity_id(hass, "binary_sensor", f"{DSN}_connectivity")
assert hass.states.get(connectivity).state == "on"
defrost = _entity_id(hass, "binary_sensor", f"{DSN}_op_status_defrost")
await _wait_for(lambda: hass.states.get(defrost).state == "on")
maintenance = _entity_id(
hass, "binary_sensor", f"{DSN}_op_status_maintenance"
)
assert hass.states.get(maintenance).state == "off"
vertical = _entity_id(
hass, "select", f"{DSN}_af_vertical_direction"
)
await _wait_for(
lambda: hass.states.get(vertical).attributes["options"]
== ["0", "1", "2", "3", "4"]
)
vertical_state = hass.states.get(vertical)
assert vertical_state.state == "2"
economy = _entity_id(hass, "switch", f"{DSN}_economy_mode")
assert hass.states.get(economy).state == "off"
connection = _entity_id(hass, "sensor", f"{DSN}_connection_state")
assert hass.states.get(connection).state == "online"
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_entity_commands(hass: HomeAssistant, mock_ac, socket_enabled):
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)
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
select_id = _entity_id(hass, "select", f"{DSN}_af_vertical_direction")
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(select_id).attributes["options"]
== ["0", "1", "2", "3", "4"]
)
await hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": climate_id, "hvac_mode": "cool"},
blocking=True,
)
assert proc.wait_line("CMD SET operation_mode=3", 10)
await hass.services.async_call(
"climate",
"set_temperature",
{"entity_id": climate_id, "temperature": 25.5},
blocking=True,
)
assert proc.wait_line("CMD SET adjust_temperature=255", 10)
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": climate_id, "fan_mode": "low"},
blocking=True,
)
assert proc.wait_line("CMD SET fan_speed=1", 10)
await hass.services.async_call(
"climate",
"set_preset_mode",
{"entity_id": climate_id, "preset_mode": "eco"},
blocking=True,
)
assert proc.wait_line("CMD SET economy_mode=True", 10)
assert proc.wait_line("CMD SET powerful_mode=False", 10)
await _wait_for(
lambda: hass.states.get(climate_id).state == "cool"
)
await hass.services.async_call(
"select",
"select_option",
{"entity_id": select_id, "option": "4"},
blocking=True,
)
assert proc.wait_line("CMD SET af_vertical_direction=4", 10)
switch_id = _entity_id(hass, "switch", f"{DSN}_economy_mode")
await hass.services.async_call(
"switch", "turn_on", {"entity_id": switch_id}, blocking=True
)
assert proc.wait_line("CMD SET economy_mode=True", 10)
assert await hass.config_entries.async_unload(entry.entry_id)
RESTRICTED_SETTINGS = [
"--set", "operation_mode=3",
"--set", "fan_speed=4",
"--set", "device_capabilities=0b100001", # cool (0) + fan auto (5)
]
async def test_capabilities_filter(
hass: HomeAssistant, mock_ac, socket_enabled
):
proc = mock_ac(RESTRICTED_SETTINGS)
entry = await _setup_entry(hass, proc.port)
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
await _wait_for(
lambda: climate_id and hass.states.get(climate_id) is not None
and hass.states.get(climate_id).state == "cool"
and "heat" not in hass.states.get(climate_id).attributes["hvac_modes"]
)
climate = hass.states.get(climate_id)
assert "heat" not in climate.attributes["hvac_modes"]
assert "cool" in climate.attributes["hvac_modes"]
assert climate.attributes["fan_modes"] == ["auto"]
assert climate.attributes.get("preset_modes") is None
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_manual_conversion_applied(
hass: HomeAssistant, mock_ac, socket_enabled
):
settings = [
"--set", "operation_mode=6",
"--set", "adjust_temperature=220",
"--set", "display_temperature=7000",
]
proc = mock_ac(settings)
entry = MockConfigEntry(
domain=DOMAIN,
data={
"host": "127.0.0.1",
"device_port": proc.port,
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"template": "A",
"name": "Mock AC",
"listen_port": 0,
"temp_step": 5,
"overrides": {
"adjust_temperature": {
"num": 1,
"den": 1,
"offset": 5,
"min": 170,
"max": 280,
}
},
},
unique_id=DSN,
)
entry.add_to_hass(hass)
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)
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
await _wait_for(
lambda: climate_id and hass.states.get(climate_id) is not None
and hass.states.get(climate_id).state == "heat"
)
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["max_temp"] == 28.0
await hass.services.async_call(
"climate",
"set_temperature",
{"entity_id": climate_id, "temperature": 25.0},
blocking=True,
)
assert proc.wait_line("CMD SET adjust_temperature=245", 10)
assert await hass.config_entries.async_unload(entry.entry_id)
+7 -6
View File
@@ -17,13 +17,14 @@ async def test_setup_unload(hass: HomeAssistant, config_entry, socket_enabled):
assert await hass.config_entries.async_setup(config_entry.entry_id) assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
client = config_entry.runtime_data runtime = config_entry.runtime_data
assert client is not None assert runtime is not None
assert isinstance(client.state, State) assert isinstance(runtime.client.state, State)
assert runtime.coordinator.data is not None
assert await hass.config_entries.async_unload(config_entry.entry_id) assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert client.cached(Prop.OPERATION_MODE) is None assert runtime.client.cached(Prop.OPERATION_MODE) is None
async def test_two_entries_default_port(hass: HomeAssistant, entry_data, socket_enabled): async def test_two_entries_default_port(hass: HomeAssistant, entry_data, socket_enabled):
@@ -44,9 +45,9 @@ async def test_two_entries_default_port(hass: HomeAssistant, entry_data, socket_
assert first.state.name == "LOADED" assert first.state.name == "LOADED"
assert second.state.name == "LOADED" assert second.state.name == "LOADED"
first_client = first.runtime_data first_runtime = first.runtime_data
assert await hass.config_entries.async_unload(first.entry_id) assert await hass.config_entries.async_unload(first.entry_id)
assert first_client.cached(Prop.OPERATION_MODE) is None assert first_runtime.client.cached(Prop.OPERATION_MODE) is None
assert second.state.name == "LOADED" assert second.state.name == "LOADED"
assert await hass.config_entries.async_unload(second.entry_id) assert await hass.config_entries.async_unload(second.entry_id)
+66
View File
@@ -0,0 +1,66 @@
"""Ручные конверсии/диапазоны (ConversionSet) без HA-рантайма."""
from __future__ import annotations
from custom_components.fglair.overrides import ConversionSet, LinearOverride
from pyfglair import Prop, Template
def test_identity_keeps_api_units():
conversions = ConversionSet(Template.A, {}, 5)
assert conversions.to_display(Prop.DISPLAY_TEMPERATURE, 200) == 200
assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 220) == 220
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 220) == 220
assert conversions.temperature_range() == (160, 300)
def test_linear_override_roundtrip_and_clamp():
override = LinearOverride(
num=1, den=1, offset=5, min_value=170, max_value=280
)
conversions = ConversionSet(
Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5
)
assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 220) == 225
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 225) == 220
assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 100) == 170
assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 999) == 280
assert conversions.temperature_range() == (170, 280)
def test_linear_override_scaled():
override = LinearOverride(num=2, den=4, offset=0)
conversions = ConversionSet(
Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5
)
assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 200) == 100
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 100) == 200
def test_from_entry_parses_spec():
conversions = ConversionSet.from_entry(
{
"template": "B",
"temp_step": 10,
"overrides": {
"adjust_temperature": {
"num": 2,
"den": 4,
"offset": 5,
"min": 170,
"max": 280,
}
},
}
)
assert conversions.template == Template.B
assert conversions.step_tenths == 10
assert conversions.temperature_range() == (170, 280)
override = conversions.override(Prop.ADJUST_TEMPERATURE)
assert (override.num, override.den, override.offset) == (2, 4, 5)
assert conversions.override(Prop.FAN_SPEED).identity
def test_from_entry_defaults():
conversions = ConversionSet.from_entry({"template": "F"})
assert conversions.step_tenths == 5
assert conversions.temperature_range() == (160, 300)