Files
fgl-aircon/custom_components/fglair/overrides.py
T
petr.polezhaev 3e0b6b3653 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 отложен (семантика не зафиксирована)
2026-09-29 12:44:34 +03:00

137 lines
5.1 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
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,
)