- M1: supported_features += TURN_ON/TURN_OFF (сервисы в HA 2026.9), async_turn_off; удалён мёртвый _enable_turn_on_off_backwards_compat; тест turn_on/turn_off → operation_mode 1/0 - M2: достижимый диапазон = override ∩ raw-таблица шаблона; raw клампится в from_display, clamp() по достижимому диапазону (молчаливый кламп ядра устранён); тесты scaled override - M3: mock_ac сбрасывает crypto/active на DELETE; тест reload → снова ONLINE - гонка: async_sync_now больше не перезаписывает данные устаревшим снимком (ONLINE мог потеряться при быстром старте) — стабильность 4×37 тестов - m1: превью-текст учитывает выбранный в форме шаблон; m2: убрана недостижимая проверка шага; m3: удалён мёртвый translation_key climate - m4: бит 21 op_status (шаблон B); m5: lru_cache template_info/prop_info; m6: operation_mode=1 без истории → hvac_mode unknown; m7: ошибка команды → HomeAssistantError; m8: unload возвращает False, не разбирая runtime - n2: уточнены описания data_description в переводах
176 lines
7.0 KiB
Python
176 lines
7.0 KiB
Python
"""Ручные конверсии/диапазоны свойств (план §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, prop_info
|
||
|
||
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 raw_range(self, prop: Prop) -> Optional[tuple[int, int]]:
|
||
"""raw-диапазон протокольной таблицы шаблона (None — не задан)."""
|
||
info = prop_info(self.template, prop)
|
||
if info is None or info.raw_min is None or info.raw_max is None:
|
||
return None
|
||
return (min(info.raw_min, info.raw_max), max(info.raw_min, info.raw_max))
|
||
|
||
def _display_of_raw(self, prop: Prop, raw: int) -> int:
|
||
override = self.override(prop)
|
||
if override.identity:
|
||
return convert_to_display(self.template, prop, raw)
|
||
return override.to_display(raw)
|
||
|
||
def from_display(self, prop: Prop, display: int) -> int:
|
||
"""Отображаемое значение → API-значение для set_int().
|
||
|
||
raw клампится по таблице шаблона (ядро тоже клампит — не отдаём
|
||
молча команду за пределами достижимого).
|
||
"""
|
||
override = self.override(prop)
|
||
raw = display if override.identity else override.from_display(display)
|
||
raw_range = self.raw_range(prop)
|
||
if raw_range is not None:
|
||
raw = max(raw_range[0], min(raw_range[1], raw))
|
||
if override.identity:
|
||
return raw
|
||
return convert_to_display(self.template, prop, raw)
|
||
|
||
def clamp(self, prop: Prop, display: int) -> int:
|
||
bounds = self.range(prop)
|
||
if bounds is None:
|
||
return display
|
||
return max(bounds[0], min(bounds[1], display))
|
||
|
||
def range(self, prop: Prop) -> Optional[tuple[int, int]]:
|
||
"""Достижимый диапазон display: override ∩ таблица шаблона."""
|
||
override = self.override(prop)
|
||
low = override.min_value
|
||
high = override.max_value
|
||
if prop == Prop.ADJUST_TEMPERATURE:
|
||
# Фактические ограничения прибора (PROTOCOL §8.3), если
|
||
# пользователь не задал свои.
|
||
low = TEMP_MIN_TENTHS if low is None else low
|
||
high = TEMP_MAX_TENTHS if high is None else high
|
||
raw_range = self.raw_range(prop)
|
||
if raw_range is not None:
|
||
raw_low = self._display_of_raw(prop, raw_range[0])
|
||
raw_high = self._display_of_raw(prop, raw_range[1])
|
||
if raw_low > raw_high:
|
||
raw_low, raw_high = raw_high, raw_low
|
||
low = raw_low if low is None else max(low, raw_low)
|
||
high = raw_high if high is None else min(high, raw_high)
|
||
if low is None or high is None:
|
||
return None
|
||
return (low, high)
|
||
|
||
def temperature_range(self) -> tuple[int, int]:
|
||
return self.range(Prop.ADJUST_TEMPERATURE) or (
|
||
TEMP_MIN_TENTHS,
|
||
TEMP_MAX_TENTHS,
|
||
)
|