Compare commits

...
11 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
petr.polezhaev a32e2f0bfc ha(ci): Gitea Actions — сборка и публикация pyfglair в PyPI-реестр пакетов Gitea
- .gitea/workflows/publish-pyfglair.yaml: ручной workflow_dispatch —
  toolchain, python -m build (wheel+sdist), smoke-тест wheel, twine upload
  в /api/packages/<owner>/pypi (PAT в секрете GITEA_PYPI_TOKEN; встроенный
  GITEA_TOKEN пакеты публиковать не умеет); вход bundled_mbedtls
- setup.py: FGL_BUNDLED_MBEDTLS=0 уважается при сборке wheel (системный
  mbedtls), по умолчанию — встроенный (проверено readelf для обоих)
- RELEASE_HA переписан: §1 внутренняя установка (Gitea Packages + команда
  pip install для HA), §2 PyPI (публично), §3 GitHub/HACS (Custom
  Repositories только на GitHub), §4-5 без изменений
- README компонента: внутренняя установка pyfglair из Gitea Packages
2026-09-29 15:44:56 +03:00
petr.polezhaev 34cdb7fef8 ha(HACS): комплект файлов для Custom Repositories
- info.md (страница HACS), LICENSE (MIT), codeowners @petr.polezhaev
- brand: icon/icon@2x/logo + dark-варианты (сгенерированные плейсхолдеры
  с силуэтом кондиционера)
- .github/workflows/validate-hacs.yaml и hassfest.yaml (включатся на GitHub)
- pyproject: license=MIT, license-files; MANIFEST: LICENSE в sdist
- README/RELEASE_HA: HACS работает только с публичным GitHub-зеркалом
  (Custom Repositories), точные команды mirror и настройки GitHub
2026-09-29 15:40:23 +03:00
41 changed files with 1799 additions and 303 deletions
+135
View File
@@ -0,0 +1,135 @@
# Сборка и публикация pyfglair в PyPI-реестр пакетов Gitea.
# Запуск: Actions → Publish pyfglair → Run workflow (workflow_dispatch).
#
# Собираются ДВА 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).
#
# Авторизация: встроенный job-токен при `permissions: packages: write`
# (Gitea ≥ 1.27) либо PAT write:package в секрете PYPI_TOKEN.
# Секрет PYPI_USER — имя пользователя к PAT; имена секретов не могут
# начинаться с GITEA_ (префикс зарезервирован).
name: Publish pyfglair
on:
workflow_dispatch:
inputs:
bundled_mbedtls:
description: "1 — встроить mbedtls, 0 — использовать системный"
required: false
default: "1"
permissions:
contents: read
packages: write
jobs:
build-glibc:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Toolchain
run: |
if ! command -v cmake >/dev/null || ! command -v ninja >/dev/null \
|| ! command -v g++ >/dev/null; then
sudo apt-get update
sudo apt-get install -y --no-install-recommends cmake ninja-build g++
fi
if [ "${{ inputs.bundled_mbedtls }}" = "0" ]; then
sudo apt-get update
sudo apt-get install -y --no-install-recommends libmbedtls-dev
fi
cmake --version | head -1
g++ --version | head -1
- name: Build wheel + sdist (glibc)
env:
BUNDLED: ${{ inputs.bundled_mbedtls }}
run: |
if [ "$BUNDLED" = "0" ]; then
export FGL_BUNDLED_MBEDTLS=0
else
export FGL_BUNDLED_MBEDTLS=1
fi
pip install --upgrade build
python -m build
ls -l dist
# Запуск из каталога без исходников: иначе python подхватывает
# ./pyfglair (без собранного .so) вместо установленного wheel.
- name: Smoke test wheel (glibc)
run: |
python -m venv /tmp/wheeltest
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 glibc wheel + sdist
env:
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: |
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
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
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
+19
View File
@@ -0,0 +1,19 @@
name: Validate (hassfest)
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
permissions: {}
jobs:
validate-hassfest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: hassfest
uses: home-assistant/actions/hassfest@master
+21
View File
@@ -0,0 +1,21 @@
name: Validate (HACS)
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
permissions: {}
jobs:
validate-hacs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: HACS validation
uses: hacs/action@main
with:
category: integration
+8
View File
@@ -125,6 +125,14 @@ else()
VERSION ${PROJECT_VERSION} VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR} 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() endif()
if(FGL_BUILD_TESTS) if(FGL_BUILD_TESTS)
+26
View File
@@ -0,0 +1,26 @@
MIT License
Copyright (c) 2026 Petr Polezhaev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Вендоренный парсер JSON `third_party/jsmn` распространяется под лицензией
MIT (см. `third_party/jsmn/LICENSE`).
+1 -1
View File
@@ -1,4 +1,4 @@
include CMakeLists.txt README.md pyproject.toml setup.py include CMakeLists.txt LICENSE README.md pyproject.toml setup.py
recursive-include include *.h *.hpp recursive-include include *.h *.hpp
recursive-include src *.cpp *.hpp recursive-include src *.cpp *.hpp
recursive-include third_party * recursive-include third_party *
+3 -2
View File
@@ -126,5 +126,6 @@ converted from the module's 0.01 °C+5000 format.
## License / provenance ## License / provenance
Protocol documentation and code reconstructed from the FGLair 3.4.3 APK for MIT — see [`LICENSE`](LICENSE). Protocol documentation and code reconstructed
interoperability; `third_party/jsmn` is MIT. from the FGLair 3.4.3 APK for interoperability; vendored `third_party/jsmn`
is MIT.
+55 -11
View File
@@ -11,9 +11,11 @@
## Требования ## Требования
* Home Assistant ≥ 2025.1 (Linux x86_64/aarch64). * Home Assistant ≥ 2025.1 (Linux x86_64/aarch64).
* Пакет `pyfglair` (манифест ставит его автоматически из PyPI). * Пакет `pyfglair`. Манифест ставит его из PyPI; при внутренней установке
До публикации на PyPI установите wheel вручную в python-окружение HA: (Gitea, без публичного PyPI) поставьте вручную из PyPI-реестра пакетов
`pip install pyfglair-*.whl` (см. [`docs/RELEASE_HA.md`](../../docs/RELEASE_HA.md)). Gitea:
`pip install --index-url https://<user>:<token>@git.ratigorsk-12.ru/api/packages/<owner>/pypi/simple --no-deps pyfglair`
(подробности и сборка wheel — [`docs/RELEASE_HA.md`](../../docs/RELEASE_HA.md)).
* Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**: * Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**:
телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503. телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503.
@@ -70,15 +72,22 @@ Assistant. Убедитесь, что `pyfglair` установлен в python-
*Должно быть видно: список с одним устройством (имя, модель, host).* *Должно быть видно: список с одним устройством (имя, модель, host).*
6. **Проверка шаблона с превью**: форма-предпросмотр рассчитанных 6. **Три страницы настройки** (все значения уже подставлены по данным
значений (режимы, диапазон температур, пример конверсии) и, при устройства — можно просто нажимать «Далее»):
необходимости, ручные коэффициенты/диапазон уставки. Сверьте с 1. **Шаблон модуля** — A/B/F с пояснениями; определяется по модели
приложением FGLair и подтвердите. модуля, а для неизвестных моделей — проверкой свойств. Рядом
показаны прочитанные с устройства значения.
2. **Возможности прибора** — тумблеры по «человекочитаемым» фичам
(режимы, скорости, swing, пресеты, флаги, заслонки) с описаниями;
включены/выключены по `device_capabilities` и ответам устройства.
Сущности создаются только для включённых возможностей.
3. **Уставка** — диапазон/шаг (у A/F 16–30 °C шаг 0.5, у B шаг 1.0) и,
при необходимости, ручная конверсия коэффициентами.
![шаг 6](screenshots/step-6.png) ![шаг 6](screenshots/step-6.png)
*Должно быть видно: таблица превью — режимы, диапазон 16–30 °C, шаг, *Должно быть видно: страница «Возможности прибора» с тумблерами и
текущая температура, capabilities, кнопки Submit.* описаниями, часть выключена по данным устройства.*
7. **Готово**: карточка устройства с сущностями climate/switch/select/ 7. **Готово**: карточка устройства с сущностями climate/switch/select/
sensor/binary_sensor. sensor/binary_sensor.
@@ -119,11 +128,46 @@ lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl
| sensor | температура в помещении, код ошибки, диагностическое состояние связи | | sensor | температура в помещении, код ошибки, диагностическое состояние связи |
| binary_sensor | connectivity + флаги op_status (defrost, maintenance, oil recovery, pump down, check operation, …) | | 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). * **503 / оба слота заняты** — в LAN уже две сессии (телефон + ESPHome).
Освободите один слот и перезапустите интеграцию. Освободите один слот и перезапустите интеграцию.
* **key_error** — LAN-ключ не совпал (`key_id` изменился). В HA появится * **key_error** — LAN-ключ не совпал (`key_id` изменился). В HA появится
+9 -2
View File
@@ -11,8 +11,9 @@ from homeassistant.helpers import issue_registry as ir
import pyfglair 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 .coordinator import FglairCoordinator, FglairRuntime, key_issue_id
from .features import FeatureSet, LiveValues
from .fglair_client import FglairClient from .fglair_client import FglairClient
from .overrides import ConversionSet from .overrides import ConversionSet
@@ -80,8 +81,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
finally: finally:
hass.data[DATA_PENDING] -= 1 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 clients[entry.entry_id] = client
entry.runtime_data = FglairRuntime(client, coordinator, conversions) entry.runtime_data = FglairRuntime(client, coordinator, conversions, features)
if len(clients) == 1: if len(clients) == 1:
pyfglair.set_log_handler(_log_handler) pyfglair.set_log_handler(_log_handler)
pyfglair.set_log_level(1) pyfglair.set_log_level(1)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 607 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+22 -16
View File
@@ -16,10 +16,6 @@ from pyfglair import Prop
from pyfglair.templates import prop_info from pyfglair.templates import prop_info
from .const import ( from .const import (
CAP_FAN,
CAP_HVAC,
CAP_SWING_HORIZONTAL,
CAP_SWING_VERTICAL,
FAN_MODE_TO_VALUE, FAN_MODE_TO_VALUE,
FAN_VALUE_TO_MODE, FAN_VALUE_TO_MODE,
HVAC_MODE_TO_VALUE, HVAC_MODE_TO_VALUE,
@@ -27,7 +23,6 @@ from .const import (
OPERATION_MODE_OFF, OPERATION_MODE_OFF,
OPERATION_MODE_ON, OPERATION_MODE_ON,
PRESET_BOOST, PRESET_BOOST,
PRESET_CAP,
PRESET_ECO, PRESET_ECO,
PRESET_PROP, PRESET_PROP,
SWING_MODES, SWING_MODES,
@@ -41,13 +36,24 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
async_add_entities([FglairClimate(runtime.coordinator)]) 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): class FglairClimate(FglairEntity, ClimateEntity):
_attr_name = None _attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(self, coordinator: FglairCoordinator) -> None: def __init__(self, coordinator: FglairCoordinator) -> None:
# translation_key не нужен: имя = имя устройства (has_entity_name). # 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 self._last_hvac_mode: Optional[HVACMode] = None
# -- свойства шаблона -------------------------------------------------- # -- свойства шаблона --------------------------------------------------
@@ -56,37 +62,37 @@ class FglairClimate(FglairEntity, ClimateEntity):
def _hvac_modes(self) -> list[HVACMode]: def _hvac_modes(self) -> list[HVACMode]:
modes: list[HVACMode] = [HVACMode.OFF] modes: list[HVACMode] = [HVACMode.OFF]
for mode, bit in CAP_HVAC.items(): for mode, key in MODE_FEATURE.items():
if self.supports_capability(bit): if self.feature_enabled(key):
modes.append(mode) modes.append(mode)
return modes return modes
def _fan_modes(self) -> Optional[list[str]]: def _fan_modes(self) -> Optional[list[str]]:
if not self._in_template(Prop.FAN_SPEED): if not self._in_template(Prop.FAN_SPEED):
return None return None
return [ modes = [
mode mode for mode, key in FAN_FEATURE.items()
for mode, bit in CAP_FAN.items() if self.feature_enabled(key)
if self.supports_capability(bit)
] ]
return modes or None
def _preset_modes(self) -> Optional[list[str]]: def _preset_modes(self) -> Optional[list[str]]:
presets = [ presets = [
preset preset
for preset, bit in PRESET_CAP.items() for preset, key in PRESET_FEATURE.items()
if self._in_template(PRESET_PROP[preset]) if self._in_template(PRESET_PROP[preset])
and self.supports_capability(bit) and self.feature_enabled(key)
] ]
return presets or None return presets or None
def _vertical_swing_supported(self) -> bool: def _vertical_swing_supported(self) -> bool:
return self._in_template(Prop.AF_VERTICAL_SWING) and ( 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: def _horizontal_swing_supported(self) -> bool:
return self._in_template(Prop.AF_HORIZONTAL_SWING) and ( return self._in_template(Prop.AF_HORIZONTAL_SWING) and (
self.supports_capability(CAP_SWING_HORIZONTAL) self.feature_enabled("swing_horizontal")
) )
# -- ClimateEntity ---------------------------------------------------- # -- ClimateEntity ----------------------------------------------------
+200 -71
View File
@@ -22,14 +22,11 @@ from pyfglair.provision import Device
from pyfglair.templates import detect from pyfglair.templates import detect
from .const import ( from .const import (
CAP_FAN,
CAP_HVAC,
CAP_SWING_HORIZONTAL,
CAP_SWING_VERTICAL,
CONF_CONFIG_JSON, CONF_CONFIG_JSON,
CONF_DEVICE_PORT, CONF_DEVICE_PORT,
CONF_DSN, CONF_DSN,
CONF_EMAIL, CONF_EMAIL,
CONF_FEATURES,
CONF_HOST, CONF_HOST,
CONF_LANIP_KEY, CONF_LANIP_KEY,
CONF_LANIP_KEY_ID, CONF_LANIP_KEY_ID,
@@ -49,6 +46,8 @@ from .const import (
DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_PORT,
DEFAULT_LISTEN_PORT, DEFAULT_LISTEN_PORT,
DOMAIN, DOMAIN,
FAN_VALUE_TO_MODE,
FEATURES,
REGIONS, REGIONS,
TEMP_MAX_TENTHS, TEMP_MAX_TENTHS,
TEMP_MIN_TENTHS, TEMP_MIN_TENTHS,
@@ -56,8 +55,9 @@ from .const import (
TRIAL_ERRORS, TRIAL_ERRORS,
TRIAL_TIMEOUT, TRIAL_TIMEOUT,
) )
from .features import resolve_features
from .overrides import ConversionSet, LinearOverride from .overrides import ConversionSet, LinearOverride
from .trial import TrialResult, trial_connect from .trial import TrialResult, probe_template, trial_connect
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -113,7 +113,24 @@ def _spec_int(spec: dict[str, Any], key: str, default: int) -> int:
return default 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"))] template = Template[str(data.get(CONF_TEMPLATE, "A"))]
overrides = data.get(CONF_OVERRIDES) overrides = data.get(CONF_OVERRIDES)
spec = ( spec = (
@@ -131,9 +148,6 @@ def _preview_schema(data: dict[str, Any]) -> vol.Schema:
step = TEMP_STEP_TENTHS[template] step = TEMP_STEP_TENTHS[template]
return vol.Schema( 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.Required(CONF_TEMP_MIN, default=temp_min / 10): vol.All(
vol.Coerce(float), vol.Range(min=-10.0, max=45.0) 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( def _device_values_text(properties: dict[Any, Any]) -> str:
template: Template, properties: dict[Any, Any] """Что прочитано с устройства (человекочитаемо) для описаний шагов."""
) -> str:
def raw(prop) -> int | None: def raw(prop) -> int | None:
value = properties.get(prop) value = properties.get(prop)
return value.int_value if value is not None else None return value.int_value if value is not None else None
caps = raw(Prop.DEVICE_CAPABILITIES) lines: list[str] = []
mode = raw(Prop.OPERATION_MODE)
def supported(bit: int) -> bool: if mode is not None:
return caps is None or bool(caps & (1 << bit)) names = {0: "off", 1: "on", 2: "auto", 3: "cool", 4: "dry",
5: "fan", 6: "heat"}
hvac = ["off"] + [ lines.append(f"- режим: {names.get(mode, '?')} ({mode})")
mode.value for mode, bit in CAP_HVAC.items() if supported(bit) fan = raw(Prop.FAN_SPEED)
] if fan is not None:
fan = [mode for mode, bit in CAP_FAN.items() if supported(bit)] lines.append(f"- скорость: {FAN_VALUE_TO_MODE.get(fan, '?')} ({fan})")
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",
]
current = raw(Prop.DISPLAY_TEMPERATURE) current = raw(Prop.DISPLAY_TEMPERATURE)
if current is not None: if current is not None:
lines.append(f"- Current temperature: {current / 10:.1f} °C") lines.append(f"- температура в помещении: {current / 10:.1f} °C")
swings = [] caps = raw(Prop.DEVICE_CAPABILITIES)
if supported(CAP_SWING_VERTICAL):
swings.append("vertical")
if supported(CAP_SWING_HORIZONTAL):
swings.append("horizontal")
if swings:
lines.append(f"- Swing: {', '.join(swings)}")
if caps is not None: if caps is not None:
lines.append(f"- Capabilities: 0x{caps:08X}") lines.append(f"- device_capabilities: 0x{caps:08X}")
lines.append(f"- Template: {template.name}") vnum = raw(Prop.AF_VERTICAL_NUM_DIR)
return "\n".join(lines) 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: 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: def _run_trial(data: dict[str, Any]) -> tuple[TrialResult, Template, bool]:
return trial_connect( """Пробная сессия; для неизвестной модели — предварительный пробинг 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], host=data[CONF_HOST],
dsn=data[CONF_DSN], dsn=data[CONF_DSN],
lanip_key=data[CONF_LANIP_KEY], lanip_key=data[CONF_LANIP_KEY],
lanip_key_id=int(data[CONF_LANIP_KEY_ID]), lanip_key_id=int(data[CONF_LANIP_KEY_ID]),
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)), 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)), listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
timeout=TRIAL_TIMEOUT, timeout=TRIAL_TIMEOUT,
) )
return result, template, probed
class FglairConfigFlow(ConfigFlow, domain=DOMAIN): class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -285,6 +312,10 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
self._devices: list[Device] = [] self._devices: list[Device] = []
self._pending_data: dict[str, Any] = {} self._pending_data: dict[str, Any] = {}
self._trial_properties: dict[Any, 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 self._reconfigure_entry: ConfigEntry | None = None
async def async_step_reconfigure( async def async_step_reconfigure(
@@ -391,7 +422,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
} }
error = await self._async_trial(data) error = await self._async_trial(data)
if error is None: if error is None:
return await self.async_step_preview() return await self.async_step_template()
errors["base"] = error errors["base"] = error
schema = vol.Schema( schema = vol.Schema(
{ {
@@ -413,7 +444,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
data = _manual_data(user_input) data = _manual_data(user_input)
error = await self._async_trial(data) error = await self._async_trial(data)
if error is None: if error is None:
return await self.async_step_preview() return await self.async_step_template()
errors["base"] = error errors["base"] = error
if user_input is None and self._reconfigure_entry is not None: if user_input is None and self._reconfigure_entry is not None:
user_input = self._reconfigure_suggestions() user_input = self._reconfigure_suggestions()
@@ -433,7 +464,7 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
else: else:
error = await self._async_trial(data) error = await self._async_trial(data)
if error is None: if error is None:
return await self.async_step_preview() return await self.async_step_template()
errors["base"] = error errors["base"] = error
return self._show_form("import_json", _import_schema(), errors, user_input) 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) error = await self._async_trial(data)
if error is not None: if error is not None:
return self.async_abort(reason=error) 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 self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> 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) data = dict(self._pending_data)
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: 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_min = int(round(float(user_input[CONF_TEMP_MIN]) * 10))
temp_max = int(round(float(user_input[CONF_TEMP_MAX]) * 10)) temp_max = int(round(float(user_input[CONF_TEMP_MAX]) * 10))
step = int(round(float(user_input[CONF_TEMP_STEP]) * 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 is None
or bounds[0] >= bounds[1] or bounds[0] >= bounds[1]
): ):
# Достижимый диапазон пуст: конверсия выводит таблицу
# шаблона за пределы, которые не пересекаются с min/max.
errors["base"] = "invalid_range" errors["base"] = "invalid_range"
else: else:
data[CONF_TEMPLATE] = template.name
data[CONF_TEMP_STEP] = step data[CONF_TEMP_STEP] = step
data[CONF_OVERRIDES] = { data[CONF_OVERRIDES] = {
Prop.ADJUST_TEMPERATURE.name.lower(): { Prop.ADJUST_TEMPERATURE.name.lower(): {
@@ -497,29 +589,52 @@ class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
"max": temp_max, "max": temp_max,
} }
} }
data[CONF_FEATURES] = dict(self._selected_features)
_LOGGER.debug( _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, template.name, temp_min, temp_max, step,
len(self._selected_features),
) )
return self._create_entry(data) return self._create_entry(data)
selected = str(data.get(CONF_TEMPLATE, Template.A.name)) if user_input is None and self._reconfigure_entry is not None:
if user_input and user_input.get(CONF_TEMPLATE) in Template.__members__: user_input = self._reconfigure_limits_suggestions()
selected = str(user_input[CONF_TEMPLATE]) return self._show_form(
if user_input is None: "limits",
user_input = {CONF_TEMPLATE: selected} _limits_schema(data),
return self.async_show_form( errors,
step_id="preview", user_input,
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
)
},
) )
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: async def _async_trial(self, data: dict[str, Any]) -> str | None:
"""None — сессия поднялась; иначе ключ ошибки формы.""" """None — сессия поднялась; иначе ключ ошибки формы."""
if self._reconfigure_entry is not 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(): for key, value in self._reconfigure_entry.data.items():
data.setdefault(key, value) 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) await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False)
if self._reconfigure_entry is not None: if self._reconfigure_entry is not None:
self._abort_if_unique_id_mismatch() self._abort_if_unique_id_mismatch()
else: else:
self._abort_if_unique_id_configured() 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) partial(_run_trial, data)
) )
if result.ok: if result.ok:
self._pending_data = data 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_properties = result.properties
self._trial_answered = result.answered
self._resolved_template = template
self._probed = probed
return None return None
_LOGGER.debug( _LOGGER.debug(
"пробное подключение не удалось: reason=%s state=%s error=%s", "пробное подключение не удалось: reason=%s state=%s error=%s",
+136 -15
View File
@@ -24,6 +24,7 @@ CONF_PASSWORD = "password"
CONF_CONFIG_JSON = "config_json" CONF_CONFIG_JSON = "config_json"
CONF_OVERRIDES = "overrides" CONF_OVERRIDES = "overrides"
CONF_TEMP_STEP = "temp_step" CONF_TEMP_STEP = "temp_step"
CONF_FEATURES = "features"
CONF_TEMP_MIN = "temp_min" CONF_TEMP_MIN = "temp_min"
CONF_TEMP_MAX = "temp_max" CONF_TEMP_MAX = "temp_max"
CONF_TEMP_NUM = "temp_num" CONF_TEMP_NUM = "temp_num"
@@ -93,25 +94,145 @@ PRESET_PROP = {
PRESET_ECO: Prop.ECONOMY_MODE, PRESET_ECO: Prop.ECONOMY_MODE,
PRESET_BOOST: Prop.POWERFUL_MODE, PRESET_BOOST: Prop.POWERFUL_MODE,
} }
PRESET_CAP = {
PRESET_ECO: CAP_ECONOMY,
PRESET_BOOST: CAP_POWERFUL,
}
SWING_MODES = ["off", "on"] SWING_MODES = ["off", "on"]
# switch-свойства: prop -> (translation_key, capability bit | None) # switch-свойства: prop -> translation_key (filtracija po FEATURES)
SWITCH_PROPS: dict[Prop, tuple[str, int | None]] = { SWITCH_PROPS: dict[Prop, str] = {
Prop.ECONOMY_MODE: ("economy_mode", CAP_ECONOMY), Prop.ECONOMY_MODE: "economy_mode",
Prop.POWERFUL_MODE: ("powerful_mode", CAP_POWERFUL), Prop.POWERFUL_MODE: "powerful_mode",
Prop.COIL_DRY_MODE: ("coil_dry_mode", CAP_COIL_DRY), Prop.COIL_DRY_MODE: "coil_dry_mode",
Prop.MIN_HEAT: ("min_heat", CAP_MIN_HEAT), Prop.MIN_HEAT: "min_heat",
Prop.OUTDOOR_LOW_NOISE: ("outdoor_low_noise", CAP_OUTDOOR_LOW_NOISE), Prop.OUTDOOR_LOW_NOISE: "outdoor_low_noise",
Prop.HUMAN_DET_AUTO_SAVE: ("human_det_auto_save", None), Prop.HUMAN_DET_AUTO_SAVE: "human_det_auto_save",
Prop.WIFI_LED_ENABLE: ("wifi_led_enable", None), Prop.WIFI_LED_ENABLE: "wifi_led_enable",
Prop.INDOOR_FAN_CONTROL: ("indoor_fan_control", CAP_INDOOR_FAN_CONTROL), 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, биты (PROTOCOL §8.4): translation_key -> bit
OP_STATUS_BITS: dict[str, int] = { OP_STATUS_BITS: dict[str, int] = {
"defrost": 24, "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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind 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 ( from .const import (
AVAILABLE_STATES, AVAILABLE_STATES,
@@ -21,6 +21,7 @@ from .const import (
CONF_NAME, CONF_NAME,
DOMAIN, DOMAIN,
) )
from .features import FeatureSet, LiveValues
from .fglair_client import FglairClient from .fglair_client import FglairClient
from .overrides import ConversionSet from .overrides import ConversionSet
@@ -40,6 +41,7 @@ class FglairRuntime:
client: FglairClient client: FglairClient
coordinator: "FglairCoordinator" coordinator: "FglairCoordinator"
conversions: ConversionSet conversions: ConversionSet
features: "FeatureSet"
def key_issue_id(dsn: str) -> str: def key_issue_id(dsn: str) -> str:
@@ -70,6 +72,9 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
) )
self.client = client self.client = client
self.conversions = conversions self.conversions = conversions
self.features = FeatureSet(
explicit=None, values=LiveValues(client.cached)
)
self.entry_id = entry_id self.entry_id = entry_id
self.key_issue_id = key_issue_id(client.data[CONF_DSN]) self.key_issue_id = key_issue_id(client.data[CONF_DSN])
self._synced = False self._synced = False
@@ -118,8 +123,20 @@ class FglairCoordinator(DataUpdateCoordinator[FglairData]):
# После разрыва при следующем ONLINE нужна повторная синхронизация. # После разрыва при следующем ONLINE нужна повторная синхронизация.
self._synced = False self._synced = False
async def async_sync_now(self) -> None: async def async_sync_now(self, online_timeout: float = 2.0) -> None:
"""Гарантирует первичный GET-батч (если уже online).""" """Гарантирует первичный 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: if self._sync_task is not None:
await self._sync_task await self._sync_task
elif self.client.state == State.ONLINE and not self._synced: 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: async def _async_initial_sync(self) -> None:
try: try:
await self.hass.async_add_executor_job(self._initial_sync) await self.hass.async_add_executor_job(self._initial_sync)
await self._async_wait_first_props()
self._synced = True self._synced = True
except Exception: # pragma: no cover - защитный путь except Exception: # pragma: no cover - защитный путь
_LOGGER.exception("первичная синхронизация свойств не удалась") _LOGGER.exception("первичная синхронизация свойств не удалась")
finally: finally:
self._sync_task = None 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: def _initial_sync(self) -> None:
self.client.batch_begin() self.client.batch_begin()
for prop in _writable_props(self.client.template): 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 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 from .coordinator import FglairCoordinator
@@ -22,6 +28,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
coordinator: FglairCoordinator, coordinator: FglairCoordinator,
unique_suffix: str, unique_suffix: str,
translation_key: Optional[str], translation_key: Optional[str],
description_key: Optional[str] = None,
) -> None: ) -> None:
super().__init__(coordinator) super().__init__(coordinator)
data = coordinator.client.data data = coordinator.client.data
@@ -29,6 +36,7 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
self._attr_unique_id = f"{dsn}_{unique_suffix}" self._attr_unique_id = f"{dsn}_{unique_suffix}"
if translation_key is not None: if translation_key is not None:
self._attr_translation_key = translation_key self._attr_translation_key = translation_key
self._description_key = description_key or translation_key
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, dsn)}, identifiers={(DOMAIN, dsn)},
name=data.get(CONF_NAME) or dsn, name=data.get(CONF_NAME) or dsn,
@@ -40,6 +48,18 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
def available(self) -> bool: def available(self) -> bool:
return self.coordinator.data.available and super().available 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]: def value(self, prop: Prop) -> Optional[Value]:
return self.coordinator.data.values.get(prop) return self.coordinator.data.values.get(prop)
@@ -54,19 +74,4 @@ class FglairEntity(CoordinatorEntity[FglairCoordinator]):
return None return None
return self.coordinator.conversions.to_display(prop, raw) 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)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"domain": "fglair", "domain": "fglair",
"name": "FGLair (Fujitsu General)", "name": "FGLair (Fujitsu General)",
"codeowners": [], "codeowners": ["@petr.polezhaev"],
"config_flow": true, "config_flow": true,
"documentation": "https://git.ratigorsk-12.ru/esphome/fgl-aircon", "documentation": "https://git.ratigorsk-12.ru/esphome/fgl-aircon",
"integration_type": "device", "integration_type": "device",
+4 -2
View File
@@ -48,7 +48,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
entities = [] entities = []
if ( 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 and prop_info(template, Prop.AF_VERTICAL_NUM_DIR) is not None
): ):
entities.append( entities.append(
@@ -60,7 +61,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
) )
) )
if ( 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 and prop_info(template, Prop.AF_HORIZONTAL_NUM_DIR) is not None
): ):
entities.append( 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)} props = {info.prop for info in template_info(template)}
entities = [FglairConnectionState(coordinator)] 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)) entities.append(FglairRoomTemperature(coordinator))
if Prop.ERROR_CODE in props: if Prop.ERROR_CODE in props:
entities.append(FglairErrorCode(coordinator)) entities.append(FglairErrorCode(coordinator))
+69 -6
View File
@@ -52,11 +52,74 @@
"config_json": "Paste a JSON object produced by fglair-discover or fglctl" "config_json": "Paste a JSON object produced by fglair-discover or fglctl"
} }
}, },
"preview": { "template": {
"title": "Template preview", "title": "Module template",
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.", "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": { "data": {
"template": "Template",
"temp_min": "Minimum setpoint (°C)", "temp_min": "Minimum setpoint (°C)",
"temp_max": "Maximum setpoint (°C)", "temp_max": "Maximum setpoint (°C)",
"temp_step": "Setpoint step (°C)", "temp_step": "Setpoint step (°C)",
@@ -66,8 +129,8 @@
}, },
"data_description": { "data_description": {
"temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)", "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_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)" "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 template = runtime.client.template
entities = [] entities = []
for prop, (key, capability) in SWITCH_PROPS.items(): for prop, key in SWITCH_PROPS.items():
if prop_info(template, prop) is None: if prop_info(template, prop) is None:
continue continue
if not _capability_supported(coordinator, capability): if not coordinator.features.enabled(key):
continue continue
entities.append(FglairSwitch(coordinator, prop, key)) entities.append(FglairSwitch(coordinator, prop, key))
async_add_entities(entities) 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" "config_json": "Paste a JSON object produced by fglair-discover or fglctl"
} }
}, },
"preview": { "template": {
"title": "Template preview", "title": "Module template",
"description": "Computed mapping (compare with the FGLair app):\n\n{preview}\n\nSetpoint range and conversion can be adjusted below.", "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": { "data": {
"template": "Template",
"temp_min": "Minimum setpoint (°C)", "temp_min": "Minimum setpoint (°C)",
"temp_max": "Maximum setpoint (°C)", "temp_max": "Maximum setpoint (°C)",
"temp_step": "Setpoint step (°C)", "temp_step": "Setpoint step (°C)",
@@ -66,8 +129,8 @@
}, },
"data_description": { "data_description": {
"temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)", "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_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)" "temp_offset": "display = raw × num / den + offset"
} }
} }
}, },
+69 -6
View File
@@ -52,11 +52,74 @@
"config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl" "config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl"
} }
}, },
"preview": { "template": {
"title": "Превью шаблона", "title": "Шаблон модуля",
"description": "Рассчитанные значения (сверьте с приложением FGLair):\n\n{preview}\n\nДиапазон уставки и конверсию можно поправить ниже.", "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": { "data": {
"template": "Шаблон",
"temp_min": "Минимум уставки (°C)", "temp_min": "Минимум уставки (°C)",
"temp_max": "Максимум уставки (°C)", "temp_max": "Максимум уставки (°C)",
"temp_step": "Шаг уставки (°C)", "temp_step": "Шаг уставки (°C)",
@@ -66,8 +129,8 @@
}, },
"data_description": { "data_description": {
"temp_num": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)", "temp_num": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)",
"temp_den": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)", "temp_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)" "temp_offset": "display = raw × num / den + offset"
} }
} }
}, },
+125 -9
View File
@@ -1,5 +1,10 @@
"""Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение """Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение
базовых свойств. Выполняется в executor (блокирующие вызовы ядра). базовых свойств и зондирование необязательных (presence).
Выполняется в executor (блокирующие вызовы ядра). Модуль отвечает только на
свойства из своей таблицы: отсутствие push = свойства нет (проверено на
AP-WC1E). Поэтому необязательные свойства не валят trial, а фиксируются в
``answered`` и используются как дефолты фич и для определения шаблона.
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,12 +16,48 @@ from typing import Iterable
from pyfglair import Config, Error, Prop, Session, State, Template, Value from pyfglair import Config, Error, Prop, Session, State, Template, Value
from pyfglair.templates import template_info from pyfglair.templates import template_info
BASE_PROPS: tuple[Prop, ...] = ( # Обязательные свойства: есть во всех шаблонах, должны ответить.
REQUIRED_PROPS: tuple[Prop, ...] = (
Prop.OPERATION_MODE, Prop.OPERATION_MODE,
Prop.FAN_SPEED, 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 @dataclass
class TrialResult: class TrialResult:
@@ -25,6 +66,7 @@ class TrialResult:
state: State = State.IDLE state: State = State.IDLE
error: Error = Error.NONE error: Error = Error.NONE
properties: dict[Prop, Value] = field(default_factory=dict) properties: dict[Prop, Value] = field(default_factory=dict)
answered: set[Prop] = field(default_factory=set)
def _failure_reason(state: State, error: Error) -> str: def _failure_reason(state: State, error: Error) -> str:
@@ -37,6 +79,24 @@ def _failure_reason(state: State, error: Error) -> str:
return "timeout" 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( def trial_connect(
host: str, host: str,
dsn: str, dsn: str,
@@ -48,9 +108,12 @@ def trial_connect(
listen_port: int = 10275, listen_port: int = 10275,
keepalive_ms: int = 15000, keepalive_ms: int = 15000,
timeout: float = 10.0, 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: ) -> TrialResult:
"""Поднимает сессию, ждёт ONLINE и читает базовые свойства. """Поднимает сессию, ждёт ONLINE, читает обязательные и зондирует
необязательные свойства.
Никогда не бросает: любая неудача — ``TrialResult(ok=False, reason=...)``. Никогда не бросает: любая неудача — ``TrialResult(ok=False, reason=...)``.
""" """
@@ -59,6 +122,9 @@ def trial_connect(
values: dict[Prop, Value] = {} values: dict[Prop, Value] = {}
valid = {info.prop for info in template_info(template)} valid = {info.prop for info in template_info(template)}
wanted = tuple(prop for prop in props if prop in valid) 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: def on_state(state: State, error: Error) -> None:
with lock: with lock:
@@ -115,12 +181,15 @@ def trial_connect(
state=state, error=error, state=state, error=error,
) )
if wanted: requested = wanted + optional
if requested:
session.batch_begin() session.batch_begin()
for prop in wanted: for prop in requested:
session.get_prop(prop) session.get_prop(prop)
session.batch_commit() session.batch_commit()
prop_deadline = min(deadline, time.monotonic() + 2.0) prop_deadline = min(
deadline, time.monotonic() + props_timeout
)
while time.monotonic() < prop_deadline: while time.monotonic() < prop_deadline:
with lock: with lock:
if all(prop in values for prop in wanted): if all(prop in values for prop in wanted):
@@ -136,7 +205,54 @@ def trial_connect(
reason="properties_missing", reason="properties_missing",
state=State.ONLINE, state=State.ONLINE,
properties=snapshot, 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: finally:
session.close() 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-флаги; display_temperature; sensors: room temp, error_code, op_status-флаги;
switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/ switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/
human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки, human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки,
demand_control; binary_sensor: connectivity). capabilities фильтруют demand_control; binary_sensor: connectivity). capabilities и выбранные в мастере
режимы/пресеты. Записи — один `batch_commit()` на действие пользователя. feature-тумблеры фильтруют сущности (`features.py`,
`entry.data["features"]`): то, что прибор не умеет или отключено
пользователем, не создаётся. Записи — один `batch_commit()` на действие
пользователя. У сущностей есть атрибут `description` (HA не поддерживает
тултипы).
Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована
(§10) — сущность не создаётся до уточнения; в H3 вместо неё (§10) — сущность не создаётся до уточнения; в H3 вместо неё
диагностический sensor состояния связи. диагностический sensor состояния связи.
+158 -59
View File
@@ -1,80 +1,179 @@
# Чек-лист релиза HA-интеграции (H5) # Релиз и установка HA-интеграции
## 1. Публикация pyfglair (PyPI) ## 1. Внутренняя сборка и установка (Gitea, текущий режим)
Манифест компонента требует `pyfglair>=1.0.0`; штатный installer HA Внутренний репозиторий — Gitea (`git.ratigorsk-12.ru`), поэтому:
резолвит требования только через PyPI. PyPI (warehouse) принимает только
`manylinux*`/`musllinux*` платформенные теги — локальный * HACS к Gitea **не подключается** (только публичный GitHub) — для
`py3-none-linux_x86_64` будет отклонён (`HTTP 400 unsupported platform внутреннего использования интеграция ставится копированием
tag`), поэтому wheel нужно «починить» auditwheel'ом в контейнере со старой `custom_components/fglair/` в `<config>/custom_components/`;
glibc (manylinux): * зависимость `pyfglair` публикуется в **PyPI-реестр пакетов Gitea** и
ставится оттуда.
### 1.1. Сборка и публикация wheel (Gitea Actions, ручной запуск)
Workflow: `.gitea/workflows/publish-pyfglair.yaml` (workflow объявляет
`permissions: contents: read, packages: write`).
Авторизация — любой из вариантов:
* **Встроенный 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 увидит
установленный дистрибутив и не будет вызывать pip для `pyfglair>=1.0.0`):
```sh
# в контейнере/venv Home Assistant (Advanced SSH & Web Terminal, docker exec и т.п.)
pip install --index-url https://<user>:<token>@git.ratigorsk-12.ru/api/packages/<owner>/pypi/simple \
--no-deps pyfglair
```
* **Через индекс pip в окружении HA** (`pip.conf`/`PIP_EXTRA_INDEX_URL`):
тогда 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. Если 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.
## 2. Публикация pyfglair на PyPI (если понадобится публично)
PyPI (warehouse) принимает только `manylinux*`/`musllinux*` теги, поэтому
локальный `py3-none-linux_x86_64` нужно чинить auditwheel'ом в контейнере
со старой glibc:
```sh ```sh
pip install build auditwheel twine pip install build auditwheel twine
# для каждой архитектуры (linux x86_64/aarch64) в manylinux-контейнере # для каждой архитектуры (linux x86_64/aarch64) в manylinux-контейнере:
# (например, quay.io/pypa/manylinux2014_<arch>): python -m build --wheel
python -m build --wheel # .so без внешнего mbedcrypto auditwheel repair dist/pyfglair-*.whl --plat manylinux2014_x86_64 -w dist/manylinux/
auditwheel repair dist/pyfglair-*.whl \ rm -f dist/pyfglair-*-linux_*.whl
--plat manylinux2014_x86_64 -w dist/manylinux/ # или manylinux_2_28 python -m build --sdist
rm -f dist/pyfglair-*-linux_*.whl # linux-тег PyPI отклонит
python -m build --sdist # sdist с C++-исходниками (MANIFEST.in)
twine upload dist/manylinux/* dist/*.tar.gz twine upload dist/manylinux/* dist/*.tar.gz
``` ```
Проверки перед публикацией: Проверки: `scripts/py-ci.sh`, `scripts/ci.sh`, `readelf -d` по `.so`
(нет внешней `libmbedcrypto`), установка wheel в чистый venv.
* `scripts/py-ci.sh` — pyfglair + acceptance self-test + components; ## 3. GitHub-зеркало и Custom Repositories (HACS)
* `scripts/ci.sh` — ядро (gcc/clang, ASan/UBSan) 11/11;
* `readelf -d build-pyfglair/libfgl-aircon.so*` (или распакованный wheel:
`pyfglair/libfgl-aircon.so*`) — нет внешней `libmbedcrypto` (встроен);
* `auditwheel show` для repaired-wheel — тег manylinux и только
базовые системные библиотеки;
* установка wheel в чистый venv вне репозитория:
`python -c "import pyfglair.session"`;
* aarch64: сборка в соответствующем manylinux-контейнере (QEMU или
нативная), иначе пользователи ARM не смогут поставить `pyfglair`.
Альтернатива — cibuildwheel (`pip install cibuildwheel`) с `auditwheel
repair` внутри, но текущий `bdist_wheel`-хук отдаёт тег
`py3-none-<plat>`, поэтому проверяйте итоговые теги вручную.
## 2. GitHub-зеркало и HACS
HACS устанавливает интеграции только с публичных **GitHub**-репозиториев HACS устанавливает интеграции только с публичных **GitHub**-репозиториев
(GitLab/Gitea не поддерживаются). Шаги: (GitLab/Gitea не поддерживаются:
https://hacs.xyz/docs/faq/other_git_providers/). Публикация в
default-репозиторий HACS не требуется — достаточно Custom Repositories, но
хостинг обязан быть GitHub.
* создать GitHub-зеркало (push mirror) и убедиться, что оно содержит ```sh
`custom_components/fglair/`, `hacs.json`, тег релиза; git remote add github git@github.com:<owner>/fgl-aircon.git
* заполнить `codeowners` в `custom_components/fglair/manifest.json` git push --mirror github
(GitHub-хендлы; сейчас пустой список); ```
* проверить `documentation`/`issue_tracker` в манифесте;
* обновить `version` манифеста под номер тега (сейчас `0.1.0`, pyfglair —
`1.0.0`; синхронизируйте по вкусу — компонент и пакет версионируются
отдельно);
* заменить заглушки `custom_components/fglair/screenshots/step-N.png`
реальными скриншотами (описания «что должно быть видно» — в README);
* заменить заглушки `custom_components/fglair/brand/{icon,logo}.png`
фирменными ассетами;
* создать git-тег (semver) в GitHub-зеркале — HACS покажет версию.
## 3. Приёмка на живом стенде После зеркала:
* заполнить на GitHub Description, Topics (`home-assistant`, `hacs`,
`fujitsu`, `air-conditioner`), включить Issues (проверяет HACS Action);
* проверить `codeowners` в manifest (`@petr.polezhaev`; для GitHub нужен
реальный GitHub-логин);
* обновить `version` манифеста под тег; заменить заглушки
`screenshots/step-N.png` и `brand/*.png` реальными ассетами;
* создать тег (semver) — HACS покажет версию.
Готовые файлы HACS: `hacs.json`, `info.md`, `LICENSE` (MIT),
`custom_components/fglair/brand/` (icon/icon@2x/logo + dark-варианты),
workflows `.github/workflows/{validate-hacs,hassfest}.yaml`.
## 4. Приёмка на живом стенде
* `tests/acceptance/test_esphome_ha.py quick` — матрица изменений (HA; * `tests/acceptance/test_esphome_ha.py quick` — матрица изменений (HA;
с `--esphome-host` шаги «режим/уставка» идут через ESPHome, проверка — по с `--esphome-host` шаги «режим/уставка» идут через ESPHome, проверка — по
состоянию в HA), возврат к исходному. состоянию в HA), возврат к исходному.
* `long --hours 24 --interval 3600 --report acceptance.csv` — суточный * `long --hours 24 --interval 3600 --report acceptance.csv`.
прогон, отчёт дописывается по ходу. * Сценарии: 503 (два слота), key_error + Repair, offline/восстановление,
* Сценарии: 503 (два слота заняты), key_error + Repair, offline и два устройства, options flow (копирование/смена ключа), reconfigure.
восстановление, два устройства (разные порты прослушивания), options flow
(копирование/смена ключа), reconfigure без потери конверсий.
## 4. Ограничения текущего стенда ## 5. Ограничения стенда
* Реального железа/облака в CI нет: протокол проверен mock-модулем * Реального железа/облака в CI нет: протокол проверен mock-модулем,
(`tests/ayla/mock_ac.py`), облако — мок-сервером; приёмочный скрипт имеет облако — мок-сервером; приёмочный скрипт имеет self-test на моке HA REST.
self-test на моке HA REST.
* Суточный soak, re-key и облачный вход — только на приборе/аккаунте. * Суточный soak, re-key и облачный вход — только на приборе/аккаунте.
* ESPHome-канал приёмки требует установленного `aioesphomeapi` и * Gitea Actions: встроенный `GITEA_TOKEN` не публикует пакеты (нужен PAT);
работающего ESPHome-компонента (PLAN_ESPHOME); без них скрипт работает `actions/checkout`/`setup-python` тянутся с github.com (для изолированного
только со стороны HA. инстанса используйте абсолютные URL зеркал).
+36
View File
@@ -0,0 +1,36 @@
# FGLair — локальное управление кондиционерами Fujitsu General
Интеграция Home Assistant для кондиционеров Fujitsu General (FGLair / Ayla)
**по локальной сети, без облака в рантайме**. Протокол и ядро — C++
(`fgl-aircon`), доступ из HA — через python-пакет `pyfglair` (cffi).
## Возможности
* `climate`: режим (off/cool/dry/fan/heat/auto), скорость вентилятора,
swing (вертикальный и горизонтальный), preset ECO/BOOST, уставка 16–30 °C,
текущая температура;
* `switch`: economy, powerful, coil dry, min heat, outdoor low noise,
human det auto save, Wi-Fi LED, indoor fan control;
* `select`: положение заслонок, `sensor`: температура/код ошибки/состояние
связи, `binary_sensor`: connectivity и флаги op_status;
* настройка через облако FGLair (однократно), вручную или импортом
`config_*.json`; превью шаблона с ручными конверсиями;
* Repair при несовпадении LAN-ключа, диагностика и переводы en/ru.
## Установка
HACS работает только с публичными **GitHub**-репозиториями — используйте
GitHub-зеркало этого репозитория:
1. HACS → ⋮ → **Custom repositories** → URL GitHub-зеркала, категория
**Integration** → Add → Download → перезапуск HA.
2. Либо вручную скопируйте `custom_components/fglair/` в
`<config>/custom_components/`.
Требуется пакет `pyfglair` (манифест ставит его из PyPI) и HA ≥ 2025.1 на
Linux x86_64/aarch64.
![Обзор устройства](custom_components/fglair/screenshots/step-7.png)
Подробная инструкция — в
[`custom_components/fglair/README.md`](custom_components/fglair/README.md).
+25
View File
@@ -8,6 +8,8 @@ from __future__ import annotations
import argparse import argparse
import os import os
import platform
import re
import shutil import shutil
import subprocess import subprocess
import sys 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: def source_root() -> Path | None:
"""Корень монорепо (CMakeLists.txt + include/fgl-aircon).""" """Корень монорепо (CMakeLists.txt + include/fgl-aircon)."""
env = os.environ.get("FGLAIR_SOURCE_ROOT") env = os.environ.get("FGLAIR_SOURCE_ROOT")
+3 -1
View File
@@ -1,5 +1,5 @@
[build-system] [build-system]
requires = ["setuptools>=68", "wheel"] requires = ["setuptools>=77", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
@@ -7,6 +7,8 @@ name = "pyfglair"
version = "1.0.0" version = "1.0.0"
description = "Local control of Fujitsu General (FGLair/Ayla) air conditioners: cffi bindings to the fgl-aircon C++ core" description = "Local control of Fujitsu General (FGLair/Ayla) air conditioners: cffi bindings to the fgl-aircon C++ core"
readme = "README.md" readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"cffi>=1.15", "cffi>=1.15",
+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"
+20 -8
View File
@@ -1,6 +1,7 @@
"""Сборка wheel pyfglair: вместе с python-пакетом собирается libfgl-aircon.so """Сборка wheel pyfglair: вместе с python-пакетом собирается libfgl-aircon.so
из корня монорепо (cmake). Метаданные — в pyproject.toml; здесь только хуки из корня монорепо (cmake). Метаданные — в pyproject.toml; здесь только хуки
setuptools для сборки нативного ядра.""" setuptools для сборки нативного ядра."""
import os
from importlib.util import module_from_spec, spec_from_file_location from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path from pathlib import Path
@@ -13,15 +14,18 @@ except ImportError: # setuptools < 70
from wheel.bdist_wheel import bdist_wheel as _bdist_wheel from wheel.bdist_wheel import bdist_wheel as _bdist_wheel
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
_COREBUILD = None
def _load_corebuild(): def _load_corebuild():
spec = spec_from_file_location( global _COREBUILD
"_pyfglair_corebuild", ROOT / "pyfglair" / "_corebuild.py" if _COREBUILD is None:
) spec = spec_from_file_location(
module = module_from_spec(spec) "_pyfglair_corebuild", ROOT / "pyfglair" / "_corebuild.py"
spec.loader.exec_module(module) )
return module _COREBUILD = module_from_spec(spec)
spec.loader.exec_module(_COREBUILD)
return _COREBUILD
class build_py(_build_py): class build_py(_build_py):
@@ -32,7 +36,8 @@ class build_py(_build_py):
dest.mkdir(parents=True, exist_ok=True) dest.mkdir(parents=True, exist_ok=True)
for stale in dest.glob("libfgl-aircon.*"): for stale in dest.glob("libfgl-aircon.*"):
stale.unlink() stale.unlink()
_load_corebuild().build_core(dest=dest, bundled_mbedtls=True) bundled = os.environ.get("FGL_BUNDLED_MBEDTLS", "1") != "0"
_load_corebuild().build_core(dest=dest, bundled_mbedtls=bundled)
super().run() super().run()
@@ -44,10 +49,17 @@ class BinaryDistribution(Distribution):
class bdist_wheel(_bdist_wheel): 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): def get_tag(self):
_, _, platform = super().get_tag() _, _, 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 return "py3", "none", platform
+158 -49
View File
@@ -14,7 +14,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry
import pyfglair import pyfglair
from custom_components.fglair.const import DOMAIN from custom_components.fglair.const import DOMAIN
from custom_components.fglair.trial import TrialResult 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 from pyfglair.provision import Device
MANUAL_INPUT = { MANUAL_INPUT = {
@@ -27,8 +27,7 @@ MANUAL_INPUT = {
"model": "AP-WC1E", "model": "AP-WC1E",
} }
PREVIEW_INPUT = { LIMITS_INPUT = {
"template": "A",
"temp_min": 16.0, "temp_min": 16.0,
"temp_max": 30.0, "temp_max": 30.0,
"temp_step": 0.5, "temp_step": 0.5,
@@ -37,8 +36,20 @@ PREVIEW_INPUT = {
"temp_offset": 0, "temp_offset": 0,
} }
TRIAL_OK = TrialResult(ok=True) TRIAL_OK = (TrialResult(ok=True), Template.A, False)
TRIAL_NETWORK_FAIL = TrialResult(ok=False, reason="unreachable") 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): 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) 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): def _suggested(schema, field: str):
for key in schema.schema: for key in schema.schema:
if getattr(key, "schema", None) == field: 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") result = await _select(hass, result["flow_id"], "manual")
assert result["type"] == "form" assert result["type"] == "form"
result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["type"] == "form" assert result["step_id"] == "template"
assert result["step_id"] == "preview" result = await _pages(hass, result["flow_id"])
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["title"] == "Bedroom" assert result["title"] == "Bedroom"
assert result["result"].unique_id == DSN assert result["result"].unique_id == DSN
@@ -98,11 +124,13 @@ async def test_manual_success(hass: HomeAssistant):
"min": 160, "min": 160,
"max": 300, "max": 300,
} }
assert isinstance(data["features"], dict)
assert data["features"]["mode_heat"] is True
trial.assert_called_once() trial.assert_called_once()
assert trial.call_args.args[0]["host"] == "192.168.1.50" 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( with patch(
"custom_components.fglair.config_flow._run_trial", "custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK, return_value=TRIAL_OK,
@@ -110,17 +138,20 @@ async def test_preview_template_change_and_conversion(hass: HomeAssistant):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
preview_input = { result = await _pages(
**PREVIEW_INPUT, hass,
"template": "B", result["flow_id"],
"temp_min": 17.0, template="B",
"temp_max": 28.0, limits={
"temp_step": 1.0, **LIMITS_INPUT,
"temp_num": 2, "temp_min": 17.0,
"temp_den": 4, "temp_max": 28.0,
"temp_offset": 5, "temp_step": 1.0,
} "temp_num": 2,
result = await _configure(hass, result["flow_id"], preview_input) "temp_den": 4,
"temp_offset": 5,
},
)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["data"]["template"] == "B" assert result["data"]["template"] == "B"
assert result["data"]["temp_step"] == 10 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( with patch(
"custom_components.fglair.config_flow._run_trial", "custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK, return_value=TRIAL_OK,
@@ -141,24 +172,23 @@ async def test_preview_invalid_range(hass: HomeAssistant):
result = await _menu(hass) result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT) 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( result = await _configure(
hass, hass,
result["flow_id"], result["flow_id"],
{ {**LIMITS_INPUT, "temp_min": 30.0, "temp_max": 16.0},
**PREVIEW_INPUT,
"template": "B",
"temp_min": 30.0,
"temp_max": 16.0,
},
) )
assert result["type"] == "form" assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_range"} assert result["errors"] == {"base": "invalid_range"}
assert "Template: B" in result["description_placeholders"]["preview"] result = await _configure(hass, result["flow_id"], LIMITS_INPUT)
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
async def test_preview_unreachable_range(hass: HomeAssistant): async def test_limits_unreachable_range(hass: HomeAssistant):
"""Конверсия выводит таблицу за min/max — пустой достижимый диапазон.""" """Конверсия выводит таблицу за min/max — пустой достижимый диапазон."""
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "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 _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT) 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( result = await _configure(
hass, hass,
result["flow_id"], result["flow_id"],
{ {
**PREVIEW_INPUT, **LIMITS_INPUT,
"temp_min": 30.0, "temp_min": 30.0,
"temp_max": 40.0, "temp_max": 40.0,
"temp_num": 1, "temp_num": 1,
@@ -182,22 +216,37 @@ async def test_preview_unreachable_range(hass: HomeAssistant):
assert result["errors"] == {"base": "invalid_range"} assert result["errors"] == {"base": "invalid_range"}
async def test_preview_contains_trial_values(hass: HomeAssistant): async def test_template_device_values(hass: HomeAssistant):
props = { props = {
Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200), 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.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011),
Prop.AF_VERTICAL_NUM_DIR: Value(ValueKind.INT, 5),
} }
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "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 _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
preview = result["description_placeholders"]["preview"] assert result["step_id"] == "template"
assert "20.0" in preview values = result["description_placeholders"]["device_values"]
assert "0x0000001B" in preview assert "20.0" in values
assert "cool" in preview and "heat" in preview 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): 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): async def test_manual_key_mismatch_error(hass: HomeAssistant):
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "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 _menu(hass)
result = await _select(hass, result["flow_id"], "manual") result = await _select(hass, result["flow_id"], "manual")
@@ -265,7 +318,7 @@ async def test_import_json_success(hass: HomeAssistant):
) )
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "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 _menu(hass)
result = await _select(hass, result["flow_id"], "import_json") result = await _select(hass, result["flow_id"], "import_json")
@@ -273,11 +326,9 @@ async def test_import_json_success(hass: HomeAssistant):
result = await _configure( result = await _configure(
hass, result["flow_id"], {"config_json": payload} hass, result["flow_id"], {"config_json": payload}
) )
assert result["step_id"] == "preview" assert result["step_id"] == "template"
assert _suggested(result["data_schema"], "template") == "B" assert _suggested(result["data_schema"], "template") == "B"
result = await _configure( result = await _pages(hass, result["flow_id"], template="B")
hass, result["flow_id"], {**PREVIEW_INPUT, "template": "B"}
)
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.60" assert result["data"]["host"] == "192.168.1.60"
assert result["data"]["template"] == "B" assert result["data"]["template"] == "B"
@@ -319,8 +370,8 @@ async def test_cloud_flow_success(hass: HomeAssistant):
) )
assert result["step_id"] == "cloud_device" assert result["step_id"] == "cloud_device"
result = await _configure(hass, result["flow_id"], {"dsn": DSN}) result = await _configure(hass, result["flow_id"], {"dsn": DSN})
assert result["step_id"] == "preview" assert result["step_id"] == "template"
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT) result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.50" assert result["data"]["host"] == "192.168.1.50"
assert result["data"]["lanip_key_id"] == KEY_ID assert result["data"]["lanip_key_id"] == KEY_ID
@@ -374,7 +425,7 @@ async def test_yaml_import(hass: HomeAssistant):
} }
with patch( with patch(
"custom_components.fglair.config_flow._run_trial", "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}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload})
await hass.async_block_till_done() await hass.async_block_till_done()
@@ -382,6 +433,7 @@ async def test_yaml_import(hass: HomeAssistant):
assert len(entries) == 1 assert len(entries) == 1
assert entries[0].data["template"] == "F" assert entries[0].data["template"] == "F"
assert entries[0].data["host"] == "192.168.1.70" assert entries[0].data["host"] == "192.168.1.70"
assert isinstance(entries[0].data["features"], dict)
async def test_yaml_import_invalid(hass: HomeAssistant): 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( result = await _configure(
hass, result["flow_id"], {"config_json": payload} 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["type"] == "create_entry"
assert result["data"]["device_port"] == 80 assert result["data"]["device_port"] == 80
assert trial.call_args.args[0]["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( result = await _configure(
hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"} hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"}
) )
assert result["step_id"] == "preview" assert result["step_id"] == "template"
result = await _configure(hass, result["flow_id"], PREVIEW_INPUT) result = await _pages(hass, result["flow_id"])
assert result["type"] == "abort" assert result["type"] == "abort"
assert result["reason"] == "reconfigure_successful" assert result["reason"] == "reconfigure_successful"
await hass.async_block_till_done() 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") assert entry.state.name in ("LOADED", "SETUP_RETRY")
if entry.state.name == "LOADED": if entry.state.name == "LOADED":
assert await hass.config_entries.async_unload(entry.entry_id) 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) climate = hass.states.get(climate_id)
assert climate.attributes["description"]
assert climate.attributes["current_temperature"] == 20.0 assert climate.attributes["current_temperature"] == 20.0
assert climate.attributes["temperature"] == 22.0 assert climate.attributes["temperature"] == 22.0
assert climate.attributes["fan_mode"] == "auto" 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 снят.""" """Repair-путь целиком: bad key_id → issue → reconfigure → online → issue снят."""
from homeassistant.config_entries import SOURCE_RECONFIGURE 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( proc = mock_ac(
["--set", "operation_mode=6"], key_id=KEY_ID + 1 ["--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, "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 = 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["type"] == "abort"
assert result["reason"] == "reconfigure_successful" 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