Compare commits

..
9 Commits
Author SHA1 Message Date
petr.polezhaev 5f09716dce ha(ux): пробинг отдаёт fatal-reason (одна сессия на недоступном модуле); SWITCH_PROPS без битов 2026-09-29 18:12:31 +03:00
petr.polezhaev 976a2dcbb8 ha(ux): исправления по ревью — быстрый выход пробинга, prefill шаблона, ниты
- major: probe_template прекращает перебор на unreachable/no_slots/key_error
  (недоступный модуль: ~10 c вместо ~49 c), продолжает только при
  properties_missing (доступен, но чужой шаблон); +тест
- m1: extra_state_attributes не падает до добавления в hass
- m2: reconfigure берёт сохранённый шаблон как дефолт (не пересчитывает по
  модели); +тест
- m3: device_capabilities/display_temperature переведены в необязательные
  зонды (trial не падает на молчащем caps; feature-модель трактует None
  как «не режем»)
- m4: async_sync_now(online_timeout) — честное имя/докстринг
- ниты: удалены PRESET_CAP/as_dict/лишние плейсхолдеры, DIRECTION_KEYS
  упрощён, тавтология в тесте исправлена, en-тире
- 59 components + 47 pyfglair/acceptance зелёные
2026-09-29 18:02:52 +03:00
petr.polezhaev d1c02d7c4b ha(ux): мастер из трёх страниц, feature-модель и capabilities-фильтрация
- config flow: template (A/B/F с пояснениями, значения с устройства,
  определение по oem_model; для неизвестных моделей — пробинг A/B/F тремя
  короткими сессиями) -> capabilities (23 тумблера с описаниями, дефолты
  из device_capabilities/num_dir/ответов) -> limits (диапазон/шаг, ручная
  конверсия); каждая страница с пометкой «значения определены
  автоматически, можно пропустить»
- features.py: FeatureSet/LiveValues/device_default/resolve_features;
  entry.data["features"] хранит выбор пользователя; reconfigure
  предзаполняет; YAML-import получает дефолты
- сущности создаются только для включённых фич: climate (режимы/скорости/
  swing/пресеты), switch, select заслонок (caps+num_dir), датчик комнаты;
  исправлено появление select без ламелей
- trial: caps/num_dir и presence-зонды (необязательные свойства), answered;
  координатор ждёт первые свойства перед созданием сущностей (feature-
  дефолты без гонки)
- атрибут description у сущностей (HA не поддерживает тултипы) + описания
  фич в мастере (en/ru)
- тесты: features, probing, обновлённые flow/entities/repairs; 57 components
2026-09-29 17:39:34 +03:00
petr.polezhaev 4ee428a6fd ha(ci): publish-скрипт на POSIX sh — bash в Alpine не нужен
set -o pipefail убран (единственный bashism), shebang /bin/sh; musl-джоб
вызывает sh scripts/publish-gitea-pypi.sh, bash из apk-списка исключён.
Логика проверена на фейковом индексе: уже загруженный linux-wheel
пропускается, musl-wheel и sdist загружаются.
2026-09-29 16:36:26 +03:00
petr.polezhaev 90b9e2783b ha(ci): bash в musl-джобе (Alpine не содержит bash по умолчанию) 2026-09-29 16:35:47 +03:00
petr.polezhaev f9d917c66d ha(packaging): musllinux-wheel для HA OS и статический C++-рантайм
- _corebuild.musl_platform_tag(): на musl (Alpine/HA OS) bdist_wheel
  выпускает py3-none-musllinux_X_Y_<arch> вместо linux_<arch> — иначе pip
  ставит glibc-сборку и dlopen падает на ld-linux-x86-64.so.2
- shared-библиотека линкуется со -static-libstdc++ -static-libgcc (gcc):
  NEEDED только libm/libc (проверено readelf), меньше зависимостей у HA OS
- Gitea Actions: job build-musllinux (container python:3.12-alpine) собирает
  и публикует musl-wheel; build-glibc — как раньше; общий
  scripts/publish-gitea-pypi.sh с пропуском уже загруженных файлов
- тесты musl_platform_tag; RELEASE_HA §1.1-1.4 (выбор wheel, переустановка
  на HA OS, ручная сборка musllinux без container:); README troubleshooting
2026-09-29 16:20:21 +03:00
petr.polezhaev 49d5533cd1 docs: troubleshooting «Invalid handler specified» (нет pyfglair / нет перезапуска)
- README компонента: проверка лога, установка pyfglair в окружение HA
  (docker exec python -m pip), полный перезапуск, проверка импорта
- RELEASE_HA: та же диагностика в разделе установки
2026-09-29 16:13:19 +03:00
petr.polezhaev 8a073d96a6 ha(ci): pre-check simple-индекса Gitea вместо --skip-existing (twine 7)
twine 7 запрещает --skip-existing для не-PyPI репозиториев
(UnsupportedConfiguration); workflow проверяет наличие версии в
simple-индексе и пропускает повторную публикацию.
2026-09-29 16:00:13 +03:00
petr.polezhaev 24ada01ceb ha(ci): fix smoke-теста wheel и токены Gitea
- smoke-тест запускается из /tmp с python -P: раньше из корня репозитория
  python подхватывал исходники pyfglair/ (без .so) вместо wheel
- permissions: contents: read, packages: write — встроенный GITEA_TOKEN;
  PAT (scope write:package) в секрете PYPI_TOKEN остаётся fallback для
  старых Gitea; имена секретов без зарезервированного префикса GITEA_
- RELEASE_HA: оба варианта авторизации и пояснение про smoke-тест
2026-09-29 15:54:55 +03:00
27 changed files with 1540 additions and 288 deletions
+77 -37
View File
@@ -1,17 +1,17 @@
# Сборка и публикация pyfglair в PyPI-реестр пакетов Gitea.
# Запуск: Actions → Publish pyfglair → Run workflow (ручной, workflow_dispatch).
# Запуск: Actions → Publish pyfglair → Run workflow (workflow_dispatch).
#
# Секреты репозитория (Settings → Actions → Secrets):
# GITEA_PYPI_TOKEN — personal access token со scope write:package
# GITEA_PYPI_USER — (опционально) имя пользователя Gitea для upload;
# по умолчанию — актор запуска.
# Собираются ДВА wheel (одинаковая версия):
# * linux_x86_64 — glibc (HA Supervised/Core на Debian/Ubuntu);
# * musllinux_X_Y — musl (HA OS: Alpine-контейнеры); собирается в
# контейнере python:3.12-alpine. Если runner не поддерживает
# `container:` — job build-musllinux упадёт на apk; в этом случае
# musllinux-wheel соберите вручную (см. docs/RELEASE_HA.md §1.4).
#
# Примечания Gitea Actions:
# * встроенный GITEA_TOKEN не может публиковать пакеты — нужен PAT;
# * actions/checkout и actions/setup-python тянутся с github.com
# (если инстанс изолирован — замените на абсолютные URL своих зеркал);
# * сборка wheel с bundled mbedtls требует доступ к github.com
# (FetchContent); при BUNDLED=0 ставится системный libmbedtls-dev.
# Авторизация: встроенный job-токен при `permissions: packages: write`
# (Gitea ≥ 1.27) либо PAT write:package в секрете PYPI_TOKEN.
# Секрет PYPI_USER — имя пользователя к PAT; имена секретов не могут
# начинаться с GITEA_ (префикс зарезервирован).
name: Publish pyfglair
on:
@@ -22,8 +22,12 @@ on:
required: false
default: "1"
permissions:
contents: read
packages: write
jobs:
publish:
build-glibc:
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -48,7 +52,7 @@ jobs:
cmake --version | head -1
g++ --version | head -1
- name: Build wheel + sdist
- name: Build wheel + sdist (glibc)
env:
BUNDLED: ${{ inputs.bundled_mbedtls }}
run: |
@@ -61,35 +65,71 @@ jobs:
python -m build
ls -l dist
- name: Smoke test wheel
# Запуск из каталога без исходников: иначе python подхватывает
# ./pyfglair (без собранного .so) вместо установленного wheel.
- name: Smoke test wheel (glibc)
run: |
python -m venv /tmp/wheeltest
/tmp/wheeltest/bin/pip install --quiet dist/*.whl
/tmp/wheeltest/bin/python -c "import pyfglair.session as s; print('pyfglair:', s.__file__)"
/tmp/wheeltest/bin/python -c "from pyfglair import Template, Prop, convert_to_display; assert convert_to_display(Template.A, Prop.DISPLAY_TEMPERATURE, 7000) == 200"
WHEEL="$(pwd)/dist"
/tmp/wheeltest/bin/pip install --quiet "$WHEEL"/*.whl
cd /tmp
/tmp/wheeltest/bin/python -P -c "import pyfglair.session as s; print('pyfglair(glibc):', s.__file__)"
/tmp/wheeltest/bin/python -P -c "from pyfglair import Template, Prop, convert_to_display; assert convert_to_display(Template.A, Prop.DISPLAY_TEMPERATURE, 7000) == 200"
- name: Publish to Gitea package registry (PyPI)
- name: Publish glibc wheel + sdist
env:
TOKEN: ${{ secrets.GITEA_PYPI_TOKEN }}
SECRET_USER: ${{ secrets.GITEA_PYPI_USER }}
SERVER: ${{ github.server_url }}
OWNER: ${{ github.repository_owner }}
TOKEN: ${{ secrets.PYPI_TOKEN }}
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
PYPI_USER: ${{ secrets.PYPI_USER }}
run: sh scripts/publish-gitea-pypi.sh
build-musllinux:
runs-on: ubuntu-latest
container: python:3.12-alpine
steps:
- name: Build musllinux wheel
env:
BUNDLED: ${{ inputs.bundled_mbedtls }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
SHA: ${{ github.sha }}
run: |
if [ -z "$TOKEN" ]; then
echo "::error::Задайте секрет GITEA_PYPI_TOKEN (PAT со scope write:package)"
apk add --no-cache git cmake ninja g++ make curl libstdc++-dev
if [ -z "$REPO_URL" ] || [ "$REPO_URL" = "/.git" ]; then
echo "::error::Пустой github.server_url/repository — задайте контексты или соберите musllinux вручную (docs/RELEASE_HA.md §1.4)"
exit 1
fi
SERVER="${GITHUB_SERVER_URL:-}"
if [ -z "$SERVER" ]; then
SERVER="$(git remote get-url origin | sed -E 's#^git@([^:]+):.*#https://\1#; s#^(https?://[^/]+)/.*#\1#')"
git clone --depth 1 "$REPO_URL" /src
cd /src
if [ -n "$SHA" ]; then
git fetch --depth 1 origin "$SHA"
git checkout --detach FETCH_HEAD
fi
REPO="${GITHUB_REPOSITORY:-$(git remote get-url origin | sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#')}"
OWNER="${REPO%%/*}"
GITEA_USER="${SECRET_USER:-${GITHUB_ACTOR:-git}}"
pip install --upgrade twine
python -m twine upload \
--repository-url "$SERVER/api/packages/$OWNER/pypi" \
--username "$GITEA_USER" \
--password "$TOKEN" \
--skip-existing \
--non-interactive \
dist/*
echo "Опубликовано: $SERVER/api/packages/$OWNER/pypi/simple/pyfglair/"
if [ "$BUNDLED" = "0" ]; then
export FGL_BUNDLED_MBEDTLS=0
else
export FGL_BUNDLED_MBEDTLS=1
fi
pip install --upgrade build
python -m build --wheel
ls -l dist
- name: Smoke test wheel (musl)
run: |
cd /src
python -m venv /tmp/wheeltest
/tmp/wheeltest/bin/pip install --quiet "$(pwd)"/dist/*.whl
cd /tmp
/tmp/wheeltest/bin/python -P -c "import pyfglair.session as s; print('pyfglair(musl):', s.__file__)"
/tmp/wheeltest/bin/python -P -c "from pyfglair import Template, Prop, convert_to_display; assert convert_to_display(Template.A, Prop.DISPLAY_TEMPERATURE, 7000) == 200"
- name: Publish musllinux wheel
env:
SERVER: ${{ github.server_url }}
OWNER: ${{ github.repository_owner }}
TOKEN: ${{ secrets.PYPI_TOKEN }}
GITEA_JOB_TOKEN: ${{ secrets.GITEA_TOKEN }}
PYPI_USER: ${{ secrets.PYPI_USER }}
DIST_DIR: /src/dist
run: cd /src && sh scripts/publish-gitea-pypi.sh
+8
View File
@@ -125,6 +125,14 @@ else()
VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR}
)
# Минимум внешних зависимостей у wheel: C++-рантайм встраиваем, остаются
# libc/libm (на musl — musl). Иначе Alpine/HA OS может не иметь
# libstdc++.so.6/libgcc_s.so.1 нужной сборки.
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
target_link_options(fgl-aircon-shared PRIVATE
-static-libstdc++ -static-libgcc
)
endif()
endif()
if(FGL_BUILD_TESTS)
+50 -8
View File
@@ -72,15 +72,22 @@ Assistant. Убедитесь, что `pyfglair` установлен в python-
*Должно быть видно: список с одним устройством (имя, модель, host).*
6. **Проверка шаблона с превью**: форма-предпросмотр рассчитанных
значений (режимы, диапазон температур, пример конверсии) и, при
необходимости, ручные коэффициенты/диапазон уставки. Сверьте с
приложением FGLair и подтвердите.
6. **Три страницы настройки** (все значения уже подставлены по данным
устройства — можно просто нажимать «Далее»):
1. **Шаблон модуля** — A/B/F с пояснениями; определяется по модели
модуля, а для неизвестных моделей — проверкой свойств. Рядом
показаны прочитанные с устройства значения.
2. **Возможности прибора** — тумблеры по «человекочитаемым» фичам
(режимы, скорости, swing, пресеты, флаги, заслонки) с описаниями;
включены/выключены по `device_capabilities` и ответам устройства.
Сущности создаются только для включённых возможностей.
3. **Уставка** — диапазон/шаг (у A/F 16–30 °C шаг 0.5, у B шаг 1.0) и,
при необходимости, ручная конверсия коэффициентами.
![шаг 6](screenshots/step-6.png)
*Должно быть видно: таблица превью — режимы, диапазон 16–30 °C, шаг,
текущая температура, capabilities, кнопки Submit.*
*Должно быть видно: страница «Возможности прибора» с тумблерами и
описаниями, часть выключена по данным устройства.*
7. **Готово**: карточка устройства с сущностями climate/switch/select/
sensor/binary_sensor.
@@ -121,11 +128,46 @@ lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl
| sensor | температура в помещении, код ошибки, диагностическое состояние связи |
| binary_sensor | connectivity + флаги op_status (defrost, maintenance, oil recovery, pump down, check operation, …) |
Список зависит от шаблона устройства (A/B/F) и маски `device_capabilities`;
режимы, которыми прибор не управляет, не создаются.
Список зависит от шаблона устройства (A/B/F), маски `device_capabilities`
и выбранных в мастере возможностей: то, что прибор не умеет или что
отключено, не создаётся. У каждой сущности есть краткое описание в
атрибуте `description` (карточка сущности → «Атрибуты»): HA не
поддерживает нативные тултипы, поэтому описание видно там.
## Диагностика и известные ситуации
* **`OSError: cannot load library ... ld-linux-x86-64.so.2: No such file
or directory`** — на HA OS (Alpine/musl) установился glibc-wheel. Нужна
musllinux-сборка `pyfglair`: переустановите пакет из индекса (см.
[`docs/RELEASE_HA.md`](../../docs/RELEASE_HA.md) §1.3) и перезапустите HA.
* **«Не удалось загрузить мастер настройки: Invalid handler specified»** —
HA не смог импортировать компонент (обычно нет пакета `pyfglair`).
Проверьте лог HA:
```sh
grep -iE "fglair|pyfglair" /config/home-assistant.log | tail -20
# ожидаемая причина: ModuleNotFoundError: No module named 'pyfglair'
```
Установите пакет в то же python-окружение, в котором работает HA, и
**полностью перезапустите** HA (не только интеграцию):
```sh
# HA OS / Supervised (SSH add-on с доступом к docker):
docker exec homeassistant python -m pip install --no-deps \
--index-url https://<user>:<token>@git.ratigorsk-12.ru/api/packages/<owner>/pypi/simple \
pyfglair
docker restart homeassistant
# HA Core в venv:
/srv/homeassistant/bin/python -m pip install --no-deps --index-url ... pyfglair
systemctl restart home-assistant@homeassistant
```
Проверка: `docker exec homeassistant python -c "import pyfglair; print(pyfglair.__version__)"` — должно печатать версию без ошибок.
Другие причины той же ошибки: компонент лежит не в
`<config>/custom_components/fglair/` (лишняя вложенность каталогов, нет
`__init__.py`), либо HA не перезапускался после копирования.
* **503 / оба слота заняты** — в LAN уже две сессии (телефон + ESPHome).
Освободите один слот и перезапустите интеграцию.
* **key_error** — LAN-ключ не совпал (`key_id` изменился). В HA появится
+9 -2
View File
@@ -11,8 +11,9 @@ from homeassistant.helpers import issue_registry as ir
import pyfglair
from .const import CONF_DSN, DOMAIN
from .const import CONF_DSN, CONF_FEATURES, DOMAIN
from .coordinator import FglairCoordinator, FglairRuntime, key_issue_id
from .features import FeatureSet, LiveValues
from .fglair_client import FglairClient
from .overrides import ConversionSet
@@ -80,8 +81,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
finally:
hass.data[DATA_PENDING] -= 1
features = FeatureSet(
explicit=entry.data.get(CONF_FEATURES),
values=LiveValues(coordinator.client.cached),
)
coordinator.features = features
clients[entry.entry_id] = client
entry.runtime_data = FglairRuntime(client, coordinator, conversions)
entry.runtime_data = FglairRuntime(client, coordinator, conversions, features)
if len(clients) == 1:
pyfglair.set_log_handler(_log_handler)
pyfglair.set_log_level(1)
+22 -16
View File
@@ -16,10 +16,6 @@ from pyfglair import Prop
from pyfglair.templates import prop_info
from .const import (
CAP_FAN,
CAP_HVAC,
CAP_SWING_HORIZONTAL,
CAP_SWING_VERTICAL,
FAN_MODE_TO_VALUE,
FAN_VALUE_TO_MODE,
HVAC_MODE_TO_VALUE,
@@ -27,7 +23,6 @@ from .const import (
OPERATION_MODE_OFF,
OPERATION_MODE_ON,
PRESET_BOOST,
PRESET_CAP,
PRESET_ECO,
PRESET_PROP,
SWING_MODES,
@@ -41,13 +36,24 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
async_add_entities([FglairClimate(runtime.coordinator)])
MODE_FEATURE = {
HVACMode.AUTO: "mode_auto",
HVACMode.COOL: "mode_cool",
HVACMode.DRY: "mode_dry",
HVACMode.FAN_ONLY: "mode_fan",
HVACMode.HEAT: "mode_heat",
}
FAN_FEATURE = {mode: f"fan_{mode}" for mode in FAN_VALUE_TO_MODE.values()}
PRESET_FEATURE = {PRESET_ECO: "economy_mode", PRESET_BOOST: "powerful_mode"}
class FglairClimate(FglairEntity, ClimateEntity):
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(self, coordinator: FglairCoordinator) -> None:
# translation_key не нужен: имя = имя устройства (has_entity_name).
super().__init__(coordinator, "climate", None)
super().__init__(coordinator, "climate", None, description_key="climate")
self._last_hvac_mode: Optional[HVACMode] = None
# -- свойства шаблона --------------------------------------------------
@@ -56,37 +62,37 @@ class FglairClimate(FglairEntity, ClimateEntity):
def _hvac_modes(self) -> list[HVACMode]:
modes: list[HVACMode] = [HVACMode.OFF]
for mode, bit in CAP_HVAC.items():
if self.supports_capability(bit):
for mode, key in MODE_FEATURE.items():
if self.feature_enabled(key):
modes.append(mode)
return modes
def _fan_modes(self) -> Optional[list[str]]:
if not self._in_template(Prop.FAN_SPEED):
return None
return [
mode
for mode, bit in CAP_FAN.items()
if self.supports_capability(bit)
modes = [
mode for mode, key in FAN_FEATURE.items()
if self.feature_enabled(key)
]
return modes or None
def _preset_modes(self) -> Optional[list[str]]:
presets = [
preset
for preset, bit in PRESET_CAP.items()
for preset, key in PRESET_FEATURE.items()
if self._in_template(PRESET_PROP[preset])
and self.supports_capability(bit)
and self.feature_enabled(key)
]
return presets or None
def _vertical_swing_supported(self) -> bool:
return self._in_template(Prop.AF_VERTICAL_SWING) and (
self.supports_capability(CAP_SWING_VERTICAL)
self.feature_enabled("swing_vertical")
)
def _horizontal_swing_supported(self) -> bool:
return self._in_template(Prop.AF_HORIZONTAL_SWING) and (
self.supports_capability(CAP_SWING_HORIZONTAL)
self.feature_enabled("swing_horizontal")
)
# -- ClimateEntity ----------------------------------------------------
+200 -71
View File
@@ -22,14 +22,11 @@ from pyfglair.provision import Device
from pyfglair.templates import detect
from .const import (
CAP_FAN,
CAP_HVAC,
CAP_SWING_HORIZONTAL,
CAP_SWING_VERTICAL,
CONF_CONFIG_JSON,
CONF_DEVICE_PORT,
CONF_DSN,
CONF_EMAIL,
CONF_FEATURES,
CONF_HOST,
CONF_LANIP_KEY,
CONF_LANIP_KEY_ID,
@@ -49,6 +46,8 @@ from .const import (
DEFAULT_DEVICE_PORT,
DEFAULT_LISTEN_PORT,
DOMAIN,
FAN_VALUE_TO_MODE,
FEATURES,
REGIONS,
TEMP_MAX_TENTHS,
TEMP_MIN_TENTHS,
@@ -56,8 +55,9 @@ from .const import (
TRIAL_ERRORS,
TRIAL_TIMEOUT,
)
from .features import resolve_features
from .overrides import ConversionSet, LinearOverride
from .trial import TrialResult, trial_connect
from .trial import TrialResult, probe_template, trial_connect
_LOGGER = logging.getLogger(__name__)
@@ -113,7 +113,24 @@ def _spec_int(spec: dict[str, Any], key: str, default: int) -> int:
return default
def _preview_schema(data: dict[str, Any]) -> vol.Schema:
def _template_schema(template: Template) -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
["A", "B", "F"]
)
}
)
def _capabilities_schema(defaults: dict[str, bool]) -> vol.Schema:
fields = {}
for key in FEATURES:
fields[vol.Optional(key, default=bool(defaults.get(key)))] = bool
return vol.Schema(fields)
def _limits_schema(data: dict[str, Any]) -> vol.Schema:
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
overrides = data.get(CONF_OVERRIDES)
spec = (
@@ -131,9 +148,6 @@ def _preview_schema(data: dict[str, Any]) -> vol.Schema:
step = TEMP_STEP_TENTHS[template]
return vol.Schema(
{
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
["A", "B", "F"]
),
vol.Required(CONF_TEMP_MIN, default=temp_min / 10): vol.All(
vol.Coerce(float), vol.Range(min=-10.0, max=45.0)
),
@@ -156,44 +170,33 @@ def _preview_schema(data: dict[str, Any]) -> vol.Schema:
)
def _preview_text(
template: Template, properties: dict[Any, Any]
) -> str:
def _device_values_text(properties: dict[Any, Any]) -> str:
"""Что прочитано с устройства (человекочитаемо) для описаний шагов."""
def raw(prop) -> int | None:
value = properties.get(prop)
return value.int_value if value is not None else None
caps = raw(Prop.DEVICE_CAPABILITIES)
def supported(bit: int) -> bool:
return caps is None or bool(caps & (1 << bit))
hvac = ["off"] + [
mode.value for mode, bit in CAP_HVAC.items() if supported(bit)
]
fan = [mode for mode, bit in CAP_FAN.items() if supported(bit)]
tmin, tmax = TEMP_MIN_TENTHS / 10, TEMP_MAX_TENTHS / 10
step = TEMP_STEP_TENTHS[template] / 10
lines = [
f"- HVAC: {', '.join(hvac)}",
f"- Fan: {', '.join(fan) or '-'}",
f"- Setpoint: {tmin:.1f}-{tmax:.1f} °C, step {step:.1f} °C",
]
lines: list[str] = []
mode = raw(Prop.OPERATION_MODE)
if mode is not None:
names = {0: "off", 1: "on", 2: "auto", 3: "cool", 4: "dry",
5: "fan", 6: "heat"}
lines.append(f"- режим: {names.get(mode, '?')} ({mode})")
fan = raw(Prop.FAN_SPEED)
if fan is not None:
lines.append(f"- скорость: {FAN_VALUE_TO_MODE.get(fan, '?')} ({fan})")
current = raw(Prop.DISPLAY_TEMPERATURE)
if current is not None:
lines.append(f"- Current temperature: {current / 10:.1f} °C")
swings = []
if supported(CAP_SWING_VERTICAL):
swings.append("vertical")
if supported(CAP_SWING_HORIZONTAL):
swings.append("horizontal")
if swings:
lines.append(f"- Swing: {', '.join(swings)}")
lines.append(f"- температура в помещении: {current / 10:.1f} °C")
caps = raw(Prop.DEVICE_CAPABILITIES)
if caps is not None:
lines.append(f"- Capabilities: 0x{caps:08X}")
lines.append(f"- Template: {template.name}")
return "\n".join(lines)
lines.append(f"- device_capabilities: 0x{caps:08X}")
vnum = raw(Prop.AF_VERTICAL_NUM_DIR)
hnum = raw(Prop.AF_HORIZONTAL_NUM_DIR)
if vnum is not None or hnum is not None:
lines.append(f"- направлений заслонок: верт. {vnum or 0}, гориз. {hnum or 0}")
return "\n".join(lines) or "- данные не получены"
def _template_name(model: str | None) -> str:
@@ -263,17 +266,41 @@ def _parse_import(source: str | dict[str, Any]) -> dict[str, Any] | None:
}
def _run_trial(data: dict[str, Any]) -> TrialResult:
return trial_connect(
def _run_trial(data: dict[str, Any]) -> tuple[TrialResult, Template, bool]:
"""Пробная сессия; для неизвестной модели — предварительный пробинг A/B/F.
Возвращает (результат, шаблон, был_ли_пробинг).
"""
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
model = str(data.get(CONF_MODEL) or "")
probed = False
if not model or detect(model) is None:
detected, _probe_props, fatal = probe_template(
data[CONF_HOST],
data[CONF_DSN],
data[CONF_LANIP_KEY],
int(data[CONF_LANIP_KEY_ID]),
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)),
listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
timeout=TRIAL_TIMEOUT,
)
probed = True
if fatal is not None:
# Модуль недоступен/нет слотов/ключ — вторую сессию не открываем.
return TrialResult(ok=False, reason=fatal), template, True
if detected is not None:
template = detected
result = trial_connect(
host=data[CONF_HOST],
dsn=data[CONF_DSN],
lanip_key=data[CONF_LANIP_KEY],
lanip_key_id=int(data[CONF_LANIP_KEY_ID]),
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)),
template=Template[str(data.get(CONF_TEMPLATE, "A"))],
template=template,
listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
timeout=TRIAL_TIMEOUT,
)
return result, template, probed
class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -285,6 +312,10 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
self._devices: list[Device] = []
self._pending_data: dict[str, Any] = {}
self._trial_properties: dict[Any, Any] = {}
self._trial_answered: set[Prop] = set()
self._resolved_template: Template = Template.A
self._probed: bool = False
self._selected_features: dict[str, bool] = {}
self._reconfigure_entry: ConfigEntry | None = None
async def async_step_reconfigure(
@@ -391,7 +422,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
}
error = await self._async_trial(data)
if error is None:
return await self.async_step_preview()
return await self.async_step_template()
errors["base"] = error
schema = vol.Schema(
{
@@ -413,7 +444,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
data = _manual_data(user_input)
error = await self._async_trial(data)
if error is None:
return await self.async_step_preview()
return await self.async_step_template()
errors["base"] = error
if user_input is None and self._reconfigure_entry is not None:
user_input = self._reconfigure_suggestions()
@@ -433,7 +464,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
else:
error = await self._async_trial(data)
if error is None:
return await self.async_step_preview()
return await self.async_step_template()
errors["base"] = error
return self._show_form("import_json", _import_schema(), errors, user_input)
@@ -447,16 +478,80 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
error = await self._async_trial(data)
if error is not None:
return self.async_abort(reason=error)
return self._create_entry(data)
entry_data = dict(self._pending_data)
entry_data[CONF_FEATURES] = resolve_features(
self._trial_properties, self._trial_answered
)
return self._create_entry(entry_data)
async def async_step_preview(
async def async_step_template(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Шаг 3: превью шаблона и ручные конверсии/диапазон уставки."""
"""Страница 1: шаблон (таблица свойств модуля) с пояснениями."""
data = dict(self._pending_data)
if user_input is not None:
template = Template[str(user_input[CONF_TEMPLATE])]
data[CONF_TEMPLATE] = template.name
self._pending_data = data
return await self.async_step_capabilities()
default = self._resolved_template.name
if self._reconfigure_entry is not None:
default = str(data.get(CONF_TEMPLATE, default))
placeholders = {
"device_values": _device_values_text(self._trial_properties),
}
if self._probed:
placeholders["probe_note"] = (
"Модель модуля неизвестна — шаблон определён проверкой "
"свойств."
)
else:
placeholders["probe_note"] = (
"Шаблон определён по модели модуля."
)
return self.async_show_form(
step_id="template",
data_schema=self.add_suggested_values_to_schema(
_template_schema(Template[default]),
{CONF_TEMPLATE: default},
),
description_placeholders=placeholders,
)
async def async_step_capabilities(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Страница 2: возможности прибора (тумблеры, дефолты с устройства)."""
if user_input is not None:
self._selected_features = {
key: bool(user_input.get(key, False)) for key in FEATURES
}
return await self.async_step_limits()
defaults = resolve_features(
self._trial_properties,
self._trial_answered,
self._reconfigure_defaults(),
)
schema = self.add_suggested_values_to_schema(
_capabilities_schema(defaults),
{key: value for key, value in defaults.items()},
)
return self.async_show_form(
step_id="capabilities",
data_schema=schema,
description_placeholders={
"device_values": _device_values_text(self._trial_properties),
},
)
async def async_step_limits(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Страница 3: диапазон и шаг уставки (при необходимости — конверсия)."""
data = dict(self._pending_data)
errors: dict[str, str] = {}
if user_input is not None:
template = Template[user_input[CONF_TEMPLATE]]
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
temp_min = int(round(float(user_input[CONF_TEMP_MIN]) * 10))
temp_max = int(round(float(user_input[CONF_TEMP_MAX]) * 10))
step = int(round(float(user_input[CONF_TEMP_STEP]) * 10))
@@ -482,11 +577,8 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
or bounds is None
or bounds[0] >= bounds[1]
):
# Достижимый диапазон пуст: конверсия выводит таблицу
# шаблона за пределы, которые не пересекаются с min/max.
errors["base"] = "invalid_range"
else:
data[CONF_TEMPLATE] = template.name
data[CONF_TEMP_STEP] = step
data[CONF_OVERRIDES] = {
Prop.ADJUST_TEMPERATURE.name.lower(): {
@@ -497,29 +589,52 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
"max": temp_max,
}
}
data[CONF_FEATURES] = dict(self._selected_features)
_LOGGER.debug(
"превью: template=%s range=%d..%d step=%d",
"limits: template=%s range=%d..%d step=%d features=%d",
template.name, temp_min, temp_max, step,
len(self._selected_features),
)
return self._create_entry(data)
selected = str(data.get(CONF_TEMPLATE, Template.A.name))
if user_input and user_input.get(CONF_TEMPLATE) in Template.__members__:
selected = str(user_input[CONF_TEMPLATE])
if user_input is None:
user_input = {CONF_TEMPLATE: selected}
return self.async_show_form(
step_id="preview",
data_schema=self.add_suggested_values_to_schema(
_preview_schema(data), user_input
),
errors=errors,
description_placeholders={
"preview": _preview_text(
Template[selected], self._trial_properties
)
},
if user_input is None and self._reconfigure_entry is not None:
user_input = self._reconfigure_limits_suggestions()
return self._show_form(
"limits",
_limits_schema(data),
errors,
user_input,
)
def _reconfigure_defaults(self) -> dict[str, bool] | None:
entry = self._reconfigure_entry
if entry is None:
return None
features = entry.data.get(CONF_FEATURES)
return features if isinstance(features, dict) else None
def _reconfigure_limits_suggestions(self) -> dict[str, Any] | None:
entry = self._reconfigure_entry
if entry is None:
return None
spec = (entry.data.get(CONF_OVERRIDES) or {}).get(
Prop.ADJUST_TEMPERATURE.name.lower(), {}
)
if not isinstance(spec, dict):
return None
return {
CONF_TEMP_MIN: _spec_int(spec, "min", TEMP_MIN_TENTHS) / 10,
CONF_TEMP_MAX: _spec_int(spec, "max", TEMP_MAX_TENTHS) / 10,
CONF_TEMP_STEP: int(
entry.data.get(CONF_TEMP_STEP) or TEMP_STEP_TENTHS[
Template[str(entry.data.get(CONF_TEMPLATE, "A"))]
]
)
/ 10,
CONF_TEMP_NUM: _spec_int(spec, "num", 1),
CONF_TEMP_DEN: _spec_int(spec, "den", 1),
CONF_TEMP_OFFSET: _spec_int(spec, "offset", 0),
}
async def _async_trial(self, data: dict[str, Any]) -> str | None:
"""None — сессия поднялась; иначе ключ ошибки формы."""
if self._reconfigure_entry is not None:
@@ -527,17 +642,31 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
# текущие конверсии, если путь их не задаёт.
for key, value in self._reconfigure_entry.data.items():
data.setdefault(key, value)
# Сохранённый шаблон важнее пересчёта по модели: пользователь
# мог выбрать его вручную (страница подтвердит/изменит).
if self._reconfigure_entry.data.get(CONF_TEMPLATE):
data[CONF_TEMPLATE] = self._reconfigure_entry.data[
CONF_TEMPLATE
]
await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False)
if self._reconfigure_entry is not None:
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured()
result: TrialResult = await self.hass.async_add_executor_job(
result, template, probed = await self.hass.async_add_executor_job(
partial(_run_trial, data)
)
if result.ok:
self._pending_data = data
if (
self._reconfigure_entry is None
or not self._reconfigure_entry.data.get(CONF_TEMPLATE)
):
self._pending_data[CONF_TEMPLATE] = template.name
self._trial_properties = result.properties
self._trial_answered = result.answered
self._resolved_template = template
self._probed = probed
return None
_LOGGER.debug(
"пробное подключение не удалось: reason=%s state=%s error=%s",
+136 -15
View File
@@ -24,6 +24,7 @@ CONF_PASSWORD = "password"
CONF_CONFIG_JSON = "config_json"
CONF_OVERRIDES = "overrides"
CONF_TEMP_STEP = "temp_step"
CONF_FEATURES = "features"
CONF_TEMP_MIN = "temp_min"
CONF_TEMP_MAX = "temp_max"
CONF_TEMP_NUM = "temp_num"
@@ -93,25 +94,145 @@ PRESET_PROP = {
PRESET_ECO: Prop.ECONOMY_MODE,
PRESET_BOOST: Prop.POWERFUL_MODE,
}
PRESET_CAP = {
PRESET_ECO: CAP_ECONOMY,
PRESET_BOOST: CAP_POWERFUL,
}
SWING_MODES = ["off", "on"]
# switch-свойства: prop -> (translation_key, capability bit | None)
SWITCH_PROPS: dict[Prop, tuple[str, int | None]] = {
Prop.ECONOMY_MODE: ("economy_mode", CAP_ECONOMY),
Prop.POWERFUL_MODE: ("powerful_mode", CAP_POWERFUL),
Prop.COIL_DRY_MODE: ("coil_dry_mode", CAP_COIL_DRY),
Prop.MIN_HEAT: ("min_heat", CAP_MIN_HEAT),
Prop.OUTDOOR_LOW_NOISE: ("outdoor_low_noise", CAP_OUTDOOR_LOW_NOISE),
Prop.HUMAN_DET_AUTO_SAVE: ("human_det_auto_save", None),
Prop.WIFI_LED_ENABLE: ("wifi_led_enable", None),
Prop.INDOOR_FAN_CONTROL: ("indoor_fan_control", CAP_INDOOR_FAN_CONTROL),
# switch-свойства: prop -> translation_key (filtracija po FEATURES)
SWITCH_PROPS: dict[Prop, str] = {
Prop.ECONOMY_MODE: "economy_mode",
Prop.POWERFUL_MODE: "powerful_mode",
Prop.COIL_DRY_MODE: "coil_dry_mode",
Prop.MIN_HEAT: "min_heat",
Prop.OUTDOOR_LOW_NOISE: "outdoor_low_noise",
Prop.HUMAN_DET_AUTO_SAVE: "human_det_auto_save",
Prop.WIFI_LED_ENABLE: "wifi_led_enable",
Prop.INDOOR_FAN_CONTROL: "indoor_fan_control",
}
# ---------------------------------------------------------------------------
# Feature-модель (план: страница capabilities в мастере + фильтрация сущностей).
# key -> (capability bit | None, свойство-источник | None).
# Для битовых фич дефолт = бит в device_capabilities; для небитовых —
# наличие свойства в ответах устройства; для заслонок — num_dir > 1.
# ---------------------------------------------------------------------------
FEATURES: dict[str, tuple[int | None, Prop | None]] = {
"mode_cool": (CAP_HVAC[HVACMode.COOL], Prop.OPERATION_MODE),
"mode_dry": (CAP_HVAC[HVACMode.DRY], Prop.OPERATION_MODE),
"mode_fan": (CAP_HVAC[HVACMode.FAN_ONLY], Prop.OPERATION_MODE),
"mode_heat": (CAP_HVAC[HVACMode.HEAT], Prop.OPERATION_MODE),
"mode_auto": (CAP_HVAC[HVACMode.AUTO], Prop.OPERATION_MODE),
"fan_quiet": (CAP_FAN["quiet"], Prop.FAN_SPEED),
"fan_low": (CAP_FAN["low"], Prop.FAN_SPEED),
"fan_medium": (CAP_FAN["medium"], Prop.FAN_SPEED),
"fan_high": (CAP_FAN["high"], Prop.FAN_SPEED),
"fan_auto": (CAP_FAN["auto"], Prop.FAN_SPEED),
"swing_vertical": (CAP_SWING_VERTICAL, Prop.AF_VERTICAL_SWING),
"swing_horizontal": (CAP_SWING_HORIZONTAL, Prop.AF_HORIZONTAL_SWING),
"economy_mode": (CAP_ECONOMY, Prop.ECONOMY_MODE),
"min_heat": (CAP_MIN_HEAT, Prop.MIN_HEAT),
"indoor_fan_control": (CAP_INDOOR_FAN_CONTROL, Prop.INDOOR_FAN_CONTROL),
"powerful_mode": (CAP_POWERFUL, Prop.POWERFUL_MODE),
"outdoor_low_noise": (CAP_OUTDOOR_LOW_NOISE, Prop.OUTDOOR_LOW_NOISE),
"coil_dry_mode": (CAP_COIL_DRY, Prop.COIL_DRY_MODE),
"display_temperature": (None, Prop.DISPLAY_TEMPERATURE),
"human_det_auto_save": (None, Prop.HUMAN_DET_AUTO_SAVE),
"wifi_led_enable": (None, Prop.WIFI_LED_ENABLE),
"af_vertical_direction": (None, Prop.AF_VERTICAL_DIRECTION),
"af_horizontal_direction": (None, Prop.AF_HORIZONTAL_DIRECTION),
}
# Описания для мастера и для атрибута description сущностей.
FEATURE_DESCRIPTIONS = {
"ru": {
"mode_cool": "Охлаждение",
"mode_dry": "Осушение",
"mode_fan": "Вентиляция (без нагрева/охлаждения)",
"mode_heat": "Обогрев",
"mode_auto": "Автоматический режим",
"fan_auto": "Автоскорость вентилятора",
"fan_quiet": "Тихая скорость",
"fan_low": "Низкая скорость",
"fan_medium": "Средняя скорость",
"fan_high": "Высокая скорость",
"swing_vertical": "Качание заслонки по вертикали",
"swing_horizontal": "Качание заслонки по горизонтали",
"economy_mode": "Экономичный режим",
"min_heat": "Минимальный обогрев (поддержание +10 °C)",
"indoor_fan_control": "Управление вентилятором при работе наружного блока",
"powerful_mode": "Мощный режим (ускоренный выход на режим)",
"outdoor_low_noise": "Тихий наружный блок",
"coil_dry_mode": "Сушка испарителя",
"display_temperature": "Датчик температуры помещения",
"human_det_auto_save": "Автосохранение при отсутствии людей (датчик присутствия)",
"wifi_led_enable": "Индикатор Wi-Fi на модуле",
"af_vertical_direction": "Выбор положения вертикальной заслонки",
"af_horizontal_direction": "Выбор положения горизонтальной заслонки",
},
"en": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic mode",
"fan_auto": "Auto fan speed",
"fan_quiet": "Quiet fan speed",
"fan_low": "Low fan speed",
"fan_medium": "Medium fan speed",
"fan_high": "High fan speed",
"swing_vertical": "Vertical louver swing",
"swing_horizontal": "Horizontal louver swing",
"economy_mode": "Economy mode",
"min_heat": "Minimum heat (keep +10 °C)",
"indoor_fan_control": "Indoor fan control while outdoor unit runs",
"powerful_mode": "Powerful mode",
"outdoor_low_noise": "Outdoor unit low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED indicator",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position",
},
}
ENTITY_DESCRIPTIONS = {
"ru": {
"climate": "Управление кондиционером: режим, скорость, уставка, "
"заслонки, пресеты",
"room_temperature": "Температура воздуха в помещении",
"error_code": "Код ошибки кондиционера (0 — ошибок нет)",
"connection_state": "Состояние связи с модулем кондиционера",
"connectivity": "Есть ли связь с модулем",
"defrost": "Идёт разморозка наружного блока",
"maintenance": "Требуется обслуживание",
"oil_recovery": "Возврат масла в компрессор",
"pump_down": "Откачка хладагента (pump down)",
"check_operation": "Проверочный прогон",
"simultaneous": "Одновременные операции",
"different_modes": "Разморозка/масло/прочие режимы (шаблон B)",
},
"en": {
"climate": "Air conditioner control: mode, fan, setpoint, louvers, presets",
"room_temperature": "Room air temperature",
"error_code": "Air conditioner error code (0 = no errors)",
"connection_state": "Connection state with the AC module",
"connectivity": "Whether the module is reachable",
"defrost": "Outdoor unit defrost in progress",
"maintenance": "Maintenance required",
"oil_recovery": "Compressor oil recovery",
"pump_down": "Refrigerant pump down",
"check_operation": "Check operation",
"simultaneous": "Simultaneous operations",
"different_modes": "Defrost/oil/other modes (template B)",
},
}
def entity_description(language: str, key: str) -> str | None:
lang = "ru" if str(language).lower().startswith("ru") else "en"
return ENTITY_DESCRIPTIONS[lang].get(key) or FEATURE_DESCRIPTIONS[lang].get(
key
)
# op_status, биты (PROTOCOL §8.4): translation_key -> bit
OP_STATUS_BITS: dict[str, int] = {
"defrost": 24,
+43 -3
View File
@@ -12,7 +12,7 @@ from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind
from pyfglair.templates import template_info
from pyfglair.templates import prop_info, template_info
from .const import (
AVAILABLE_STATES,
@@ -21,6 +21,7 @@ from .const import (
CONF_NAME,
DOMAIN,
)
from .features import FeatureSet, LiveValues
from .fglair_client import FglairClient
from .overrides import ConversionSet
@@ -40,6 +41,7 @@ class FglairRuntime:
client: FglairClient
coordinator: "FglairCoordinator"
conversions: ConversionSet
features: "FeatureSet"
def key_issue_id(dsn: str) -> str:
@@ -70,6 +72,9 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
)
self.client = client
self.conversions = conversions
self.features = FeatureSet(
explicit=None, values=LiveValues(client.cached)
)
self.entry_id = entry_id
self.key_issue_id = key_issue_id(client.data[CONF_DSN])
self._synced = False
@@ -118,8 +123,20 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
# После разрыва при следующем ONLINE нужна повторная синхронизация.
self._synced = False
async def async_sync_now(self) -> None:
"""Гарантирует первичный GET-батч (если уже online)."""
async def async_sync_now(self, online_timeout: float = 2.0) -> None:
"""Гарантирует первичный GET-батч и ожидание ключевых свойств.
Ждёт ONLINE не дольше ``online_timeout``, затем — завершения
синхронизации (сама синхронизация ограничена своим таймаутом),
чтобы сущности создавались с корректными feature-дефолтами.
"""
deadline = self.hass.loop.time() + online_timeout
while (
self._sync_task is None
and self.client.state != State.ONLINE
and self.hass.loop.time() < deadline
):
await asyncio.sleep(0.05)
if self._sync_task is not None:
await self._sync_task
elif self.client.state == State.ONLINE and not self._synced:
@@ -169,12 +186,35 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
async def _async_initial_sync(self) -> None:
try:
await self.hass.async_add_executor_job(self._initial_sync)
await self._async_wait_first_props()
self._synced = True
except Exception: # pragma: no cover - защитный путь
_LOGGER.exception("первичная синхронизация свойств не удалась")
finally:
self._sync_task = None
async def _async_wait_first_props(self, timeout: float = 3.0) -> None:
"""Ждёт ключевые свойства (caps/режим/num_dir), чтобы сущности
создавались с корректными feature-дефолтами."""
template = self.client.template
wanted = [
Prop.OPERATION_MODE,
Prop.DEVICE_CAPABILITIES,
Prop.AF_VERTICAL_NUM_DIR,
Prop.AF_HORIZONTAL_NUM_DIR,
Prop.DISPLAY_TEMPERATURE,
Prop.HUMAN_DET_AUTO_SAVE,
Prop.WIFI_LED_ENABLE,
]
wanted = [
prop for prop in wanted if prop_info(template, prop) is not None
]
deadline = self.hass.loop.time() + timeout
while self.hass.loop.time() < deadline:
if all(self.client.cached(prop) is not None for prop in wanted):
return
await asyncio.sleep(0.05)
def _initial_sync(self) -> None:
self.client.batch_begin()
for prop in _writable_props(self.client.template):
+21 -16
View File
@@ -8,7 +8,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from pyfglair import Prop, Value
from .const import CONF_DSN, CONF_MODEL, CONF_NAME, DOMAIN
from .const import (
CONF_DSN,
CONF_MODEL,
CONF_NAME,
DOMAIN,
entity_description,
)
from .coordinator import FglairCoordinator
@@ -22,6 +28,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
coordinator: FglairCoordinator,
unique_suffix: str,
translation_key: Optional[str],
description_key: Optional[str] = None,
) -> None:
super().__init__(coordinator)
data = coordinator.client.data
@@ -29,6 +36,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
self._attr_unique_id = f"{dsn}_{unique_suffix}"
if translation_key is not None:
self._attr_translation_key = translation_key
self._description_key = description_key or translation_key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, dsn)},
name=data.get(CONF_NAME) or dsn,
@@ -40,6 +48,18 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
def available(self) -> bool:
return self.coordinator.data.available and super().available
@property
def extra_state_attributes(self) -> Optional[dict]:
"""HA не поддерживает тултипы сущностей: краткое описание видно в
атрибутах (More info → Атрибуты)."""
if self._description_key is None or self.hass is None:
return None
text = entity_description(self.hass.config.language, self._description_key)
return {"description": text} if text else None
def feature_enabled(self, key: str) -> bool:
return self.coordinator.features.enabled(key)
def value(self, prop: Prop) -> Optional[Value]:
return self.coordinator.data.values.get(prop)
@@ -54,19 +74,4 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
return None
return self.coordinator.conversions.to_display(prop, raw)
def capabilities(self) -> Optional[int]:
return self.raw_value(Prop.DEVICE_CAPABILITIES)
def capability(self, bit: int) -> Optional[bool]:
caps = self.capabilities()
if not caps:
# 0/None: возможностей ещё не сообщили — не фильтруем.
return None
return bool(caps & (1 << bit))
def supports_capability(self, bit: Optional[int]) -> bool:
"""None — считать поддержанным (capabilities ещё не пришли)."""
if bit is None:
return True
cap = self.capability(bit)
return True if cap is None else cap
+112
View File
@@ -0,0 +1,112 @@
"""Feature-модель: какие возможности реально есть у прибора.
Дефолты берутся из `device_capabilities` и фактических ответов устройства,
пользователь может переопределить их в мастере (и в reconfigure); результат
хранится в entry.data[CONF_FEATURES] и фильтрует создание сущностей.
"""
from __future__ import annotations
from typing import Any, Mapping, Optional
from pyfglair import Prop, Value
from .const import FEATURES
DIRECTION_KEYS = {
"af_vertical_direction": Prop.AF_VERTICAL_NUM_DIR,
"af_horizontal_direction": Prop.AF_HORIZONTAL_NUM_DIR,
}
def _int_value(values: Mapping[Prop, Value], prop: Prop) -> Optional[int]:
value = values.get(prop)
return value.int_value if value is not None else None
def _caps(values: Mapping[Prop, Value]) -> Optional[int]:
raw = _int_value(values, Prop.DEVICE_CAPABILITIES)
return raw if raw else None
def device_default(
key: str,
values: Mapping[Prop, Value],
answered: Optional[set[Prop]] = None,
) -> bool:
"""Дефолт фичи по данным устройства (без пользовательских override)."""
bit, prop = FEATURES[key]
if bit is not None:
caps = _caps(values)
if caps is None:
return True # возможностей не сообщили — не режем
return bool(caps & (1 << bit))
if key in DIRECTION_KEYS:
num = _int_value(values, DIRECTION_KEYS[key])
return bool(num and num > 1)
if prop is not None:
if answered is not None:
return prop in answered
return prop in values
return True
def resolve_features(
values: Mapping[Prop, Value],
answered: Optional[set[Prop]] = None,
explicit: Optional[Mapping[str, Any]] = None,
) -> dict[str, bool]:
"""Полный набор фич: дефолт с устройства + пользовательские значения."""
resolved = {
key: bool(value)
for key, value in (
explicit or {}
).items()
if key in FEATURES
}
for key in FEATURES:
resolved.setdefault(key, device_default(key, values, answered))
return resolved
class LiveValues(Mapping):
"""Mapping поверх client.cached(prop) — значения кэша ядра «на сейчас».
Используется вместо снимка coordinator.data, чтобы фильтрация сущностей
не зависела от гонки с обработкой push-событий в loop.
"""
def __init__(self, getter) -> None:
self._getter = getter
def __getitem__(self, prop: Prop) -> Value:
value = self._getter(prop)
if value is None:
raise KeyError(prop)
return value
def __iter__(self):
return iter(())
def __len__(self) -> int:
return 0
class FeatureSet:
"""Фильтр создания сущностей для runtime (entry + live-значения ядра)."""
def __init__(
self,
explicit: Optional[Mapping[str, Any]],
values: Mapping[Prop, Value],
) -> None:
self._explicit = {
key: bool(value)
for key, value in (explicit or {}).items()
if key in FEATURES
}
self._values = values
def enabled(self, key: str) -> bool:
if key in self._explicit:
return self._explicit[key]
return device_default(key, self._values)
+4 -2
View File
@@ -48,7 +48,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
entities = []
if (
prop_info(template, Prop.AF_VERTICAL_DIRECTION) is not None
coordinator.features.enabled("af_vertical_direction")
and prop_info(template, Prop.AF_VERTICAL_DIRECTION) is not None
and prop_info(template, Prop.AF_VERTICAL_NUM_DIR) is not None
):
entities.append(
@@ -60,7 +61,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
)
)
if (
prop_info(template, Prop.AF_HORIZONTAL_DIRECTION) is not None
coordinator.features.enabled("af_horizontal_direction")
and prop_info(template, Prop.AF_HORIZONTAL_DIRECTION) is not None
and prop_info(template, Prop.AF_HORIZONTAL_NUM_DIR) is not None
):
entities.append(
+3 -1
View File
@@ -71,7 +71,9 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
props = {info.prop for info in template_info(template)}
entities = [FglairConnectionState(coordinator)]
if Prop.DISPLAY_TEMPERATURE in props:
if Prop.DISPLAY_TEMPERATURE in props and runtime.features.enabled(
"display_temperature"
):
entities.append(FglairRoomTemperature(coordinator))
if Prop.ERROR_CODE in props:
entities.append(FglairErrorCode(coordinator))
+69 -6
View File
@@ -52,11 +52,74 @@
"config_json": "Paste a JSON object produced by fglair-discover or fglctl"
}
},
"preview": {
"title": "Template preview",
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.",
"template": {
"title": "Module template",
"description": "Read from the device:\n\n{device_values}\n\n{probe_note} The template defines the module's property table (names, types, conversions); actual features are tuned on the next step.\n\nIf unsure, keep the default.",
"data": {
"template": "Template"
},
"data_description": {
"template": "A — AP-WA/AP-WC/AP-WD modules: room temperature sensor, louver directions and swing, all flags. B — AP-WB modules: no room temperature sensor, no louver directions, 1.0 °C setpoint step. F — like A but for AP-WF modules (monitor1, filter_sign_reset)."
}
},
"capabilities": {
"title": "Device capabilities",
"description": "Toggles are pre-set from the device:\n\n{device_values}\n\nDisable what the device cannot do or what you do not want to expose. Entities are created only for enabled features.\n\nIf unsure, keep as is.",
"data": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic",
"fan_quiet": "Quiet fan",
"fan_low": "Low fan",
"fan_medium": "Medium fan",
"fan_high": "High fan",
"fan_auto": "Auto fan",
"swing_vertical": "Vertical swing",
"swing_horizontal": "Horizontal swing",
"economy_mode": "Economy",
"min_heat": "Minimum heat",
"indoor_fan_control": "Indoor fan control",
"powerful_mode": "Powerful",
"outdoor_low_noise": "Outdoor low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
},
"data_description": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic mode",
"fan_auto": "Auto fan speed",
"fan_quiet": "Quiet fan speed",
"fan_low": "Low fan speed",
"fan_medium": "Medium fan speed",
"fan_high": "High fan speed",
"swing_vertical": "Vertical louver swing",
"swing_horizontal": "Horizontal louver swing",
"economy_mode": "Economy mode",
"min_heat": "Minimum heat (keep +10 °C)",
"indoor_fan_control": "Indoor fan control while outdoor unit runs",
"powerful_mode": "Powerful mode",
"outdoor_low_noise": "Outdoor unit low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED indicator",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
}
},
"limits": {
"title": "Setpoint (range and step)",
"description": "Range and step are auto-detected (A/F step 0.5 °C, B — 1.0). Optionally enable a custom linear conversion (API units are 0.1 °C).\n\nIf unsure, keep as is.",
"data": {
"template": "Template",
"temp_min": "Minimum setpoint (°C)",
"temp_max": "Maximum setpoint (°C)",
"temp_step": "Setpoint step (°C)",
@@ -66,8 +129,8 @@
},
"data_description": {
"temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)",
"temp_den": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)",
"temp_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)"
"temp_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset"
}
}
},
+2 -13
View File
@@ -40,21 +40,10 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
template = runtime.client.template
entities = []
for prop, (key, capability) in SWITCH_PROPS.items():
for prop, key in SWITCH_PROPS.items():
if prop_info(template, prop) is None:
continue
if not _capability_supported(coordinator, capability):
if not coordinator.features.enabled(key):
continue
entities.append(FglairSwitch(coordinator, prop, key))
async_add_entities(entities)
def _capability_supported(
coordinator: FglairCoordinator, bit: int | None
) -> bool:
if bit is None:
return True
caps = coordinator.data.values.get(Prop.DEVICE_CAPABILITIES)
if caps is None or not caps.int_value:
return True
return bool(caps.int_value & (1 << bit))
+69 -6
View File
@@ -52,11 +52,74 @@
"config_json": "Paste a JSON object produced by fglair-discover or fglctl"
}
},
"preview": {
"title": "Template preview",
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.",
"template": {
"title": "Module template",
"description": "Read from the device:\n\n{device_values}\n\n{probe_note} The template defines the module's property table (names, types, conversions); actual features are tuned on the next step.\n\nIf unsure, keep the default.",
"data": {
"template": "Template"
},
"data_description": {
"template": "A — AP-WA/AP-WC/AP-WD modules: room temperature sensor, louver directions and swing, all flags. B — AP-WB modules: no room temperature sensor, no louver directions, 1.0 °C setpoint step. F — like A but for AP-WF modules (monitor1, filter_sign_reset)."
}
},
"capabilities": {
"title": "Device capabilities",
"description": "Toggles are pre-set from the device:\n\n{device_values}\n\nDisable what the device cannot do or what you do not want to expose. Entities are created only for enabled features.\n\nIf unsure, keep as is.",
"data": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic",
"fan_quiet": "Quiet fan",
"fan_low": "Low fan",
"fan_medium": "Medium fan",
"fan_high": "High fan",
"fan_auto": "Auto fan",
"swing_vertical": "Vertical swing",
"swing_horizontal": "Horizontal swing",
"economy_mode": "Economy",
"min_heat": "Minimum heat",
"indoor_fan_control": "Indoor fan control",
"powerful_mode": "Powerful",
"outdoor_low_noise": "Outdoor low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
},
"data_description": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic mode",
"fan_auto": "Auto fan speed",
"fan_quiet": "Quiet fan speed",
"fan_low": "Low fan speed",
"fan_medium": "Medium fan speed",
"fan_high": "High fan speed",
"swing_vertical": "Vertical louver swing",
"swing_horizontal": "Horizontal louver swing",
"economy_mode": "Economy mode",
"min_heat": "Minimum heat (keep +10 °C)",
"indoor_fan_control": "Indoor fan control while outdoor unit runs",
"powerful_mode": "Powerful mode",
"outdoor_low_noise": "Outdoor unit low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED indicator",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
}
},
"limits": {
"title": "Setpoint (range and step)",
"description": "Range and step are auto-detected (A/F step 0.5 °C, B — 1.0). Optionally enable a custom linear conversion (API units are 0.1 °C).\n\nIf unsure, keep as is.",
"data": {
"template": "Template",
"temp_min": "Minimum setpoint (°C)",
"temp_max": "Maximum setpoint (°C)",
"temp_step": "Setpoint step (°C)",
@@ -66,8 +129,8 @@
},
"data_description": {
"temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)",
"temp_den": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)",
"temp_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)"
"temp_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset"
}
}
},
+69 -6
View File
@@ -52,11 +52,74 @@
"config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl"
}
},
"preview": {
"title": "Превью шаблона",
"description": "Рассчитанные значения (сверьте с приложением FGLair):\n\n{preview}\n\nДиапазон уставки и конверсию можно поправить ниже.",
"template": {
"title": "Шаблон модуля",
"description": "С устройства прочитано:\n\n{device_values}\n\n{probe_note} Шаблон задаёт таблицу свойств модуля (имена, типы, конверсии); фактические возможности прибора уточняются на следующем шаге.\n\nЕсли не уверены — оставьте значение по умолчанию.",
"data": {
"template": "Шаблон"
},
"data_description": {
"template": "A — модули AP-WA/AP-WC/AP-WD: есть датчик температуры помещения, направления и swing заслонок, все флаги. B — модули AP-WB: без датчика температуры помещения и без направлений заслонок, шаг уставки 1,0 °C. F — как A, но для модулей AP-WF (monitor1, filter_sign_reset)."
}
},
"capabilities": {
"title": "Возможности прибора",
"description": "Тумблеры выставлены по данным устройства:\n\n{device_values}\n\nОтключите то, что прибор не умеет или что не нужно показывать. Сущности создаются только для включённых возможностей.\n\nЕсли не уверены — оставьте как есть.",
"data": {
"mode_cool": "Охлаждение",
"mode_dry": "Осушение",
"mode_fan": "Вентиляция",
"mode_heat": "Обогрев",
"mode_auto": "Автоматический режим",
"fan_quiet": "Тихая скорость",
"fan_low": "Низкая скорость",
"fan_medium": "Средняя скорость",
"fan_high": "Высокая скорость",
"fan_auto": "Автоскорость",
"swing_vertical": "Swing вертикальный",
"swing_horizontal": "Swing горизонтальный",
"economy_mode": "Экономичный режим",
"min_heat": "Минимальный обогрев",
"indoor_fan_control": "Управление вентилятором",
"powerful_mode": "Мощный режим",
"outdoor_low_noise": "Тихий наружный блок",
"coil_dry_mode": "Сушка испарителя",
"display_temperature": "Датчик температуры помещения",
"human_det_auto_save": "Автосохранение (датчик присутствия)",
"wifi_led_enable": "Wi-Fi индикатор",
"af_vertical_direction": "Заслонка вертикальная",
"af_horizontal_direction": "Заслонка горизонтальная"
},
"data_description": {
"mode_cool": "Охлаждение",
"mode_dry": "Осушение",
"mode_fan": "Вентиляция (без нагрева/охлаждения)",
"mode_heat": "Обогрев",
"mode_auto": "Автоматический режим",
"fan_auto": "Автоскорость вентилятора",
"fan_quiet": "Тихая скорость",
"fan_low": "Низкая скорость",
"fan_medium": "Средняя скорость",
"fan_high": "Высокая скорость",
"swing_vertical": "Качание заслонки по вертикали",
"swing_horizontal": "Качание заслонки по горизонтали",
"economy_mode": "Экономичный режим",
"min_heat": "Минимальный обогрев (поддержание +10 °C)",
"indoor_fan_control": "Управление вентилятором при работе наружного блока",
"powerful_mode": "Мощный режим (ускоренный выход на режим)",
"outdoor_low_noise": "Тихий наружный блок",
"coil_dry_mode": "Сушка испарителя",
"display_temperature": "Датчик температуры помещения",
"human_det_auto_save": "Автосохранение при отсутствии людей (датчик присутствия)",
"wifi_led_enable": "Индикатор Wi-Fi на модуле",
"af_vertical_direction": "Выбор положения вертикальной заслонки",
"af_horizontal_direction": "Выбор положения горизонтальной заслонки"
}
},
"limits": {
"title": "Уставка (диапазон и шаг)",
"description": "Диапазон/шаг заданы автоматически (у A/F шаг 0,5 °C, у B — 1,0). При необходимости включите ручную конверсию коэффициентами (единицы API — 0,1 °C).\n\nЕсли не уверены — оставьте как есть.",
"data": {
"template": "Шаблон",
"temp_min": "Минимум уставки (°C)",
"temp_max": "Максимум уставки (°C)",
"temp_step": "Шаг уставки (°C)",
@@ -66,8 +129,8 @@
},
"data_description": {
"temp_num": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)",
"temp_den": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)",
"temp_offset": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)"
"temp_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset"
}
}
},
+125 -9
View File
@@ -1,5 +1,10 @@
"""Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение
базовых свойств. Выполняется в executor (блокирующие вызовы ядра).
базовых свойств и зондирование необязательных (presence).
Выполняется в executor (блокирующие вызовы ядра). Модуль отвечает только на
свойства из своей таблицы: отсутствие push = свойства нет (проверено на
AP-WC1E). Поэтому необязательные свойства не валят trial, а фиксируются в
``answered`` и используются как дефолты фич и для определения шаблона.
"""
from __future__ import annotations
@@ -11,12 +16,48 @@ from typing import Iterable
from pyfglair import Config, Error, Prop, Session, State, Template, Value
from pyfglair.templates import template_info
BASE_PROPS: tuple[Prop, ...] = (
# Обязательные свойства: есть во всех шаблонах, должны ответить.
REQUIRED_PROPS: tuple[Prop, ...] = (
Prop.OPERATION_MODE,
Prop.FAN_SPEED,
Prop.DISPLAY_TEMPERATURE,
)
# Зондируемые свойства (в пределах выбранного шаблона): наличие в ответе —
# информация для дефолтов фич и распознавания шаблона.
OPTIONAL_PROPS: tuple[Prop, ...] = (
Prop.DEVICE_CAPABILITIES,
Prop.DISPLAY_TEMPERATURE,
Prop.AF_VERTICAL_DIRECTION,
Prop.AF_HORIZONTAL_DIRECTION,
Prop.AF_VERTICAL_NUM_DIR,
Prop.AF_HORIZONTAL_NUM_DIR,
Prop.AF_VERTICAL_SWING,
Prop.AF_HORIZONTAL_SWING,
Prop.HUMAN_DET_AUTO_SAVE,
Prop.WIFI_LED_ENABLE,
Prop.FILTER_SIGN_RESET_DISPLAY,
Prop.MONITOR1,
)
# Подписи, по которым распознаётся шаблон при неизвестной oem_model:
# B отвечает move_step1, F — monitor1/filter_sign_reset (у A их нет).
PROBE_PROPS: dict[Template, tuple[Prop, ...]] = {
Template.A: (
Prop.AF_VERTICAL_DIRECTION,
Prop.DISPLAY_TEMPERATURE,
Prop.FILTER_SIGN_RESET_DISPLAY,
),
Template.B: (
Prop.AF_VERTICAL_MOVE_STEP1,
Prop.AF_HORIZONTAL_MOVE_STEP1,
),
Template.F: (
Prop.MONITOR1,
Prop.FILTER_SIGN_RESET,
Prop.AF_VERTICAL_DIRECTION,
),
}
@dataclass
class TrialResult:
@@ -25,6 +66,7 @@ class TrialResult:
state: State = State.IDLE
error: Error = Error.NONE
properties: dict[Prop, Value] = field(default_factory=dict)
answered: set[Prop] = field(default_factory=set)
def _failure_reason(state: State, error: Error) -> str:
@@ -37,6 +79,24 @@ def _failure_reason(state: State, error: Error) -> str:
return "timeout"
def classify_template(answered: set[Prop]) -> Template | None:
"""Шаблон по ответам зондов; None — недостаточно данных."""
if Prop.AF_VERTICAL_MOVE_STEP1 in answered and (
Prop.AF_VERTICAL_DIRECTION not in answered
):
return Template.B
if Prop.MONITOR1 in answered or Prop.FILTER_SIGN_RESET in answered:
if Prop.AF_VERTICAL_DIRECTION in answered:
return Template.F
if (
Prop.AF_VERTICAL_DIRECTION in answered
or Prop.DISPLAY_TEMPERATURE in answered
or Prop.FILTER_SIGN_RESET_DISPLAY in answered
):
return Template.A
return None
def trial_connect(
host: str,
dsn: str,
@@ -48,9 +108,12 @@ def trial_connect(
listen_port: int = 10275,
keepalive_ms: int = 15000,
timeout: float = 10.0,
props: Iterable[Prop] = BASE_PROPS,
props: Iterable[Prop] = REQUIRED_PROPS,
optional_props: Iterable[Prop] = OPTIONAL_PROPS,
props_timeout: float = 3.0,
) -> TrialResult:
"""Поднимает сессию, ждёт ONLINE и читает базовые свойства.
"""Поднимает сессию, ждёт ONLINE, читает обязательные и зондирует
необязательные свойства.
Никогда не бросает: любая неудача — ``TrialResult(ok=False, reason=...)``.
"""
@@ -59,6 +122,9 @@ def trial_connect(
values: dict[Prop, Value] = {}
valid = {info.prop for info in template_info(template)}
wanted = tuple(prop for prop in props if prop in valid)
optional = tuple(
prop for prop in optional_props if prop in valid and prop not in wanted
)
def on_state(state: State, error: Error) -> None:
with lock:
@@ -115,12 +181,15 @@ def trial_connect(
state=state, error=error,
)
if wanted:
requested = wanted + optional
if requested:
session.batch_begin()
for prop in wanted:
for prop in requested:
session.get_prop(prop)
session.batch_commit()
prop_deadline = min(deadline, time.monotonic() + 2.0)
prop_deadline = min(
deadline, time.monotonic() + props_timeout
)
while time.monotonic() < prop_deadline:
with lock:
if all(prop in values for prop in wanted):
@@ -136,7 +205,54 @@ def trial_connect(
reason="properties_missing",
state=State.ONLINE,
properties=snapshot,
answered=set(snapshot),
)
return TrialResult(ok=True, state=State.ONLINE, properties=snapshot)
return TrialResult(
ok=True,
state=State.ONLINE,
properties=snapshot,
answered=set(snapshot),
)
finally:
session.close()
def probe_template(
host: str,
dsn: str,
lanip_key: str,
lanip_key_id: int,
*,
device_port: int = 80,
listen_port: int = 10275,
timeout: float = 10.0,
) -> tuple[Template | None, dict[Prop, Value], str | None]:
"""Определяет шаблон по ответам, перебирая A/B/F короткими сессиями.
Возвращает (template, properties, fatal_reason): если модуль недоступен
(или нет слотов/ключ не тот), перебор бессмыслен и причина возвращается
третьим элементом, чтобы вызывающий не открывал ещё одну сессию.
"""
answered: set[Prop] = set()
properties: dict[Prop, Value] = {}
for template in (Template.A, Template.B, Template.F):
result = trial_connect(
host, dsn, lanip_key, lanip_key_id,
device_port=device_port, template=template,
listen_port=listen_port, timeout=timeout,
props=PROBE_PROPS[template] + (Prop.OPERATION_MODE,),
optional_props=(),
props_timeout=2.0,
)
if not result.ok:
if result.reason == "properties_missing":
# Модуль доступен, но свойств шаблона нет — пробуем дальше.
continue
# Недоступен/нет слотов/ключ — перебор шаблонов бессмыслен.
return None, properties, result.reason
answered |= result.answered
properties.update(result.properties)
detected = classify_template(answered)
if detected is not None:
return detected, properties, None
return classify_template(answered), properties, None
+6 -2
View File
@@ -99,8 +99,12 @@ Repair (теоретический): `key_error` → «Ключ устройст
display_temperature; sensors: room temp, error_code, op_status-флаги;
switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/
human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки,
demand_control; binary_sensor: connectivity). capabilities фильтруют
режимы/пресеты. Записи — один `batch_commit()` на действие пользователя.
demand_control; binary_sensor: connectivity). capabilities и выбранные в мастере
feature-тумблеры фильтруют сущности (`features.py`,
`entry.data["features"]`): то, что прибор не умеет или отключено
пользователем, не создаётся. Записи — один `batch_commit()` на действие
пользователя. У сущностей есть атрибут `description` (HA не поддерживает
тултипы).
Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована
(§10) — сущность не создаётся до уточнения; в H3 вместо неё
диагностический sensor состояния связи.
+75 -16
View File
@@ -12,26 +12,48 @@
### 1.1. Сборка и публикация wheel (Gitea Actions, ручной запуск)
Workflow: `.gitea/workflows/publish-pyfglair.yaml`.
Workflow: `.gitea/workflows/publish-pyfglair.yaml` (workflow объявляет
`permissions: contents: read, packages: write`).
1. Создайте personal access token Gitea со scope `write:package`
(Settings → Applications → Generate token).
2. Settings репозитория → Actions → Secrets:
* `GITEA_PYPI_TOKEN` — этот токен (обязательно);
* `GITEA_PYPI_USER` — имя пользователя Gitea (опционально; иначе актор
запуска).
3. Actions → **Publish pyfglair** → Run workflow (вход `bundled_mbedtls`:
`1` — встроить mbedtls, `0` — системный `libmbedtls-dev`).
4. Артефакт появится в: `https://git.ratigorsk-12.ru/api/packages/<owner>/pypi/simple/pyfglair/`
(`<owner>` — владелец репозитория, напр. `esphome`).
Авторизация — любой из вариантов:
Workflow собирает `wheel` + `sdist`, прогоняет smoke-тест установки и
публикует через `twine upload --skip-existing`. Повторная публикация той же
версии пропускается; для новой версии поднимите `version` в
`pyproject.toml`.
* **Встроенный job-токен** (Gitea ≥ 1.27, где `packages` поддержан у
features): ничего настраивать не нужно, workflow возьмёт
`${{ secrets.GITEA_TOKEN }}`;
* **Personal access token** со scope `write:package` (для версий, где
публикация пакетов job-токеном ещё не реализована — см. HACS-док Gitea
«Package repository authorization»): положите его в секрет
`PYPI_TOKEN`, при необходимости задайте `PYPI_USER` (имя пользователя к
PAT; иначе — актор запуска). **Имена секретов не могут начинаться с
`GITEA_`** — префикс зарезервирован Gitea.
Запуск: Actions → **Publish pyfglair** → Run workflow (вход
`bundled_mbedtls`: `1` — встроить mbedtls, `0` — системный
`libmbedtls-dev`). Собираются **два** wheel одной версии:
* `py3-none-linux_x86_64` — glibc (HA Supervised/Core на Debian/Ubuntu);
* `py3-none-musllinux_1_2_x86_64` — musl (**HA OS**, Alpine-контейнер);
job `build-musllinux` выполняется в `container: python:3.12-alpine`
(нужен `container:` у runner'а; иначе см. §1.4).
Артефакты появятся в
`https://git.ratigorsk-12.ru/api/packages/<owner>/pypi/simple/pyfglair/`
(`<owner>` — владелец репозитория, напр. `esphome`). C++-рантайм в .so
встроен (`-static-libstdc++ -static-libgcc`), поэтому wheel зависит только
от libc/libm соответствующей платформы.
Workflow собирает `wheel` + `sdist`, прогоняет smoke-тест установки
(запускается из `/tmp` с `python -P`, чтобы не подхватить исходники
`pyfglair/` вместо wheel) и публикует через `twine upload`. Twine 7 не
поддерживает `--skip-existing` для не-PyPI репозиториев, поэтому workflow
сам проверяет simple-индекс Gitea и пропускает загрузку, если версия уже
опубликована. Для новой версии поднимите `version` в `pyproject.toml`.
### 1.2. Установка pyfglair в Home Assistant
Выбор wheel pip делает сам по платформе: на HA OS (Alpine/musl) — из
musllinux, на HA Supervised/Core (Debian/glibc) — из linux-сборки.
Пакет обязателен (cffi-биндинги). Варианты:
* **Вручную в окружение HA** (проще всего для теста; HA увидит
@@ -47,9 +69,46 @@ Workflow собирает `wheel` + `sdist`, прогоняет smoke-тест
тогда HA сам поставит `pyfglair` из манифеста. Учтите риск dependency
confusion при `--extra-index-url` — для внутреннего стенда допустимо.
Если при добавлении интеграции HA пишет «Invalid handler specified» —
пакет `pyfglair` не установлен (или компонент не перезапущен). Смотрите
`grep -iE "fglair|pyfglair" /config/home-assistant.log`: при
`ModuleNotFoundError: No module named 'pyfglair'` установите пакет в
окружение HA (`docker exec homeassistant python -m pip install ...`) и
полностью перезапустите HA.
* **Публичный PyPI** — см. §2 (для распространения вне сети).
### 1.3. Обновление интеграции
### 1.3. Если HA уже установил glibc-wheel на HA OS
Симптом: `OSError: cannot load library ... Error loading shared library
ld-linux-x86-64.so.2: No such file or directory`. Переустановите пакет с
musllinux-сборкой:
```sh
docker exec homeassistant python -m pip uninstall -y pyfglair
docker exec homeassistant python -m pip install --no-deps --index-url \
https://<user>:<token>@git.ratigorsk-12.ru/api/packages/<owner>/pypi/simple \
pyfglair
docker exec homeassistant python -c "import pyfglair; print(pyfglair.__version__)"
docker restart homeassistant
```
### 1.4. Ручная сборка musllinux (если runner без `container:`)
```sh
docker run --rm -v "$PWD":/src -w /src python:3.12-alpine sh -c '
apk add --no-cache git cmake ninja g++ make curl libstdc++-dev &&
pip install build &&
python -m build --wheel'
# dist/pyfglair-<ver>-py3-none-musllinux_1_2_x86_64.whl
pip install twine
python -m twine upload --repository-url \
https://git.ratigorsk-12.ru/api/packages/<owner>/pypi \
-u <user> -p <token> dist/*musllinux*.whl
```
### 1.5. Обновление интеграции
`custom_components/fglair/` обновляется из Gitea (git pull / копирование),
затем перезапуск HA. `pyfglair` — переустановкой из пакетов Gitea.
+25
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import argparse
import os
import platform
import re
import shutil
import subprocess
import sys
@@ -22,6 +24,29 @@ LIB_GLOBS = (
)
def musl_platform_tag() -> str | None:
"""musllinux-тег, если сборка идёт на musl (Alpine/HA OS), иначе None.
wheel-тег `linux_<arch>` на musl вводит pip в заблуждение: он ставит
glibc-сборку на Alpine, и dlopen падает на отсутствующем
ld-linux-<arch>.so.2. На musl отдаём корректный `musllinux_X_Y_<arch>`.
"""
machine = platform.machine()
try:
result = subprocess.run(
["ldd", "--version"], capture_output=True, text=True, timeout=10
)
except (OSError, subprocess.SubprocessError):
return None
text = (result.stdout or "") + (result.stderr or "")
if "musl" not in text.lower():
return None
match = re.search(r"(\d+)\.(\d+)", text)
major, minor = (match.group(1), match.group(2)) if match else ("1", "2")
arch = {"amd64": "x86_64", "arm64": "aarch64"}.get(machine, machine)
return f"musllinux_{major}_{minor}_{arch}"
def source_root() -> Path | None:
"""Корень монорепо (CMakeLists.txt + include/fgl-aircon)."""
env = os.environ.get("FGLAIR_SOURCE_ROOT")
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# Публикация файлов из DIST_DIR в PyPI-реестр пакетов Gitea.
# Уже загруженные (по имени файла в simple-индексе) пропускаются: twine 7 не
# поддерживает --skip-existing для не-PyPI репозиториев.
# POSIX sh (Alpine busybox/dash) — внешний bash не требуется.
#
# Переменные:
# TOKEN (или GITEA_JOB_TOKEN) — обязательный токен (PAT write:package или
# встроенный job-токен при packages: write)
# DIST_DIR — каталог с *.whl/*.tar.gz (по умолчанию dist)
# SERVER, OWNER — Gitea и владелец; если не заданы, берутся
# из git remote (origin)
# PYPI_USER — имя пользователя (по умолчанию актор)
set -eu
DIST_DIR="${DIST_DIR:-dist}"
TOKEN="${TOKEN:-${GITEA_JOB_TOKEN:-}}"
if [ -z "${SERVER:-}" ]; then
SERVER="$(git remote get-url origin \
| sed -E 's#^git@([^:]+):.*#https://\1#; s#^(https?://[^/]+)/.*#\1#')"
fi
if [ -z "${OWNER:-}" ]; then
REPO="${GITHUB_REPOSITORY:-$(git remote get-url origin \
| sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#')}"
OWNER="${REPO%%/*}"
fi
USER_NAME="${PYPI_USER:-${GITHUB_ACTOR:-git}}"
if [ -z "$TOKEN" ]; then
echo "нет токена: задайте секрет PYPI_TOKEN (PAT write:package) или" >&2
echo "разрешите 'packages: write' для встроенного GITEA_TOKEN" >&2
exit 1
fi
if [ ! -d "$DIST_DIR" ]; then
echo "нет каталога $DIST_DIR" >&2
exit 1
fi
INDEX="$SERVER/api/packages/$OWNER/pypi/simple/pyfglair/"
pip install --upgrade --quiet twine
for FILE in "$DIST_DIR"/*; do
NAME="$(basename "$FILE")"
if curl -fsS -u "$USER_NAME:$TOKEN" "$INDEX" 2>/dev/null \
| grep -qF "$NAME"; then
echo "уже опубликовано: $NAME"
continue
fi
python -m twine upload \
--repository-url "$SERVER/api/packages/$OWNER/pypi" \
--username "$USER_NAME" \
--password "$TOKEN" \
--non-interactive \
"$FILE"
echo "опубликовано: $NAME"
done
echo "индекс: $INDEX"
+17 -7
View File
@@ -14,15 +14,18 @@ except ImportError: # setuptools < 70
from wheel.bdist_wheel import bdist_wheel as _bdist_wheel
ROOT = Path(__file__).resolve().parent
_COREBUILD = None
def _load_corebuild():
spec = spec_from_file_location(
"_pyfglair_corebuild", ROOT / "pyfglair" / "_corebuild.py"
)
module = module_from_spec(spec)
spec.loader.exec_module(module)
return module
global _COREBUILD
if _COREBUILD is None:
spec = spec_from_file_location(
"_pyfglair_corebuild", ROOT / "pyfglair" / "_corebuild.py"
)
_COREBUILD = module_from_spec(spec)
spec.loader.exec_module(_COREBUILD)
return _COREBUILD
class build_py(_build_py):
@@ -46,10 +49,17 @@ class BinaryDistribution(Distribution):
class bdist_wheel(_bdist_wheel):
"""Тег py3-none-<plat>: .so собран в ABI-режиме cffi (не CPython-ABI)."""
"""Тег py3-none-<plat>: .so собран в ABI-режиме cffi (не CPython-ABI).
На musl (Alpine/HA OS) платформа заменяется на musllinux_X_Y_<arch>,
иначе pip поставит glibc-сборку и dlopen упадёт.
"""
def get_tag(self):
_, _, platform = super().get_tag()
musl = _load_corebuild().musl_platform_tag()
if musl is not None and platform.startswith("linux_"):
platform = musl
return "py3", "none", platform
+158 -49
View File
@@ -14,7 +14,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
import pyfglair
from custom_components.fglair.const import DOMAIN
from custom_components.fglair.trial import TrialResult
from pyfglair import Prop, Value, ValueKind
from pyfglair import Prop, Template, Value, ValueKind
from pyfglair.provision import Device
MANUAL_INPUT = {
@@ -27,8 +27,7 @@ MANUAL_INPUT = {
"model": "AP-WC1E",
}
PREVIEW_INPUT = {
"template": "A",
LIMITS_INPUT = {
"temp_min": 16.0,
"temp_max": 30.0,
"temp_step": 0.5,
@@ -37,8 +36,20 @@ PREVIEW_INPUT = {
"temp_offset": 0,
}
TRIAL_OK = TrialResult(ok=True)
TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable")
TRIAL_OK = (TrialResult(ok=True), Template.A, False)
TRIAL_NETWORK_FAIL = (
TrialResult(ok=False, reason="unreachable"),
Template.A,
False,
)
def _trial_ok(properties=None):
return (
TrialResult(ok=True, properties=properties or {}),
Template.A,
False,
)
async def _menu(hass: HomeAssistant):
@@ -57,6 +68,22 @@ async def _configure(hass: HomeAssistant, flow_id: str, user_input: dict):
return await hass.config_entries.flow.async_configure(flow_id, user_input)
async def _pages(
hass: HomeAssistant,
flow_id: str,
*,
template: str = "A",
caps: dict | None = None,
limits: dict | None = None,
):
"""Проходит страницы template → capabilities → limits."""
result = await _configure(hass, flow_id, {"template": template})
assert result["step_id"] == "capabilities", result
result = await _configure(hass, flow_id, caps or {})
assert result["step_id"] == "limits", result
return await _configure(hass, flow_id, limits or LIMITS_INPUT)
def _suggested(schema, field: str):
for key in schema.schema:
if getattr(key, "schema", None) == field:
@@ -81,9 +108,8 @@ async def test_manual_success(hass: HomeAssistant):
result = await _select(hass, result["flow_id"], "manual")
assert result["type"] == "form"
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["type"] == "form"
assert result["step_id"] == "preview"
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["title"] == "Bedroom"
assert result["result"].unique_id == DSN
@@ -98,11 +124,13 @@ async def test_manual_success(hass: HomeAssistant):
"min": 160,
"max": 300,
}
assert isinstance(data["features"], dict)
assert data["features"]["mode_heat"] is True
trial.assert_called_once()
assert trial.call_args.args[0]["host"] == "192.168.1.50"
async def test_preview_template_change_and_conversion(hass: HomeAssistant):
async def test_template_change_and_conversion(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
@@ -110,17 +138,20 @@ async def test_preview_template_change_and_conversion(hass: HomeAssistant):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
preview_input = {
**PREVIEW_INPUT,
"template": "B",
"temp_min": 17.0,
"temp_max": 28.0,
"temp_step": 1.0,
"temp_num": 2,
"temp_den": 4,
"temp_offset": 5,
}
result = await _configure(hass, result["flow_id"], preview_input)
result = await _pages(
hass,
result["flow_id"],
template="B",
limits={
**LIMITS_INPUT,
"temp_min": 17.0,
"temp_max": 28.0,
"temp_step": 1.0,
"temp_num": 2,
"temp_den": 4,
"temp_offset": 5,
},
)
assert result["type"] == "create_entry"
assert result["data"]["template"] == "B"
assert result["data"]["temp_step"] == 10
@@ -133,7 +164,7 @@ async def test_preview_template_change_and_conversion(hass: HomeAssistant):
}
async def test_preview_invalid_range(hass: HomeAssistant):
async def test_limits_invalid_range(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
@@ -141,24 +172,23 @@ async def test_preview_invalid_range(hass: HomeAssistant):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {"template": "B"}
)
result = await _configure(hass, result["flow_id"], {})
assert result["step_id"] == "limits"
result = await _configure(
hass,
result["flow_id"],
{
**PREVIEW_INPUT,
"template": "B",
"temp_min": 30.0,
"temp_max": 16.0,
},
{**LIMITS_INPUT, "temp_min": 30.0, "temp_max": 16.0},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_range"}
assert "Template: B" in result["description_placeholders"]["preview"]
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
result = await _configure(hass, result["flow_id"], LIMITS_INPUT)
assert result["type"] == "create_entry"
async def test_preview_unreachable_range(hass: HomeAssistant):
async def test_limits_unreachable_range(hass: HomeAssistant):
"""Конверсия выводит таблицу за min/max — пустой достижимый диапазон."""
with patch(
"custom_components.fglair.config_flow._run_trial",
@@ -167,11 +197,15 @@ async def test_preview_unreachable_range(hass: HomeAssistant):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {"template": "A"}
)
result = await _configure(hass, result["flow_id"], {})
result = await _configure(
hass,
result["flow_id"],
{
**PREVIEW_INPUT,
**LIMITS_INPUT,
"temp_min": 30.0,
"temp_max": 40.0,
"temp_num": 1,
@@ -182,22 +216,37 @@ async def test_preview_unreachable_range(hass: HomeAssistant):
assert result["errors"] == {"base": "invalid_range"}
async def test_preview_contains_trial_values(hass: HomeAssistant):
async def test_template_device_values(hass: HomeAssistant):
props = {
Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200),
Prop.OPERATION_MODE: Value(ValueKind.INT, 3),
Prop.FAN_SPEED: Value(ValueKind.INT, 4),
Prop.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011),
Prop.AF_VERTICAL_NUM_DIR: Value(ValueKind.INT, 5),
}
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TrialResult(ok=True, properties=props),
return_value=(
TrialResult(ok=True, properties=props),
Template.A,
False,
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
preview = result["description_placeholders"]["preview"]
assert "20.0" in preview
assert "0x0000001B" in preview
assert "cool" in preview and "heat" in preview
assert result["step_id"] == "template"
values = result["description_placeholders"]["device_values"]
assert "20.0" in values
assert "0x0000001B" in values
assert "cool" in values
assert "направлений заслонок: верт. 5" in values
# capabilities: дефолты из маски, swing выключен
result = await _configure(hass, result["flow_id"], {"template": "A"})
assert result["step_id"] == "capabilities"
assert _suggested(result["data_schema"], "swing_vertical") is False
assert _suggested(result["data_schema"], "mode_cool") is True
assert _suggested(result["data_schema"], "af_vertical_direction") is True
async def test_manual_connect_error(hass: HomeAssistant):
@@ -215,7 +264,11 @@ async def test_manual_connect_error(hass: HomeAssistant):
async def test_manual_key_mismatch_error(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TrialResult(ok=False, reason="key_mismatch"),
return_value=(
TrialResult(ok=False, reason="key_mismatch"),
Template.A,
False,
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
@@ -265,7 +318,7 @@ async def test_import_json_success(hass: HomeAssistant):
)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
return_value=(TrialResult(ok=True), Template.B, False),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json")
@@ -273,11 +326,9 @@ async def test_import_json_success(hass: HomeAssistant):
result = await _configure(
hass, result["flow_id"], {"config_json": payload}
)
assert result["step_id"] == "preview"
assert result["step_id"] == "template"
assert _suggested(result["data_schema"], "template") == "B"
result = await _configure(
hass, result["flow_id"], {**PREVIEW_INPUT, "template": "B"}
)
result = await _pages(hass, result["flow_id"], template="B")
assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.60"
assert result["data"]["template"] == "B"
@@ -319,8 +370,8 @@ async def test_cloud_flow_success(hass: HomeAssistant):
)
assert result["step_id"] == "cloud_device"
result = await _configure(hass, result["flow_id"], {"dsn": DSN})
assert result["step_id"] == "preview"
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.50"
assert result["data"]["lanip_key_id"] == KEY_ID
@@ -374,7 +425,7 @@ async def test_yaml_import(hass: HomeAssistant):
}
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
return_value=(TrialResult(ok=True), Template.F, False),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload})
await hass.async_block_till_done()
@@ -382,6 +433,7 @@ async def test_yaml_import(hass: HomeAssistant):
assert len(entries) == 1
assert entries[0].data["template"] == "F"
assert entries[0].data["host"] == "192.168.1.70"
assert isinstance(entries[0].data["features"], dict)
async def test_yaml_import_invalid(hass: HomeAssistant):
@@ -410,7 +462,8 @@ async def test_import_json_null_device_port(hass: HomeAssistant):
result = await _configure(
hass, result["flow_id"], {"config_json": payload}
)
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["data"]["device_port"] == 80
assert trial.call_args.args[0]["device_port"] == 80
@@ -448,8 +501,8 @@ async def test_reconfigure_success(hass: HomeAssistant, mock_ac, socket_enabled)
result = await _configure(
hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"}
)
assert result["step_id"] == "preview"
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "abort"
assert result["reason"] == "reconfigure_successful"
await hass.async_block_till_done()
@@ -520,3 +573,59 @@ async def test_options_flow_new_key_reloads(
assert entry.state.name in ("LOADED", "SETUP_RETRY")
if entry.state.name == "LOADED":
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_run_trial_probes_unknown_model(hass: HomeAssistant):
"""Без oem_model шаблон определяется пробингом A/B/F."""
from custom_components.fglair import config_flow as cf
with patch.object(
cf, "probe_template", return_value=(Template.B, {}, None)
) as probe, patch.object(
cf, "trial_connect", return_value=TrialResult(ok=True)
):
result = await hass.async_add_executor_job(
cf._run_trial,
{
"host": "127.0.0.1",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"model": "",
"template": "A",
},
)
trial_result, template, probed = result
assert trial_result.ok
assert template is Template.B
assert probed is True
probe.assert_called_once()
async def test_reconfigure_prefills_stored_template(
hass: HomeAssistant, mock_ac, socket_enabled
):
"""При reconfigure дефолт — сохранённый шаблон, а не пересчитанный."""
from test_entities import _setup_entry
proc = mock_ac(["--set", "operation_mode=6"])
entry = await _setup_entry(hass, proc.port)
hass.config_entries.async_update_entry(
entry, data={**entry.data, "template": "B"}
)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(TrialResult(ok=True), Template.A, False),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(
hass, result["flow_id"], {**MANUAL_INPUT, "host": "127.0.0.1",
"device_port": proc.port}
)
assert result["step_id"] == "template"
assert _suggested(result["data_schema"], "template") == "B"
await hass.config_entries.async_unload(entry.entry_id)
+1
View File
@@ -77,6 +77,7 @@ async def test_entities_snapshot(hass: HomeAssistant, mock_ac, socket_enabled):
)
climate = hass.states.get(climate_id)
assert climate.attributes["description"]
assert climate.attributes["current_temperature"] == 20.0
assert climate.attributes["temperature"] == 22.0
assert climate.attributes["fan_mode"] == "auto"
+122
View File
@@ -0,0 +1,122 @@
"""Дефолты feature-модели: device_capabilities, num_dir, presence."""
from __future__ import annotations
from custom_components.fglair.features import (
FeatureSet,
device_default,
resolve_features,
)
from pyfglair import Prop, Template, Value, ValueKind
def _value(value: int) -> Value:
return Value(ValueKind.INT, value)
def test_caps_defaults_ap_wc1e():
values = {Prop.DEVICE_CAPABILITIES: _value(5119)} # 0x13FF
assert device_default("mode_cool", values) is True
assert device_default("mode_heat", values) is True
assert device_default("fan_auto", values) is True
assert device_default("swing_vertical", values) is False
assert device_default("swing_horizontal", values) is False
assert device_default("economy_mode", values) is True
assert device_default("powerful_mode", values) is False
assert device_default("min_heat", values) is False
assert device_default("coil_dry_mode", values) is False
def test_caps_unknown_keeps_everything():
assert device_default("swing_vertical", {}) is True
assert device_default(
"min_heat", {Prop.DEVICE_CAPABILITIES: _value(0)}
) is True
def test_directions_need_num_dir():
zero = {Prop.AF_VERTICAL_NUM_DIR: _value(0)}
five = {Prop.AF_VERTICAL_NUM_DIR: _value(5)}
assert device_default("af_vertical_direction", zero) is False
assert device_default("af_vertical_direction", five) is True
assert device_default("af_vertical_direction", {}) is False
assert device_default("af_horizontal_direction", zero) is False
def test_presence_defaults():
values = {Prop.DISPLAY_TEMPERATURE: _value(200)}
assert device_default("display_temperature", values) is True
assert device_default("wifi_led_enable", values) is False
assert (
device_default(
"display_temperature", {}, {Prop.DISPLAY_TEMPERATURE}
)
is True
)
assert device_default("wifi_led_enable", {}, set()) is False
def test_resolve_and_featureset_override():
values = {Prop.DEVICE_CAPABILITIES: _value(5119)}
resolved = resolve_features(values, set(), {"swing_vertical": True})
assert resolved["swing_vertical"] is True
assert resolved["powerful_mode"] is False
from custom_components.fglair.const import FEATURES
assert len(resolved) == len(FEATURES)
feature_set = FeatureSet({"swing_vertical": True}, values)
assert feature_set.enabled("swing_vertical") is True
assert feature_set.enabled("powerful_mode") is False
default_set = FeatureSet(None, values)
assert default_set.enabled("economy_mode") is True
assert default_set.enabled("swing_vertical") is False
def test_classify_template():
from custom_components.fglair.trial import classify_template
assert classify_template(
{Prop.AF_VERTICAL_MOVE_STEP1}
) is Template.B
assert classify_template(
{Prop.MONITOR1, Prop.AF_VERTICAL_DIRECTION}
) is Template.F
assert classify_template(
{Prop.AF_VERTICAL_DIRECTION}
) is Template.A
assert classify_template({Prop.DISPLAY_TEMPERATURE}) is Template.A
assert classify_template(set()) is None
def test_resolved_features_for_ap_wc1e_without_sensor():
"""Канальный блок: датчик комнаты есть, ламелей/swing нет."""
values = {
Prop.DEVICE_CAPABILITIES: _value(5119),
Prop.AF_VERTICAL_NUM_DIR: _value(0),
Prop.AF_HORIZONTAL_NUM_DIR: _value(0),
Prop.DISPLAY_TEMPERATURE: _value(205),
}
resolved = resolve_features(values, set(values))
assert resolved["display_temperature"] is True
assert resolved["swing_vertical"] is False
assert resolved["af_vertical_direction"] is False
assert resolved["af_horizontal_direction"] is False
assert resolved["mode_cool"] is True
assert resolved["economy_mode"] is True
assert resolved["powerful_mode"] is False
def test_probe_stops_on_unreachable(monkeypatch):
"""Недоступный модуль не перебираем трижды — выходим после первой сессии."""
from custom_components.fglair import trial as tr
calls: list = []
def fake_trial(*args, **kwargs):
calls.append(kwargs.get("template"))
return tr.TrialResult(ok=False, reason="unreachable")
monkeypatch.setattr(tr, "trial_connect", fake_trial)
template, props, fatal = tr.probe_template("host", "dsn", "key", 1)
assert template is None
assert props == {}
assert fatal == "unreachable"
assert calls == [Template.A]
+10 -3
View File
@@ -58,7 +58,7 @@ async def test_reconfigure_fixes_key_error(
):
"""Repair-путь целиком: bad key_id → issue → reconfigure → online → issue снят."""
from homeassistant.config_entries import SOURCE_RECONFIGURE
from test_config_flow import MANUAL_INPUT, PREVIEW_INPUT
from test_config_flow import LIMITS_INPUT, MANUAL_INPUT
proc = mock_ac(
["--set", "operation_mode=6"], key_id=KEY_ID + 1
@@ -91,9 +91,16 @@ async def test_reconfigure_fixes_key_error(
"lanip_key_id": KEY_ID + 1,
},
)
assert result["step_id"] == "preview", result.get("errors")
assert result["step_id"] == "template", result.get("errors")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], PREVIEW_INPUT
result["flow_id"], {"template": "A"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {}
)
assert result["step_id"] == "limits"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], LIMITS_INPUT
)
assert result["type"] == "abort"
assert result["reason"] == "reconfigure_successful"
+49
View File
@@ -0,0 +1,49 @@
"""Определение платформы wheel: musl (Alpine/HA OS) vs glibc."""
from __future__ import annotations
from pyfglair import _corebuild
class _Result:
def __init__(self, stdout: str = "", stderr: str = "") -> None:
self.stdout = stdout
self.stderr = stderr
def test_glibc_returns_none(monkeypatch):
monkeypatch.setattr(
_corebuild.subprocess,
"run",
lambda *args, **kwargs: _Result("ldd (GNU libc) 2.42\n"),
)
assert _corebuild.musl_platform_tag() is None
def test_musl_x86_64(monkeypatch):
monkeypatch.setattr(
_corebuild.subprocess,
"run",
lambda *args, **kwargs: _Result("musl libc (x86_64)\nVersion 1.2.5\n"),
)
monkeypatch.setattr(_corebuild.platform, "machine", lambda: "x86_64")
assert _corebuild.musl_platform_tag() == "musllinux_1_2_x86_64"
def test_musl_aarch64(monkeypatch):
monkeypatch.setattr(
_corebuild.subprocess,
"run",
lambda *args, **kwargs: _Result(
"musl libc (aarch64)\nVersion 1.2.4\n"
),
)
monkeypatch.setattr(_corebuild.platform, "machine", lambda: "aarch64")
assert _corebuild.musl_platform_tag() == "musllinux_1_2_aarch64"
def test_ldd_missing(monkeypatch):
def boom(*args, **kwargs):
raise FileNotFoundError("ldd")
monkeypatch.setattr(_corebuild.subprocess, "run", boom)
assert _corebuild.musl_platform_tag() is None