"""Ручные конверсии/диапазоны свойств (план §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, )