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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Положение заслонки (гориз.)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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` только оттуда |
|
||||||
|
|||||||
+5
-3
@@ -276,13 +276,15 @@ 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
|
||||||
try:
|
try:
|
||||||
handler(*args)
|
handler(*args)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"
|
result = await _configure(hass, result["flow_id"], {"dsn": DSN})
|
||||||
with patch(
|
assert result["step_id"] == "preview"
|
||||||
"custom_components.fglair.config_flow._run_trial",
|
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
|
||||||
return_value=TRIAL_OK,
|
|
||||||
):
|
|
||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user