Files
fgl-aircon/custom_components/fglair/overrides.py
T
petr.polezhaev d86ed60b51 ha(H3): исправления по ревью — turn_on/off, кламп конверсий, reload, гонка ONLINE
- 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 в переводах
2026-09-29 13:41:20 +03:00

176 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ручные конверсии/диапазоны свойств (план §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,
)