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:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user