Compare commits

..
7 Commits
Author SHA1 Message Date
petr.polezhaev bfdf22ba64 core(M2): машина состояний сессии Ayla LAN + mock-модуль + интеграционные сценарии
- session.{hpp,cpp}: state machine (idle/registering/online/recovering/
  offline/key_error); httpd-обработчики key_exchange (200/426/412, re-key
  прозрачно), commands (одна команда, 206/200, envelope, глобальный seq_no),
  datapoint (unpack -> PropertyEvent / 401+тишина 50с для re-key-восстановления);
  сессионный поток: local_reg POST?dsn/PUT (local_ip_for), keep-alive, backoff
  x1.6->60с, 503->offline/NoSlot, activation-timeout->recovering, delete_session
  с ожиданием выдачи; очередь с coalescing + batch; телеметрия; колбэки из
  двух потоков с задокументированным контрактом; буферы datapoint-пути в Impl.
- platform: local_ip_for (UDP-connect) posix+esp-idf; стек httpd 24576
  (переполнение 16КБ поймано gdb на Release).
- mock_ac.py: мок-модуль, stdlib-only чистый python AES-256 (свёрстан с
  pycryptodome); сценарии: 503, no-poll, rekey-every, stale-gap (эмуляция
  'вернувшегося' приложения), fail-pushes (битая подпись), garbage-pushes
  (обрыв блока), break-outbound (исходящий десинк -> модуль ре-кает на
  local_reg, как probe1-3), push-every, fail-first-ke.
- session_runner + test_session_mock.py: 9 сценариев через ctest, включая
  самосинхронизацию CBC и восстановление после исходящего десинка.
- Прибор AP-WC1E: активация <=1с; re-key семантика ИСПРАВЛЕНА по живым
  тестам: re-key при зазоре local_reg >= ~44-50с (не по возрасту сессии!);
  при честном keep-alive 15с сессия стабильна без re-key; PROTOCOL/LEGACY/
  PLAN обновлены; восстановление = тишина >порога + возврат.
- CI: 7/7 x3 (gcc-Rel, gcc-ASan/UBSan, clang); ESP-IDF esp32 build complete.
Ревью под-агентом: 2 круга (стек httpd, залипание состояний, dangling cfg,
физика десинка) — APPROVED.
2026-09-27 10:53:28 +03:00
petr.polezhaev 345fe19ca7 docs: M0, M1 отмечены выполненными в плане библиотеки 2026-09-22 15:46:27 +03:00
petr.polezhaev e91d4605b1 core(M1): ayla-криптография, конверт, JSON (jsmn), HTTP-клиент
- crypto: KDF Ayla (двойной HMAC, suffix 0x30/31/32; app/dev направления),
  AES-256-CBC с непрерывной цепочкой (iv обновляется mbedtls на месте),
  Java-паддинг >=1 NUL; mode-latch против misuse (encrypt|decrypt);
  zeroize ключей при повторном init; векторы из APK (4 сессии × 4 сообщения,
  включая legacy-приём без NUL) — scripts/gen_kdf_vectors.py.
- envelope: pack/unpack {"enc","sign"}; расшифровка (движение цепочки)
  ДО проверки подписи; сравнение подписи в константном времени;
  extract_seq_no — depth-1 сканер без лимита токенов (OOB после escape
  исправлен, регресс-тесты по ASan-репро ревьюера).
- json: Writer (фикс. буфер, стек глубин, escape, ok()=false при
  переполнении) + Doc на jsmn (64 токена, unescape, overflow-guard).
- httpc: блокирующий POST/PUT для local_reg (статус 200-599, дренаж,
  shutdown перед close).
- third_party/jsmn (MIT, JSMN_STATIC).
- CMake: mbedtls системный (/usr/include/mbedtls3) или FetchContent;
  IDF: PRIV_REQUIRES mbedtls.
- CI: 3 конфигурации — gcc-Release, gcc-Debug+ASan/UBSan, clang-Release;
  6/6 тестов стабильно; ESP-IDF esp32 build complete.
Ревью под-агентом: 3 круга (OOB-блокер + тестовые флаки закрыты), APPROVED.
2026-09-22 15:46:18 +03:00
petr.polezhaev b44004627b core(M0): монорепо-каркас — CMake (posix+esp-idf), платслой, лог, мини-httpd
- CMakeLists в корне: ветвление ESP_PLATFORM (idf_component_register,
  lwip/esp_timer/esp_hw_support/pthread) / POSIX (статическая библиотека
  fgl-aircon, C++20, -fno-exceptions -fno-rtti, -Werror).
- src/ayla/platform: сокеты/потоки/CSPRNG/время; posix (getrandom, poll,
  pthread_join) и esp-idf (lwip_select, esp_fill_random, pthread-слой IDF);
  tcp_shutdown/tcp_local_port/thread_join для управляемой остановки.
- src/ayla: log (sink, без printf); мини-httpd/1.1 (keep-alive, Content-Length,
  лимиты заголовков/тела, ephemeral-порт, жизненный цикл с гарантией
  завершения потока: shutdown(active)→join→close).
- tests/ayla: platform (join, loopback+shutdown) и httpd (404, keep-alive,
  обработчик/парсинг, oversize-400, stop при живом соединении, стрим
  заголовков). doctest через FetchContent.
- scripts/ci.sh: сборка+ctest. ESP-IDF v5.5.5 esp32: смоук-сборка с ядром
  как компонентом — Project build complete.
Ревью под-агентом: 3 круга, все блокеры (жизненный цикл httpd) закрыты, APPROVED.
2026-09-22 00:27:33 +03:00
petr.polezhaev b21817ab9f docs: корректировки планов — монорепо, слои ayla/aircon, конверсии, приёмка
- PLAN_CORE: монорепозиторий (CMakeLists в корне; components/fglair,
  custom_components/fglair, include/fgl-aircon, src/{ayla,aircon},
  src/ayla/platform); логическое разделение ay­la (протокол+цикл) /
  aircon (конверсии+шаблоны+API); тесты зеркалят слои (tests/{ayla,aircon});
  конверсии: шаблон / линейные коэффициенты / функция-указатель
  (лямбды ESPHome); оценка httpd/json (jsmn вендор, свой мини-httpd на
  BSD-сокетах, conan не нужен); README библиотеки в M4.
- PLAN_ESPHOME: host вместо ip_address (DNS + Ayla-mDNS :10276),
  секреты в примерах, кастомные конверсии через !lambda, advanced-пример
  (триггеры режимов + LVGL с пропусками), README-план, скрипт приёмки
  (aioesphomeapi + HA REST).
- PLAN_HOME_ASSISTANT: шаг config flow с превью рассчитанных значений
  шаблона (через cffi в C-ядро, без дублей), README с HACS-инструкцией
  и заглушками под скриншоты с описаниями, приёмка (long-lived token).
- Убраны реальные dsn/ip/lanip_key/key_id из примеров; probe_mdns.py
  принимает DSN аргументом.
2026-09-21 13:20:29 +03:00
petr.polezhaev 790cee3c78 refactor: legacy и apk перенесены в docs/, дедупликация исходников legacy
- docs/legacy/: рабочая раскладка изменённого форка (main.py + пакет
  aircon/); плоские дубликаты .py удалены, вложенный .git клона удалён
  (это изменённая копия, а не чистый клон), __pycache__ и .gitignore
  апстрима убраны. Локальный config_kata.json не версионируется
  (содержит lanip_key устройства).
- docs/apk/: манифест APK; бинарники *.apk и icon.png не версионируются.
- Обновлены пути в документации и tools/.
2026-09-17 19:48:55 +03:00
petr.polezhaev 024b290b89 docs: реконструкция LAN-протокола FGLair, анализ legacy, планы fglair-core/HA/ESPHome
- PROTOCOL.md: полная спецификация (KDF, envelope/CBC-цепочка, local_reg,
  key exchange, commands.json 206/200, datapoint push, тайминги, таблицы
  свойств шаблонов A/B/F, облачный provisioning). Факты из APK помечены
  [APK], проверенные живыми экспериментами на AP-WC1E — [ПРОВЕРЕНО НА
  ПРИБОРЕ]: mDNS только :10276; лимит 2 LAN-сессии (3-я -> 503);
  принудительный re-key при возрасте сессии >= ~44с (единственный механизм
  самолечения десинхрона — 400/401 модуль игнорирует); записи не
  эхируются; delete_session освобождает слот.
- LEGACY_ANALYSIS.md: причины рассинхрона (keep-alive 1200с вместо 10-15с
  + seq_no-фильтр) и перегрузки модуля; требования к новой реализации.
- PLAN_CORE_LIBRARY.md: план C++20-библиотеки fglair-core (Linux + ESP-IDF),
  ключ lanip_key считается статичным, ротация — только ошибка + ручной
  перепровижининг.
- PLAN_HOME_ASSISTANT.md: pyfglair (cffi wheel) + custom component,
  облачный provisioning только в config flow, ключ виден в диагностике
  для копирования в ESPHome.
- PLAN_ESPHOME.md: external component, только ESP-IDF framework.
- tools/probe_reference.py: эталонный клиент протокола (проверен на
  приборе end-to-end); tools/probe_mdns.py — mDNS-проба.
- legacy/: снимок скрипта (апстрим gyro-labs/AirCon, вложенный клон не
  версионируется); apk/*.apk исключены из версионирования.
2026-09-17 19:44:49 +03:00
107 changed files with 60 additions and 12524 deletions
-135
View File
@@ -1,135 +0,0 @@
# Сборка и публикация 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
@@ -1,19 +0,0 @@
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
@@ -1,21 +0,0 @@
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
+1 -8
View File
@@ -4,19 +4,12 @@ docs/apk/*.apk
# Python # Python
__pycache__/ __pycache__/
*.pyc *.pyc
*.egg-info/ .venv/
dist/
.venv*/
venv/ venv/
# Локальный конфиг устройства (содержит lanip_key) # Локальный конфиг устройства (содержит lanip_key)
config_kata.json
docs/legacy/config_kata.json docs/legacy/config_kata.json
# Сборка # Сборка
build/ build/
build-*/ build-*/
# Coredump-файлы отладки
core
core.*
+16 -62
View File
@@ -15,9 +15,6 @@ if(ESP_PLATFORM)
"src/ayla/httpd.cpp" "src/ayla/httpd.cpp"
"src/ayla/log.cpp" "src/ayla/log.cpp"
"src/ayla/session.cpp" "src/ayla/session.cpp"
"src/aircon/c_api.cpp"
"src/aircon/session.cpp"
"src/aircon/tables.cpp"
"src/ayla/platform/esp-idf/platform.cpp" "src/ayla/platform/esp-idf/platform.cpp"
INCLUDE_DIRS INCLUDE_DIRS
"include" "include"
@@ -43,8 +40,6 @@ else()
project(fgl-aircon VERSION 0.1.0 LANGUAGES CXX) project(fgl-aircon VERSION 0.1.0 LANGUAGES CXX)
option(FGL_BUILD_TESTS "Build tests" ON) option(FGL_BUILD_TESTS "Build tests" ON)
option(FGL_BUILD_EXAMPLES "Build examples (fglctl)" ON)
option(FGL_BUILD_SHARED "Build shared library (pyfglair/cffi)" ON)
option(FGL_BUNDLED_MBEDTLS "Fetch and build Mbed TLS instead of system" OFF) option(FGL_BUNDLED_MBEDTLS "Fetch and build Mbed TLS instead of system" OFF)
# ---- mbedtls ---- # ---- mbedtls ----
@@ -72,80 +67,39 @@ else()
set(USE_SHARED_MBEDTLS_LIBRARY OFF CACHE BOOL "" FORCE) set(USE_SHARED_MBEDTLS_LIBRARY OFF CACHE BOOL "" FORCE)
set(USE_STATIC_MBEDTLS_LIBRARY ON CACHE BOOL "" FORCE) set(USE_STATIC_MBEDTLS_LIBRARY ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(mbedtls) FetchContent_MakeAvailable(mbedtls)
set(FGL_MBEDTLS_TARGET mbedcrypto) set(FGL_MBEDTLS_TARGET mbedcrypto STATIC)
set(FGL_MBEDTLS_INCLUDE_DIR "") set(FGL_MBEDTLS_INCLUDE_DIR "")
# Статический mbedcrypto нужен и для shared-библиотеки (pyfglair).
set_target_properties(mbedcrypto PROPERTIES POSITION_INDEPENDENT_CODE ON)
else() else()
set(FGL_MBEDTLS_TARGET "${MBEDTLS_CRYPTO_LIB}") set(FGL_MBEDTLS_TARGET "${MBEDTLS_CRYPTO_LIB}")
set(FGL_MBEDTLS_INCLUDE_DIR "${MBEDTLS_INCLUDE_DIR}") set(FGL_MBEDTLS_INCLUDE_DIR "${MBEDTLS_INCLUDE_DIR}")
endif() endif()
set(FGL_SOURCES add_library(fgl-aircon STATIC
src/ayla/crypto.cpp src/ayla/crypto.cpp
src/ayla/envelope.cpp src/ayla/envelope.cpp
src/ayla/httpc.cpp src/ayla/httpc.cpp
src/ayla/httpd.cpp src/ayla/httpd.cpp
src/ayla/log.cpp src/ayla/log.cpp
src/ayla/session.cpp src/ayla/session.cpp
src/aircon/c_api.cpp
src/aircon/session.cpp
src/aircon/tables.cpp
src/ayla/platform/posix/platform.cpp src/ayla/platform/posix/platform.cpp
) )
target_include_directories(fgl-aircon
function(fgl_configure_target tgt) PUBLIC
target_include_directories(${tgt} "${CMAKE_CURRENT_SOURCE_DIR}/include"
PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}"
"${CMAKE_CURRENT_SOURCE_DIR}/include" "${FGL_MBEDTLS_INCLUDE_DIR}"
"${CMAKE_CURRENT_SOURCE_DIR}" PRIVATE
"${FGL_MBEDTLS_INCLUDE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/src"
PRIVATE )
"${CMAKE_CURRENT_SOURCE_DIR}/src" target_link_libraries(fgl-aircon PUBLIC ${FGL_MBEDTLS_TARGET})
) target_compile_features(fgl-aircon PUBLIC cxx_std_20)
target_link_libraries(${tgt} PUBLIC ${FGL_MBEDTLS_TARGET}) target_compile_options(fgl-aircon PRIVATE
target_compile_features(${tgt} PUBLIC cxx_std_20) -Wall -Wextra -Werror
target_compile_options(${tgt} PRIVATE -fno-exceptions -fno-rtti
-Wall -Wextra -Werror )
-fno-exceptions -fno-rtti
)
endfunction()
add_library(fgl-aircon STATIC ${FGL_SOURCES})
fgl_configure_target(fgl-aircon)
# Shared-библиотека для cffi/pyfglair (Home Assistant). Имя то же
# (libfgl-aircon.so), чтобы dlopen по имени был предсказуем.
if(FGL_BUILD_SHARED)
add_library(fgl-aircon-shared SHARED ${FGL_SOURCES})
fgl_configure_target(fgl-aircon-shared)
set_target_properties(fgl-aircon-shared PROPERTIES
OUTPUT_NAME fgl-aircon
POSITION_INDEPENDENT_CODE ON
VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR}
)
# Минимум внешних зависимостей у wheel: C++-рантайм встраиваем, остаются
# libc/libm (на musl — musl). Иначе Alpine/HA OS может не иметь
# libstdc++.so.6/libgcc_s.so.1 нужной сборки.
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
target_link_options(fgl-aircon-shared PRIVATE
-static-libstdc++ -static-libgcc
)
endif()
endif()
if(FGL_BUILD_TESTS) if(FGL_BUILD_TESTS)
enable_testing() enable_testing()
add_subdirectory(tests) add_subdirectory(tests)
endif() endif()
if(FGL_BUILD_EXAMPLES)
add_executable(fglctl examples/cli/fglctl.cpp)
target_compile_features(fglctl PRIVATE cxx_std_20)
target_link_libraries(fglctl PRIVATE fgl-aircon)
target_include_directories(fglctl PRIVATE "${CMAKE_SOURCE_DIR}/src")
target_compile_options(fglctl PRIVATE -Wall -Wextra -Werror
-fno-exceptions -fno-rtti)
endif()
endif() endif()
-26
View File
@@ -1,26 +0,0 @@
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`).
-5
View File
@@ -1,5 +0,0 @@
include CMakeLists.txt LICENSE README.md pyproject.toml setup.py
recursive-include include *.h *.hpp
recursive-include src *.cpp *.hpp
recursive-include third_party *
recursive-include pyfglair *.py py.typed
-131
View File
@@ -1,131 +0,0 @@
# fglair-core
Local-control library for Fujitsu General air conditioners (FGLair / Ayla LAN
protocol). One C++20 codebase — Linux and ESP-IDF (ESP32) — with the full
protocol reconstructed from the official APK and verified on real hardware.
See [`docs/PROTOCOL.md`](docs/PROTOCOL.md) for the protocol reference and
[`docs/PLAN_CORE_LIBRARY.md`](docs/PLAN_CORE_LIBRARY.md) for the design.
## Layout
```
include/fgl-aircon/ public API (types, templates, session, C API for cffi)
src/ayla/ Ayla LAN protocol: crypto, envelope, HTTP, session loop
src/aircon/ property templates A/B/F, conversions, API implementation
pyfglair/ Python bindings (cffi) for Home Assistant (docs/PLAN_HOME_ASSISTANT.md)
components/ ESPHome external component (planned, docs/PLAN_ESPHOME.md)
custom_components/ Home Assistant custom component (docs/PLAN_HOME_ASSISTANT.md)
tests/ ayla (protocol), aircon (conversions), pyfglair, tools
examples/cli/ fglctl — sample CLI
tools/ fglair-discover, protocol probes
```
## Build (Linux / POSIX)
Requirements: CMake ≥ 3.16, Ninja (or Make), g++/clang with C++20, Mbed TLS
(system `libmbedcrypto`; otherwise it is fetched and built automatically).
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
# CLI:
./build/fglctl <config.json> status
```
## Build (ESP-IDF component)
The repository root is an ESP-IDF component. Put it on `EXTRA_COMPONENT_DIRS`
(or copy into your project's `components/`):
```cmake
set(EXTRA_COMPONENT_DIRS "/path/to/aircon")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(my_app)
```
```cmake
# main/CMakeLists.txt
idf_component_register(SRCS "main.cpp" PRIV_REQUIRES aircon)
```
```cpp
#include "fgl-aircon/session.hpp"
extern "C" void app_main() {
fgl::aircon::Config cfg{};
cfg.host = "ac.local"; // DNS name or IP
cfg.dsn = "..."; // from fglair-discover
cfg.lanip_key = "..."; // from fglair-discover (static per module)
cfg.lanip_key_id = 12345;
cfg.tmpl = fgl::aircon::template_detect("AP-WC1E");
fgl::aircon::Callbacks cbs{};
auto* s = fgl::aircon::Session::create(cfg, cbs);
s->start();
}
```
Tested with ESP-IDF v5.5 (esp32); no Arduino support.
## Tests
```sh
./scripts/ci.sh # Release + ASan/UBSan + clang builds, all tests
# or:
ctest --test-dir build --output-on-failure
```
`tests/ayla` covers the protocol (KDF vectors from the APK, envelope, HTTP,
session scenarios against a Python mock of the module).
`tests/aircon` covers template tables and conversions.
No hardware needed.
## pyfglair (Home Assistant)
`pyfglair/` — python-пакет (cffi, ABI-режим) поверх того же C-ядра: сессия с
доставкой событий в asyncio, интроспекция шаблонов и облачный discovery.
```sh
pip install . # wheel: cmake собирает libfgl-aircon.so
python -m pyfglair discover --region eu --email you@example.com
python -m pyfglair monitor --config config_home.json --duration 60
```
Тесты (без железа: mock-модуль + мок-облако):
`scripts/py-ci.sh --setup && scripts/py-ci.sh` (или `pytest tests/pyfglair`).
## Home Assistant
Компонент `custom_components/fglair/` (HACS custom repository) — climate,
switch/select/sensor/binary_sensor, config flow с превью шаблона, repair
для несовпадения ключа и диагностика. Установка: HACS (нужно публичное
GitHub-зеркало) или ручное копирование `custom_components/fglair/`.
Инструкция: [`custom_components/fglair/README.md`](custom_components/fglair/README.md);
приёмка на живом стенде — `tests/acceptance/test_esphome_ha.py`.
## Getting the key (`lanip_key`)
The key is static (baked into the AC module; only served by the Ayla cloud):
```sh
tools/fglair-discover --region eu --email you@example.com
# prints one config JSON per device, or a secrets block:
tools/fglair-discover --region eu --email you@example.com --format esphome-secrets
```
## fglctl
```sh
fglctl config.json status # session check + cached temperature
fglctl config.json monitor 60 # follow all events for 60 s
fglctl config.json get OperationMode FanSpeed
fglctl config.json set AdjustTemperature 220 # 22.0 °C (unit 0.1 °C)
```
Values are in API units: temperatures in 0.1 °C, `DisplayTemperature`
converted from the module's 0.01 °C+5000 format.
## License / provenance
MIT — see [`LICENSE`](LICENSE). Protocol documentation and code reconstructed
from the FGLair 3.4.3 APK for interoperability; vendored `third_party/jsmn`
is MIT.
BIN
View File
Binary file not shown.
-194
View File
@@ -1,194 +0,0 @@
# FGLair — интеграция Home Assistant
Локальное управление кондиционерами Fujitsu General (FGLair / Ayla LAN)
без облака в рантайме. Вся протокольная логика — в C++-ядре
[`fgl-aircon`](../../README.md) через python-пакет `pyfglair` (cffi).
> Скриншоты `screenshots/step-N.png` — заглушки-плейсхолдеры: владелец
> заменит их реальными снимками, описания «что должно быть видно» даны
> рядом с каждым шагом.
## Требования
* Home Assistant ≥ 2025.1 (Linux x86_64/aarch64).
* Пакет `pyfglair`. Манифест ставит его из PyPI; при внутренней установке
(Gitea, без публичного PyPI) поставьте вручную из PyPI-реестра пакетов
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-сессии**:
телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503.
## Установка
### Вариант A: HACS (нужно GitHub-зеркало)
HACS работает только с публичными репозиториями на **GitHub**; GitLab/Gitea
(включая текущий хостинг проекта) напрямую не поддерживаются. Зеркалируйте
репозиторий на GitHub и добавьте зеркало в HACS:
1. HACS → ⋮ → **Custom repositories** → URL GitHub-зеркала, категория
**Integration** → **Add**.
![шаг 1](screenshots/step-1.png)
*Должно быть видно: диалог добавления custom repository с заполненным URL
и выбранной категорией Integration.*
2. Найдите **FGLair** в HACS → **Download** → перезапустите Home Assistant.
![шаг 2](screenshots/step-2.png)
*Должно быть видно: страница загрузки интеграции с кнопкой Download и
версией.*
### Вариант B: вручную (любой хостинг)
Скопируйте каталог `custom_components/fglair/` в
`<config>/custom_components/fglair/` вашего HA и перезапустите Home
Assistant. Убедитесь, что `pyfglair` установлен в python-окружение HA
(см. «Требования»).
## Добавление устройства
3. Settings → Devices & Services → **Add Integration** → «FGLair».
![шаг 3](screenshots/step-3.png)
*Должно быть видно: диалог поиска интеграции с введённым «FGLair» и
выделенным результатом.*
4. **Вход в облако**: e-mail/пароль FGLair и регион. Облако
используется один раз — получить статический LAN-ключ модуля.
![шаг 4](screenshots/step-4.png)
*Должно быть видно: форма с заполненными регионом EU и e-mail, пароль
скрыт.*
5. **Выбор устройства**: список найденных кондиционеров (имя, модель, IP).
![шаг 5](screenshots/step-5.png)
*Должно быть видно: список с одним устройством (имя, модель, host).*
6. **Три страницы настройки** (все значения уже подставлены по данным
устройства — можно просто нажимать «Далее»):
1. **Шаблон модуля** — A/B/F с пояснениями; определяется по модели
модуля, а для неизвестных моделей — проверкой свойств. Рядом
показаны прочитанные с устройства значения.
2. **Возможности прибора** — тумблеры по «человекочитаемым» фичам
(режимы, скорости, swing, пресеты, флаги, заслонки) с описаниями;
включены/выключены по `device_capabilities` и ответам устройства.
Сущности создаются только для включённых возможностей.
3. **Уставка** — диапазон/шаг (у A/F 16–30 °C шаг 0.5, у B шаг 1.0) и,
при необходимости, ручная конверсия коэффициентами.
![шаг 6](screenshots/step-6.png)
*Должно быть видно: страница «Возможности прибора» с тумблерами и
описаниями, часть выключена по данным устройства.*
7. **Готово**: карточка устройства с сущностями climate/switch/select/
sensor/binary_sensor.
![шаг 7](screenshots/step-7.png)
*Должно быть видно: страница устройства с созданными сущностями.*
## Где взять ключ для ESPHome
Download diagnostics на странице устройства показывает `dsn`,
`lanip_key_id`, `host` и **маску** ключа. Полный ключ (для
`secrets.yaml` ESPHome) получите явным действием:
* **Настройка** (шестерёнка → Configure) на карточке интеграции: поле
«LAN IP key» содержит полный ключ — скопируйте его (там же можно
заменить ключ без облака); либо
* **Настроить заново** (Reconfigure) — повторный вход в облако FGLair
обновит ключ; либо
* `python -m pyfglair discover --region eu --email you@example.com
--format esphome-secrets` на хосте HA.
![шаг 8](screenshots/step-8.png)
*Должно быть видно: страница Diagnostics с полями dsn/lanip_key_id/host
и маской lanip_key.*
Альтернатива без облака — ручной ввод параметров (host, dsn, lanip_key,
lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl`.
## Сущности
| Платформа | Что создаётся |
|---|---|
| climate | режим (off/cool/dry/fan/heat/auto), скорость, swing (верт./гориз.), preset ECO/BOOST, уставка, текущая температура |
| switch | economy, powerful, coil dry, min heat, outdoor low noise, human det auto save, Wi-Fi LED, indoor fan control (по шаблону и capabilities) |
| select | положение вертикальной/горизонтальной заслонки (0…N−1) |
| sensor | температура в помещении, код ошибки, диагностическое состояние связи |
| binary_sensor | connectivity + флаги op_status (defrost, maintenance, oil recovery, pump down, check operation, …) |
Список зависит от шаблона устройства (A/B/F), маски `device_capabilities`
и выбранных в мастере возможностей: то, что прибор не умеет или что
отключено, не создаётся. У каждой сущности есть краткое описание в
атрибуте `description` (карточка сущности → «Атрибуты»): HA не
поддерживает нативные тултипы, поэтому описание видно там.
## Диагностика и известные ситуации
* **`OSError: cannot load library ... ld-linux-x86-64.so.2: No such file
or directory`** — на HA OS (Alpine/musl) установился glibc-wheel. Нужна
musllinux-сборка `pyfglair`: переустановите пакет из индекса (см.
[`docs/RELEASE_HA.md`](../../docs/RELEASE_HA.md) §1.3) и перезапустите HA.
* **«Не удалось загрузить мастер настройки: Invalid handler specified»** —
HA не смог импортировать компонент (обычно нет пакета `pyfglair`).
Проверьте лог HA:
```sh
grep -iE "fglair|pyfglair" /config/home-assistant.log | tail -20
# ожидаемая причина: ModuleNotFoundError: No module named 'pyfglair'
```
Установите пакет в то же python-окружение, в котором работает HA, и
**полностью перезапустите** HA (не только интеграцию):
```sh
# HA OS / Supervised (SSH add-on с доступом к docker):
docker exec homeassistant python -m pip install --no-deps \
--index-url https://<user>:<token>@git.ratigorsk-12.ru/api/packages/<owner>/pypi/simple \
pyfglair
docker restart homeassistant
# HA Core в venv:
/srv/homeassistant/bin/python -m pip install --no-deps --index-url ... pyfglair
systemctl restart home-assistant@homeassistant
```
Проверка: `docker exec homeassistant python -c "import pyfglair; print(pyfglair.__version__)"` — должно печатать версию без ошибок.
Другие причины той же ошибки: компонент лежит не в
`<config>/custom_components/fglair/` (лишняя вложенность каталогов, нет
`__init__.py`), либо HA не перезапускался после копирования.
* **503 / оба слота заняты** — в LAN уже две сессии (телефон + ESPHome).
Освободите один слот и перезапустите интеграцию.
* **key_error** — LAN-ключ не совпал (`key_id` изменился). В HA появится
Repair; нажмите **Настроить заново** и повторите вход в облако (или
вставьте новый `config_*.json`).
* **unavailable** — модуль недоступен (сеть/питание) или нет слотов;
диагностический сенсор «Состояние связи» показывает детали
(idle/registering/online/recovering/offline/key_error).
* Записи не эхо-подтверждаются модулем: состояние обновляется
оптимистично и подтверждается push-обновлением.
## Приёмка на живом стенде
`tests/acceptance/test_esphome_ha.py` (stdlib) прогоняет матрицу изменений
через REST API HA по long-lived token, с возвратом к исходному состоянию:
```sh
python tests/acceptance/test_esphome_ha.py quick \
--ha-url http://homeassistant.local:8123 --ha-token TOKEN \
--entity climate.ac
python tests/acceptance/test_esphome_ha.py long \
--ha-url ... --ha-token ... --entity climate.ac \
--hours 24 --interval 3600 --report acceptance.csv
```
-121
View File
@@ -1,121 +0,0 @@
"""FGLair (Fujitsu General) — локальное управление через LAN-протокол Ayla."""
from __future__ import annotations
import logging
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
import pyfglair
from .const import CONF_DSN, CONF_FEATURES, DOMAIN
from .coordinator import FglairCoordinator, FglairRuntime, key_issue_id
from .features import FeatureSet, LiveValues
from .fglair_client import FglairClient
from .overrides import ConversionSet
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.CLIMATE,
Platform.SENSOR,
Platform.BINARY_SENSOR,
Platform.SWITCH,
Platform.SELECT,
]
_LOG_LEVELS = {
0: logging.DEBUG,
1: logging.INFO,
2: logging.WARNING,
3: logging.ERROR,
}
DATA_CLIENTS = f"{DOMAIN}_clients"
DATA_PENDING = f"{DOMAIN}_pending"
def _log_handler(level: int, message: str) -> None:
_LOGGER.log(_LOG_LEVELS.get(level, logging.DEBUG), "pyfglair: %s", message)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
if DOMAIN not in config:
return True
if not isinstance(config[DOMAIN], dict):
_LOGGER.warning(
"fglair: ожидался словарь параметров устройства, получено %s",
type(config[DOMAIN]).__name__,
)
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=config[DOMAIN],
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
clients: dict[str, FglairClient] = hass.data.setdefault(DATA_CLIENTS, {})
hass.data[DATA_PENDING] = hass.data.get(DATA_PENDING, 0) + 1
client = FglairClient(hass, entry.data)
conversions = ConversionSet.from_entry(entry.data)
# Координатор создаётся (и подписывается) до старта сессии, чтобы не
# пропустить первое ONLINE.
coordinator = FglairCoordinator(hass, client, conversions, entry.entry_id)
try:
await client.async_start()
await coordinator.async_config_entry_first_refresh()
await coordinator.async_sync_now()
except Exception:
await coordinator.async_shutdown()
await client.async_stop()
raise
finally:
hass.data[DATA_PENDING] -= 1
features = FeatureSet(
explicit=entry.data.get(CONF_FEATURES),
values=LiveValues(coordinator.client.cached),
)
coordinator.features = features
clients[entry.entry_id] = client
entry.runtime_data = FglairRuntime(client, coordinator, conversions, features)
if len(clients) == 1:
pyfglair.set_log_handler(_log_handler)
pyfglair.set_log_level(1)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
runtime: FglairRuntime = entry.runtime_data
unloaded = await hass.config_entries.async_unload_platforms(
entry, PLATFORMS
)
if not unloaded:
return False
await runtime.coordinator.async_shutdown()
runtime.coordinator.async_remove_issue()
await runtime.client.async_stop()
clients: dict[str, FglairClient] = hass.data.get(DATA_CLIENTS, {})
clients.pop(entry.entry_id, None)
if not clients and not hass.data.get(DATA_PENDING, 0):
await hass.async_add_executor_job(pyfglair.set_log_handler, None)
return unloaded
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
ir.async_delete_issue(
hass, DOMAIN, key_issue_id(str(entry.data.get(CONF_DSN, "")))
)
-64
View File
@@ -1,64 +0,0 @@
"""Binary sensors: connectivity и флаги op_status (PROTOCOL §8.4)."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import EntityCategory
from pyfglair import Prop, State
from pyfglair.templates import template_info
from .const import OP_STATUS_BITS
from .coordinator import FglairCoordinator, FglairRuntime
from .entity import FglairEntity
class FglairConnectivity(FglairEntity, BinarySensorEntity):
_attr_translation_key = "connectivity"
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
def __init__(self, coordinator: FglairCoordinator) -> None:
super().__init__(coordinator, "connectivity", "connectivity")
@property
def available(self) -> bool:
return True
@property
def is_on(self) -> bool:
return self.coordinator.data.state == State.ONLINE
class FglairStatusFlag(FglairEntity, BinarySensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self, coordinator: FglairCoordinator, key: str, bit: int
) -> None:
super().__init__(coordinator, f"op_status_{key}", key)
self._bit = bit
@property
def is_on(self) -> bool:
status = self.raw_value(Prop.OP_STATUS)
if status is None:
return False
return bool(status & (1 << self._bit))
async def async_setup_entry(hass, entry, async_add_entities) -> None:
runtime: FglairRuntime = entry.runtime_data
coordinator = runtime.coordinator
props = {
info.prop for info in template_info(runtime.client.template)
}
entities = [FglairConnectivity(coordinator)]
if Prop.OP_STATUS in props:
entities.extend(
FglairStatusFlag(coordinator, key, bit)
for key, bit in OP_STATUS_BITS.items()
)
async_add_entities(entities)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

-268
View File
@@ -1,268 +0,0 @@
"""Climate-сущность FGLair: режим, уставка, fan, swing, preset ECO/BOOST."""
from __future__ import annotations
from typing import Any, Optional
from homeassistant.components.climate import (
ATTR_TEMPERATURE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import callback
from pyfglair import Prop
from pyfglair.templates import prop_info
from .const import (
FAN_MODE_TO_VALUE,
FAN_VALUE_TO_MODE,
HVAC_MODE_TO_VALUE,
HVAC_VALUE_TO_MODE,
OPERATION_MODE_OFF,
OPERATION_MODE_ON,
PRESET_BOOST,
PRESET_ECO,
PRESET_PROP,
SWING_MODES,
)
from .coordinator import FglairCoordinator, FglairRuntime
from .entity import FglairEntity
async def async_setup_entry(hass, entry, async_add_entities) -> None:
runtime: FglairRuntime = entry.runtime_data
async_add_entities([FglairClimate(runtime.coordinator)])
MODE_FEATURE = {
HVACMode.AUTO: "mode_auto",
HVACMode.COOL: "mode_cool",
HVACMode.DRY: "mode_dry",
HVACMode.FAN_ONLY: "mode_fan",
HVACMode.HEAT: "mode_heat",
}
FAN_FEATURE = {mode: f"fan_{mode}" for mode in FAN_VALUE_TO_MODE.values()}
PRESET_FEATURE = {PRESET_ECO: "economy_mode", PRESET_BOOST: "powerful_mode"}
class FglairClimate(FglairEntity, ClimateEntity):
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(self, coordinator: FglairCoordinator) -> None:
# translation_key не нужен: имя = имя устройства (has_entity_name).
super().__init__(coordinator, "climate", None, description_key="climate")
self._last_hvac_mode: Optional[HVACMode] = None
# -- свойства шаблона --------------------------------------------------
def _in_template(self, prop: Prop) -> bool:
return prop_info(self.coordinator.client.template, prop) is not None
def _hvac_modes(self) -> list[HVACMode]:
modes: list[HVACMode] = [HVACMode.OFF]
for mode, key in MODE_FEATURE.items():
if self.feature_enabled(key):
modes.append(mode)
return modes
def _fan_modes(self) -> Optional[list[str]]:
if not self._in_template(Prop.FAN_SPEED):
return None
modes = [
mode for mode, key in FAN_FEATURE.items()
if self.feature_enabled(key)
]
return modes or None
def _preset_modes(self) -> Optional[list[str]]:
presets = [
preset
for preset, key in PRESET_FEATURE.items()
if self._in_template(PRESET_PROP[preset])
and self.feature_enabled(key)
]
return presets or None
def _vertical_swing_supported(self) -> bool:
return self._in_template(Prop.AF_VERTICAL_SWING) and (
self.feature_enabled("swing_vertical")
)
def _horizontal_swing_supported(self) -> bool:
return self._in_template(Prop.AF_HORIZONTAL_SWING) and (
self.feature_enabled("swing_horizontal")
)
# -- ClimateEntity ----------------------------------------------------
@property
def supported_features(self) -> ClimateEntityFeature:
features = ClimateEntityFeature(0)
if self._in_template(Prop.ADJUST_TEMPERATURE):
features |= ClimateEntityFeature.TARGET_TEMPERATURE
if self._fan_modes():
features |= ClimateEntityFeature.FAN_MODE
if self._preset_modes():
features |= ClimateEntityFeature.PRESET_MODE
if self._vertical_swing_supported():
features |= ClimateEntityFeature.SWING_MODE
if self._horizontal_swing_supported():
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
return features
@property
def hvac_modes(self) -> list[HVACMode]:
return self._hvac_modes()
@property
def hvac_mode(self) -> Optional[HVACMode]:
value = self.raw_value(Prop.OPERATION_MODE)
if value is None:
return None
if value == OPERATION_MODE_OFF:
return HVACMode.OFF
if value == OPERATION_MODE_ON:
# Режим включения (1) не несёт информации о режиме: до первого
# явного режима состояние неизвестно.
return self._last_hvac_mode
return HVAC_VALUE_TO_MODE.get(value)
@property
def target_temperature(self) -> Optional[float]:
display = self.display_value(Prop.ADJUST_TEMPERATURE)
return display / 10 if display is not None else None
@property
def target_temperature_step(self) -> Optional[float]:
return self.coordinator.conversions.step_tenths / 10
@property
def min_temp(self) -> float:
return self.coordinator.conversions.temperature_range()[0] / 10
@property
def max_temp(self) -> float:
return self.coordinator.conversions.temperature_range()[1] / 10
@property
def current_temperature(self) -> Optional[float]:
display = self.display_value(Prop.DISPLAY_TEMPERATURE)
return display / 10 if display is not None else None
@property
def fan_mode(self) -> Optional[str]:
value = self.raw_value(Prop.FAN_SPEED)
return FAN_VALUE_TO_MODE.get(value) if value is not None else None
@property
def fan_modes(self) -> Optional[list[str]]:
return self._fan_modes()
@property
def preset_mode(self) -> Optional[str]:
if self.raw_value(PRESET_PROP[PRESET_ECO]) == 1:
return PRESET_ECO
if self.raw_value(PRESET_PROP[PRESET_BOOST]) == 1:
return PRESET_BOOST
return None
@property
def preset_modes(self) -> Optional[list[str]]:
return self._preset_modes()
@property
def swing_mode(self) -> Optional[str]:
if not self._vertical_swing_supported():
return None
value = self.raw_value(Prop.AF_VERTICAL_SWING)
return None if value is None else ("on" if value else "off")
@property
def swing_modes(self) -> Optional[list[str]]:
return SWING_MODES if self._vertical_swing_supported() else None
@property
def swing_horizontal_mode(self) -> Optional[str]:
if not self._horizontal_swing_supported():
return None
value = self.raw_value(Prop.AF_HORIZONTAL_SWING)
return None if value is None else ("on" if value else "off")
@property
def swing_horizontal_modes(self) -> Optional[list[str]]:
return SWING_MODES if self._horizontal_swing_supported() else None
# -- команды ----------------------------------------------------------
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
await self.coordinator.async_write(
[(Prop.OPERATION_MODE, OPERATION_MODE_OFF)]
)
return
value = HVAC_MODE_TO_VALUE.get(hvac_mode)
if value is None:
return
self._last_hvac_mode = hvac_mode
await self.coordinator.async_write([(Prop.OPERATION_MODE, value)])
async def async_turn_on(self) -> None:
await self.coordinator.async_write(
[(Prop.OPERATION_MODE, OPERATION_MODE_ON)]
)
async def async_turn_off(self) -> None:
await self.coordinator.async_write(
[(Prop.OPERATION_MODE, OPERATION_MODE_OFF)]
)
async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
display = int(round(temperature * 10))
display = self.coordinator.conversions.clamp(
Prop.ADJUST_TEMPERATURE, display
)
api_value = self.coordinator.conversions.from_display(
Prop.ADJUST_TEMPERATURE, display
)
await self.coordinator.async_write(
[(Prop.ADJUST_TEMPERATURE, api_value)]
)
async def async_set_fan_mode(self, fan_mode: str) -> None:
value = FAN_MODE_TO_VALUE.get(fan_mode)
if value is None:
return
await self.coordinator.async_write([(Prop.FAN_SPEED, value)])
async def async_set_preset_mode(self, preset_mode: str) -> None:
eco = 1 if preset_mode == PRESET_ECO else 0
boost = 1 if preset_mode == PRESET_BOOST else 0
updates = [(prop, value) for prop, value in (
(PRESET_PROP[PRESET_ECO], eco),
(PRESET_PROP[PRESET_BOOST], boost),
) if self._in_template(prop)]
await self.coordinator.async_write(updates)
async def async_set_swing_mode(self, swing_mode: str) -> None:
await self.coordinator.async_write(
[(Prop.AF_VERTICAL_SWING, 1 if swing_mode == "on" else 0)]
)
async def async_set_swing_horizontal_mode(
self, swing_mode: str
) -> None:
await self.coordinator.async_write(
[(Prop.AF_HORIZONTAL_SWING, 1 if swing_mode == "on" else 0)]
)
@callback
def _handle_coordinator_update(self) -> None:
value = self.raw_value(Prop.OPERATION_MODE)
mode = HVAC_VALUE_TO_MODE.get(value) if value is not None else None
if mode is not None:
self._last_hvac_mode = mode
super()._handle_coordinator_update()
-720
View File
@@ -1,720 +0,0 @@
"""Config flow fglair: облако / ручной ввод / импорт config_*.json + пробная
сессия (start → ONLINE ≤ 10 c → базовые свойства)."""
from __future__ import annotations
import json
import logging
from functools import partial
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.helpers import selector
import pyfglair
from pyfglair import Prop, Template
from pyfglair.provision import Device
from pyfglair.templates import detect
from .const import (
CONF_CONFIG_JSON,
CONF_DEVICE_PORT,
CONF_DSN,
CONF_EMAIL,
CONF_FEATURES,
CONF_HOST,
CONF_LANIP_KEY,
CONF_LANIP_KEY_ID,
CONF_LISTEN_PORT,
CONF_MODEL,
CONF_NAME,
CONF_OVERRIDES,
CONF_PASSWORD,
CONF_REGION,
CONF_TEMP_DEN,
CONF_TEMP_MAX,
CONF_TEMP_MIN,
CONF_TEMP_NUM,
CONF_TEMP_OFFSET,
CONF_TEMP_STEP,
CONF_TEMPLATE,
DEFAULT_DEVICE_PORT,
DEFAULT_LISTEN_PORT,
DOMAIN,
FAN_VALUE_TO_MODE,
FEATURES,
REGIONS,
TEMP_MAX_TENTHS,
TEMP_MIN_TENTHS,
TEMP_STEP_TENTHS,
TRIAL_ERRORS,
TRIAL_TIMEOUT,
)
from .features import resolve_features
from .overrides import ConversionSet, LinearOverride
from .trial import TrialResult, probe_template, trial_connect
_LOGGER = logging.getLogger(__name__)
def _password_selector() -> selector.TextSelector:
return selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
)
def _cloud_schema() -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): _password_selector(),
vol.Required(CONF_REGION, default="eu"): vol.In(REGIONS),
}
)
def _manual_schema() -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(
CONF_DEVICE_PORT, default=DEFAULT_DEVICE_PORT
): vol.All(int, vol.Range(min=1, max=65535)),
vol.Required(CONF_DSN): str,
vol.Required(CONF_LANIP_KEY): _password_selector(),
vol.Required(CONF_LANIP_KEY_ID): vol.All(int, vol.Range(min=0)),
vol.Optional(CONF_NAME): str,
vol.Optional(CONF_MODEL): str,
}
)
def _import_schema() -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_CONFIG_JSON): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT, multiline=True
)
)
}
)
def _spec_int(spec: dict[str, Any], key: str, default: int) -> int:
try:
return int(spec.get(key, default))
except (TypeError, ValueError):
return default
def _template_schema(template: Template) -> vol.Schema:
return vol.Schema(
{
vol.Required(CONF_TEMPLATE, default=template.name): vol.In(
["A", "B", "F"]
)
}
)
def _capabilities_schema(defaults: dict[str, bool]) -> vol.Schema:
fields = {}
for key in FEATURES:
fields[vol.Optional(key, default=bool(defaults.get(key)))] = bool
return vol.Schema(fields)
def _limits_schema(data: dict[str, Any]) -> vol.Schema:
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
overrides = data.get(CONF_OVERRIDES)
spec = (
overrides.get(Prop.ADJUST_TEMPERATURE.name.lower(), {})
if isinstance(overrides, dict)
else {}
)
if not isinstance(spec, dict):
spec = {}
temp_min = _spec_int(spec, "min", TEMP_MIN_TENTHS)
temp_max = _spec_int(spec, "max", TEMP_MAX_TENTHS)
try:
step = int(data.get(CONF_TEMP_STEP) or TEMP_STEP_TENTHS[template])
except (TypeError, ValueError):
step = TEMP_STEP_TENTHS[template]
return vol.Schema(
{
vol.Required(CONF_TEMP_MIN, default=temp_min / 10): vol.All(
vol.Coerce(float), vol.Range(min=-10.0, max=45.0)
),
vol.Required(CONF_TEMP_MAX, default=temp_max / 10): vol.All(
vol.Coerce(float), vol.Range(min=-10.0, max=45.0)
),
vol.Required(CONF_TEMP_STEP, default=step / 10): vol.All(
vol.Coerce(float), vol.Range(min=0.1, max=5.0)
),
vol.Required(
CONF_TEMP_NUM, default=_spec_int(spec, "num", 1)
): vol.All(int, vol.Range(min=1, max=1000)),
vol.Required(
CONF_TEMP_DEN, default=_spec_int(spec, "den", 1)
): vol.All(int, vol.Range(min=1, max=1000)),
vol.Required(
CONF_TEMP_OFFSET, default=_spec_int(spec, "offset", 0)
): vol.All(int, vol.Range(min=-1000, max=1000)),
}
)
def _device_values_text(properties: dict[Any, Any]) -> str:
"""Что прочитано с устройства (человекочитаемо) для описаний шагов."""
def raw(prop) -> int | None:
value = properties.get(prop)
return value.int_value if value is not None else None
lines: list[str] = []
mode = raw(Prop.OPERATION_MODE)
if mode is not None:
names = {0: "off", 1: "on", 2: "auto", 3: "cool", 4: "dry",
5: "fan", 6: "heat"}
lines.append(f"- режим: {names.get(mode, '?')} ({mode})")
fan = raw(Prop.FAN_SPEED)
if fan is not None:
lines.append(f"- скорость: {FAN_VALUE_TO_MODE.get(fan, '?')} ({fan})")
current = raw(Prop.DISPLAY_TEMPERATURE)
if current is not None:
lines.append(f"- температура в помещении: {current / 10:.1f} °C")
caps = raw(Prop.DEVICE_CAPABILITIES)
if caps is not None:
lines.append(f"- device_capabilities: 0x{caps:08X}")
vnum = raw(Prop.AF_VERTICAL_NUM_DIR)
hnum = raw(Prop.AF_HORIZONTAL_NUM_DIR)
if vnum is not None or hnum is not None:
lines.append(f"- направлений заслонок: верт. {vnum or 0}, гориз. {hnum or 0}")
return "\n".join(lines) or "- данные не получены"
def _template_name(model: str | None) -> str:
if model:
found = detect(model)
if found is not None:
return found.name
return Template.A.name
def _manual_data(user_input: dict[str, Any]) -> dict[str, Any]:
model = str(user_input.get(CONF_MODEL) or "").strip()
return {
CONF_HOST: str(user_input[CONF_HOST]).strip(),
CONF_DEVICE_PORT: int(
user_input.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)
),
CONF_DSN: str(user_input[CONF_DSN]).strip(),
CONF_LANIP_KEY: str(user_input[CONF_LANIP_KEY]).strip(),
CONF_LANIP_KEY_ID: int(user_input[CONF_LANIP_KEY_ID]),
CONF_NAME: str(user_input.get(CONF_NAME) or "").strip(),
CONF_MODEL: model,
CONF_TEMPLATE: _template_name(model),
}
def _parse_import(source: str | dict[str, Any]) -> dict[str, Any] | None:
"""config_*.json (fglair-discover/fglctl) → данные записи."""
if isinstance(source, str):
try:
obj = json.loads(source)
except ValueError:
return None
else:
obj = source
if not isinstance(obj, dict):
return None
host = obj.get("ip_address") or obj.get("host")
dsn = obj.get("dsn")
lanip_key = obj.get("lanip_key")
lanip_key_id = obj.get("lanip_key_id")
if not host or not dsn or not lanip_key or lanip_key_id is None:
return None
def to_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
key_id = to_int(lanip_key_id)
if key_id is None or key_id < 0:
return None
port = to_int(obj.get("device_port", DEFAULT_DEVICE_PORT))
if port is None or not 1 <= port <= 65535:
port = DEFAULT_DEVICE_PORT
model = str(obj.get("model") or "").strip()
return {
CONF_HOST: str(host).strip(),
CONF_DEVICE_PORT: port,
CONF_DSN: str(dsn).strip(),
CONF_LANIP_KEY: str(lanip_key).strip(),
CONF_LANIP_KEY_ID: key_id,
CONF_NAME: str(obj.get("name") or "").strip(),
CONF_MODEL: model,
CONF_TEMPLATE: _template_name(model),
}
def _run_trial(data: dict[str, Any]) -> tuple[TrialResult, Template, bool]:
"""Пробная сессия; для неизвестной модели — предварительный пробинг A/B/F.
Возвращает (результат, шаблон, был_ли_пробинг).
"""
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
model = str(data.get(CONF_MODEL) or "")
probed = False
if not model or detect(model) is None:
detected, _probe_props, fatal = probe_template(
data[CONF_HOST],
data[CONF_DSN],
data[CONF_LANIP_KEY],
int(data[CONF_LANIP_KEY_ID]),
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)),
listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
timeout=TRIAL_TIMEOUT,
)
probed = True
if fatal is not None:
# Модуль недоступен/нет слотов/ключ — вторую сессию не открываем.
return TrialResult(ok=False, reason=fatal), template, True
if detected is not None:
template = detected
result = trial_connect(
host=data[CONF_HOST],
dsn=data[CONF_DSN],
lanip_key=data[CONF_LANIP_KEY],
lanip_key_id=int(data[CONF_LANIP_KEY_ID]),
device_port=int(data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)),
template=template,
listen_port=int(data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)),
timeout=TRIAL_TIMEOUT,
)
return result, template, probed
class FglairConfigFlow(ConfigFlow, domain=DOMAIN):
"""Настройка интеграции: облако, ручной ввод или импорт."""
VERSION = 1
def __init__(self) -> None:
self._devices: list[Device] = []
self._pending_data: dict[str, Any] = {}
self._trial_properties: dict[Any, Any] = {}
self._trial_answered: set[Prop] = set()
self._resolved_template: Template = Template.A
self._probed: bool = False
self._selected_features: dict[str, bool] = {}
self._reconfigure_entry: ConfigEntry | None = None
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Повторная настройка (в т.ч. смена LAN-ключа после key_error)."""
self._reconfigure_entry = self._get_reconfigure_entry()
return await self.async_step_user()
def _reconfigure_suggestions(self) -> dict[str, Any] | None:
entry = self._reconfigure_entry
if entry is None:
return None
fields = (
CONF_HOST,
CONF_DEVICE_PORT,
CONF_DSN,
CONF_LANIP_KEY,
CONF_LANIP_KEY_ID,
CONF_NAME,
CONF_MODEL,
)
return {key: entry.data[key] for key in fields if key in entry.data}
def _show_form(
self,
step_id: str,
schema: vol.Schema,
errors: dict[str, str],
user_input: dict[str, Any] | None = None,
*,
exclude: tuple[str, ...] = (),
) -> ConfigFlowResult:
"""Показывает форму, возвращая введённые значения (кроме секретов)."""
if user_input:
suggested = {
key: value
for key, value in user_input.items()
if key not in exclude
}
schema = self.add_suggested_values_to_schema(schema, suggested)
return self.async_show_form(
step_id=step_id, data_schema=schema, errors=errors
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
return self.async_show_menu(
step_id="user", menu_options=["cloud", "manual", "import_json"]
)
async def async_step_cloud(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
try:
self._devices = await pyfglair.discover(
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
user_input[CONF_REGION],
)
except pyfglair.ProvisionError as err:
_LOGGER.debug("discover не удался: %s", err)
errors["base"] = {
"auth": "invalid_auth",
"no_devices": "no_devices",
}.get(err.kind, "cannot_connect")
except Exception: # pragma: no cover - защитный путь
_LOGGER.exception("discover: неожиданная ошибка")
errors["base"] = "cannot_connect"
else:
if not self._devices:
errors["base"] = "no_devices"
else:
return await self.async_step_cloud_device()
return self._show_form(
"cloud", _cloud_schema(), errors, user_input,
exclude=(CONF_PASSWORD,),
)
async def async_step_cloud_device(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
device = next(
(d for d in self._devices if d.dsn == user_input[CONF_DSN]), None
)
if device is None:
errors["base"] = "unknown"
else:
data = {
CONF_HOST: device.lan_ip or device.dsn,
CONF_DEVICE_PORT: DEFAULT_DEVICE_PORT,
CONF_DSN: device.dsn,
CONF_LANIP_KEY: device.lanip_key,
CONF_LANIP_KEY_ID: device.lanip_key_id,
CONF_NAME: device.name,
CONF_MODEL: device.model,
CONF_TEMPLATE: _template_name(device.model),
CONF_REGION: device.region,
}
error = await self._async_trial(data)
if error is None:
return await self.async_step_template()
errors["base"] = error
schema = vol.Schema(
{
vol.Required(CONF_DSN): vol.In(
{
d.dsn: f"{d.name} ({d.model or '?'}, {d.lan_ip or '?'})"
for d in self._devices
}
)
}
)
return self._show_form("cloud_device", schema, errors, user_input)
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
data = _manual_data(user_input)
error = await self._async_trial(data)
if error is None:
return await self.async_step_template()
errors["base"] = error
if user_input is None and self._reconfigure_entry is not None:
user_input = self._reconfigure_suggestions()
return self._show_form(
"manual", _manual_schema(), errors, user_input,
exclude=(CONF_LANIP_KEY,),
)
async def async_step_import_json(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
errors: dict[str, str] = {}
if user_input is not None:
data = _parse_import(user_input[CONF_CONFIG_JSON])
if data is None:
errors["base"] = "invalid_import"
else:
error = await self._async_trial(data)
if error is None:
return await self.async_step_template()
errors["base"] = error
return self._show_form("import_json", _import_schema(), errors, user_input)
async def async_step_import(
self, import_data: dict[str, Any]
) -> ConfigFlowResult:
"""Импорт из configuration.yaml / discovery-конфига."""
data = _parse_import(import_data) if isinstance(import_data, dict) else None
if data is None:
return self.async_abort(reason="invalid_import")
error = await self._async_trial(data)
if error is not None:
return self.async_abort(reason=error)
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_template(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Страница 1: шаблон (таблица свойств модуля) с пояснениями."""
data = dict(self._pending_data)
if user_input is not None:
template = Template[str(user_input[CONF_TEMPLATE])]
data[CONF_TEMPLATE] = template.name
self._pending_data = data
return await self.async_step_capabilities()
default = self._resolved_template.name
if self._reconfigure_entry is not None:
default = str(data.get(CONF_TEMPLATE, default))
placeholders = {
"device_values": _device_values_text(self._trial_properties),
}
if self._probed:
placeholders["probe_note"] = (
"Модель модуля неизвестна — шаблон определён проверкой "
"свойств."
)
else:
placeholders["probe_note"] = (
"Шаблон определён по модели модуля."
)
return self.async_show_form(
step_id="template",
data_schema=self.add_suggested_values_to_schema(
_template_schema(Template[default]),
{CONF_TEMPLATE: default},
),
description_placeholders=placeholders,
)
async def async_step_capabilities(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Страница 2: возможности прибора (тумблеры, дефолты с устройства)."""
if user_input is not None:
self._selected_features = {
key: bool(user_input.get(key, False)) for key in FEATURES
}
return await self.async_step_limits()
defaults = resolve_features(
self._trial_properties,
self._trial_answered,
self._reconfigure_defaults(),
)
schema = self.add_suggested_values_to_schema(
_capabilities_schema(defaults),
{key: value for key, value in defaults.items()},
)
return self.async_show_form(
step_id="capabilities",
data_schema=schema,
description_placeholders={
"device_values": _device_values_text(self._trial_properties),
},
)
async def async_step_limits(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Страница 3: диапазон и шаг уставки (при необходимости — конверсия)."""
data = dict(self._pending_data)
errors: dict[str, str] = {}
if user_input is not None:
template = Template[str(data.get(CONF_TEMPLATE, "A"))]
temp_min = int(round(float(user_input[CONF_TEMP_MIN]) * 10))
temp_max = int(round(float(user_input[CONF_TEMP_MAX]) * 10))
step = int(round(float(user_input[CONF_TEMP_STEP]) * 10))
num = int(user_input[CONF_TEMP_NUM])
den = int(user_input[CONF_TEMP_DEN])
offset = int(user_input[CONF_TEMP_OFFSET])
conversions = ConversionSet(
template,
{
Prop.ADJUST_TEMPERATURE: LinearOverride(
num=num,
den=den,
offset=offset,
min_value=temp_min,
max_value=temp_max,
)
},
step,
)
bounds = conversions.range(Prop.ADJUST_TEMPERATURE)
if (
temp_min >= temp_max
or bounds is None
or bounds[0] >= bounds[1]
):
errors["base"] = "invalid_range"
else:
data[CONF_TEMP_STEP] = step
data[CONF_OVERRIDES] = {
Prop.ADJUST_TEMPERATURE.name.lower(): {
"num": num,
"den": den,
"offset": offset,
"min": temp_min,
"max": temp_max,
}
}
data[CONF_FEATURES] = dict(self._selected_features)
_LOGGER.debug(
"limits: template=%s range=%d..%d step=%d features=%d",
template.name, temp_min, temp_max, step,
len(self._selected_features),
)
return self._create_entry(data)
if user_input is None and self._reconfigure_entry is not None:
user_input = self._reconfigure_limits_suggestions()
return self._show_form(
"limits",
_limits_schema(data),
errors,
user_input,
)
def _reconfigure_defaults(self) -> dict[str, bool] | None:
entry = self._reconfigure_entry
if entry is None:
return None
features = entry.data.get(CONF_FEATURES)
return features if isinstance(features, dict) else None
def _reconfigure_limits_suggestions(self) -> dict[str, Any] | None:
entry = self._reconfigure_entry
if entry is None:
return None
spec = (entry.data.get(CONF_OVERRIDES) or {}).get(
Prop.ADJUST_TEMPERATURE.name.lower(), {}
)
if not isinstance(spec, dict):
return None
return {
CONF_TEMP_MIN: _spec_int(spec, "min", TEMP_MIN_TENTHS) / 10,
CONF_TEMP_MAX: _spec_int(spec, "max", TEMP_MAX_TENTHS) / 10,
CONF_TEMP_STEP: int(
entry.data.get(CONF_TEMP_STEP) or TEMP_STEP_TENTHS[
Template[str(entry.data.get(CONF_TEMPLATE, "A"))]
]
)
/ 10,
CONF_TEMP_NUM: _spec_int(spec, "num", 1),
CONF_TEMP_DEN: _spec_int(spec, "den", 1),
CONF_TEMP_OFFSET: _spec_int(spec, "offset", 0),
}
async def _async_trial(self, data: dict[str, Any]) -> str | None:
"""None — сессия поднялась; иначе ключ ошибки формы."""
if self._reconfigure_entry is not None:
# Сохраняем служебные поля записи (listen_port/keepalive_ms) и
# текущие конверсии, если путь их не задаёт.
for key, value in self._reconfigure_entry.data.items():
data.setdefault(key, value)
# Сохранённый шаблон важнее пересчёта по модели: пользователь
# мог выбрать его вручную (страница подтвердит/изменит).
if self._reconfigure_entry.data.get(CONF_TEMPLATE):
data[CONF_TEMPLATE] = self._reconfigure_entry.data[
CONF_TEMPLATE
]
await self.async_set_unique_id(data[CONF_DSN], raise_on_progress=False)
if self._reconfigure_entry is not None:
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured()
result, template, probed = await self.hass.async_add_executor_job(
partial(_run_trial, data)
)
if result.ok:
self._pending_data = data
if (
self._reconfigure_entry is None
or not self._reconfigure_entry.data.get(CONF_TEMPLATE)
):
self._pending_data[CONF_TEMPLATE] = template.name
self._trial_properties = result.properties
self._trial_answered = result.answered
self._resolved_template = template
self._probed = probed
return None
_LOGGER.debug(
"пробное подключение не удалось: reason=%s state=%s error=%s",
result.reason, result.state, result.error,
)
return TRIAL_ERRORS.get(result.reason, "cannot_connect")
def _create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
title = data.get(CONF_NAME) or data[CONF_DSN]
if self._reconfigure_entry is not None:
return self.async_update_reload_and_abort(
self._reconfigure_entry, title=title, data=data
)
return self.async_create_entry(title=title, data=data)
@staticmethod
def async_get_options_flow(config_entry: ConfigEntry) -> "FglairOptionsFlow":
return FglairOptionsFlow()
class FglairOptionsFlow(OptionsFlow):
"""Показывает полный LAN-ключ (для secrets.yaml ESPHome) и позволяет
обновить его без облака."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
entry = self.config_entry
if user_input is not None:
key = str(user_input[CONF_LANIP_KEY]).strip()
if key and key != entry.data.get(CONF_LANIP_KEY):
self.hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_LANIP_KEY: key}
)
self.hass.config_entries.async_schedule_reload(entry.entry_id)
return self.async_create_entry(title="", data={})
schema = vol.Schema(
{vol.Required(CONF_LANIP_KEY): selector.TextSelector()}
)
schema = self.add_suggested_values_to_schema(
schema, {CONF_LANIP_KEY: entry.data.get(CONF_LANIP_KEY, "")}
)
return self.async_show_form(
step_id="init",
data_schema=schema,
description_placeholders={
"dsn": str(entry.data.get(CONF_DSN, "")),
"lanip_key_id": str(entry.data.get(CONF_LANIP_KEY_ID, "")),
"host": str(entry.data.get(CONF_HOST, "")),
},
)
-258
View File
@@ -1,258 +0,0 @@
"""Константы и словари значений интеграции fglair (PROTOCOL §8)."""
from __future__ import annotations
from homeassistant.components.climate import HVACMode
from pyfglair import Prop, State, Template
from pyfglair.provision import REGIONS as _PROVISION_REGIONS
DOMAIN = "fglair"
CONF_HOST = "host"
CONF_DEVICE_PORT = "device_port"
CONF_DSN = "dsn"
CONF_LANIP_KEY = "lanip_key"
CONF_LANIP_KEY_ID = "lanip_key_id"
CONF_MODEL = "model"
CONF_TEMPLATE = "template"
CONF_NAME = "name"
CONF_LISTEN_PORT = "listen_port"
CONF_KEEPALIVE_MS = "keepalive_ms"
CONF_REGION = "region"
CONF_EMAIL = "email"
CONF_PASSWORD = "password"
CONF_CONFIG_JSON = "config_json"
CONF_OVERRIDES = "overrides"
CONF_TEMP_STEP = "temp_step"
CONF_FEATURES = "features"
CONF_TEMP_MIN = "temp_min"
CONF_TEMP_MAX = "temp_max"
CONF_TEMP_NUM = "temp_num"
CONF_TEMP_DEN = "temp_den"
CONF_TEMP_OFFSET = "temp_offset"
DEFAULT_DEVICE_PORT = 80
# 0 => свободный эфемерный порт: несколько ConfigEntry (несколько
# кондиционеров) не конфликтуют; модулю фактический порт сообщается в
# local_reg (PROTOCOL §1).
DEFAULT_LISTEN_PORT = 0
DEFAULT_KEEPALIVE_MS = 15000
TRIAL_TIMEOUT = 10.0
REGIONS = sorted(_PROVISION_REGIONS)
TRIAL_ERRORS = {
"no_slots": "no_slots",
"key_mismatch": "key_mismatch",
"unreachable": "cannot_connect",
"timeout": "timeout",
"start_failed": "cannot_connect",
"properties_missing": "properties_missing",
}
# ---------------------------------------------------------------------------
# operation_mode (PROTOCOL §8.3): 0=OFF, 1=ON, 2=AUTO, 3=COOL, 4=DRY,
# 5=FAN, 6=HEAT
# ---------------------------------------------------------------------------
OPERATION_MODE_OFF = 0
OPERATION_MODE_ON = 1
HVAC_MODE_TO_VALUE = {
HVACMode.AUTO: 2,
HVACMode.COOL: 3,
HVACMode.DRY: 4,
HVACMode.FAN_ONLY: 5,
HVACMode.HEAT: 6,
}
HVAC_VALUE_TO_MODE = {value: mode for mode, value in HVAC_MODE_TO_VALUE.items()}
# device_capabilities, биты (PROTOCOL §8.5)
CAP_HVAC = {
HVACMode.AUTO: 4,
HVACMode.COOL: 0,
HVACMode.DRY: 1,
HVACMode.FAN_ONLY: 2,
HVACMode.HEAT: 3,
}
CAP_FAN = {"auto": 5, "high": 6, "medium": 7, "low": 8, "quiet": 9}
CAP_SWING_VERTICAL = 10
CAP_SWING_HORIZONTAL = 11
CAP_ECONOMY = 12
CAP_MIN_HEAT = 13
CAP_INDOOR_FAN_CONTROL = 14
CAP_POWERFUL = 16
CAP_OUTDOOR_LOW_NOISE = 17
CAP_COIL_DRY = 18
# fan_speed (PROTOCOL §8.3)
FAN_VALUE_TO_MODE = {0: "quiet", 1: "low", 2: "medium", 3: "high", 4: "auto"}
FAN_MODE_TO_VALUE = {mode: value for value, mode in FAN_VALUE_TO_MODE.items()}
# preset: ECO = economy_mode, BOOST = powerful_mode
PRESET_ECO = "eco"
PRESET_BOOST = "boost"
PRESET_PROP = {
PRESET_ECO: Prop.ECONOMY_MODE,
PRESET_BOOST: Prop.POWERFUL_MODE,
}
SWING_MODES = ["off", "on"]
# switch-свойства: prop -> translation_key (filtracija po FEATURES)
SWITCH_PROPS: dict[Prop, str] = {
Prop.ECONOMY_MODE: "economy_mode",
Prop.POWERFUL_MODE: "powerful_mode",
Prop.COIL_DRY_MODE: "coil_dry_mode",
Prop.MIN_HEAT: "min_heat",
Prop.OUTDOOR_LOW_NOISE: "outdoor_low_noise",
Prop.HUMAN_DET_AUTO_SAVE: "human_det_auto_save",
Prop.WIFI_LED_ENABLE: "wifi_led_enable",
Prop.INDOOR_FAN_CONTROL: "indoor_fan_control",
}
# ---------------------------------------------------------------------------
# Feature-модель (план: страница capabilities в мастере + фильтрация сущностей).
# key -> (capability bit | None, свойство-источник | None).
# Для битовых фич дефолт = бит в device_capabilities; для небитовых —
# наличие свойства в ответах устройства; для заслонок — num_dir > 1.
# ---------------------------------------------------------------------------
FEATURES: dict[str, tuple[int | None, Prop | None]] = {
"mode_cool": (CAP_HVAC[HVACMode.COOL], Prop.OPERATION_MODE),
"mode_dry": (CAP_HVAC[HVACMode.DRY], Prop.OPERATION_MODE),
"mode_fan": (CAP_HVAC[HVACMode.FAN_ONLY], Prop.OPERATION_MODE),
"mode_heat": (CAP_HVAC[HVACMode.HEAT], Prop.OPERATION_MODE),
"mode_auto": (CAP_HVAC[HVACMode.AUTO], Prop.OPERATION_MODE),
"fan_quiet": (CAP_FAN["quiet"], Prop.FAN_SPEED),
"fan_low": (CAP_FAN["low"], Prop.FAN_SPEED),
"fan_medium": (CAP_FAN["medium"], Prop.FAN_SPEED),
"fan_high": (CAP_FAN["high"], Prop.FAN_SPEED),
"fan_auto": (CAP_FAN["auto"], Prop.FAN_SPEED),
"swing_vertical": (CAP_SWING_VERTICAL, Prop.AF_VERTICAL_SWING),
"swing_horizontal": (CAP_SWING_HORIZONTAL, Prop.AF_HORIZONTAL_SWING),
"economy_mode": (CAP_ECONOMY, Prop.ECONOMY_MODE),
"min_heat": (CAP_MIN_HEAT, Prop.MIN_HEAT),
"indoor_fan_control": (CAP_INDOOR_FAN_CONTROL, Prop.INDOOR_FAN_CONTROL),
"powerful_mode": (CAP_POWERFUL, Prop.POWERFUL_MODE),
"outdoor_low_noise": (CAP_OUTDOOR_LOW_NOISE, Prop.OUTDOOR_LOW_NOISE),
"coil_dry_mode": (CAP_COIL_DRY, Prop.COIL_DRY_MODE),
"display_temperature": (None, Prop.DISPLAY_TEMPERATURE),
"human_det_auto_save": (None, Prop.HUMAN_DET_AUTO_SAVE),
"wifi_led_enable": (None, Prop.WIFI_LED_ENABLE),
"af_vertical_direction": (None, Prop.AF_VERTICAL_DIRECTION),
"af_horizontal_direction": (None, Prop.AF_HORIZONTAL_DIRECTION),
}
# Описания для мастера и для атрибута description сущностей.
FEATURE_DESCRIPTIONS = {
"ru": {
"mode_cool": "Охлаждение",
"mode_dry": "Осушение",
"mode_fan": "Вентиляция (без нагрева/охлаждения)",
"mode_heat": "Обогрев",
"mode_auto": "Автоматический режим",
"fan_auto": "Автоскорость вентилятора",
"fan_quiet": "Тихая скорость",
"fan_low": "Низкая скорость",
"fan_medium": "Средняя скорость",
"fan_high": "Высокая скорость",
"swing_vertical": "Качание заслонки по вертикали",
"swing_horizontal": "Качание заслонки по горизонтали",
"economy_mode": "Экономичный режим",
"min_heat": "Минимальный обогрев (поддержание +10 °C)",
"indoor_fan_control": "Управление вентилятором при работе наружного блока",
"powerful_mode": "Мощный режим (ускоренный выход на режим)",
"outdoor_low_noise": "Тихий наружный блок",
"coil_dry_mode": "Сушка испарителя",
"display_temperature": "Датчик температуры помещения",
"human_det_auto_save": "Автосохранение при отсутствии людей (датчик присутствия)",
"wifi_led_enable": "Индикатор Wi-Fi на модуле",
"af_vertical_direction": "Выбор положения вертикальной заслонки",
"af_horizontal_direction": "Выбор положения горизонтальной заслонки",
},
"en": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic mode",
"fan_auto": "Auto fan speed",
"fan_quiet": "Quiet fan speed",
"fan_low": "Low fan speed",
"fan_medium": "Medium fan speed",
"fan_high": "High fan speed",
"swing_vertical": "Vertical louver swing",
"swing_horizontal": "Horizontal louver swing",
"economy_mode": "Economy mode",
"min_heat": "Minimum heat (keep +10 °C)",
"indoor_fan_control": "Indoor fan control while outdoor unit runs",
"powerful_mode": "Powerful mode",
"outdoor_low_noise": "Outdoor unit low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED indicator",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position",
},
}
ENTITY_DESCRIPTIONS = {
"ru": {
"climate": "Управление кондиционером: режим, скорость, уставка, "
"заслонки, пресеты",
"room_temperature": "Температура воздуха в помещении",
"error_code": "Код ошибки кондиционера (0 — ошибок нет)",
"connection_state": "Состояние связи с модулем кондиционера",
"connectivity": "Есть ли связь с модулем",
"defrost": "Идёт разморозка наружного блока",
"maintenance": "Требуется обслуживание",
"oil_recovery": "Возврат масла в компрессор",
"pump_down": "Откачка хладагента (pump down)",
"check_operation": "Проверочный прогон",
"simultaneous": "Одновременные операции",
"different_modes": "Разморозка/масло/прочие режимы (шаблон B)",
},
"en": {
"climate": "Air conditioner control: mode, fan, setpoint, louvers, presets",
"room_temperature": "Room air temperature",
"error_code": "Air conditioner error code (0 = no errors)",
"connection_state": "Connection state with the AC module",
"connectivity": "Whether the module is reachable",
"defrost": "Outdoor unit defrost in progress",
"maintenance": "Maintenance required",
"oil_recovery": "Compressor oil recovery",
"pump_down": "Refrigerant pump down",
"check_operation": "Check operation",
"simultaneous": "Simultaneous operations",
"different_modes": "Defrost/oil/other modes (template B)",
},
}
def entity_description(language: str, key: str) -> str | None:
lang = "ru" if str(language).lower().startswith("ru") else "en"
return ENTITY_DESCRIPTIONS[lang].get(key) or FEATURE_DESCRIPTIONS[lang].get(
key
)
# op_status, биты (PROTOCOL §8.4): translation_key -> bit
OP_STATUS_BITS: dict[str, int] = {
"defrost": 24,
"maintenance": 22,
"oil_recovery": 28,
"pump_down": 29,
"check_operation": 30,
"simultaneous": 25,
"different_modes": 21,
}
# Уставка: фактические ограничения бытового прибора (PROTOCOL §8.3 [LEGACY]);
# единицы API = 0.1 °C.
TEMP_MIN_TENTHS = 160
TEMP_MAX_TENTHS = 300
TEMP_STEP_TENTHS = {
Template.A: 5,
Template.B: 10,
Template.F: 5,
}
# Состояния, в которых сущности доступны (PROTOCOL/план §5).
AVAILABLE_STATES = (State.ONLINE, State.RECOVERING)
-262
View File
@@ -1,262 +0,0 @@
"""Push-driven координатор: кэш свойств + доступность по состоянию сессии."""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field, replace
from typing import Optional
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from pyfglair import Error, Prop, PropertyEvent, State, Template, Value, ValueKind
from pyfglair.templates import prop_info, template_info
from .const import (
AVAILABLE_STATES,
CONF_DSN,
CONF_HOST,
CONF_NAME,
DOMAIN,
)
from .features import FeatureSet, LiveValues
from .fglair_client import FglairClient
from .overrides import ConversionSet
_LOGGER = logging.getLogger(__name__)
@dataclass
class FglairData:
values: dict[Prop, Value] = field(default_factory=dict)
state: State = State.IDLE
last_error: Error = Error.NONE
available: bool = False
@dataclass
class FglairRuntime:
client: FglairClient
coordinator: "FglairCoordinator"
conversions: ConversionSet
features: "FeatureSet"
def key_issue_id(dsn: str) -> str:
return f"key_mismatch_{dsn}"
def _writable_props(template: Template):
for info in template_info(template):
if info.kind != ValueKind.STRING:
yield info.prop
class FglairCoordinator(DataUpdateCoordinator[FglairData]):
"""Держит актуальный снимок свойств и рассылает его сущностям."""
def __init__(
self,
hass: HomeAssistant,
client: FglairClient,
conversions: ConversionSet,
entry_id: str,
) -> None:
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN}-{client.data[CONF_DSN]}",
update_interval=None,
)
self.client = client
self.conversions = conversions
self.features = FeatureSet(
explicit=None, values=LiveValues(client.cached)
)
self.entry_id = entry_id
self.key_issue_id = key_issue_id(client.data[CONF_DSN])
self._synced = False
self._sync_task: Optional[asyncio.Task] = None
self._unsubs = [
client.add_state_listener(self._on_state),
client.add_property_listener(self._on_property),
]
self.data = self._snapshot()
# -- снимок -----------------------------------------------------------
def _snapshot(self) -> FglairData:
values: dict[Prop, Value] = {}
for prop in _writable_props(self.client.template):
value = self.client.cached(prop)
if value is not None:
values[prop] = value
state = self.client.state
return FglairData(
values=values,
state=state,
last_error=self.client.last_error,
available=state in AVAILABLE_STATES,
)
async def _async_update_data(self) -> FglairData:
return await self.hass.async_add_executor_job(self._snapshot)
# -- события ----------------------------------------------------------
@callback
def _on_state(self, state: State, error: Error) -> None:
self.data = replace(
self.data,
state=state,
last_error=error,
available=state in AVAILABLE_STATES,
)
self.async_set_updated_data(self.data)
self.sync_issue(state)
if state == State.ONLINE:
if not self._synced and self._sync_task is None:
self._sync_task = self.hass.async_create_task(
self._async_initial_sync()
)
else:
# После разрыва при следующем ONLINE нужна повторная синхронизация.
self._synced = False
async def async_sync_now(self, online_timeout: float = 2.0) -> None:
"""Гарантирует первичный GET-батч и ожидание ключевых свойств.
Ждёт ONLINE не дольше ``online_timeout``, затем — завершения
синхронизации (сама синхронизация ограничена своим таймаутом),
чтобы сущности создавались с корректными feature-дефолтами.
"""
deadline = self.hass.loop.time() + online_timeout
while (
self._sync_task is None
and self.client.state != State.ONLINE
and self.hass.loop.time() < deadline
):
await asyncio.sleep(0.05)
if self._sync_task is not None:
await self._sync_task
elif self.client.state == State.ONLINE and not self._synced:
self._sync_task = self.hass.async_create_task(
self._async_initial_sync()
)
await self._sync_task
# Снимок создавался до ONLINE: перечитываем его без await (события
# не могут вклиниться между чтением и присваиванием).
self.data = self._snapshot()
self.async_set_updated_data(self.data)
self.sync_issue()
@callback
def sync_issue(self, state: Optional[State] = None) -> None:
"""Repair-issue key_error: создать/снять по состоянию сессии."""
current = state if state is not None else self.data.state
if current == State.KEY_ERROR:
ir.async_create_issue(
self.hass,
DOMAIN,
self.key_issue_id,
is_fixable=True,
severity=ir.IssueSeverity.ERROR,
translation_key="key_mismatch",
translation_placeholders={
"device": self.client.data.get(CONF_NAME)
or self.client.data[CONF_DSN],
"host": str(self.client.data.get(CONF_HOST, "?")),
},
data={"entry_id": self.entry_id},
)
else:
ir.async_delete_issue(self.hass, DOMAIN, self.key_issue_id)
@callback
def async_remove_issue(self) -> None:
ir.async_delete_issue(self.hass, DOMAIN, self.key_issue_id)
@callback
def _on_property(self, event: PropertyEvent) -> None:
values = dict(self.data.values)
values[event.prop] = event.value
self.data = replace(self.data, values=values)
self.async_set_updated_data(self.data)
async def _async_initial_sync(self) -> None:
try:
await self.hass.async_add_executor_job(self._initial_sync)
await self._async_wait_first_props()
self._synced = True
except Exception: # pragma: no cover - защитный путь
_LOGGER.exception("первичная синхронизация свойств не удалась")
finally:
self._sync_task = None
async def _async_wait_first_props(self, timeout: float = 3.0) -> None:
"""Ждёт ключевые свойства (caps/режим/num_dir), чтобы сущности
создавались с корректными feature-дефолтами."""
template = self.client.template
wanted = [
Prop.OPERATION_MODE,
Prop.DEVICE_CAPABILITIES,
Prop.AF_VERTICAL_NUM_DIR,
Prop.AF_HORIZONTAL_NUM_DIR,
Prop.DISPLAY_TEMPERATURE,
Prop.HUMAN_DET_AUTO_SAVE,
Prop.WIFI_LED_ENABLE,
]
wanted = [
prop for prop in wanted if prop_info(template, prop) is not None
]
deadline = self.hass.loop.time() + timeout
while self.hass.loop.time() < deadline:
if all(self.client.cached(prop) is not None for prop in wanted):
return
await asyncio.sleep(0.05)
def _initial_sync(self) -> None:
self.client.batch_begin()
for prop in _writable_props(self.client.template):
self.client.get_prop(prop)
self.client.batch_commit()
# -- записи -----------------------------------------------------------
async def async_write(
self, updates: list[tuple[Prop, int]]
) -> None:
"""Одно действие пользователя — один batch_commit()."""
if not updates:
return
ok = await self.hass.async_add_executor_job(self._write, updates)
if not ok:
raise HomeAssistantError(
"FGLair: команда не отправлена (сессия недоступна)"
)
values = dict(self.data.values)
for prop, _ in updates:
value = self.client.cached(prop)
if value is not None:
values[prop] = value
self.data = replace(self.data, values=values)
self.async_set_updated_data(self.data)
def _write(self, updates: list[tuple[Prop, int]]) -> bool:
if not self.client.batch_begin():
return False
ok = True
for prop, value in updates:
if not self.client.set_int(prop, value):
ok = False
if not self.client.batch_commit():
ok = False
return ok
async def async_shutdown(self) -> None:
for unsub in self._unsubs:
unsub()
self._unsubs.clear()
if self._sync_task is not None:
self._sync_task.cancel()
self._sync_task = None
await super().async_shutdown()
-98
View File
@@ -1,98 +0,0 @@
"""Диагностика fglair: параметры устройства для проверки и поддержки.
Дамп redacted: lanip_key маскируется (HA-конвенция; дампы прикладывают в
issues). Полный ключ для secrets.yaml ESPHome берётся явным действием:
«Настройка» (шестерёнка, options flow) на карточке интеграции —
одноимённое поле содержит полный ключ; либо CLI
``python -m pyfglair discover`` на хосте HA.
"""
from __future__ import annotations
from typing import Any, Optional
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from pyfglair.templates import template_info
from .const import (
CONF_DEVICE_PORT,
CONF_DSN,
CONF_EMAIL,
CONF_HOST,
CONF_LANIP_KEY,
CONF_LANIP_KEY_ID,
CONF_LISTEN_PORT,
CONF_MODEL,
CONF_NAME,
CONF_PASSWORD,
CONF_TEMP_STEP,
CONF_TEMPLATE,
)
from .coordinator import FglairRuntime
REDACTED = "**REDACTED**"
TO_REDACT = {CONF_LANIP_KEY, CONF_PASSWORD, CONF_EMAIL}
def _masked(value: Any) -> Any:
if not isinstance(value, str) or len(value) <= 4:
return REDACTED
return f"{value[:4]}…{REDACTED}"
def _base_diagnostics(entry: ConfigEntry) -> dict[str, Any]:
data = entry.data
return {
"config": {
CONF_HOST: data.get(CONF_HOST),
CONF_DEVICE_PORT: data.get(CONF_DEVICE_PORT),
CONF_DSN: data.get(CONF_DSN),
CONF_NAME: data.get(CONF_NAME),
CONF_MODEL: data.get(CONF_MODEL),
CONF_TEMPLATE: data.get(CONF_TEMPLATE),
CONF_TEMP_STEP: data.get(CONF_TEMP_STEP),
CONF_LISTEN_PORT: data.get(CONF_LISTEN_PORT),
},
"esp_home": {
CONF_DSN: data.get(CONF_DSN),
CONF_LANIP_KEY: _masked(data.get(CONF_LANIP_KEY)),
CONF_LANIP_KEY_ID: data.get(CONF_LANIP_KEY_ID),
CONF_HOST: data.get(CONF_HOST),
},
"entry_data": async_redact_data(dict(data), TO_REDACT),
"overrides": data.get("overrides", {}),
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
diag = _base_diagnostics(entry)
runtime: Optional[FglairRuntime] = getattr(entry, "runtime_data", None)
if runtime is None:
diag["runtime"] = {"loaded": False}
return diag
client = runtime.client
values: dict[str, Any] = {}
for info in template_info(client.template):
value = client.cached(info.prop)
if value is None:
continue
values[info.name] = {
"raw": value.int_value,
"display": runtime.conversions.to_display(
info.prop, value.int_value
),
}
diag["runtime"] = {
"loaded": True,
"state": client.state.name.lower(),
"last_error": client.last_error.name.lower(),
"events_dropped": client.events_dropped,
"values": values,
}
return diag
-77
View File
@@ -1,77 +0,0 @@
"""Базовая сущность fglair: DeviceInfo, значения из координатора."""
from __future__ import annotations
from typing import Optional
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from pyfglair import Prop, Value
from .const import (
CONF_DSN,
CONF_MODEL,
CONF_NAME,
DOMAIN,
entity_description,
)
from .coordinator import FglairCoordinator
class FglairEntity(CoordinatorEntity[FglairCoordinator]):
"""Общий базис: unique_id = <dsn>_<key>, device_info — один прибор."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FglairCoordinator,
unique_suffix: str,
translation_key: Optional[str],
description_key: Optional[str] = None,
) -> None:
super().__init__(coordinator)
data = coordinator.client.data
dsn = data[CONF_DSN]
self._attr_unique_id = f"{dsn}_{unique_suffix}"
if translation_key is not None:
self._attr_translation_key = translation_key
self._description_key = description_key or translation_key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, dsn)},
name=data.get(CONF_NAME) or dsn,
manufacturer="Fujitsu General",
model=data.get(CONF_MODEL) or None,
)
@property
def available(self) -> bool:
return self.coordinator.data.available and super().available
@property
def extra_state_attributes(self) -> Optional[dict]:
"""HA не поддерживает тултипы сущностей: краткое описание видно в
атрибутах (More info → Атрибуты)."""
if self._description_key is None or self.hass is None:
return None
text = entity_description(self.hass.config.language, self._description_key)
return {"description": text} if text else None
def feature_enabled(self, key: str) -> bool:
return self.coordinator.features.enabled(key)
def value(self, prop: Prop) -> Optional[Value]:
return self.coordinator.data.values.get(prop)
def raw_value(self, prop: Prop) -> Optional[int]:
value = self.value(prop)
return value.int_value if value is not None else None
def display_value(self, prop: Prop) -> Optional[int]:
"""Значение свойства в единицах отображения (с ручной конверсией)."""
raw = self.raw_value(prop)
if raw is None:
return None
return self.coordinator.conversions.to_display(prop, raw)
-112
View File
@@ -1,112 +0,0 @@
"""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)
-203
View File
@@ -1,203 +0,0 @@
"""Фоновый клиент FGLair: сессия pyfglair + доставка колбэков в event loop HA."""
from __future__ import annotations
import logging
from typing import Any, Callable, Mapping, Optional
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from pyfglair import (
Config,
Error,
Prop,
PropertyEvent,
Session,
State,
Template,
Value,
)
from pyfglair.templates import detect
from .const import (
CONF_DEVICE_PORT,
CONF_DSN,
CONF_HOST,
CONF_KEEPALIVE_MS,
CONF_LANIP_KEY,
CONF_LANIP_KEY_ID,
CONF_LISTEN_PORT,
CONF_MODEL,
CONF_TEMPLATE,
DEFAULT_DEVICE_PORT,
DEFAULT_KEEPALIVE_MS,
DEFAULT_LISTEN_PORT,
)
_LOGGER = logging.getLogger(__name__)
StateListener = Callable[[State, Error], None]
PropertyListener = Callable[[PropertyEvent], None]
def resolve_template(data: Mapping[str, Any]) -> Template:
"""Шаблон из данных записи: явный или по oem_model, иначе A."""
name = data.get(CONF_TEMPLATE)
if isinstance(name, str) and name in Template.__members__:
return Template[name]
model = data.get(CONF_MODEL)
if model:
found = detect(model)
if found is not None:
return found
return Template.A
class FglairClient:
"""Жизненный цикл сессии для одной ConfigEntry.
Создание/старт выполняются в executor (вызовы C-ядра), колбэки приходят
в event loop (Session создаётся с ``loop=hass.loop``).
"""
def __init__(self, hass: HomeAssistant, data: Mapping[str, Any]) -> None:
self.hass = hass
self._data = dict(data)
self._session: Optional[Session] = None
self._state = State.IDLE
self._last_error = Error.NONE
self._state_listeners: list[StateListener] = []
self._property_listeners: list[PropertyListener] = []
self._dropped_notified = 0
@property
def state(self) -> State:
return self._state
@property
def last_error(self) -> Error:
return self._last_error
@property
def template(self) -> Template:
return resolve_template(self._data)
@property
def events_dropped(self) -> int:
if self._session is None:
return 0
return self._session.events_dropped
@property
def data(self) -> Mapping[str, Any]:
return self._data
@callback
def add_state_listener(self, listener: StateListener) -> Callable[[], None]:
self._state_listeners.append(listener)
return lambda: self._state_listeners.remove(listener)
@callback
def add_property_listener(
self, listener: PropertyListener
) -> Callable[[], None]:
self._property_listeners.append(listener)
return lambda: self._property_listeners.remove(listener)
@callback
def _on_state(self, state: State, error: Error) -> None:
self._state = state
self._last_error = error
self._notify_state()
if self._session is not None:
dropped = self._session.events_dropped
if dropped > self._dropped_notified:
_LOGGER.warning(
"FGLair: потеряно событий устройства: %d",
dropped - self._dropped_notified,
)
self._dropped_notified = dropped
@callback
def _on_property(self, event: PropertyEvent) -> None:
for listener in list(self._property_listeners):
listener(event)
def _notify_state(self) -> None:
for listener in list(self._state_listeners):
listener(self._state, self._last_error)
def _build_session(self) -> Session:
return Session(
Config(
host=self._data[CONF_HOST],
device_port=int(
self._data.get(CONF_DEVICE_PORT, DEFAULT_DEVICE_PORT)
),
dsn=self._data[CONF_DSN],
lanip_key=self._data[CONF_LANIP_KEY],
lanip_key_id=int(self._data[CONF_LANIP_KEY_ID]),
template=self.template,
listen_port=int(
self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)
),
keepalive_ms=int(
self._data.get(CONF_KEEPALIVE_MS, DEFAULT_KEEPALIVE_MS)
),
),
loop=self.hass.loop,
on_state=self._on_state,
on_property=self._on_property,
)
async def async_start(self) -> None:
if self._session is not None:
return
session = await self.hass.async_add_executor_job(self._build_session)
try:
started = await self.hass.async_add_executor_job(session.start)
except Exception:
await session.async_stop()
raise
if not started:
await session.async_stop()
port = self._data.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)
raise ConfigEntryNotReady(
"не удалось запустить локальный HTTP-сервер FGLair"
+ (f" (порт {port})" if port else "")
)
self._session = session
self._state = session.state
async def async_stop(self) -> None:
session, self._session = self._session, None
if session is not None:
await session.async_stop()
def cached(self, prop: Prop) -> Optional[Value]:
if self._session is None:
return None
return self._session.cached(prop)
def set_int(self, prop: Prop, value: int) -> bool:
return self._session is not None and self._session.set_int(prop, value)
def set_bool(self, prop: Prop, value: bool) -> bool:
return self._session is not None and self._session.set_bool(prop, value)
def set_string(self, prop: Prop, value: str) -> bool:
return (
self._session is not None and self._session.set_string(prop, value)
)
def get_prop(self, prop: Prop) -> bool:
return self._session is not None and self._session.get_prop(prop)
def batch_begin(self) -> bool:
return self._session is not None and self._session.batch_begin()
def batch_commit(self) -> bool:
return self._session is not None and self._session.batch_commit()
def batch_abort(self) -> bool:
return self._session is not None and self._session.batch_abort()
-13
View File
@@ -1,13 +0,0 @@
{
"domain": "fglair",
"name": "FGLair (Fujitsu General)",
"codeowners": ["@petr.polezhaev"],
"config_flow": true,
"documentation": "https://git.ratigorsk-12.ru/esphome/fgl-aircon",
"integration_type": "device",
"iot_class": "local_push",
"issue_tracker": "https://git.ratigorsk-12.ru/esphome/fgl-aircon/-/issues",
"loggers": ["pyfglair"],
"requirements": ["pyfglair>=1.0.0"],
"version": "0.1.0"
}
-175
View File
@@ -1,175 +0,0 @@
"""Ручные конверсии/диапазоны свойств (план §3 шаг 3, §4).
Ядро конвертирует raw→API по таблице шаблона; ручная конверсия задаётся
коэффициентами ``display = raw*num/den + offset`` (в единицах API, для
температуры это 0.1 °C) и диапазоном. Применение — на стороне HA:
display = linear(raw), raw = convert_from_input(template, api)
api = convert_to_display(template, linear_inverse(display))
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping, Optional
from pyfglair import Prop, Template
from pyfglair.templates import convert_from_input, convert_to_display, prop_info
from .const import (
CONF_OVERRIDES,
CONF_TEMP_STEP,
TEMP_MAX_TENTHS,
TEMP_MIN_TENTHS,
TEMP_STEP_TENTHS,
)
def _div_trunc(numerator: int, denominator: int) -> int:
quotient = abs(numerator) // abs(denominator)
return quotient if (numerator < 0) == (denominator < 0) else -quotient
@dataclass(frozen=True)
class LinearOverride:
num: int = 1
den: int = 1
offset: int = 0
min_value: Optional[int] = None
max_value: Optional[int] = None
@property
def identity(self) -> bool:
return self.num == 1 and self.den == 1 and self.offset == 0
def to_display(self, raw: int) -> int:
return _div_trunc(raw * self.num, self.den) + self.offset
def from_display(self, display: int) -> int:
return _div_trunc((display - self.offset) * self.den, self.num)
def clamp(self, display: int) -> int:
if self.min_value is not None and display < self.min_value:
return self.min_value
if self.max_value is not None and display > self.max_value:
return self.max_value
return display
class ConversionSet:
"""Конверсии записи: ручные override поверх шаблонных конверсий ядра."""
def __init__(
self,
template: Template,
overrides: Mapping[Prop, LinearOverride],
step_tenths: int,
) -> None:
self.template = template
self.overrides = dict(overrides)
self.step_tenths = step_tenths
@classmethod
def from_entry(cls, data: Mapping[str, Any]) -> "ConversionSet":
template = Template[str(data.get("template", "A"))]
raw_overrides = data.get(CONF_OVERRIDES) or {}
overrides: dict[Prop, LinearOverride] = {}
if isinstance(raw_overrides, dict):
for name, spec in raw_overrides.items():
try:
prop = Prop[str(name).upper()]
except KeyError:
continue
if not isinstance(spec, dict):
continue
num = int(spec.get("num", 1)) or 1
den = int(spec.get("den", 1)) or 1
overrides[prop] = LinearOverride(
num=num,
den=den,
offset=int(spec.get("offset", 0)),
min_value=(
int(spec["min"]) if spec.get("min") is not None else None
),
max_value=(
int(spec["max"]) if spec.get("max") is not None else None
),
)
step = int(data.get(CONF_TEMP_STEP) or TEMP_STEP_TENTHS[template])
return cls(template, overrides, step)
def override(self, prop: Prop) -> LinearOverride:
return self.overrides.get(prop, LinearOverride())
def to_display(self, prop: Prop, api_value: int) -> int:
"""API-значение из ядра → отображаемое (при override — единицы API).
Без override отображаемое значение равно API-значению ядра
(например, display_temperature API 200 = 20.0 °C). С линейным
override: display = raw*num/den + offset.
"""
override = self.override(prop)
if override.identity:
return api_value
raw = convert_from_input(self.template, prop, api_value)
return override.to_display(raw)
def raw_range(self, prop: Prop) -> Optional[tuple[int, int]]:
"""raw-диапазон протокольной таблицы шаблона (None — не задан)."""
info = prop_info(self.template, prop)
if info is None or info.raw_min is None or info.raw_max is None:
return None
return (min(info.raw_min, info.raw_max), max(info.raw_min, info.raw_max))
def _display_of_raw(self, prop: Prop, raw: int) -> int:
override = self.override(prop)
if override.identity:
return convert_to_display(self.template, prop, raw)
return override.to_display(raw)
def from_display(self, prop: Prop, display: int) -> int:
"""Отображаемое значение → API-значение для set_int().
raw клампится по таблице шаблона (ядро тоже клампит — не отдаём
молча команду за пределами достижимого).
"""
override = self.override(prop)
raw = display if override.identity else override.from_display(display)
raw_range = self.raw_range(prop)
if raw_range is not None:
raw = max(raw_range[0], min(raw_range[1], raw))
if override.identity:
return raw
return convert_to_display(self.template, prop, raw)
def clamp(self, prop: Prop, display: int) -> int:
bounds = self.range(prop)
if bounds is None:
return display
return max(bounds[0], min(bounds[1], display))
def range(self, prop: Prop) -> Optional[tuple[int, int]]:
"""Достижимый диапазон display: override ∩ таблица шаблона."""
override = self.override(prop)
low = override.min_value
high = override.max_value
if prop == Prop.ADJUST_TEMPERATURE:
# Фактические ограничения прибора (PROTOCOL §8.3), если
# пользователь не задал свои.
low = TEMP_MIN_TENTHS if low is None else low
high = TEMP_MAX_TENTHS if high is None else high
raw_range = self.raw_range(prop)
if raw_range is not None:
raw_low = self._display_of_raw(prop, raw_range[0])
raw_high = self._display_of_raw(prop, raw_range[1])
if raw_low > raw_high:
raw_low, raw_high = raw_high, raw_low
low = raw_low if low is None else max(low, raw_low)
high = raw_high if high is None else min(high, raw_high)
if low is None or high is None:
return None
return (low, high)
def temperature_range(self) -> tuple[int, int]:
return self.range(Prop.ADJUST_TEMPERATURE) or (
TEMP_MIN_TENTHS,
TEMP_MAX_TENTHS,
)
-49
View File
@@ -1,49 +0,0 @@
"""Repair flow fglair: key_mismatch → подсказка и reload записи.
Issue не удаляем молча: подтверждение перезагружает ConfigEntry, и если
ключ всё ещё неверен, coordinator создаёт issue заново (issue снимается
только когда сессия выходит из key_error).
"""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
class KeyMismatchRepairFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
entry_id = (self.data or {}).get("entry_id")
if entry_id:
self.hass.config_entries.async_schedule_reload(entry_id)
return self.async_create_entry(data={})
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders=self._placeholders(),
)
def _placeholders(self) -> dict[str, str]:
from homeassistant.helpers import issue_registry as ir
issue = ir.async_get(self.hass).async_get_issue(
self.handler, self.issue_id
)
return dict(issue.translation_placeholders or {}) if issue else {}
async def async_create_fix_flow(
hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None
) -> KeyMismatchRepairFlow:
return KeyMismatchRepairFlow()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

-76
View File
@@ -1,76 +0,0 @@
"""Selects: положения заслонок (af_*_direction, число позиций из num_dir)."""
from __future__ import annotations
from homeassistant.components.select import SelectEntity
from pyfglair import Prop
from pyfglair.templates import prop_info
from .coordinator import FglairCoordinator, FglairRuntime
from .entity import FglairEntity
class FglairDirectionSelect(FglairEntity, SelectEntity):
def __init__(
self,
coordinator: FglairCoordinator,
prop: Prop,
num_dir_prop: Prop,
key: str,
) -> None:
super().__init__(coordinator, prop.name.lower(), key)
self._prop = prop
self._num_dir_prop = num_dir_prop
@property
def options(self) -> list[str]:
num = self.raw_value(self._num_dir_prop)
if num is None or num <= 0 or num > 15:
return ["0"]
return [str(value) for value in range(num)]
@property
def current_option(self) -> str | None:
value = self.raw_value(self._prop)
if value is None:
return None
text = str(value)
return text if text in self.options else self.options[0]
async def async_select_option(self, option: str) -> None:
await self.coordinator.async_write([(self._prop, int(option))])
async def async_setup_entry(hass, entry, async_add_entities) -> None:
runtime: FglairRuntime = entry.runtime_data
coordinator = runtime.coordinator
template = runtime.client.template
entities = []
if (
coordinator.features.enabled("af_vertical_direction")
and prop_info(template, Prop.AF_VERTICAL_DIRECTION) is not None
and prop_info(template, Prop.AF_VERTICAL_NUM_DIR) is not None
):
entities.append(
FglairDirectionSelect(
coordinator,
Prop.AF_VERTICAL_DIRECTION,
Prop.AF_VERTICAL_NUM_DIR,
"af_vertical_direction",
)
)
if (
coordinator.features.enabled("af_horizontal_direction")
and prop_info(template, Prop.AF_HORIZONTAL_DIRECTION) is not None
and prop_info(template, Prop.AF_HORIZONTAL_NUM_DIR) is not None
):
entities.append(
FglairDirectionSelect(
coordinator,
Prop.AF_HORIZONTAL_DIRECTION,
Prop.AF_HORIZONTAL_NUM_DIR,
"af_horizontal_direction",
)
)
async_add_entities(entities)
-80
View File
@@ -1,80 +0,0 @@
"""Sensors: температура помещения, код ошибки, состояние связи."""
from __future__ import annotations
from typing import Optional
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfTemperature
from pyfglair import Prop, State
from pyfglair.templates import template_info
from .coordinator import FglairCoordinator, FglairRuntime
from .entity import FglairEntity
class FglairRoomTemperature(FglairEntity, SensorEntity):
_attr_translation_key = "room_temperature"
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_suggested_display_precision = 1
def __init__(self, coordinator: FglairCoordinator) -> None:
super().__init__(coordinator, "display_temperature", "room_temperature")
@property
def native_value(self) -> Optional[float]:
display = self.display_value(Prop.DISPLAY_TEMPERATURE)
return display / 10 if display is not None else None
class FglairErrorCode(FglairEntity, SensorEntity):
_attr_translation_key = "error_code"
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: FglairCoordinator) -> None:
super().__init__(coordinator, "error_code", "error_code")
@property
def native_value(self) -> Optional[int]:
return self.raw_value(Prop.ERROR_CODE)
class FglairConnectionState(FglairEntity, SensorEntity):
_attr_translation_key = "connection_state"
_attr_device_class = SensorDeviceClass.ENUM
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_options = [state.name.lower() for state in State]
def __init__(self, coordinator: FglairCoordinator) -> None:
super().__init__(coordinator, "connection_state", "connection_state")
@property
def available(self) -> bool:
# Диагностика связи должна быть видна и при offline.
return True
@property
def native_value(self) -> str:
return self.coordinator.data.state.name.lower()
async def async_setup_entry(hass, entry, async_add_entities) -> None:
runtime: FglairRuntime = entry.runtime_data
coordinator = runtime.coordinator
template = runtime.client.template
props = {info.prop for info in template_info(template)}
entities = [FglairConnectionState(coordinator)]
if Prop.DISPLAY_TEMPERATURE in props and runtime.features.enabled(
"display_temperature"
):
entities.append(FglairRoomTemperature(coordinator))
if Prop.ERROR_CODE in props:
entities.append(FglairErrorCode(coordinator))
async_add_entities(entities)
-267
View File
@@ -1,267 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "FGLair",
"menu_options": {
"cloud": "Cloud sign-in (FGLair account)",
"manual": "Manual setup",
"import_json": "Import config_*.json"
}
},
"cloud": {
"title": "FGLair cloud sign-in",
"description": "Used once for provisioning: fetches the static LAN key of the device.",
"data": {
"email": "Email",
"password": "Password",
"region": "Region"
},
"data_description": {
"region": "FGLair/Ayla account region"
}
},
"cloud_device": {
"title": "Select air conditioner",
"data": {
"dsn": "Device"
}
},
"manual": {
"title": "Device parameters",
"data": {
"host": "Host (IP or DNS name)",
"device_port": "Device port",
"dsn": "DSN",
"lanip_key": "LAN IP key",
"lanip_key_id": "LAN IP key ID",
"name": "Name (optional)",
"model": "Model / oem_model (optional)"
},
"data_description": {
"device_port": "Default 80; change only for port-forwarding setups",
"lanip_key": "From cloud discovery or ESPHome diagnostics"
}
},
"import_json": {
"title": "Import config_*.json",
"data": {
"config_json": "config JSON"
},
"data_description": {
"config_json": "Paste a JSON object produced by fglair-discover or fglctl"
}
},
"template": {
"title": "Module template",
"description": "Read from the device:\n\n{device_values}\n\n{probe_note} The template defines the module's property table (names, types, conversions); actual features are tuned on the next step.\n\nIf unsure, keep the default.",
"data": {
"template": "Template"
},
"data_description": {
"template": "A — AP-WA/AP-WC/AP-WD modules: room temperature sensor, louver directions and swing, all flags. B — AP-WB modules: no room temperature sensor, no louver directions, 1.0 °C setpoint step. F — like A but for AP-WF modules (monitor1, filter_sign_reset)."
}
},
"capabilities": {
"title": "Device capabilities",
"description": "Toggles are pre-set from the device:\n\n{device_values}\n\nDisable what the device cannot do or what you do not want to expose. Entities are created only for enabled features.\n\nIf unsure, keep as is.",
"data": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic",
"fan_quiet": "Quiet fan",
"fan_low": "Low fan",
"fan_medium": "Medium fan",
"fan_high": "High fan",
"fan_auto": "Auto fan",
"swing_vertical": "Vertical swing",
"swing_horizontal": "Horizontal swing",
"economy_mode": "Economy",
"min_heat": "Minimum heat",
"indoor_fan_control": "Indoor fan control",
"powerful_mode": "Powerful",
"outdoor_low_noise": "Outdoor low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
},
"data_description": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic mode",
"fan_auto": "Auto fan speed",
"fan_quiet": "Quiet fan speed",
"fan_low": "Low fan speed",
"fan_medium": "Medium fan speed",
"fan_high": "High fan speed",
"swing_vertical": "Vertical louver swing",
"swing_horizontal": "Horizontal louver swing",
"economy_mode": "Economy mode",
"min_heat": "Minimum heat (keep +10 °C)",
"indoor_fan_control": "Indoor fan control while outdoor unit runs",
"powerful_mode": "Powerful mode",
"outdoor_low_noise": "Outdoor unit low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED indicator",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
}
},
"limits": {
"title": "Setpoint (range and step)",
"description": "Range and step are auto-detected (A/F step 0.5 °C, B — 1.0). Optionally enable a custom linear conversion (API units are 0.1 °C).\n\nIf unsure, keep as is.",
"data": {
"temp_min": "Minimum setpoint (°C)",
"temp_max": "Maximum setpoint (°C)",
"temp_step": "Setpoint step (°C)",
"temp_num": "Conversion numerator",
"temp_den": "Conversion denominator",
"temp_offset": "Conversion offset (0.1 °C)"
},
"data_description": {
"temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)",
"temp_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset"
}
}
},
"error": {
"cannot_connect": "Could not connect to the device",
"no_slots": "Both LAN slots are busy (FGLair app or ESPHome connected). Close one and retry",
"key_mismatch": "LAN key does not match the device; re-run discovery or re-import the config",
"timeout": "Timed out waiting for the device to come online",
"invalid_auth": "Invalid email or password",
"invalid_import": "Invalid config JSON",
"no_devices": "No devices found in the account",
"unknown": "Unexpected error",
"properties_missing": "The device connected but did not report its base properties",
"invalid_range": "Minimum must be less than maximum"
},
"abort": {
"already_configured": "This device is already configured",
"invalid_import": "Invalid config JSON",
"cannot_connect": "Could not connect to the device",
"no_slots": "Both LAN slots are busy",
"key_mismatch": "LAN key does not match the device",
"timeout": "Timed out waiting for the device",
"properties_missing": "The device connected but did not report its base properties",
"unique_id_mismatch": "This is a different device (unique ID mismatch)",
"reconfigure_successful": "Reconfiguration successful"
}
},
"entity": {
"sensor": {
"room_temperature": {
"name": "Room temperature"
},
"error_code": {
"name": "Error code"
},
"connection_state": {
"name": "Connection state",
"state": {
"idle": "Idle",
"registering": "Registering",
"online": "Online",
"recovering": "Recovering",
"offline": "Offline",
"key_error": "Key error"
}
}
},
"binary_sensor": {
"connectivity": {
"name": "Connectivity"
},
"defrost": {
"name": "Defrost"
},
"maintenance": {
"name": "Maintenance"
},
"oil_recovery": {
"name": "Oil recovery"
},
"pump_down": {
"name": "Pump down"
},
"check_operation": {
"name": "Check operation"
},
"simultaneous": {
"name": "Simultaneous operation"
},
"different_modes": {
"name": "Freeze/oil/other modes (template B)"
}
},
"switch": {
"economy_mode": {
"name": "Economy"
},
"powerful_mode": {
"name": "Powerful"
},
"coil_dry_mode": {
"name": "Coil dry"
},
"min_heat": {
"name": "Minimum heat"
},
"outdoor_low_noise": {
"name": "Outdoor low noise"
},
"human_det_auto_save": {
"name": "Human detection auto save"
},
"wifi_led_enable": {
"name": "Wi-Fi LED"
},
"indoor_fan_control": {
"name": "Indoor fan control"
}
},
"select": {
"af_vertical_direction": {
"name": "Vertical airflow direction"
},
"af_horizontal_direction": {
"name": "Horizontal airflow direction"
}
}
},
"issues": {
"key_mismatch": {
"title": "FGLair: LAN key mismatch",
"description": "Device {device} ({host}) rejected the stored LAN key (key_id mismatch). Use Reconfigure on the integration card to fetch the current key (cloud sign-in) or paste a fresh config_*.json.",
"fix_flow": {
"step": {
"confirm": {
"title": "FGLair: LAN key mismatch",
"description": "Confirm to reload device {device} ({host}). If the key is still wrong, use Reconfigure on the integration card: sign in to the FGLair cloud to fetch the current key or paste a fresh config_*.json. This repair item disappears once the device comes online."
}
}
}
}
},
"options": {
"step": {
"init": {
"title": "FGLair LAN key",
"description": "Full LAN key (for ESPHome secrets.yaml). Copy it, or paste a new one to replace the stored key. DSN: {dsn}; key ID: {lanip_key_id}; host: {host}. Saving a changed key reloads the device.",
"data": {
"lanip_key": "LAN IP key"
}
}
}
}
}
-49
View File
@@ -1,49 +0,0 @@
"""Switches: флаги-свойства (economy/powerful/min_heat/...)."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from pyfglair import Prop
from pyfglair.templates import prop_info
from .const import SWITCH_PROPS
from .coordinator import FglairCoordinator, FglairRuntime
from .entity import FglairEntity
class FglairSwitch(FglairEntity, SwitchEntity):
def __init__(
self,
coordinator: FglairCoordinator,
prop: Prop,
key: str,
) -> None:
super().__init__(coordinator, prop.name.lower(), key)
self._prop = prop
@property
def is_on(self) -> bool:
return self.raw_value(self._prop) == 1
async def async_turn_on(self, **kwargs: Any) -> None:
await self.coordinator.async_write([(self._prop, 1)])
async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.async_write([(self._prop, 0)])
async def async_setup_entry(hass, entry, async_add_entities) -> None:
runtime: FglairRuntime = entry.runtime_data
coordinator = runtime.coordinator
template = runtime.client.template
entities = []
for prop, key in SWITCH_PROPS.items():
if prop_info(template, prop) is None:
continue
if not coordinator.features.enabled(key):
continue
entities.append(FglairSwitch(coordinator, prop, key))
async_add_entities(entities)
@@ -1,267 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "FGLair",
"menu_options": {
"cloud": "Cloud sign-in (FGLair account)",
"manual": "Manual setup",
"import_json": "Import config_*.json"
}
},
"cloud": {
"title": "FGLair cloud sign-in",
"description": "Used once for provisioning: fetches the static LAN key of the device.",
"data": {
"email": "Email",
"password": "Password",
"region": "Region"
},
"data_description": {
"region": "FGLair/Ayla account region"
}
},
"cloud_device": {
"title": "Select air conditioner",
"data": {
"dsn": "Device"
}
},
"manual": {
"title": "Device parameters",
"data": {
"host": "Host (IP or DNS name)",
"device_port": "Device port",
"dsn": "DSN",
"lanip_key": "LAN IP key",
"lanip_key_id": "LAN IP key ID",
"name": "Name (optional)",
"model": "Model / oem_model (optional)"
},
"data_description": {
"device_port": "Default 80; change only for port-forwarding setups",
"lanip_key": "From cloud discovery or ESPHome diagnostics"
}
},
"import_json": {
"title": "Import config_*.json",
"data": {
"config_json": "config JSON"
},
"data_description": {
"config_json": "Paste a JSON object produced by fglair-discover or fglctl"
}
},
"template": {
"title": "Module template",
"description": "Read from the device:\n\n{device_values}\n\n{probe_note} The template defines the module's property table (names, types, conversions); actual features are tuned on the next step.\n\nIf unsure, keep the default.",
"data": {
"template": "Template"
},
"data_description": {
"template": "A — AP-WA/AP-WC/AP-WD modules: room temperature sensor, louver directions and swing, all flags. B — AP-WB modules: no room temperature sensor, no louver directions, 1.0 °C setpoint step. F — like A but for AP-WF modules (monitor1, filter_sign_reset)."
}
},
"capabilities": {
"title": "Device capabilities",
"description": "Toggles are pre-set from the device:\n\n{device_values}\n\nDisable what the device cannot do or what you do not want to expose. Entities are created only for enabled features.\n\nIf unsure, keep as is.",
"data": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic",
"fan_quiet": "Quiet fan",
"fan_low": "Low fan",
"fan_medium": "Medium fan",
"fan_high": "High fan",
"fan_auto": "Auto fan",
"swing_vertical": "Vertical swing",
"swing_horizontal": "Horizontal swing",
"economy_mode": "Economy",
"min_heat": "Minimum heat",
"indoor_fan_control": "Indoor fan control",
"powerful_mode": "Powerful",
"outdoor_low_noise": "Outdoor low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
},
"data_description": {
"mode_cool": "Cooling",
"mode_dry": "Dry",
"mode_fan": "Fan only",
"mode_heat": "Heating",
"mode_auto": "Automatic mode",
"fan_auto": "Auto fan speed",
"fan_quiet": "Quiet fan speed",
"fan_low": "Low fan speed",
"fan_medium": "Medium fan speed",
"fan_high": "High fan speed",
"swing_vertical": "Vertical louver swing",
"swing_horizontal": "Horizontal louver swing",
"economy_mode": "Economy mode",
"min_heat": "Minimum heat (keep +10 °C)",
"indoor_fan_control": "Indoor fan control while outdoor unit runs",
"powerful_mode": "Powerful mode",
"outdoor_low_noise": "Outdoor unit low noise",
"coil_dry_mode": "Coil dry",
"display_temperature": "Room temperature sensor",
"human_det_auto_save": "Human detection auto save",
"wifi_led_enable": "Wi-Fi LED indicator",
"af_vertical_direction": "Vertical louver position",
"af_horizontal_direction": "Horizontal louver position"
}
},
"limits": {
"title": "Setpoint (range and step)",
"description": "Range and step are auto-detected (A/F step 0.5 °C, B — 1.0). Optionally enable a custom linear conversion (API units are 0.1 °C).\n\nIf unsure, keep as is.",
"data": {
"temp_min": "Minimum setpoint (°C)",
"temp_max": "Maximum setpoint (°C)",
"temp_step": "Setpoint step (°C)",
"temp_num": "Conversion numerator",
"temp_den": "Conversion denominator",
"temp_offset": "Conversion offset (0.1 °C)"
},
"data_description": {
"temp_num": "display = raw × num / den + offset; raw is the protocol value (setpoint: 0.1 °C)",
"temp_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset"
}
}
},
"error": {
"cannot_connect": "Could not connect to the device",
"no_slots": "Both LAN slots are busy (FGLair app or ESPHome connected). Close one and retry",
"key_mismatch": "LAN key does not match the device; re-run discovery or re-import the config",
"timeout": "Timed out waiting for the device to come online",
"invalid_auth": "Invalid email or password",
"invalid_import": "Invalid config JSON",
"no_devices": "No devices found in the account",
"unknown": "Unexpected error",
"properties_missing": "The device connected but did not report its base properties",
"invalid_range": "Minimum must be less than maximum"
},
"abort": {
"already_configured": "This device is already configured",
"invalid_import": "Invalid config JSON",
"cannot_connect": "Could not connect to the device",
"no_slots": "Both LAN slots are busy",
"key_mismatch": "LAN key does not match the device",
"timeout": "Timed out waiting for the device",
"properties_missing": "The device connected but did not report its base properties",
"unique_id_mismatch": "This is a different device (unique ID mismatch)",
"reconfigure_successful": "Reconfiguration successful"
}
},
"entity": {
"sensor": {
"room_temperature": {
"name": "Room temperature"
},
"error_code": {
"name": "Error code"
},
"connection_state": {
"name": "Connection state",
"state": {
"idle": "Idle",
"registering": "Registering",
"online": "Online",
"recovering": "Recovering",
"offline": "Offline",
"key_error": "Key error"
}
}
},
"binary_sensor": {
"connectivity": {
"name": "Connectivity"
},
"defrost": {
"name": "Defrost"
},
"maintenance": {
"name": "Maintenance"
},
"oil_recovery": {
"name": "Oil recovery"
},
"pump_down": {
"name": "Pump down"
},
"check_operation": {
"name": "Check operation"
},
"simultaneous": {
"name": "Simultaneous operation"
},
"different_modes": {
"name": "Freeze/oil/other modes (template B)"
}
},
"switch": {
"economy_mode": {
"name": "Economy"
},
"powerful_mode": {
"name": "Powerful"
},
"coil_dry_mode": {
"name": "Coil dry"
},
"min_heat": {
"name": "Minimum heat"
},
"outdoor_low_noise": {
"name": "Outdoor low noise"
},
"human_det_auto_save": {
"name": "Human detection auto save"
},
"wifi_led_enable": {
"name": "Wi-Fi LED"
},
"indoor_fan_control": {
"name": "Indoor fan control"
}
},
"select": {
"af_vertical_direction": {
"name": "Vertical airflow direction"
},
"af_horizontal_direction": {
"name": "Horizontal airflow direction"
}
}
},
"issues": {
"key_mismatch": {
"title": "FGLair: LAN key mismatch",
"description": "Device {device} ({host}) rejected the stored LAN key (key_id mismatch). Use Reconfigure on the integration card to fetch the current key (cloud sign-in) or paste a fresh config_*.json.",
"fix_flow": {
"step": {
"confirm": {
"title": "FGLair: LAN key mismatch",
"description": "Confirm to reload device {device} ({host}). If the key is still wrong, use Reconfigure on the integration card: sign in to the FGLair cloud to fetch the current key or paste a fresh config_*.json. This repair item disappears once the device comes online."
}
}
}
}
},
"options": {
"step": {
"init": {
"title": "FGLair LAN key",
"description": "Full LAN key (for ESPHome secrets.yaml). Copy it, or paste a new one to replace the stored key. DSN: {dsn}; key ID: {lanip_key_id}; host: {host}. Saving a changed key reloads the device.",
"data": {
"lanip_key": "LAN IP key"
}
}
}
}
}
@@ -1,267 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "FGLair",
"menu_options": {
"cloud": "Вход в облако FGLair",
"manual": "Ввод параметров вручную",
"import_json": "Импорт config_*.json"
}
},
"cloud": {
"title": "Вход в облако FGLair",
"description": "Нужен однократно для провижинига: получает статический LAN-ключ устройства.",
"data": {
"email": "E-mail",
"password": "Пароль",
"region": "Регион"
},
"data_description": {
"region": "Регион аккаунта FGLair/Ayla"
}
},
"cloud_device": {
"title": "Выбор кондиционера",
"data": {
"dsn": "Устройство"
}
},
"manual": {
"title": "Параметры устройства",
"data": {
"host": "Хост (IP или DNS-имя)",
"device_port": "Порт модуля",
"dsn": "DSN",
"lanip_key": "LAN IP key",
"lanip_key_id": "LAN IP key ID",
"name": "Имя (необязательно)",
"model": "Модель / oem_model (необязательно)"
},
"data_description": {
"device_port": "По умолчанию 80; меняйте только для проброса портов",
"lanip_key": "Из облачного discovery или диагностики ESPHome"
}
},
"import_json": {
"title": "Импорт config_*.json",
"data": {
"config_json": "JSON конфигурации"
},
"data_description": {
"config_json": "Вставьте объект JSON, созданный fglair-discover или fglctl"
}
},
"template": {
"title": "Шаблон модуля",
"description": "С устройства прочитано:\n\n{device_values}\n\n{probe_note} Шаблон задаёт таблицу свойств модуля (имена, типы, конверсии); фактические возможности прибора уточняются на следующем шаге.\n\nЕсли не уверены — оставьте значение по умолчанию.",
"data": {
"template": "Шаблон"
},
"data_description": {
"template": "A — модули AP-WA/AP-WC/AP-WD: есть датчик температуры помещения, направления и swing заслонок, все флаги. B — модули AP-WB: без датчика температуры помещения и без направлений заслонок, шаг уставки 1,0 °C. F — как A, но для модулей AP-WF (monitor1, filter_sign_reset)."
}
},
"capabilities": {
"title": "Возможности прибора",
"description": "Тумблеры выставлены по данным устройства:\n\n{device_values}\n\nОтключите то, что прибор не умеет или что не нужно показывать. Сущности создаются только для включённых возможностей.\n\nЕсли не уверены — оставьте как есть.",
"data": {
"mode_cool": "Охлаждение",
"mode_dry": "Осушение",
"mode_fan": "Вентиляция",
"mode_heat": "Обогрев",
"mode_auto": "Автоматический режим",
"fan_quiet": "Тихая скорость",
"fan_low": "Низкая скорость",
"fan_medium": "Средняя скорость",
"fan_high": "Высокая скорость",
"fan_auto": "Автоскорость",
"swing_vertical": "Swing вертикальный",
"swing_horizontal": "Swing горизонтальный",
"economy_mode": "Экономичный режим",
"min_heat": "Минимальный обогрев",
"indoor_fan_control": "Управление вентилятором",
"powerful_mode": "Мощный режим",
"outdoor_low_noise": "Тихий наружный блок",
"coil_dry_mode": "Сушка испарителя",
"display_temperature": "Датчик температуры помещения",
"human_det_auto_save": "Автосохранение (датчик присутствия)",
"wifi_led_enable": "Wi-Fi индикатор",
"af_vertical_direction": "Заслонка вертикальная",
"af_horizontal_direction": "Заслонка горизонтальная"
},
"data_description": {
"mode_cool": "Охлаждение",
"mode_dry": "Осушение",
"mode_fan": "Вентиляция (без нагрева/охлаждения)",
"mode_heat": "Обогрев",
"mode_auto": "Автоматический режим",
"fan_auto": "Автоскорость вентилятора",
"fan_quiet": "Тихая скорость",
"fan_low": "Низкая скорость",
"fan_medium": "Средняя скорость",
"fan_high": "Высокая скорость",
"swing_vertical": "Качание заслонки по вертикали",
"swing_horizontal": "Качание заслонки по горизонтали",
"economy_mode": "Экономичный режим",
"min_heat": "Минимальный обогрев (поддержание +10 °C)",
"indoor_fan_control": "Управление вентилятором при работе наружного блока",
"powerful_mode": "Мощный режим (ускоренный выход на режим)",
"outdoor_low_noise": "Тихий наружный блок",
"coil_dry_mode": "Сушка испарителя",
"display_temperature": "Датчик температуры помещения",
"human_det_auto_save": "Автосохранение при отсутствии людей (датчик присутствия)",
"wifi_led_enable": "Индикатор Wi-Fi на модуле",
"af_vertical_direction": "Выбор положения вертикальной заслонки",
"af_horizontal_direction": "Выбор положения горизонтальной заслонки"
}
},
"limits": {
"title": "Уставка (диапазон и шаг)",
"description": "Диапазон/шаг заданы автоматически (у A/F шаг 0,5 °C, у B — 1,0). При необходимости включите ручную конверсию коэффициентами (единицы API — 0,1 °C).\n\nЕсли не уверены — оставьте как есть.",
"data": {
"temp_min": "Минимум уставки (°C)",
"temp_max": "Максимум уставки (°C)",
"temp_step": "Шаг уставки (°C)",
"temp_num": "Числитель конверсии",
"temp_den": "Знаменатель конверсии",
"temp_offset": "Смещение конверсии (0,1 °C)"
},
"data_description": {
"temp_num": "display = raw × num / den + offset; raw — протокольное значение (уставка — 0,1 °C)",
"temp_den": "display = raw × num / den + offset",
"temp_offset": "display = raw × num / den + offset"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться к устройству",
"no_slots": "Оба LAN-слота заняты (приложение FGLair или ESPHome). Освободите один и повторите",
"key_mismatch": "LAN-ключ не совпадает с устройством; повторите discovery или импортируйте config заново",
"timeout": "Устройство не вышло на связь за отведённое время",
"invalid_auth": "Неверный e-mail или пароль",
"invalid_import": "Некорректный config JSON",
"no_devices": "В аккаунте не найдено устройств",
"unknown": "Неожиданная ошибка",
"properties_missing": "Устройство подключилось, но не отдало базовые свойства",
"invalid_range": "Минимум должен быть меньше максимума"
},
"abort": {
"already_configured": "Устройство уже настроено",
"invalid_import": "Некорректный config JSON",
"cannot_connect": "Не удалось подключиться к устройству",
"no_slots": "Оба LAN-слота заняты",
"key_mismatch": "LAN-ключ не совпадает с устройством",
"timeout": "Устройство не вышло на связь за отведённое время",
"properties_missing": "Устройство подключилось, но не отдало базовые свойства",
"unique_id_mismatch": "Это другое устройство (не совпадает unique ID)",
"reconfigure_successful": "Перенастройка выполнена"
}
},
"entity": {
"sensor": {
"room_temperature": {
"name": "Температура в помещении"
},
"error_code": {
"name": "Код ошибки"
},
"connection_state": {
"name": "Состояние связи",
"state": {
"idle": "Покой",
"registering": "Регистрация",
"online": "На связи",
"recovering": "Восстановление",
"offline": "Не в сети",
"key_error": "Ошибка ключа"
}
}
},
"binary_sensor": {
"connectivity": {
"name": "Связь"
},
"defrost": {
"name": "Разморозка"
},
"maintenance": {
"name": "Обслуживание"
},
"oil_recovery": {
"name": "Возврат масла"
},
"pump_down": {
"name": "Pump down"
},
"check_operation": {
"name": "Проверка работы"
},
"simultaneous": {
"name": "Одновременные операции"
},
"different_modes": {
"name": "Разморозка/масло/прочие режимы (шаблон B)"
}
},
"switch": {
"economy_mode": {
"name": "Экономичный режим"
},
"powerful_mode": {
"name": "Мощный режим"
},
"coil_dry_mode": {
"name": "Сушка испарителя"
},
"min_heat": {
"name": "Минимальный обогрев"
},
"outdoor_low_noise": {
"name": "Тихий наружный блок"
},
"human_det_auto_save": {
"name": "Автосохранение при отсутствии людей"
},
"wifi_led_enable": {
"name": "Wi-Fi индикатор"
},
"indoor_fan_control": {
"name": "Управление вентилятором"
}
},
"select": {
"af_vertical_direction": {
"name": "Положение заслонки (верт.)"
},
"af_horizontal_direction": {
"name": "Положение заслонки (гориз.)"
}
}
},
"issues": {
"key_mismatch": {
"title": "FGLair: не совпадает LAN-ключ",
"description": "Устройство {device} ({host}) отклонило сохранённый LAN-ключ (несовпадение key_id). Используйте «Настроить заново» на карточке интеграции: вход в облако получит актуальный ключ, либо вставьте новый config_*.json.",
"fix_flow": {
"step": {
"confirm": {
"title": "FGLair: не совпадает LAN-ключ",
"description": "Подтвердите — интеграция перезагрузится (устройство {device}, {host}). Если ключ всё ещё неверен, используйте «Настроить заново» на карточке интеграции: вход в облако FGLair получит актуальный ключ, либо вставьте новый config_*.json. Repair исчезнет, когда устройство выйдет на связь."
}
}
}
}
},
"options": {
"step": {
"init": {
"title": "LAN-ключ FGLair",
"description": "Полный LAN-ключ (для secrets.yaml ESPHome). Скопируйте его или вставьте новый, чтобы заменить сохранённый. DSN: {dsn}; key ID: {lanip_key_id}; host: {host}. Сохранение изменённого ключа перезагружает устройство.",
"data": {
"lanip_key": "LAN-ключ"
}
}
}
}
}
-258
View File
@@ -1,258 +0,0 @@
"""Пробное подключение при настройке: старт сессии, ONLINE ≤ timeout, чтение
базовых свойств и зондирование необязательных (presence).
Выполняется в executor (блокирующие вызовы ядра). Модуль отвечает только на
свойства из своей таблицы: отсутствие push = свойства нет (проверено на
AP-WC1E). Поэтому необязательные свойства не валят trial, а фиксируются в
``answered`` и используются как дефолты фич и для определения шаблона.
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass, field
from typing import Iterable
from pyfglair import Config, Error, Prop, Session, State, Template, Value
from pyfglair.templates import template_info
# Обязательные свойства: есть во всех шаблонах, должны ответить.
REQUIRED_PROPS: tuple[Prop, ...] = (
Prop.OPERATION_MODE,
Prop.FAN_SPEED,
)
# Зондируемые свойства (в пределах выбранного шаблона): наличие в ответе —
# информация для дефолтов фич и распознавания шаблона.
OPTIONAL_PROPS: tuple[Prop, ...] = (
Prop.DEVICE_CAPABILITIES,
Prop.DISPLAY_TEMPERATURE,
Prop.AF_VERTICAL_DIRECTION,
Prop.AF_HORIZONTAL_DIRECTION,
Prop.AF_VERTICAL_NUM_DIR,
Prop.AF_HORIZONTAL_NUM_DIR,
Prop.AF_VERTICAL_SWING,
Prop.AF_HORIZONTAL_SWING,
Prop.HUMAN_DET_AUTO_SAVE,
Prop.WIFI_LED_ENABLE,
Prop.FILTER_SIGN_RESET_DISPLAY,
Prop.MONITOR1,
)
# Подписи, по которым распознаётся шаблон при неизвестной oem_model:
# B отвечает move_step1, F — monitor1/filter_sign_reset (у A их нет).
PROBE_PROPS: dict[Template, tuple[Prop, ...]] = {
Template.A: (
Prop.AF_VERTICAL_DIRECTION,
Prop.DISPLAY_TEMPERATURE,
Prop.FILTER_SIGN_RESET_DISPLAY,
),
Template.B: (
Prop.AF_VERTICAL_MOVE_STEP1,
Prop.AF_HORIZONTAL_MOVE_STEP1,
),
Template.F: (
Prop.MONITOR1,
Prop.FILTER_SIGN_RESET,
Prop.AF_VERTICAL_DIRECTION,
),
}
@dataclass
class TrialResult:
ok: bool
reason: str = ""
state: State = State.IDLE
error: Error = Error.NONE
properties: dict[Prop, Value] = field(default_factory=dict)
answered: set[Prop] = field(default_factory=set)
def _failure_reason(state: State, error: Error) -> str:
if state == State.KEY_ERROR or error == Error.KEY_MISMATCH:
return "key_mismatch"
if error == Error.NO_SLOT:
return "no_slots"
if error in (Error.UNREACHABLE, Error.ACTIVATION_TIMEOUT):
return "unreachable"
return "timeout"
def classify_template(answered: set[Prop]) -> Template | None:
"""Шаблон по ответам зондов; None — недостаточно данных."""
if Prop.AF_VERTICAL_MOVE_STEP1 in answered and (
Prop.AF_VERTICAL_DIRECTION not in answered
):
return Template.B
if Prop.MONITOR1 in answered or Prop.FILTER_SIGN_RESET in answered:
if Prop.AF_VERTICAL_DIRECTION in answered:
return Template.F
if (
Prop.AF_VERTICAL_DIRECTION in answered
or Prop.DISPLAY_TEMPERATURE in answered
or Prop.FILTER_SIGN_RESET_DISPLAY in answered
):
return Template.A
return None
def trial_connect(
host: str,
dsn: str,
lanip_key: str,
lanip_key_id: int,
*,
device_port: int = 80,
template: Template = Template.A,
listen_port: int = 10275,
keepalive_ms: int = 15000,
timeout: float = 10.0,
props: Iterable[Prop] = REQUIRED_PROPS,
optional_props: Iterable[Prop] = OPTIONAL_PROPS,
props_timeout: float = 3.0,
) -> TrialResult:
"""Поднимает сессию, ждёт ONLINE, читает обязательные и зондирует
необязательные свойства.
Никогда не бросает: любая неудача — ``TrialResult(ok=False, reason=...)``.
"""
lock = threading.Lock()
box = {"state": State.IDLE, "error": Error.NONE}
values: dict[Prop, Value] = {}
valid = {info.prop for info in template_info(template)}
wanted = tuple(prop for prop in props if prop in valid)
optional = tuple(
prop for prop in optional_props if prop in valid and prop not in wanted
)
def on_state(state: State, error: Error) -> None:
with lock:
box["state"] = state
box["error"] = error
def on_property(event) -> None:
with lock:
values[event.prop] = event.value
try:
session = Session(
Config(
host=host,
device_port=device_port,
dsn=dsn,
lanip_key=lanip_key,
lanip_key_id=lanip_key_id,
template=template,
listen_port=listen_port,
keepalive_ms=keepalive_ms,
),
loop=None,
on_state=on_state,
on_property=on_property,
)
except Exception: # pragma: no cover - защитный путь
return TrialResult(ok=False, reason="start_failed")
try:
try:
started = session.start()
except Exception: # pragma: no cover - защитный путь
return TrialResult(ok=False, reason="start_failed")
if not started:
return TrialResult(ok=False, reason="start_failed")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with lock:
state, error = box["state"], box["error"]
if state == State.ONLINE:
break
if state == State.KEY_ERROR:
return TrialResult(
ok=False, reason=_failure_reason(state, error),
state=state, error=error,
)
time.sleep(0.05)
else:
with lock:
state, error = box["state"], box["error"]
return TrialResult(
ok=False, reason=_failure_reason(state, error),
state=state, error=error,
)
requested = wanted + optional
if requested:
session.batch_begin()
for prop in requested:
session.get_prop(prop)
session.batch_commit()
prop_deadline = min(
deadline, time.monotonic() + props_timeout
)
while time.monotonic() < prop_deadline:
with lock:
if all(prop in values for prop in wanted):
break
time.sleep(0.05)
with lock:
snapshot = dict(values)
missing = [prop for prop in wanted if prop not in snapshot]
if missing:
return TrialResult(
ok=False,
reason="properties_missing",
state=State.ONLINE,
properties=snapshot,
answered=set(snapshot),
)
return TrialResult(
ok=True,
state=State.ONLINE,
properties=snapshot,
answered=set(snapshot),
)
finally:
session.close()
def probe_template(
host: str,
dsn: str,
lanip_key: str,
lanip_key_id: int,
*,
device_port: int = 80,
listen_port: int = 10275,
timeout: float = 10.0,
) -> tuple[Template | None, dict[Prop, Value], str | None]:
"""Определяет шаблон по ответам, перебирая A/B/F короткими сессиями.
Возвращает (template, properties, fatal_reason): если модуль недоступен
(или нет слотов/ключ не тот), перебор бессмыслен и причина возвращается
третьим элементом, чтобы вызывающий не открывал ещё одну сессию.
"""
answered: set[Prop] = set()
properties: dict[Prop, Value] = {}
for template in (Template.A, Template.B, Template.F):
result = trial_connect(
host, dsn, lanip_key, lanip_key_id,
device_port=device_port, template=template,
listen_port=listen_port, timeout=timeout,
props=PROBE_PROPS[template] + (Prop.OPERATION_MODE,),
optional_props=(),
props_timeout=2.0,
)
if not result.ok:
if result.reason == "properties_missing":
# Модуль доступен, но свойств шаблона нет — пробуем дальше.
continue
# Недоступен/нет слотов/ключ — перебор шаблонов бессмыслен.
return None, properties, result.reason
answered |= result.answered
properties.update(result.properties)
detected = classify_template(answered)
if detected is not None:
return detected, properties, None
return classify_template(answered), properties, None
+3 -3
View File
@@ -296,9 +296,9 @@ HA-превью (PLAN_HOME_ASSISTANT §4) и тестами.
| M0 ✅ | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 | | M0 ✅ | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 |
| M1 ✅ | `src/ayla`: crypto+envelope, мини-httpd/httpc, jsmn-вендор | Векторы зелёные; httpd-тесты; совместимость с probe_reference.py | | M1 ✅ | `src/ayla`: crypto+envelope, мини-httpd/httpc, jsmn-вендор | Векторы зелёные; httpd-тесты; совместимость с probe_reference.py |
| M2 ✅ | `src/ayla`: сессия (установка/активация/keep-alive/re-key/слоты/503/delete) с mock-модулем | Все сценарии mock; на приборе: активация ≤5 с; семантика re-key: при зазоре local_reg ≥ ~44–50 с (при честном keep-alive 15 с — 0 re-key за 100 с; при 50 с — 3 re-key) | | M2 ✅ | `src/ayla`: сессия (установка/активация/keep-alive/re-key/слоты/503/delete) с mock-модулем | Все сценарии mock; на приборе: активация ≤5 с; семантика re-key: при зазоре local_reg ≥ ~44–50 с (при честном keep-alive 15 с — 0 re-key за 100 с; при 50 с — 3 re-key) |
| M3 ✅ | `src/aircon`: шаблоны, конверсии+override, публичный API, batch | `tests/aircon` зелёные; на приборе: чтение всех свойств, batch=1 notify | | M3 | `src/aircon`: шаблоны, конверсии+override, публичный API, batch | `tests/aircon` зелёные; на приборе: чтение всех свойств, batch=1 notify |
| M4 ✅ | fglctl-пример, `tools/fglair-discover` (в т.ч. `--format esphome-secrets`), README библиотеки (сборка IDF/POSIX, тесты) | README готов; fglctl/дискавери проверены (мок-облако + прибор). 60-мин soak на приборе: 0 рассинхронов (docs/reports/M4_SOAK.md); полный 24-ч soak — приёмочный шаг перед релизом | | M4 | fglctl-пример, `tools/fglair-discover` (в т.ч. `--format esphome-secrets`), README библиотеки (сборка IDF/POSIX, тесты) | 24 ч на приборе: 0 рассинхронов; README готов |
| M5 | **ВНЕ СКОУПА core-релиза** (по решению владельца): `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | — | | M5 | (Опция) `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | Два устройства одновременно |
README.md библиотеки (после M4, для людей): сборка в ESP-IDF (как README.md библиотеки (после M4, для людей): сборка в ESP-IDF (как
компонент), сборка POSIX (cmake), запуск тестов (ctest + mock), краткий компонент), сборка POSIX (cmake), запуск тестов (ctest + mock), краткий
+1 -1
View File
@@ -135,7 +135,7 @@ ESPHome-лямбды компилируются в C++-функции и пер
2. **CLI-дискавери** (облако Ayla, без установки HA): 2. **CLI-дискавери** (облако Ayla, без установки HA):
```bash ```bash
# печатает блок для secrets.yaml # печатает блок для secrets.yaml
python tools/fglair-discover --region eu --email <email> --format esphome-secrets python tools/fglair-discover --region eu --email <email> --output esphome-secrets
# ac_dsn: "AC000W00XXXXXXX" # ac_dsn: "AC000W00XXXXXXX"
# ac_lanip_key: "<base64>" # ac_lanip_key: "<base64>"
# ac_lanip_key_id: 62999 # ac_lanip_key_id: 62999
+18 -34
View File
@@ -31,17 +31,14 @@ Fallback, если сборка wheel станет блокером: сборк
* `pyfglair/_corebuild.py` — cmake-сборка при упаковке wheel; * `pyfglair/_corebuild.py` — cmake-сборка при упаковке wheel;
* `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl-aircon/c_api.h`; * `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl-aircon/c_api.h`;
* `pyfglair/session.py` — обёртка `Session(cfg)`; колбэки ядра → asyncio * `pyfglair/session.py` — обёртка `Session(cfg)`; колбэки ядра → asyncio
через `loop.call_soon_threadsafe`. Транспорт событий — C-очередь через `loop.call_soon_threadsafe`;
(`fgl_session_wait_events`) и выделенный python-поток: прямые cffi-колбэки
из потоков ядра небезопасны (у session-потока стек 8 КБ — CPython
падает по stack overflow);
* `pyfglair/templates.py` — интроспекция шаблонов через cffi-вызовы * `pyfglair/templates.py` — интроспекция шаблонов через cffi-вызовы
`fgl_template_info_get` / `fgl_convert_to_display` (для превью в `fgl_template_info_get` / `fgl_convert_to_display` (для превью в
config flow; единый источник данных — C-таблицы, дублей нет); config flow; единый источник данных — C-таблицы, дублей нет);
* `pyfglair/provision.py` — облачный discovery (перенос логики * `pyfglair/provision.py` — облачный discovery (перенос логики
`docs/legacy/aircon/discovery.py` на современный aiohttp): e-mail/ `docs/legacy/aircon/discovery.py` на современный aiohttp): e-mail/
пароль/регион → dsn, host/ip, oem_model, lanip_key, lanip_key_id; пароль/регион → dsn, host/ip, oem_model, lanip_key, lanip_key_id;
* CLI: `python -m pyfglair discover` / `monitor` / `--format esphome-secrets` * CLI: `python -m pyfglair discover` / `monitor` / `--output esphome-secrets`
(печать блока для secrets.yaml ESPHome). (печать блока для secrets.yaml ESPHome).
2. **`custom_components/fglair/`**: 2. **`custom_components/fglair/`**:
``` ```
@@ -50,7 +47,7 @@ Fallback, если сборка wheel станет блокером: сборк
fglair_client.py # фоновый поток с FglSession fglair_client.py # фоновый поток с FglSession
coordinator.py # push-driven coordinator coordinator.py # push-driven coordinator
climate.py sensor.py switch.py select.py binary_sensor.py climate.py sensor.py switch.py select.py binary_sensor.py
diagnostics.py # dsn/key_id/host + маска lanip_key; полный ключ — options flow diagnostics.py # lanip_key/key_id/dsn видны для копирования в ESPHome
translations/{en,ru}.json translations/{en,ru}.json
``` ```
@@ -87,11 +84,9 @@ Repair (теоретический): `key_error` → «Ключ устройст
перепровижинируйте» (повторный облако-вход по требованию). Облако в рантайме перепровижинируйте» (повторный облако-вход по требованию). Облако в рантайме
не используется, ключ статичен. не используется, ключ статичен.
**Диагностика**: device-страница показывает `dsn`, `lanip_key_id`, `host` и **Диагностика**: device-страница показывает `dsn`, `lanip_key`, `lanip_key_id`,
маску `lanip_key` (дамп прикладывают в issues — секретов в нём нет). Полный `host` — источник для копирования в secrets ESPHome. В redacted-дампе ключ
ключ для secrets ESPHome берётся явным действием: reconfigure («Настроить маскируется.
заново») на карточке интеграции или CLI `python -m pyfglair discover` на
хосте HA.
## 4. Сущности ## 4. Сущности
@@ -99,15 +94,8 @@ 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 фильтруют
feature-тумблеры фильтруют сущности (`features.py`, режимы/пресеты. Записи — один `batch_commit()` на действие пользователя.
`entry.data["features"]`): то, что прибор не умеет или отключено
пользователем, не создаётся. Записи — один `batch_commit()` на действие
пользователя. У сущностей есть атрибут `description` (HA не поддерживает
тултипы).
Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована
(§10) — сущность не создаётся до уточнения; в H3 вместо неё
диагностический sensor состояния связи.
## 5. Runtime ## 5. Runtime
@@ -122,9 +110,8 @@ asyncio → push-coordinator. Состояния: `online` → available;
Структура (`screenshots/step-N.png` — заглушки-плейсхолдеры, владелец заменит Структура (`screenshots/step-N.png` — заглушки-плейсхолдеры, владелец заменит
реальными скриншотами; рядом с каждой — описание что должно быть видно): реальными скриншотами; рядом с каждой — описание что должно быть видно):
1. **Установка через HACS** (нужно публичное GitHub-зеркало — HACS не 1. **Установка через HACS**:
поддерживает GitLab/Gitea; либо ручное копирование `custom_components/`): * HACS → ⋮ → Custom repositories → URL репозитория, категория
* HACS → ⋮ → Custom repositories → URL GitHub-зеркала, категория
Integration → Add. Скриншот: диалог добавления custom repository с Integration → Add. Скриншот: диалог добавления custom repository с
заполненным URL и выбранной категорией Integration. заполненным URL и выбранной категорией Integration.
* FGLair → Download → перезапуск HA. Скриншот: страница загрузки * FGLair → Download → перезапуск HA. Скриншот: страница загрузки
@@ -142,7 +129,7 @@ asyncio → push-coordinator. Состояния: `online` → available;
6. **Готово**: карточка устройства со списком сущностей. Скриншот: страница 6. **Готово**: карточка устройства со списком сущностей. Скриншот: страница
устройства с созданными climate/sensor/switch сущностями. устройства с созданными climate/sensor/switch сущностями.
7. **Где взять ключ для ESPHome**: диагностика устройства. Скриншот: страница 7. **Где взять ключ для ESPHome**: диагностика устройства. Скриншот: страница
Diagnostics с полями dsn/lanip_key_id/host и маской lanip_key. Diagnostics с полями dsn/lanip_key/lanip_key_id.
8. Troubleshooting: 503 (оба слота заняты — телефон+ESP?), key_error, 8. Troubleshooting: 503 (оба слота заняты — телефон+ESP?), key_error,
недоступность. недоступность.
@@ -157,18 +144,15 @@ Security → Long-lived access tokens) и REST API: вызов сервисов
(OAuth-флоу) не нужна — пользователь просто создаёт токен и передаёт (OAuth-флоу) не нужна — пользователь просто создаёт токен и передаёт
скрипту (`--ha-url`, `--ha-token`). скрипту (`--ha-url`, `--ha-token`).
Режимы: quick (матрица burst/не-burst, обе стороны, с возвратом) и `--long` Режимы: quick (матрица burst/не-burst, обе стороны, с возвратом) и `--long`
(24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт дописывается (24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт). Скрипт НЕ
по ходу). Скрипт НЕ открывает собственную сессию к кондиционеру (оба слота открывает собственную сессию к кондиционеру (оба слота заняты HA+ESP).
заняты HA+ESP). ESPHome-канал (опционально, `--esphome-host`): через native
API отправляются шаги «режим» и «уставка», проверка — по состоянию в HA
(сквозная цепочка ESPHome → модуль → HA); fan/swing — только со стороны HA.
## 8. Этапы ## 8. Этапы
| # | Содержимое | | # | Содержимое |
|---|-----------| |---|-----------|
| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor | | H1 | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение | | H2 | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
| H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности | | H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
| H4 ✅ | repair, диагностика (ключ для ESPHome), translations | | H4 | repair, диагностика (ключ для ESPHome), translations |
| H5 ✅ | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), hacs.json; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64) — релизное действие, чек-лист `docs/RELEASE_HA.md` | | H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз |
-4
View File
@@ -32,10 +32,6 @@ docs/ # документация (ниже) + матер
| `PLAN_ESPHOME.md` | План ESPHome-компонента: host+DNS, секреты, кастомные конверсии-лямбды, advanced-пример, приёмка. | | `PLAN_ESPHOME.md` | План ESPHome-компонента: host+DNS, секреты, кастомные конверсии-лямбды, advanced-пример, приёмка. |
| `legacy/` | Изменённый legacy-скрипт (форк [gyro-labs/AirCon](https://github.com/gyro-labs/AirCon) / hisense_ac): `main.py` + пакет `aircon/`. Локальный `config_kata.json` не версионируется (содержит lanip_key). | | `legacy/` | Изменённый legacy-скрипт (форк [gyro-labs/AirCon](https://github.com/gyro-labs/AirCon) / hisense_ac): `main.py` + пакет `aircon/`. Локальный `config_kata.json` не версионируется (содержит lanip_key). |
| `apk/` | Материалы анализа APK FGLair 3.4.3 (`manifest.json`; .apk-бинарники лежат локально, не версионируются). | | `apk/` | Материалы анализа APK FGLair 3.4.3 (`manifest.json`; .apk-бинарники лежат локально, не версионируются). |
| `../README.md` | README библиотеки: сборка POSIX/ESP-IDF, тесты, fglctl, `tools/fglair-discover`. |
| `../tools/fglair-discover` | Облачный provisioning (ключ устройства, `--format esphome-secrets`). |
| `../examples/cli/fglctl.cpp` | Пример CLI на публичном API. |
| `reports/M4_SOAK.md` | Отчёт приёмочного soak-прогона на приборе (60 мин, 0 рассинхронов). |
## Краткая выжимка протокола ## Краткая выжимка протокола
-179
View File
@@ -1,179 +0,0 @@
# Релиз и установка HA-интеграции
## 1. Внутренняя сборка и установка (Gitea, текущий режим)
Внутренний репозиторий — Gitea (`git.ratigorsk-12.ru`), поэтому:
* HACS к Gitea **не подключается** (только публичный GitHub) — для
внутреннего использования интеграция ставится копированием
`custom_components/fglair/` в `<config>/custom_components/`;
* зависимость `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
pip install build auditwheel twine
# для каждой архитектуры (linux x86_64/aarch64) в manylinux-контейнере:
python -m build --wheel
auditwheel repair dist/pyfglair-*.whl --plat manylinux2014_x86_64 -w dist/manylinux/
rm -f dist/pyfglair-*-linux_*.whl
python -m build --sdist
twine upload dist/manylinux/* dist/*.tar.gz
```
Проверки: `scripts/py-ci.sh`, `scripts/ci.sh`, `readelf -d` по `.so`
(нет внешней `libmbedcrypto`), установка wheel в чистый venv.
## 3. GitHub-зеркало и Custom Repositories (HACS)
HACS устанавливает интеграции только с публичных **GitHub**-репозиториев
(GitLab/Gitea не поддерживаются:
https://hacs.xyz/docs/faq/other_git_providers/). Публикация в
default-репозиторий HACS не требуется — достаточно Custom Repositories, но
хостинг обязан быть GitHub.
```sh
git remote add github git@github.com:<owner>/fgl-aircon.git
git push --mirror github
```
После зеркала:
* заполнить на 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;
с `--esphome-host` шаги «режим/уставка» идут через ESPHome, проверка — по
состоянию в HA), возврат к исходному.
* `long --hours 24 --interval 3600 --report acceptance.csv`.
* Сценарии: 503 (два слота), key_error + Repair, offline/восстановление,
два устройства, options flow (копирование/смена ключа), reconfigure.
## 5. Ограничения стенда
* Реального железа/облака в CI нет: протокол проверен mock-модулем,
облако — мок-сервером; приёмочный скрипт имеет self-test на моке HA REST.
* Суточный soak, re-key и облачный вход — только на приборе/аккаунте.
* Gitea Actions: встроенный `GITEA_TOKEN` не публикует пакеты (нужен PAT);
`actions/checkout`/`setup-python` тянутся с github.com (для изолированного
инстанса используйте абсолютные URL зеркал).
+1 -1
View File
@@ -8,7 +8,7 @@ python /opt/aircon/main.py \
run \ run \
--port 3355 \ --port 3355 \
--mqtt_host 192.168.88.1 \ --mqtt_host 192.168.88.1 \
--mqtt_user "${MQTT_USER:-user:password}" \ --mqtt_user "aircon:ic6ahrahbaijaVe3eTip" \
--local_ip 192.168.88.1 \ --local_ip 192.168.88.1 \
--config /opt/aircon/config_kata.json \ --config /opt/aircon/config_kata.json \
"$@" "$@"
-35
View File
@@ -1,35 +0,0 @@
# M4 soak-отчёт: fglctl monitor на живом приборе
- **Дата**: 2026-09-29, 00:16–01:16 MSK (60 минут)
- **Прибор**: AP-WC1E (fw 2.6.17-fgl2), IP/DSN — в локальном
`docs/legacy/config_kata.json` (не версионируется)
- **Команда**: `fglctl docs/legacy/config_kata.json monitor 3600`
- **Лог**: локально (артефакт прогона; сводка ниже)
## Результаты
| Метрика | Значение |
|---------|----------|
| Смены состояний | registering → online → idle (stop) |
| recovering / offline / KEY_ERROR | **0** |
| Свойств получено | 33 (ответы на стартовый batch GET, все свойства шаблона A, вкл. строковые) |
| Push'ей со статусом ≠ 200 | **0** |
| `pushes_ok` / `pushes_bad` | 33 / **0** |
| Опросов `commands.json` модулем | 273 (~каждые 13 с) |
| Re-key | 1 (только первичный; keep-alive 15 с предотвращает stale-gap re-key — PROTOCOL §4.4) |
Строковые свойства подтверждены на приборе: `DeviceName="Wave13"`,
`BuildingName="NoGroup"`, `ServiceContact*=""`.
## Вывод
60-минутный soak с непрерывным опросом модуля: 0 рассинхронов, 0 потерь,
сессия стабильна, `delete_session` при завершении отправлен.
**Полный 24-часовой прогон — приёмочный шаг перед релизом** (выполняется
владельцем; рекомендуется на том же стенде):
```sh
fglctl docs/legacy/config_kata.json monitor 86400 > soak24h.log 2>&1
# Accept: нет строк recovering/offline/KEY_ERROR; pushes_bad=0 в финальном STATS
```
-269
View File
@@ -1,269 +0,0 @@
// fglctl — CLI для fglair-core (пример использования публичного API).
//
// fglctl <config.json> monitor [сек] — события/состояния/статистика
// fglctl <config.json> get <PropEnum...> — прочитать свойства
// fglctl <config.json> set <PropEnum> <знач> — записать свойство
// fglctl <config.json> status — краткий статус и выход
//
// config.json — формат tools/fglair-discover / config_*.json:
// {"ip_address": "...", "dsn": "...", "lanip_key": "...",
// "lanip_key_id": N, "model": "AP-WC1E", ...}
#include <cerrno>
#include <chrono>
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <thread>
#include "fgl-aircon/session.hpp"
#include "fgl-aircon/templates.hpp"
#include "ayla/json.hpp"
using fgl::aircon::Prop;
using fgl::aircon::Session;
using fgl::aircon::State;
using fgl::aircon::Template;
using fgl::aircon::Value;
namespace {
const char* state_name(State st) {
switch (st) {
case State::kIdle: return "idle";
case State::kRegistering: return "registering";
case State::kOnline: return "online";
case State::kRecovering: return "recovering";
case State::kOffline: return "offline";
case State::kKeyError: return "KEY_ERROR";
}
return "?";
}
void log_sink(int level, const char* msg, size_t len, void*) {
const char* tag = level == 0 ? "D" : level == 1 ? "I" : level == 2 ? "W" : "E";
fprintf(stderr, "[%s] %.*s\n", tag, static_cast<int>(len), msg);
}
void on_state(void* /*ctx*/, State st, fgl::aircon::Error err) {
printf("STATE %s err=%d\n", state_name(st), static_cast<int>(err));
fflush(stdout);
}
void on_property(void* /*ctx*/, const fgl::aircon::PropertyEvent& ev) {
const char* n = fgl::aircon::prop_enum_name(ev.prop);
if (ev.value.kind == fgl::aircon::ValueKind::kString) {
printf("PROP %s = \"%s\"", n, ev.value.s);
} else {
printf("PROP %s = %lld", n, static_cast<long long>(ev.value.i));
if (ev.value.kind == fgl::aircon::ValueKind::kBool) printf(" (bool)");
}
if (ev.cmd_id >= 0) printf(" [cmd_id=%d status=%d]", ev.cmd_id, ev.status);
printf("\n");
fflush(stdout);
}
bool load_config(const char* path, fgl::aircon::Config* cfg,
Template* tmpl) {
FILE* f = fopen(path, "rb");
if (f == nullptr) {
fprintf(stderr, "Не открыть %s\n", path);
return false;
}
static char buf[4096];
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
bool truncated = n == sizeof(buf) - 1 && !feof(f);
fclose(f);
buf[n] = '\0';
if (truncated) {
fprintf(stderr, "ВНИМАНИЕ: config обрезан (> %zu байт)\n",
sizeof(buf) - 1);
}
fgl::ayla::json::Doc doc;
if (!doc.parse(buf)) {
fprintf(stderr, "config не разобран (ожидался JSON-объект)\n");
return false;
}
static char host[64], dsn[48], key[64], model[24];
if (!doc.get_string("ip_address", host, sizeof(host)) ||
!doc.get_string("dsn", dsn, sizeof(dsn)) ||
!doc.get_string("lanip_key", key, sizeof(key))) {
fprintf(stderr,
"config обязан содержать ip_address, dsn, lanip_key, lanip_key_id\n");
return false;
}
int64_t key_id = 0;
if (!doc.get_int("lanip_key_id", &key_id)) {
fprintf(stderr, "config: нет lanip_key_id\n");
return false;
}
*cfg = fgl::aircon::Config{};
cfg->device_port = 80;
cfg->listen_port = 10275;
cfg->keepalive_ms = 15000;
cfg->max_queue = 40;
cfg->host = host;
cfg->dsn = dsn;
cfg->lanip_key = key;
cfg->lanip_key_id = static_cast<uint32_t>(key_id);
// Модель опциональна; неизвестные → A (PROTOCOL §8.1, как legacy).
if (doc.get_string("model", model, sizeof(model))) {
*tmpl = fgl::aircon::template_detect(model);
if (!fgl::aircon::template_is_known(model)) {
fprintf(stderr, "ВНИМАНИЕ: модель %s не распознана, используется "
"шаблон A\n", model);
}
} else {
*tmpl = Template::kA;
}
return true;
}
Prop prop_from_name(const char* name) {
for (uint8_t p = 0; p < static_cast<uint8_t>(Prop::kCount); p++) {
if (strcmp(fgl::aircon::prop_enum_name(static_cast<Prop>(p)), name) == 0) {
return static_cast<Prop>(p);
}
}
return Prop::kCount;
}
int wait_online(Session* s, uint32_t timeout_ms) {
auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(timeout_ms);
while (std::chrono::steady_clock::now() < deadline) {
if (s->state() == State::kOnline) return 0;
if (s->state() == State::kKeyError) return 2;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
return 1;
}
void print_stats(Session* s) {
printf("STATS state=%s rekeys=%u pushes_ok=%u pushes_bad=%u cmds=%u\n",
state_name(s->state()), s->rekey_count(), s->pushes_ok(),
s->pushes_bad(), s->commands_served());
}
} // namespace
int main(int argc, char** argv) {
if (argc < 3) {
fprintf(stderr,
"usage: %s <config.json> monitor [sec] | get <Prop...> | "
"set <Prop> <value> | status\n"
" свойства: имена enum (OperationMode, FanSpeed, "
"AdjustTemperature, ...)\n",
argv[0]);
return 2;
}
fgl::aircon::set_log_sink(log_sink, nullptr);
fgl::aircon::set_log_level(1);
fgl::aircon::Config cfg{};
Template tmpl = Template::kA;
if (!load_config(argv[1], &cfg, &tmpl)) return 2;
cfg.tmpl = tmpl;
fgl::aircon::Callbacks cbs{};
cbs.on_state = on_state;
cbs.on_property = on_property;
Session* s = Session::create(cfg, cbs);
if (s == nullptr) {
fprintf(stderr, "Session::create failed (конфиг?)\n");
return 2;
}
if (!s->start()) {
fprintf(stderr, "start failed (порт занят?)\n");
delete s;
return 2;
}
const char* cmd = argv[2];
int rc = 0;
if (strcmp(cmd, "monitor") == 0) {
int secs = 60;
if (argc > 3) {
char* end = nullptr;
errno = 0;
long v = strtol(argv[3], &end, 10);
if (errno != 0 || end == argv[3] || *end != '\0' || v < 0) {
fprintf(stderr, "Некорректная длительность: %s\n", argv[3]);
rc = 2;
goto cleanup;
}
secs = static_cast<int>(v);
}
if ((rc = wait_online(s, 15000)) == 0) {
s->batch_begin();
for (auto* info = fgl::aircon::prop_info_begin(tmpl);
info != fgl::aircon::prop_info_begin(tmpl) +
fgl::aircon::prop_info_count(tmpl);
info++) {
s->get_prop(info->prop);
}
s->batch_commit();
auto deadline = std::chrono::steady_clock::now() +
std::chrono::seconds(secs);
while (std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
}
} else if (strcmp(cmd, "get") == 0) {
if ((rc = wait_online(s, 15000)) == 0 && argc > 3) {
s->batch_begin();
for (int i = 3; i < argc; i++) {
Prop p = prop_from_name(argv[i]);
if (p == Prop::kCount) {
fprintf(stderr, "Неизвестное свойство: %s\n", argv[i]);
rc = 2;
} else {
s->get_prop(p);
}
}
s->batch_commit();
std::this_thread::sleep_for(std::chrono::seconds(3));
}
} else if (strcmp(cmd, "set") == 0) {
if (argc < 5) {
fprintf(stderr, "set требует <Prop> <value>\n");
rc = 2;
} else if ((rc = wait_online(s, 15000)) == 0) {
Prop p = prop_from_name(argv[3]);
if (p == Prop::kCount) {
fprintf(stderr, "Неизвестное свойство: %s\n", argv[3]);
rc = 2;
} else {
char* end = nullptr;
errno = 0;
int64_t v = strtoll(argv[4], &end, 10);
if (errno != 0 || end == argv[4] || *end != '\0') {
fprintf(stderr, "Некорректное значение: %s\n", argv[4]);
rc = 2;
} else {
bool ok = s->set_int(p, v);
printf("SET %s %lld -> %s\n", argv[3],
static_cast<long long>(v), ok ? "ok" : "REJECTED");
if (!ok) rc = 3;
std::this_thread::sleep_for(std::chrono::seconds(2));
}
}
}
} else if (strcmp(cmd, "status") == 0) {
rc = wait_online(s, 10000);
Value v{};
if (s->cached(Prop::AdjustTemperature, &v)) {
printf("AdjustTemperature = %lld (0.1°C)\n",
static_cast<long long>(v.i));
}
} else {
fprintf(stderr, "Неизвестная команда: %s\n", cmd);
rc = 2;
}
cleanup:
print_stats(s);
s->stop();
delete s;
return rc;
}
-5
View File
@@ -1,5 +0,0 @@
{
"name": "FGLair",
"homeassistant": "2025.1.0",
"render_readme": true
}
-218
View File
@@ -1,218 +0,0 @@
// C-API (extern "C") fgl-aircon — для cffi/pyfglair (Home Assistant).
// C++-пользователям — fgl-aircon/*.hpp.
#pragma once
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// Версия библиотеки.
const char* fgl_version(void);
// Логирование. Логи ядра всегда пишутся в кольцевой буфер (см.
// fgl_log_wait_events) — sink лишь дополнительный прямой колбэк. Прямой
// колбэк вызывается из потоков ядра с малым стеком (session-поток — 8 КБ):
// вызывать из него Python нельзя; для cffi используйте очередь.
typedef void (*fgl_log_sink_fn)(int level, const char* msg, unsigned len,
void* ctx);
void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx);
void fgl_log_set_level(int min_level);
// Состояния/ошибки (значения совпадают с fgl::aircon::State/Error).
typedef enum {
FGL_STATE_IDLE = 0,
FGL_STATE_REGISTERING = 1,
FGL_STATE_ONLINE = 2,
FGL_STATE_RECOVERING = 3,
FGL_STATE_OFFLINE = 4,
FGL_STATE_KEY_ERROR = 5,
} fgl_state_t;
typedef enum {
FGL_ERR_NONE = 0,
FGL_ERR_NO_SLOT = 1,
FGL_ERR_UNREACHABLE = 2,
FGL_ERR_KEY_MISMATCH = 3,
FGL_ERR_BAD_KEY_EXCHANGE = 4,
FGL_ERR_ACTIVATION_TIMEOUT = 5,
FGL_ERR_DECRYPT_FAILED = 6,
} fgl_error_t;
typedef enum {
FGL_PROP_OPERATION_MODE = 0,
FGL_PROP_FAN_SPEED = 1,
FGL_PROP_ADJUST_TEMPERATURE = 2,
FGL_PROP_DISPLAY_TEMPERATURE = 3,
FGL_PROP_AF_VERTICAL_DIRECTION = 4,
FGL_PROP_AF_VERTICAL_SWING = 5,
FGL_PROP_AF_HORIZONTAL_DIRECTION = 6,
FGL_PROP_AF_HORIZONTAL_SWING = 7,
FGL_PROP_AF_VERTICAL_MOVE_STEP1 = 8,
FGL_PROP_AF_HORIZONTAL_MOVE_STEP1 = 9,
FGL_PROP_OUTDOOR_LOW_NOISE = 10,
FGL_PROP_INDOOR_FAN_CONTROL = 11,
FGL_PROP_HUMAN_DET_AUTO_SAVE = 12,
FGL_PROP_MIN_HEAT = 13,
FGL_PROP_POWERFUL_MODE = 14,
FGL_PROP_COIL_DRY_MODE = 15,
FGL_PROP_ECONOMY_MODE = 16,
FGL_PROP_MASTER_TIMER_ON_OFF_1 = 17,
FGL_PROP_MASTER_TIMER_ON_OFF_2 = 18,
FGL_PROP_ERROR_CODE = 19,
FGL_PROP_DEMAND_CONTROL = 20,
FGL_PROP_FILTER_SIGN_RESET_DISPLAY = 21,
FGL_PROP_FILTER_SIGN_RESET = 22,
FGL_PROP_OP_STATUS = 23,
FGL_PROP_DEVICE_NAME = 24,
FGL_PROP_BUILDING_NAME = 25,
FGL_PROP_WIFI_LED_ENABLE = 26,
FGL_PROP_SERVICE_CONTACT_NAME = 27,
FGL_PROP_SERVICE_CONTACT_PHONE = 28,
FGL_PROP_SERVICE_CONTACT_EMAIL = 29,
FGL_PROP_AF_HORIZONTAL_NUM_DIR = 30,
FGL_PROP_AF_VERTICAL_NUM_DIR = 31,
FGL_PROP_DEVICE_CAPABILITIES = 32,
FGL_PROP_GET_PROP = 33,
FGL_PROP_HUMAN_DET = 34,
FGL_PROP_MONITOR1 = 35,
FGL_PROP_REFRESH = 36,
FGL_PROP_COUNT = 37,
} fgl_prop_t;
typedef enum {
FGL_VALUE_INT = 0,
FGL_VALUE_BOOL = 1,
FGL_VALUE_STRING = 2,
} fgl_value_kind_t;
typedef struct {
fgl_value_kind_t kind;
int64_t i;
char s[64];
} fgl_value_t;
typedef enum {
FGL_TEMPLATE_A = 0,
FGL_TEMPLATE_B = 1,
FGL_TEMPLATE_F = 2,
} fgl_template_t;
// Конфигурация. Строки копируются при создании.
typedef struct {
const char* host;
uint16_t device_port;
const char* dsn;
const char* lanip_key;
uint32_t lanip_key_id;
fgl_template_t tmpl;
uint16_t listen_port;
uint32_t keepalive_ms;
uint8_t max_queue;
} fgl_config_t;
typedef struct {
fgl_prop_t prop;
int32_t cmd_id; // -1 — спонтанное обновление
int32_t status;
fgl_value_t value;
} fgl_property_event_t;
typedef void (*fgl_on_state_fn)(void* ctx, fgl_state_t st, fgl_error_t err);
typedef void (*fgl_on_property_fn)(void* ctx,
const fgl_property_event_t* ev);
typedef struct {
fgl_on_state_fn on_state;
fgl_on_property_fn on_property;
void* ctx;
} fgl_callbacks_t;
// Непрозрачная сессия (определение — в реализации).
typedef struct fgl_session fgl_session_t;
// ---------------------------------------------------------------------------
// Очередь событий (опросный транспорт для cffi/pyfglair).
// КОНТРАКТ: колбэки fgl_callbacks_t вызываются из потоков ядра с малым стеком
// (session-поток — 8 КБ), поэтому вызывать из них интерпретатор Python
// небезопасно. Вместо этого ядро складывает события/логи в ограниченные
// кольцевые буферы, а python-поток забирает их fgl_session_wait_events /
// fgl_log_wait_events. Колбэки fgl_callbacks_t при этом продолжают работать
// (для C/C++ потребителей).
// ---------------------------------------------------------------------------
typedef enum {
FGL_EVENT_STATE = 0,
FGL_EVENT_PROPERTY = 1,
} fgl_event_type_t;
typedef struct {
int level;
char msg[160];
} fgl_log_event_t;
typedef struct {
fgl_event_type_t type;
union {
struct {
fgl_state_t state;
fgl_error_t error;
} state;
fgl_property_event_t property;
} data;
} fgl_event_t;
// Неблокирующая выборка событий; возвращает число заполненных out
// (0..max_events). При переполнении буфера самые старые события теряются.
int fgl_session_poll_events(fgl_session_t* s, fgl_event_t* out, int max_events);
// Блокирующая выборка: ждёт событие/таймаут (timeout_ms; < 0 — бесконечно).
// Возвращает число заполненных out.
int fgl_session_wait_events(fgl_session_t* s, fgl_event_t* out, int max_events,
int timeout_ms);
// Счётчик потерянных (переполнение кольца) событий сессии/логов.
uint64_t fgl_session_events_dropped(const fgl_session_t* s);
uint64_t fgl_log_events_dropped(void);
// Логи ядра (общий кольцевой буфер; включается при создании C-API-сессии).
int fgl_log_poll_events(fgl_log_event_t* out, int max_events);
int fgl_log_wait_events(fgl_log_event_t* out, int max_events, int timeout_ms);
fgl_session_t* fgl_session_create(const fgl_config_t* cfg,
const fgl_callbacks_t* cbs);
void fgl_session_destroy(fgl_session_t* s);
int fgl_session_start(fgl_session_t* s); // 1 ok / 0 ошибка
void fgl_session_stop(fgl_session_t* s);
fgl_state_t fgl_session_state(const fgl_session_t* s);
fgl_error_t fgl_session_last_error(const fgl_session_t* s);
int fgl_session_set_int(fgl_session_t* s, fgl_prop_t prop, int64_t value);
int fgl_session_set_bool(fgl_session_t* s, fgl_prop_t prop, int value);
int fgl_session_set_string(fgl_session_t* s, fgl_prop_t prop,
const char* value);
int fgl_session_get_prop(fgl_session_t* s, fgl_prop_t prop);
int fgl_session_batch_begin(fgl_session_t* s);
int fgl_session_batch_commit(fgl_session_t* s);
int fgl_session_batch_abort(fgl_session_t* s);
int fgl_session_cached(const fgl_session_t* s, fgl_prop_t prop,
fgl_value_t* out);
// Интроспекция шаблонов (превью HA, CLI).
int fgl_template_detect(const char* oem_model); // -1 — неизвестно
const char* fgl_prop_name(fgl_template_t t, fgl_prop_t prop); // NULL — нет
const char* fgl_prop_base_type(fgl_template_t t, fgl_prop_t prop);
int fgl_prop_read_only(fgl_template_t t, fgl_prop_t prop); // -1 — нет
// raw-диапазон свойства из таблицы шаблона: 1 — определён (min/max заполнены),
// 0 — диапазона нет, -1 — свойства нет в шаблоне.
int fgl_prop_raw_range(fgl_template_t t, fgl_prop_t prop, int64_t* min,
int64_t* max);
int64_t fgl_convert_to_display(fgl_template_t t, fgl_prop_t prop,
int64_t raw);
int64_t fgl_convert_from_input(fgl_template_t t, fgl_prop_t prop,
int64_t api_value);
#ifdef __cplusplus
} // extern "C"
#endif
-63
View File
@@ -1,63 +0,0 @@
// Публичная сессия fgl-aircon: обёртка над ayla-сессией с таблицами свойств
// шаблонов, конверсиями и кэшем значений.
#pragma once
#include "fgl-aircon/templates.hpp"
#include "fgl-aircon/types.hpp"
namespace fgl::aircon {
// Глобальный приёмник логов ядра (nullptr — отключено). Быстрый, реентерабельный.
// КОНТРАКТ: вызывать ДО create() сессий; не менять при живых сессиях.
using LogSink = void (*)(int level, const char* msg, size_t len, void* ctx);
void set_log_sink(LogSink sink, void* ctx);
void set_log_level(int min_level); // 0=debug..3=error, default 1
class Session {
public:
// Создаёт сессию (одноразовые аллокации). nullptr при некорректном конфиге.
static Session* create(const Config& cfg, const Callbacks& cbs);
~Session();
Session(const Session&) = delete;
Session& operator=(const Session&) = delete;
// Запуск (httpd + сессионный поток). false — ошибка bind/listen.
bool start();
// Штатное завершение: DELETE-команда + notify, ожидание выдачи ≤2с.
void stop();
State state() const;
Error last_error() const;
// ---- Управление свойствами (потокобезопасны; очередь с coalescing) ----
// set_int/set_bool: read-only свойства и свойства не из шаблона → false.
// Значение клампится по диапазону (override), конверсия from_input.
bool set_int(Prop prop, int64_t value);
bool set_bool(Prop prop, bool value);
bool set_string(Prop prop, const char* value); // только строковые
// Запросить обновление (ответ придёт on_property с cmd_id).
bool get_prop(Prop prop);
// Пакет команд: один local_reg notify=1 на commit.
bool batch_begin();
bool batch_commit();
bool batch_abort();
// Кэш значений (обновляется push'ами; set_* — оптимистично).
bool cached(Prop prop, Value* out) const;
// Телеметрия.
uint32_t rekey_count() const;
uint32_t pushes_ok() const;
uint32_t pushes_bad() const;
uint32_t commands_served() const;
private:
Session();
struct Impl;
Impl* impl_;
};
} // namespace fgl::aircon
-58
View File
@@ -1,58 +0,0 @@
// Интроспекция шаблонов и конверсии (для HA-превью, ESPHome, CLI, тестов).
#pragma once
#include <cstddef>
#include <cstdint>
#include "fgl-aircon/types.hpp"
namespace fgl::aircon {
// Описание свойства в шаблоне.
struct PropInfo {
Prop prop;
const char* name; // имя в протоколе ("operation_mode")
const char* base_type; // "integer" | "boolean" | "string"
bool read_only;
ValueKind kind;
int64_t raw_min; // диапазон raw (справочно, из таблиц приложения)
int64_t raw_max;
};
// Список свойств шаблона (constexpr-таблица; kCount-терминатора нет —
// используйте prop_info_count).
const PropInfo* prop_info_begin(Template t);
size_t prop_info_count(Template t);
// Поиск по enum (в шаблоне). nullptr — свойство не входит в шаблон.
const PropInfo* prop_info_find(Template t, Prop prop);
// Поиск по имени протокола (в шаблоне). nullptr — не найдено.
const PropInfo* prop_info_find_by_name(Template t, const char* name);
// Имя enum-значения свойства ("OperationMode") — для логов/CLI.
const char* prop_enum_name(Prop prop);
// ---------------------------------------------------------------------------
// Конверсии. По умолчанию (kTemplateDefault):
// - почти все свойства — тождественны (API-значение == raw);
// - DisplayTemperature: raw 0.01°C со смещением 5000 → API 0.1°C:
// api = (raw - 5000) / 10 [округление к нулю; raw=7300 → 230 → 23.0°C]
// Override (linear/custom) применяется поверх (план §4).
// КОНТРАКТ Linear (при прямом вызове без Session::create): num != 0 и
// den != 0 — Session::create это валидирует; самодельные списки override'ов
// для прямых вызовов конверсий должны удовлетворять тому же.
// ---------------------------------------------------------------------------
int64_t convert_to_display(Template t, Prop prop, int64_t raw,
const PropOverride* overrides);
// Обратная конверсия (для set_*). kLinear в from_input — ИНВЕРСИЯ той же
// спецификации: raw = (api - offset) * den / num (задавайте те же
// коэффициенты, что и в to_display).
int64_t convert_from_input(Template t, Prop prop, int64_t api_value,
const PropOverride* overrides);
// Диапазон API-значений с учётом override (false — диапазона нет).
bool prop_api_range(Template t, Prop prop, const PropOverride* overrides,
int64_t* min, int64_t* max);
} // namespace fgl::aircon
-167
View File
@@ -1,167 +0,0 @@
// Публичные типы fgl-aircon (уровень aircon: свойства, конверсии, конфиг).
// Протокольные детали скрыты в src/ayla; этот заголовок — стабильный API
// для интеграций (ESPHome, HA/pyfglair, CLI).
#pragma once
#include <cstddef>
#include <cstdint>
namespace fgl::aircon {
// ---------------------------------------------------------------------------
// Состояние сессии (зеркалирует ayla-слой).
// ---------------------------------------------------------------------------
enum class State : uint8_t {
kIdle = 0, // создан, не запущен
kRegistering, // local_reg отправлен, ждём key exchange
kOnline, // сессия активна
kRecovering, // самолечение (тишина → re-key)
kOffline, // модуль недоступен (backoff) или нет слотов
kKeyError, // lanip_key_id не совпал — смена конфига вручную
};
enum class Error : int {
kNone = 0,
kNoSlot = 1,
kUnreachable = 2,
kKeyMismatch = 3,
kBadKeyExchange = 4,
kActivationTimeout = 5,
kDecryptFailed = 6,
};
// ---------------------------------------------------------------------------
// Свойства (шаблоны A/B/F; PROTOCOL.md §8.2)
// ---------------------------------------------------------------------------
enum class Prop : uint8_t {
OperationMode = 0,
FanSpeed,
AdjustTemperature,
DisplayTemperature,
AfVerticalDirection,
AfVerticalSwing,
AfHorizontalDirection,
AfHorizontalSwing,
AfVerticalMoveStep1,
AfHorizontalMoveStep1,
OutdoorLowNoise,
IndoorFanControl,
HumanDetAutoSave,
MinHeat,
PowerfulMode,
CoilDryMode,
EconomyMode,
MasterTimerOnOff1,
MasterTimerOnOff2,
ErrorCode,
DemandControl,
FilterSignResetDisplay,
FilterSignReset,
OpStatus,
DeviceName,
BuildingName,
WifiLedEnable,
ServiceContactName,
ServiceContactPhone,
ServiceContactEmail,
AfHorizontalNumDir,
AfVerticalNumDir,
DeviceCapabilities,
GetProp,
HumanDet,
Monitor1,
Refresh,
kCount
};
// Тип значения свойства.
enum class ValueKind : uint8_t { kInt = 0, kBool, kString };
struct Value {
ValueKind kind = ValueKind::kInt;
int64_t i = 0; // kInt / kBool (0|1)
char s[64] = {}; // kString
};
// ---------------------------------------------------------------------------
// Шаблоны устройств (oem_model → шаблон, PROTOCOL.md §8.1)
// ---------------------------------------------------------------------------
enum class Template : uint8_t { kA = 0, kB, kF };
// Определение шаблона по oem_model ("AP-WC1E" → kA). Неизвестные модели
// трактуются как kA (как и legacy) — различить можно template_is_known.
Template template_detect(const char* oem_model);
// Известна ли модель (false → template_detect вернёт kA как fallback).
bool template_is_known(const char* oem_model);
// ---------------------------------------------------------------------------
// Конверсии (PROTOCOL.md §8.3; требования плана §4)
// ---------------------------------------------------------------------------
enum class ConvKind : uint8_t {
kTemplateDefault = 0, // из таблицы шаблона
kLinear, // disp = raw*num/den + offset (int64, округление к нулю)
kCustomFn, // disp = fn(raw, ctx)
};
struct Linear {
int64_t num = 1;
int64_t den = 1;
int64_t offset = 0;
// to_display: api = raw*num/den + offset
// from_input: raw = (api - offset)*den/num (инверсия)
};
using ConvFn = int64_t (*)(int64_t raw, void* ctx);
struct Conversion {
ConvKind kind = ConvKind::kTemplateDefault;
Linear linear{}; // при kLinear
ConvFn fn = nullptr; // при kCustomFn
void* ctx = nullptr; // контекст kCustomFn
};
// Точечная настройка одного свойства (nullptr-terminnated массив в конфиге).
struct PropOverride {
Prop prop;
bool has_range = false;
int64_t min = 0; // в API-единицах (после конверсии)
int64_t max = 0;
Conversion to_display; // raw -> API
Conversion from_input; // API -> raw
};
// ---------------------------------------------------------------------------
// Конфигурация и колбэки сессии
// ---------------------------------------------------------------------------
struct Config {
// host/dsn/lanip_key копируются при create; overrides — нет (см. ниже).
const char* host = nullptr; // DNS-имя или IP модуля
uint16_t device_port = 80;
const char* dsn = nullptr; // "AC000W00XXXXXXX"
const char* lanip_key = nullptr; // base64-строка как есть
uint32_t lanip_key_id = 0;
Template tmpl = Template::kA;
uint16_t listen_port = 10275;
uint32_t keepalive_ms = 15000;
uint8_t max_queue = 40; // >= полного батча шаблона (33)
// Переопределения конверсий. МАССИВ ДОЛЖЕН ПЕРЕЖИТЬ СЕССИЮ (не копируется;
// читается при каждом set/push). Терминатор: элемент с prop == kCount.
const PropOverride* overrides = nullptr;
};
struct PropertyEvent {
Prop prop;
Value value; // уже сконвертированное (to_display)
int cmd_id = -1; // ответ на GET; -1 — спонтанное обновление
int status = 0;
};
struct Callbacks {
// КОНТРАКТ: вызываются из потоков ядра; быстрые и реентерабельные;
// вызывать stop() из колбэка запрещено.
void (*on_state)(void* ctx, State st, Error err);
void (*on_property)(void* ctx, const PropertyEvent& ev);
void* ctx = nullptr;
};
} // namespace fgl::aircon
-36
View File
@@ -1,36 +0,0 @@
# 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).
-69
View File
@@ -1,69 +0,0 @@
"""pyfglair — локальное управление кондиционерами Fujitsu General (FGLair/Ayla).
Python-обёртка над C++-ядром fgl-aircon (cffi, ABI-режим). Пакет собирает
ядро cmake'ом при упаковке wheel (см. ``python -m pyfglair._corebuild``).
Модули, требующие нативной библиотеки (``session``, ``templates``),
загружаются лениво: provisioning и перечисления доступны без неё.
"""
from __future__ import annotations
import importlib
from typing import Any
from .const import Error, Prop, State, Template, ValueKind
from .provision import Device, ProvisionError, REGIONS, discover, esphome_secrets
__version__ = "1.0.0"
_LAZY: dict[str, tuple[str, str]] = {
"Config": ("pyfglair.session", "Config"),
"PropertyEvent": ("pyfglair.session", "PropertyEvent"),
"Session": ("pyfglair.session", "Session"),
"Value": ("pyfglair.session", "Value"),
"set_log_handler": ("pyfglair.session", "set_log_handler"),
"set_log_level": ("pyfglair.session", "set_log_level"),
"PropertyInfo": ("pyfglair.templates", "PropertyInfo"),
"template_info": ("pyfglair.templates", "template_info"),
"prop_info": ("pyfglair.templates", "prop_info"),
"detect": ("pyfglair.templates", "detect"),
"api_range": ("pyfglair.templates", "api_range"),
"convert_to_display": ("pyfglair.templates", "convert_to_display"),
"convert_from_input": ("pyfglair.templates", "convert_from_input"),
"ensure_core": ("pyfglair._corebuild", "ensure_core"),
}
__all__ = [
"Config",
"Device",
"Error",
"Prop",
"PropertyEvent",
"PropertyInfo",
"ProvisionError",
"REGIONS",
"Session",
"State",
"Template",
"Value",
"ValueKind",
"api_range",
"convert_from_input",
"convert_to_display",
"detect",
"discover",
"ensure_core",
"esphome_secrets",
"prop_info",
"set_log_handler",
"set_log_level",
"template_info",
]
def __getattr__(name: str) -> Any:
entry = _LAZY.get(name)
if entry is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module = importlib.import_module(entry[0])
return getattr(module, entry[1])
-217
View File
@@ -1,217 +0,0 @@
"""CLI pyfglair: discover (облако) и monitor (LAN-сессия).
python -m pyfglair discover --region eu --email user@example.com
python -m pyfglair discover ... --format esphome-secrets
python -m pyfglair monitor --config config_home.json --duration 60
"""
from __future__ import annotations
import argparse
import asyncio
import getpass
import json
import os
import sys
from typing import Any, Optional
from .const import State, Template, ValueKind
from .provision import REGIONS, ProvisionError
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="pyfglair",
description="FGLair (Fujitsu General) LAN-управление: provisioning и "
"монитор сессии",
)
sub = parser.add_subparsers(dest="command", required=True)
disc = sub.add_parser("discover", help="облачный discovery (lanip_key)")
disc.add_argument("--region", choices=sorted(REGIONS), default="eu")
disc.add_argument("--email", default=os.environ.get("FGLAIR_EMAIL"))
disc.add_argument("--password", default=os.environ.get("FGLAIR_PASSWORD"))
disc.add_argument("--device", help="фильтр по имени устройства")
disc.add_argument("--format", choices=["json", "esphome-secrets"],
default="json")
disc.add_argument("--out", help="записать в файл (права 0600)")
disc.add_argument("--insecure", action="store_true",
help="не проверять TLS-сертификаты")
disc.add_argument("--api-base",
help="ВНУТРЕННЕЕ: базовый URL мок-облака (тесты)")
mon = sub.add_parser("monitor", help="монитор LAN-сессии (события)")
mon.add_argument("--config", help="config_*.json (fglair-discover/fglctl)")
mon.add_argument("--host")
mon.add_argument("--dsn")
mon.add_argument("--lanip-key")
mon.add_argument("--lanip-key-id", type=int)
mon.add_argument("--template", choices=["A", "B", "F"],
help="шаблон (по умолчанию — по model, иначе A)")
mon.add_argument("--device-port", type=int, default=80)
mon.add_argument("--listen-port", type=int, default=10275)
mon.add_argument("--keepalive-ms", type=int, default=15000)
mon.add_argument("--duration", type=float, default=60.0)
mon.add_argument("--get", help="свойства через запятую (enum или имя)")
mon.add_argument("--log-level", type=int, default=1)
return parser
def _load_monitor_config(args: argparse.Namespace) -> dict[str, Any]:
if args.config:
with open(args.config, "r", encoding="utf-8") as fh:
data = json.load(fh)
data.setdefault("ip_address", data.get("host"))
return data
if not (args.host and args.dsn and args.lanip_key
and args.lanip_key_id is not None):
raise SystemExit(
"monitor: нужен --config или --host/--dsn/--lanip-key/"
"--lanip-key-id"
)
return {
"ip_address": args.host,
"dsn": args.dsn,
"lanip_key": args.lanip_key,
"lanip_key_id": args.lanip_key_id,
"model": args.template,
}
def _resolve_template(data: dict[str, Any], explicit: Optional[str]) -> Template:
name = explicit or None
model = data.get("model")
if name is None and model in ("A", "B", "F"):
name = model
if name is not None:
return Template[name]
if model:
from .templates import detect
found = detect(model)
if found is None:
print(f"модель {model} неизвестна ядру — шаблон A",
file=sys.stderr)
return Template.A
return found
return Template.A
async def _run_discover(args: argparse.Namespace) -> int:
from .provision import discover, esphome_secrets
email = args.email
if not email:
raise SystemExit("discover: укажите --email или FGLAIR_EMAIL")
password = args.password or getpass.getpass("Пароль FGLair: ")
devices = await discover(
email, password, args.region, base_url=args.api_base,
insecure=args.insecure,
)
if args.device:
devices = [d for d in devices if d.name == args.device]
if not devices:
raise SystemExit(f"discover: устройство {args.device!r} не найдено")
if args.format == "esphome-secrets":
text = esphome_secrets(devices)
else:
text = "\n".join(
json.dumps(d.as_config(), ensure_ascii=False) for d in devices
) + "\n"
if args.out:
fd = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(text)
print(f"Записано: {args.out} (права 0600)", file=sys.stderr)
else:
sys.stdout.write(text)
return 0
def _fmt_value(kind: ValueKind, value: int, text: str) -> str:
if kind == ValueKind.STRING:
return f'"{text}"'
suffix = " (bool)" if kind == ValueKind.BOOL else ""
return f"{value}{suffix}"
async def _run_monitor(args: argparse.Namespace) -> int:
from .session import Config, Session, set_log_level
from .templates import template_info
data = _load_monitor_config(args)
host = data.get("ip_address")
dsn = data.get("dsn")
key = data.get("lanip_key")
key_id = data.get("lanip_key_id")
if not (host and dsn and key and key_id is not None):
raise SystemExit("monitor: в config нет ip_address/dsn/lanip_key/"
"lanip_key_id")
template = _resolve_template(data, args.template)
cfg = Config(
host=host,
dsn=dsn,
lanip_key=key,
lanip_key_id=int(key_id),
template=template,
device_port=args.device_port,
listen_port=args.listen_port,
keepalive_ms=args.keepalive_ms,
)
def on_state(state: State, error) -> None:
print(f"STATE {state.name.lower()} err={int(error)}", flush=True)
def on_property(ev) -> None:
print(
f"PROP {ev.prop.name} = "
f"{_fmt_value(ev.value.kind, ev.value.int_value, ev.value.str_value)}"
+ (f" [cmd_id={ev.cmd_id} status={ev.status}]"
if ev.cmd_id >= 0 else ""),
flush=True,
)
set_log_level(args.log_level)
session = Session(cfg, on_state=on_state, on_property=on_property)
if not session.start():
print("monitor: не удалось запустить сессию", file=sys.stderr)
session.close()
return 2
if args.get:
def normalize(name: str) -> str:
return name.strip().lower().replace("_", "")
wanted = {normalize(name) for name in args.get.split(",")}
for info in template_info(template):
if normalize(info.name) in wanted or normalize(info.prop.name) in wanted:
session.get_prop(info.prop)
try:
await asyncio.sleep(args.duration)
finally:
await session.async_stop()
return 0
def main(argv: Optional[list[str]] = None) -> int:
args = _build_parser().parse_args(argv)
try:
if args.command == "discover":
return asyncio.run(_run_discover(args))
if args.command == "monitor":
return asyncio.run(_run_monitor(args))
except ProvisionError as err:
print(f"pyfglair: {err}", file=sys.stderr)
return 2
except KeyboardInterrupt:
return 130
return 2
if __name__ == "__main__":
sys.exit(main())
-237
View File
@@ -1,237 +0,0 @@
"""cffi-декларации поверх include/fgl-aircon/c_api.h (ABI-режим, dlopen).
Библиотека ищется в порядке: $FGL_AIRCON_LIB, рядом с пакетом
(pyfglair/libfgl-aircon.so*), затем по системному имени (ldconfig).
"""
from __future__ import annotations
import ctypes.util
import os
import re
from pathlib import Path
from cffi import FFI
ffi = FFI()
ffi.cdef(
"""
const char* fgl_version(void);
typedef void (*fgl_log_sink_fn)(int level, const char* msg, unsigned len,
void* ctx);
void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx);
void fgl_log_set_level(int min_level);
typedef enum {
FGL_STATE_IDLE = 0,
FGL_STATE_REGISTERING = 1,
FGL_STATE_ONLINE = 2,
FGL_STATE_RECOVERING = 3,
FGL_STATE_OFFLINE = 4,
FGL_STATE_KEY_ERROR = 5,
} fgl_state_t;
typedef enum {
FGL_ERR_NONE = 0,
FGL_ERR_NO_SLOT = 1,
FGL_ERR_UNREACHABLE = 2,
FGL_ERR_KEY_MISMATCH = 3,
FGL_ERR_BAD_KEY_EXCHANGE = 4,
FGL_ERR_ACTIVATION_TIMEOUT = 5,
FGL_ERR_DECRYPT_FAILED = 6,
} fgl_error_t;
typedef enum {
FGL_PROP_OPERATION_MODE = 0,
FGL_PROP_FAN_SPEED = 1,
FGL_PROP_ADJUST_TEMPERATURE = 2,
FGL_PROP_DISPLAY_TEMPERATURE = 3,
FGL_PROP_AF_VERTICAL_DIRECTION = 4,
FGL_PROP_AF_VERTICAL_SWING = 5,
FGL_PROP_AF_HORIZONTAL_DIRECTION = 6,
FGL_PROP_AF_HORIZONTAL_SWING = 7,
FGL_PROP_AF_VERTICAL_MOVE_STEP1 = 8,
FGL_PROP_AF_HORIZONTAL_MOVE_STEP1 = 9,
FGL_PROP_OUTDOOR_LOW_NOISE = 10,
FGL_PROP_INDOOR_FAN_CONTROL = 11,
FGL_PROP_HUMAN_DET_AUTO_SAVE = 12,
FGL_PROP_MIN_HEAT = 13,
FGL_PROP_POWERFUL_MODE = 14,
FGL_PROP_COIL_DRY_MODE = 15,
FGL_PROP_ECONOMY_MODE = 16,
FGL_PROP_MASTER_TIMER_ON_OFF_1 = 17,
FGL_PROP_MASTER_TIMER_ON_OFF_2 = 18,
FGL_PROP_ERROR_CODE = 19,
FGL_PROP_DEMAND_CONTROL = 20,
FGL_PROP_FILTER_SIGN_RESET_DISPLAY = 21,
FGL_PROP_FILTER_SIGN_RESET = 22,
FGL_PROP_OP_STATUS = 23,
FGL_PROP_DEVICE_NAME = 24,
FGL_PROP_BUILDING_NAME = 25,
FGL_PROP_WIFI_LED_ENABLE = 26,
FGL_PROP_SERVICE_CONTACT_NAME = 27,
FGL_PROP_SERVICE_CONTACT_PHONE = 28,
FGL_PROP_SERVICE_CONTACT_EMAIL = 29,
FGL_PROP_AF_HORIZONTAL_NUM_DIR = 30,
FGL_PROP_AF_VERTICAL_NUM_DIR = 31,
FGL_PROP_DEVICE_CAPABILITIES = 32,
FGL_PROP_GET_PROP = 33,
FGL_PROP_HUMAN_DET = 34,
FGL_PROP_MONITOR1 = 35,
FGL_PROP_REFRESH = 36,
FGL_PROP_COUNT = 37,
} fgl_prop_t;
typedef enum {
FGL_VALUE_INT = 0,
FGL_VALUE_BOOL = 1,
FGL_VALUE_STRING = 2,
} fgl_value_kind_t;
typedef struct {
fgl_value_kind_t kind;
int64_t i;
char s[64];
} fgl_value_t;
typedef enum {
FGL_TEMPLATE_A = 0,
FGL_TEMPLATE_B = 1,
FGL_TEMPLATE_F = 2,
} fgl_template_t;
typedef struct {
const char* host;
uint16_t device_port;
const char* dsn;
const char* lanip_key;
uint32_t lanip_key_id;
fgl_template_t tmpl;
uint16_t listen_port;
uint32_t keepalive_ms;
uint8_t max_queue;
} fgl_config_t;
typedef struct {
fgl_prop_t prop;
int32_t cmd_id;
int32_t status;
fgl_value_t value;
} fgl_property_event_t;
typedef void (*fgl_on_state_fn)(void* ctx, fgl_state_t st, fgl_error_t err);
typedef void (*fgl_on_property_fn)(void* ctx,
const fgl_property_event_t* ev);
typedef struct {
fgl_on_state_fn on_state;
fgl_on_property_fn on_property;
void* ctx;
} fgl_callbacks_t;
typedef struct fgl_session fgl_session_t;
typedef enum {
FGL_EVENT_STATE = 0,
FGL_EVENT_PROPERTY = 1,
} fgl_event_type_t;
typedef struct {
int level;
char msg[160];
} fgl_log_event_t;
typedef struct {
fgl_event_type_t type;
union {
struct {
fgl_state_t state;
fgl_error_t error;
} state;
fgl_property_event_t property;
} data;
} fgl_event_t;
fgl_session_t* fgl_session_create(const fgl_config_t* cfg,
const fgl_callbacks_t* cbs);
void fgl_session_destroy(fgl_session_t* s);
int fgl_session_start(fgl_session_t* s);
void fgl_session_stop(fgl_session_t* s);
fgl_state_t fgl_session_state(const fgl_session_t* s);
fgl_error_t fgl_session_last_error(const fgl_session_t* s);
int fgl_session_set_int(fgl_session_t* s, fgl_prop_t prop, int64_t value);
int fgl_session_set_bool(fgl_session_t* s, fgl_prop_t prop, int value);
int fgl_session_set_string(fgl_session_t* s, fgl_prop_t prop,
const char* value);
int fgl_session_get_prop(fgl_session_t* s, fgl_prop_t prop);
int fgl_session_batch_begin(fgl_session_t* s);
int fgl_session_batch_commit(fgl_session_t* s);
int fgl_session_batch_abort(fgl_session_t* s);
int fgl_session_cached(const fgl_session_t* s, fgl_prop_t prop,
fgl_value_t* out);
int fgl_session_poll_events(fgl_session_t* s, fgl_event_t* out, int max_events);
int fgl_session_wait_events(fgl_session_t* s, fgl_event_t* out, int max_events,
int timeout_ms);
uint64_t fgl_session_events_dropped(const fgl_session_t* s);
uint64_t fgl_log_events_dropped(void);
int fgl_log_poll_events(fgl_log_event_t* out, int max_events);
int fgl_log_wait_events(fgl_log_event_t* out, int max_events, int timeout_ms);
int fgl_template_detect(const char* oem_model);
const char* fgl_prop_name(fgl_template_t t, fgl_prop_t prop);
const char* fgl_prop_base_type(fgl_template_t t, fgl_prop_t prop);
int fgl_prop_read_only(fgl_template_t t, fgl_prop_t prop);
int fgl_prop_raw_range(fgl_template_t t, fgl_prop_t prop, int64_t* min,
int64_t* max);
int64_t fgl_convert_to_display(fgl_template_t t, fgl_prop_t prop,
int64_t raw);
int64_t fgl_convert_from_input(fgl_template_t t, fgl_prop_t prop,
int64_t api_value);
"""
)
_cdll = None
def find_library() -> str:
env = os.environ.get("FGL_AIRCON_LIB")
if env:
if not Path(env).exists():
raise FileNotFoundError(f"FGL_AIRCON_LIB={env} не существует")
return env
pkg = Path(__file__).resolve().parent
pattern = re.compile(r"^libfgl-aircon\.(so(\.[0-9.]+)?|dylib|\d+\.dylib)$")
def version_key(path: Path) -> tuple[int, ...]:
match = re.search(r"\.so\.(\d+(?:\.\d+)*)$", path.name)
return tuple(int(part) for part in match.group(1).split(".")) if match else ()
candidates = sorted(
(p for p in pkg.iterdir() if p.is_file() and pattern.match(p.name)),
key=version_key,
)
if candidates:
return str(candidates[-1])
system = ctypes.util.find_library("fgl-aircon")
if system:
return system
raise OSError(
"библиотека fgl-aircon не найдена. Соберите её из корня монорепо: "
"python -m pyfglair._corebuild --inplace (или задайте FGL_AIRCON_LIB)"
)
def get_library():
"""dlopen ядра (однократно на процесс)."""
global _cdll
if _cdll is None:
_cdll = ffi.dlopen(find_library())
return _cdll
lib = get_library()
-167
View File
@@ -1,167 +0,0 @@
"""Сборка libfgl-aircon.so из корня монорепо (cmake).
Используется при упаковке wheel (setup.py) и для локальной разработки:
python -m pyfglair._corebuild --inplace
"""
from __future__ import annotations
import argparse
import os
import platform
import re
import shutil
import subprocess
import sys
from pathlib import Path
LIB_GLOBS = (
"libfgl-aircon.so",
"libfgl-aircon.so.*",
"libfgl-aircon.*.dylib",
"libfgl-aircon.dylib",
"fgl-aircon.dll",
)
def musl_platform_tag() -> str | None:
"""musllinux-тег, если сборка идёт на musl (Alpine/HA OS), иначе None.
wheel-тег `linux_<arch>` на musl вводит pip в заблуждение: он ставит
glibc-сборку на Alpine, и dlopen падает на отсутствующем
ld-linux-<arch>.so.2. На musl отдаём корректный `musllinux_X_Y_<arch>`.
"""
machine = platform.machine()
try:
result = subprocess.run(
["ldd", "--version"], capture_output=True, text=True, timeout=10
)
except (OSError, subprocess.SubprocessError):
return None
text = (result.stdout or "") + (result.stderr or "")
if "musl" not in text.lower():
return None
match = re.search(r"(\d+)\.(\d+)", text)
major, minor = (match.group(1), match.group(2)) if match else ("1", "2")
arch = {"amd64": "x86_64", "arm64": "aarch64"}.get(machine, machine)
return f"musllinux_{major}_{minor}_{arch}"
def source_root() -> Path | None:
"""Корень монорепо (CMakeLists.txt + include/fgl-aircon)."""
env = os.environ.get("FGLAIR_SOURCE_ROOT")
if env:
root = Path(env).resolve()
if (root / "CMakeLists.txt").exists():
return root
for parent in Path(__file__).resolve().parents:
if (parent / "include" / "fgl-aircon").is_dir() and (
parent / "CMakeLists.txt"
).exists():
return parent
return None
def _run(argv: list[str], cwd: Path | None = None) -> None:
print("+", " ".join(argv), flush=True)
subprocess.run(argv, cwd=cwd, check=True)
def find_built_library(build_dir: Path) -> Path:
found: list[Path] = []
for pattern in LIB_GLOBS:
found.extend(build_dir.rglob(pattern))
real = {p.resolve() for p in found if p.resolve().is_file()}
if not real:
raise FileNotFoundError(
f"libfgl-aircon не найдена после сборки в {build_dir}"
)
return max(real, key=lambda p: p.stat().st_mtime)
def build_core(
dest: Path | None = None,
*,
source: Path | None = None,
build_dir: Path | None = None,
jobs: int | None = None,
clean: bool = False,
bundled_mbedtls: bool | None = None,
) -> Path:
"""Собирает shared-ядро и (при dest) копирует его в каталог пакета.
Для wheel mbedtls по умолчанию встраивается (bundled), чтобы .so не
зависел от системной soname; FGL_BUNDLED_MBEDTLS=0 — использовать
системный.
"""
src = Path(source) if source else source_root()
if src is None:
raise RuntimeError(
"не найден корень fgl-aircon (CMakeLists.txt); задайте "
"FGLAIR_SOURCE_ROOT"
)
if bundled_mbedtls is None:
env = os.environ.get("FGL_BUNDLED_MBEDTLS")
bundled_mbedtls = env != "0"
src = Path(src).resolve()
build = Path(build_dir) if build_dir else src / "build-pyfglair"
if clean and build.exists():
shutil.rmtree(build)
generator = ["-G", "Ninja"] if shutil.which("ninja") else []
_run([
"cmake", "-S", str(src), "-B", str(build), *generator,
"-DCMAKE_BUILD_TYPE=Release",
"-DFGL_BUILD_TESTS=OFF",
"-DFGL_BUILD_EXAMPLES=OFF",
"-DFGL_BUILD_SHARED=ON",
"-DFGL_BUNDLED_MBEDTLS=" + ("ON" if bundled_mbedtls else "OFF"),
])
_run([
"cmake", "--build", str(build), "--target", "fgl-aircon-shared",
"--parallel", str(jobs or os.cpu_count() or 2),
])
lib = find_built_library(build)
if dest is None:
return lib
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
target = dest / lib.name
shutil.copy2(lib, target)
return target
def ensure_core(dest: Path | None = None) -> Path:
"""Собирает ядро, если рядом с пакетом нет готовой библиотеки."""
pkg = Path(__file__).resolve().parent
for pattern in LIB_GLOBS:
for candidate in sorted(pkg.glob(pattern)):
if candidate.is_file():
return candidate
return build_core(dest=dest or pkg)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Сборка libfgl-aircon (pyfglair)")
ap.add_argument("--inplace", action="store_true",
help="скопировать .so в каталог пакета pyfglair")
ap.add_argument("--dest", type=Path, help="каталог для копии .so")
ap.add_argument("--source", type=Path, help="корень монорепо")
ap.add_argument("--build-dir", type=Path, help="каталог сборки cmake")
ap.add_argument("--clean", action="store_true")
args = ap.parse_args(argv)
dest = args.dest
if args.inplace and dest is None:
dest = Path(__file__).resolve().parent
lib = build_core(
dest=dest, source=args.source, build_dir=args.build_dir,
clean=args.clean,
)
print(f"ядро: {lib}")
return 0
if __name__ == "__main__":
sys.exit(main())
-75
View File
@@ -1,75 +0,0 @@
"""Перечисления pyfglair (значения совпадают с include/fgl-aircon/c_api.h)."""
from __future__ import annotations
from enum import IntEnum
class State(IntEnum):
IDLE = 0
REGISTERING = 1
ONLINE = 2
RECOVERING = 3
OFFLINE = 4
KEY_ERROR = 5
class Error(IntEnum):
NONE = 0
NO_SLOT = 1
UNREACHABLE = 2
KEY_MISMATCH = 3
BAD_KEY_EXCHANGE = 4
ACTIVATION_TIMEOUT = 5
DECRYPT_FAILED = 6
class Template(IntEnum):
A = 0
B = 1
F = 2
class Prop(IntEnum):
OPERATION_MODE = 0
FAN_SPEED = 1
ADJUST_TEMPERATURE = 2
DISPLAY_TEMPERATURE = 3
AF_VERTICAL_DIRECTION = 4
AF_VERTICAL_SWING = 5
AF_HORIZONTAL_DIRECTION = 6
AF_HORIZONTAL_SWING = 7
AF_VERTICAL_MOVE_STEP1 = 8
AF_HORIZONTAL_MOVE_STEP1 = 9
OUTDOOR_LOW_NOISE = 10
INDOOR_FAN_CONTROL = 11
HUMAN_DET_AUTO_SAVE = 12
MIN_HEAT = 13
POWERFUL_MODE = 14
COIL_DRY_MODE = 15
ECONOMY_MODE = 16
MASTER_TIMER_ON_OFF_1 = 17
MASTER_TIMER_ON_OFF_2 = 18
ERROR_CODE = 19
DEMAND_CONTROL = 20
FILTER_SIGN_RESET_DISPLAY = 21
FILTER_SIGN_RESET = 22
OP_STATUS = 23
DEVICE_NAME = 24
BUILDING_NAME = 25
WIFI_LED_ENABLE = 26
SERVICE_CONTACT_NAME = 27
SERVICE_CONTACT_PHONE = 28
SERVICE_CONTACT_EMAIL = 29
AF_HORIZONTAL_NUM_DIR = 30
AF_VERTICAL_NUM_DIR = 31
DEVICE_CAPABILITIES = 32
GET_PROP = 33
HUMAN_DET = 34
MONITOR1 = 35
REFRESH = 36
class ValueKind(IntEnum):
INT = 0
BOOL = 1
STRING = 2
-234
View File
@@ -1,234 +0,0 @@
"""Облачный provisioning FGLair/Ayla: e-mail/пароль/регион → параметры устройства.
Облако используется только при настройке (lanip_key статичен, PROTOCOL §7).
Логика — перенос tools/fglair-discover / legacy discovery на aiohttp.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
import aiohttp
_LOGGER = logging.getLogger(__name__)
USER_AGENT = "pyfglair/1.0 (fglair-core)"
REGIONS = {
"eu": {
"user_server": "user-field-eu.aylanetworks.com",
"device_server": "ads-eu.aylanetworks.com",
"app_id": "FGLair-eu-id",
"app_secret": "FGLair-eu-gpFbVBRoiJ8E3QWJ-QRULLL3j3U",
},
"us": {
"user_server": "user-field.aylanetworks.com",
"device_server": "ads-field.aylanetworks.com",
"app_id": "CJIOSP-id",
"app_secret": "CJIOSP-Vb8MQL_lFiYQ7DKjN0eCFXznKZE",
},
"cn": {
"user_server": "user-field.ayla.com.cn",
"device_server": "ads-field.ayla.com.cn",
"app_id": "FGLairField-cn-id",
"app_secret": "FGLairField-cn-zezg7Y60YpAvy3HPwxvWLnd4Oh4",
},
}
class ProvisionError(Exception):
"""Ошибка provisioning (текст — для показа пользователю).
``kind``: "auth" | "no_devices" | "network" | "format" — для маппинга в
ошибки config flow без разбора текста.
"""
def __init__(self, message: str, kind: str = "unknown") -> None:
super().__init__(message)
self.kind = kind
@dataclass(frozen=True)
class Device:
name: str
model: str
dsn: str
lan_ip: str
lanip_key: str
lanip_key_id: int
region: str
mac: str = ""
@property
def temp_type(self) -> str:
return "C" if self.region == "eu" else "F"
def as_config(self) -> dict:
return {
"name": self.name,
"app": f"fglair-{self.region}",
"model": self.model,
"dsn": self.dsn,
"temp_type": self.temp_type,
"mac_address": self.mac,
"ip_address": self.lan_ip,
"lanip_key": self.lanip_key,
"lanip_key_id": self.lanip_key_id,
}
def esphome_secrets(devices: list[Device]) -> str:
lines = []
for dev in devices:
slug = "".join(c for c in dev.name.lower() if c.isalnum()) or "ac"
lines.append(f"# {dev.name} ({dev.model})")
lines.append(f'{slug}_dsn: "{dev.dsn}"')
lines.append(f'{slug}_lanip_key: "{dev.lanip_key}"')
lines.append(f"{slug}_lanip_key_id: {dev.lanip_key_id}")
lines.append("")
return "\n".join(lines)
async def _request_json(session, method, url, *, token=None, payload=None,
insecure=False):
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
if token:
headers["Authorization"] = f"auth_token {token}"
try:
async with session.request(
method,
url,
json=payload,
headers=headers,
ssl=False if insecure else None,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
try:
data = await resp.json(content_type=None)
except (aiohttp.ContentTypeError, ValueError):
data = None
return resp.status, data
except aiohttp.ClientError as err:
raise ProvisionError(f"Сеть недоступна ({url}): {err}", "network") from err
except TimeoutError as err:
raise ProvisionError(f"Таймаут запроса ({url})", "network") from err
async def _sign_in(session, email, password, region, base_url, insecure):
payload = {
"user": {
"email": email,
"password": password,
"application": {
"app_id": region["app_id"],
"app_secret": region["app_secret"],
},
}
}
url = base_url or f"https://{region['user_server']}"
status, data = await _request_json(
session, "POST", f"{url}/users/sign_in.json",
payload=payload, insecure=insecure,
)
if status != 200 or not isinstance(data, dict):
detail = data.get("error") if isinstance(data, dict) else data
raise ProvisionError(f"Ошибка входа ({status}): {detail}", "auth")
token = data.get("access_token")
if not token:
raise ProvisionError("Вход выполнен без access_token", "auth")
return token
async def _get_devices(session, token, region, base_url, insecure):
url = base_url or f"https://{region['device_server']}"
status, data = await _request_json(
session, "GET", f"{url}/apiv1/devices.json",
token=token, insecure=insecure,
)
if status != 200 or not isinstance(data, list):
raise ProvisionError(f"Ошибка списка устройств ({status}): {data}", "network")
devices = []
for item in data:
dev = item.get("device") if isinstance(item, dict) else None
if not isinstance(dev, dict) or not isinstance(dev.get("dsn"), str):
raise ProvisionError("Облако вернуло неожиданный формат устройства", "format")
devices.append(dev)
return devices
async def _get_lanip(session, token, region, base_url, insecure, dsn):
url = base_url or f"https://{region['device_server']}"
status, data = await _request_json(
session, "GET", f"{url}/apiv1/dsns/{dsn}/lan.json",
token=token, insecure=insecure,
)
if status != 200 or not isinstance(data, dict):
raise ProvisionError(f"Ошибка lan.json для {dsn} ({status}): {data}", "network")
lanip = data.get("lanip")
if lanip is not None and not isinstance(lanip, dict):
raise ProvisionError(
f"Облако вернуло неожиданный lanip для {dsn}: {lanip!r}",
"format",
)
return lanip or {}
async def discover(
email: str,
password: str,
region: str = "eu",
*,
base_url: Optional[str] = None,
session: Optional[aiohttp.ClientSession] = None,
insecure: bool = False,
) -> list[Device]:
"""Список устройств аккаунта с lanip_key (для config flow / CLI)."""
if region not in REGIONS:
raise ProvisionError(f"Неизвестный регион: {region}", "format")
region_cfg = REGIONS[region]
base = base_url.rstrip("/") if base_url else None
own_session = session is None
if own_session:
session = aiohttp.ClientSession()
try:
token = await _sign_in(
session, email, password, region_cfg, base, insecure
)
raw_devices = await _get_devices(
session, token, region_cfg, base, insecure
)
if not raw_devices:
raise ProvisionError("У аккаунта нет устройств", "no_devices")
out: list[Device] = []
for dev in raw_devices:
dsn = dev["dsn"]
lanip = await _get_lanip(
session, token, region_cfg, base, insecure, dsn
)
key = lanip.get("lanip_key")
key_id = lanip.get("lanip_key_id")
if not key or key_id is None:
_LOGGER.warning(
"Устройство %s: lan.json без ключа — пропуск", dsn
)
continue
out.append(
Device(
name=dev.get("product_name") or dsn,
model=dev.get("oem_model", ""),
dsn=dsn,
lan_ip=dev.get("lan_ip", ""),
lanip_key=key,
lanip_key_id=int(key_id),
region=region,
mac=(dev.get("mac") or "").replace(":", "").lower(),
)
)
if not out:
raise ProvisionError("Не найдено устройств с ключом LAN", "no_devices")
return out
finally:
if own_session:
await session.close()
View File
-435
View File
@@ -1,435 +0,0 @@
"""Обёртка сессии fgl-aircon: cffi-ядро + доставка событий в asyncio.
События забирает выделенный python-поток (``fgl_session_wait_events``) и
перекладывает их в event loop через ``loop.call_soon_threadsafe``. Прямые
колбэки cffi из потоков ядра не используются: у session-потока ядра стек
8 КБ, вызов интерпретатора оттуда небезопасен.
``stop()/close()`` блокирующие (≤ ~2 c: DELETE-команда и join потоков ядра) —
в async-коде используйте ``async_stop``.
"""
from __future__ import annotations
import asyncio
import logging
import threading
import time
from dataclasses import dataclass
from typing import Callable, Optional
from ._cffi import ffi, lib
from .const import Error, Prop, State, Template, ValueKind
_LOGGER = logging.getLogger(__name__)
StateHandler = Callable[[State, Error], None]
PropertyHandler = Callable[["PropertyEvent"], None]
LogHandler = Callable[[int, str], None]
@dataclass(frozen=True)
class Value:
kind: ValueKind
int_value: int = 0
str_value: str = ""
@dataclass(frozen=True)
class PropertyEvent:
prop: Prop
value: Value
cmd_id: int = -1
status: int = 0
@dataclass
class Config:
host: str
dsn: str
lanip_key: str
lanip_key_id: int
template: Template = Template.A
device_port: int = 80
listen_port: int = 10275
keepalive_ms: int = 15000
max_queue: int = 40
_GLOBAL_LOG: Optional[LogHandler] = None
_LOG_PUMP: Optional["_LogPump"] = None
_LOG_PUMP_LOCK = threading.Lock()
class _LogPump(threading.Thread):
"""Единственный на процесс читатель кольца логов ядра."""
def __init__(self) -> None:
super().__init__(name="pyfglair-log", daemon=True)
self._stop = threading.Event()
self.stopping = False
def run(self) -> None:
out = ffi.new("fgl_log_event_t[]", 8)
dropped = 0
warned_at = 0.0
while not self._stop.is_set():
total = int(lib.fgl_log_events_dropped())
if total > dropped:
if time.monotonic() - warned_at > 30.0:
_LOGGER.warning(
"переполнение очереди логов: потеряно %d",
total - dropped,
)
warned_at = time.monotonic()
dropped = total
count = lib.fgl_log_wait_events(out, 8, 250)
handler = _GLOBAL_LOG
if handler is None:
continue
for i in range(count):
try:
handler(
int(out[i].level),
ffi.string(out[i].msg).decode("utf-8", "replace"),
)
except Exception:
_LOGGER.exception("log handler failed")
def stop(self) -> None:
self.stopping = True
self._stop.set()
self.join(timeout=2.0)
def set_log_handler(handler: Optional[LogHandler]) -> None:
"""Глобальный приёмник логов ядра (0=debug..3=error).
``None`` останавливает pump-поток (если он запущен). Новый pump не
поднимается, пока предыдущий не завершился: параллельные читатели одного
кольца недопустимы.
"""
global _GLOBAL_LOG, _LOG_PUMP
with _LOG_PUMP_LOCK:
_GLOBAL_LOG = handler
if handler is None:
if _LOG_PUMP is not None:
_LOG_PUMP.stop()
return
pump = _LOG_PUMP
if pump is not None and pump.is_alive():
if not pump.stopping:
return # уже работает и не остановлен
pump.join(timeout=2.0)
if pump.is_alive():
_LOGGER.warning(
"лог-памп не завершился; новый не запускаю (обработчик "
"логов блокируется?)"
)
return
_LOG_PUMP = _LogPump()
_LOG_PUMP.start()
def set_log_level(level: int) -> None:
lib.fgl_log_set_level(int(level))
def _running_loop() -> Optional[asyncio.AbstractEventLoop]:
try:
return asyncio.get_running_loop()
except RuntimeError:
return None
def _read_value(raw) -> Value:
kind = ValueKind(int(raw.kind))
if kind == ValueKind.STRING:
text = ffi.string(raw.s).decode("utf-8", "replace")
return Value(kind, 0, text)
return Value(kind, int(raw.i), "")
class _SessionReader(threading.Thread):
"""Читает очередь событий сессии и передаёт их в ``Session``."""
def __init__(self, session: "Session") -> None:
super().__init__(name="pyfglair-events", daemon=True)
self._session = session
def run(self) -> None:
out = ffi.new("fgl_event_t[]", 16)
finalize = ffi.NULL
dropped = 0
warned_at = 0.0
try:
while True:
with self._session._lock:
if self._session._closed:
# Единственный владелец stop+destroy при закрытии —
# reader (защищает от гонки с close() и позволяет
# close() из колбэка в этом же потоке).
finalize = self._session._session
self._session._session = ffi.NULL
return
raw = self._session._session
if raw == ffi.NULL:
return
total = int(lib.fgl_session_events_dropped(raw))
self._session._dropped = total
if total > dropped:
if time.monotonic() - warned_at > 30.0:
_LOGGER.warning(
"переполнение очереди событий: потеряно %d",
total - dropped,
)
warned_at = time.monotonic()
dropped = total
count = lib.fgl_session_wait_events(raw, out, 16, 250)
for i in range(count):
try:
self._session._dispatch_event(out[i])
except Exception:
_LOGGER.exception("event dispatch failed")
finally:
if finalize != ffi.NULL:
lib.fgl_session_destroy(finalize)
class Session:
"""Сессия к одному модулю кондиционера.
Создание/использование — из event loop потока; методы ядра
потокобезопасны. Колбэки вызываются в ``loop``.
"""
def __init__(
self,
config: Config,
*,
loop: Optional[asyncio.AbstractEventLoop] = None,
on_state: Optional[StateHandler] = None,
on_property: Optional[PropertyHandler] = None,
) -> None:
self._config = config
self._on_state = on_state
self._on_property = on_property
self._loop = loop if loop is not None else _running_loop()
self._lock = threading.Lock()
self._closed = False
self._dropped = 0
self._cfg_strings: list = []
def cstr(text: str):
buf = ffi.new("char[]", text.encode("utf-8"))
self._cfg_strings.append(buf)
return buf
cfg = ffi.new("fgl_config_t*")
cfg.host = cstr(config.host)
cfg.device_port = config.device_port
cfg.dsn = cstr(config.dsn)
cfg.lanip_key = cstr(config.lanip_key)
cfg.lanip_key_id = config.lanip_key_id
cfg.tmpl = int(config.template)
cfg.listen_port = config.listen_port
cfg.keepalive_ms = config.keepalive_ms
cfg.max_queue = config.max_queue
self._session = lib.fgl_session_create(cfg, ffi.NULL)
if self._session == ffi.NULL:
raise ValueError(
"fgl_session_create вернул NULL: проверьте host/dsn/lanip_key "
"и параметры сессии"
)
self._reader = _SessionReader(self)
self._reader.start()
def _dispatch_event(self, ev) -> None:
if int(ev.type) == 0:
self._emit(
self._on_state,
State(int(ev.data.state.state)),
Error(int(ev.data.state.error)),
)
return
kind = ValueKind(int(ev.data.property.value.kind))
if kind == ValueKind.STRING:
value = Value(
kind,
0,
ffi.string(ev.data.property.value.s).decode("utf-8", "replace"),
)
else:
value = Value(kind, int(ev.data.property.value.i), "")
self._emit(
self._on_property,
PropertyEvent(
Prop(int(ev.data.property.prop)),
value,
int(ev.data.property.cmd_id),
int(ev.data.property.status),
),
)
def _emit(self, handler, *args) -> None:
if handler is None:
return
loop = self._loop
if loop is not None:
if not loop.is_running():
_LOGGER.debug("event loop остановлен, колбэк отброшен")
return
try:
loop.call_soon_threadsafe(handler, *args)
except RuntimeError:
_LOGGER.debug("event loop закрыт, колбэк отброшен")
return
try:
handler(*args)
except Exception:
_LOGGER.exception("callback failed")
@property
def state(self) -> State:
with self._lock:
if self._closed:
return State.IDLE
return State(int(lib.fgl_session_state(self._session)))
@property
def last_error(self) -> Error:
with self._lock:
if self._closed:
return Error.NONE
return Error(int(lib.fgl_session_last_error(self._session)))
@property
def closed(self) -> bool:
return self._closed
@property
def events_dropped(self) -> int:
"""Сколько событий потеряно из-за переполнения очереди (диагностика)."""
with self._lock:
if self._closed or self._session == ffi.NULL:
return self._dropped
self._dropped = int(lib.fgl_session_events_dropped(self._session))
return self._dropped
@property
def config(self) -> Config:
return self._config
def start(self) -> bool:
with self._lock:
if self._closed:
return False
return bool(lib.fgl_session_start(self._session))
def stop(self) -> None:
"""Полное завершение сессии (синоним close: stop + destroy)."""
self.close()
def close(self) -> None:
"""Останавливает сессию, джойнит reader и освобождает ядро.
Из колбэка в reader-потоке (Session без loop) возвращается сразу:
освобождение завершит сам reader. Повторный вызов из другого потока
дожидается фактического освобождения (join идемпотентен).
"""
with self._lock:
self._closed = True
if self._reader is threading.current_thread():
return
self._reader.join(timeout=10.0)
with self._lock:
if self._reader.is_alive():
_LOGGER.error(
"читатель событий не завершился за 10 с; сессия не "
"освобождена"
)
return
raw = self._session
self._session = ffi.NULL
if raw != ffi.NULL:
# Reader завершился, не освободив сессию (неожиданное исключение).
lib.fgl_session_destroy(raw)
def __del__(self) -> None:
try:
if not self._closed:
_LOGGER.warning(
"Session не закрыта: вызовите close()/async_stop()"
)
except Exception:
pass
async def async_stop(self) -> None:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self.close)
def set_int(self, prop: Prop, value: int) -> bool:
with self._lock:
if self._closed:
return False
return bool(lib.fgl_session_set_int(self._session, int(prop), value))
def set_bool(self, prop: Prop, value: bool) -> bool:
with self._lock:
if self._closed:
return False
return bool(
lib.fgl_session_set_bool(
self._session, int(prop), 1 if value else 0
)
)
def set_string(self, prop: Prop, value: str) -> bool:
with self._lock:
if self._closed:
return False
return bool(
lib.fgl_session_set_string(
self._session, int(prop), value.encode("utf-8")
)
)
def get_prop(self, prop: Prop) -> bool:
with self._lock:
if self._closed:
return False
return bool(lib.fgl_session_get_prop(self._session, int(prop)))
def batch_begin(self) -> bool:
with self._lock:
if self._closed:
return False
return bool(lib.fgl_session_batch_begin(self._session))
def batch_commit(self) -> bool:
with self._lock:
if self._closed:
return False
return bool(lib.fgl_session_batch_commit(self._session))
def batch_abort(self) -> bool:
with self._lock:
if self._closed:
return False
return bool(lib.fgl_session_batch_abort(self._session))
def cached(self, prop: Prop) -> Optional[Value]:
with self._lock:
if self._closed:
return None
out = ffi.new("fgl_value_t*")
if not lib.fgl_session_cached(self._session, int(prop), out):
return None
return _read_value(out)
def __enter__(self) -> "Session":
return self
def __exit__(self, *exc_info) -> None:
self.close()
-95
View File
@@ -1,95 +0,0 @@
"""Интроспекция шаблонов A/B/F через C-ядро (единый источник — таблицы
src/aircon/tables.cpp; дублей в Python нет)."""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from typing import Optional, Tuple
from ._cffi import ffi, lib
from .const import Prop, Template, ValueKind
_BASE_TYPE_KINDS = {
"integer": ValueKind.INT,
"boolean": ValueKind.BOOL,
"string": ValueKind.STRING,
}
@dataclass(frozen=True)
class PropertyInfo:
prop: Prop
name: str
base_type: str
kind: ValueKind
read_only: bool
raw_min: Optional[int]
raw_max: Optional[int]
def detect(oem_model: Optional[str]) -> Optional[Template]:
"""Шаблон по oem_model; None — модель неизвестна C-ядру."""
if not oem_model:
return None
result = int(lib.fgl_template_detect(oem_model.encode("utf-8")))
if result < 0:
return None
return Template(result)
@lru_cache(maxsize=None)
def template_info(template: Template) -> Tuple[PropertyInfo, ...]:
"""Все свойства шаблона (порядок констант Prop)."""
out = []
for prop in Prop:
name = lib.fgl_prop_name(int(template), int(prop))
if name == ffi.NULL:
continue
base_type = ffi.string(
lib.fgl_prop_base_type(int(template), int(prop))
).decode("ascii")
read_only = int(lib.fgl_prop_read_only(int(template), int(prop))) == 1
raw_min = ffi.new("int64_t*")
raw_max = ffi.new("int64_t*")
has_range = (
int(lib.fgl_prop_raw_range(int(template), int(prop), raw_min, raw_max))
== 1
)
out.append(
PropertyInfo(
prop=prop,
name=ffi.string(name).decode("ascii"),
base_type=base_type,
kind=_BASE_TYPE_KINDS.get(base_type, ValueKind.INT),
read_only=read_only,
raw_min=int(raw_min[0]) if has_range else None,
raw_max=int(raw_max[0]) if has_range else None,
)
)
return tuple(out)
@lru_cache(maxsize=None)
def prop_info(template: Template, prop: Prop) -> Optional[PropertyInfo]:
for info in template_info(template):
if info.prop == prop:
return info
return None
def convert_to_display(template: Template, prop: Prop, raw: int) -> int:
return int(lib.fgl_convert_to_display(int(template), int(prop), raw))
def convert_from_input(template: Template, prop: Prop, api_value: int) -> int:
return int(lib.fgl_convert_from_input(int(template), int(prop), api_value))
def api_range(template: Template, prop: Prop) -> Optional[Tuple[int, int]]:
"""Диапазон API-значений (после to_display); None — диапазон не задан."""
info = prop_info(template, prop)
if info is None or info.raw_min is None or info.raw_max is None:
return None
lo = convert_to_display(template, prop, info.raw_min)
hi = convert_to_display(template, prop, info.raw_max)
return (min(lo, hi), max(lo, hi))
-32
View File
@@ -1,32 +0,0 @@
[build-system]
requires = ["setuptools>=77", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "pyfglair"
version = "1.0.0"
description = "Local control of Fujitsu General (FGLair/Ayla) air conditioners: cffi bindings to the fgl-aircon C++ core"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.10"
dependencies = [
"cffi>=1.15",
"aiohttp>=3.9",
]
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: POSIX :: Linux",
"Topic :: Home Automation",
]
[project.scripts]
pyfglair = "pyfglair.__main__:main"
[tool.setuptools]
packages = ["pyfglair"]
include-package-data = true
zip-safe = false
[tool.setuptools.package-data]
pyfglair = ["py.typed"]
-5
View File
@@ -1,5 +0,0 @@
[pytest]
asyncio_mode = auto
testpaths = tests/pyfglair
filterwarnings =
error::ResourceWarning
-7
View File
@@ -27,11 +27,4 @@ if command -v clang++ >/dev/null 2>&1; then
else else
echo "clang++ не найден — сборка пропущена" echo "clang++ не найден — сборка пропущена"
fi fi
# Python-часть (pyfglair): нужен подготовленный venv (scripts/py-ci.sh --setup).
if [ -x "${PY_VENV:-.venv}/bin/python" ]; then
bash scripts/py-ci.sh
else
echo "pyfglair: ${PY_VENV:-.venv} не найден — пропуск (scripts/py-ci.sh --setup)"
fi
echo "CI OK" echo "CI OK"
-58
View File
@@ -1,58 +0,0 @@
#!/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"
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
# CI python-части репозитория: pyfglair + HA-компонент.
# scripts/py-ci.sh --setup # venv для pyfglair-тестов
# scripts/py-ci.sh --setup-ha # venv с homeassistant/pytest-плагином (тяжёлый)
# scripts/py-ci.sh # pytest: tests/pyfglair + tests/components (если есть venv)
set -euo pipefail
cd "$(dirname "$0")/.."
VENV="${PY_VENV:-.venv}"
HA_VENV="${HA_VENV:-.venv-ha}"
case "${1:-}" in
--setup)
python3 -m venv "$VENV"
"$VENV/bin/pip" install -q --upgrade pip
"$VENV/bin/pip" install -q cffi aiohttp pytest pytest-asyncio
;;
--setup-ha)
python3 -m venv "$HA_VENV"
"$HA_VENV/bin/pip" install -q --upgrade pip
"$HA_VENV/bin/pip" install -q -r tests/components/requirements.txt
;;
esac
if [ ! -x "$VENV/bin/python" ]; then
echo "py-ci: нет $VENV (запустите: scripts/py-ci.sh --setup)" >&2
exit 1
fi
"$VENV/bin/python" -m pytest tests/pyfglair tests/acceptance -q
if [ -x "$HA_VENV/bin/python" ]; then
"$HA_VENV/bin/python" -m pytest tests/components -q
elif [ "${PY_CI_SKIP_HA:-0}" = "1" ]; then
echo "py-ci: HA-тесты пропущены (PY_CI_SKIP_HA=1)"
else
echo "py-ci: нет $HA_VENV — запустите scripts/py-ci.sh --setup-ha" >&2
exit 1
fi
-69
View File
@@ -1,69 +0,0 @@
"""Сборка wheel pyfglair: вместе с python-пакетом собирается libfgl-aircon.so
из корня монорепо (cmake). Метаданные — в pyproject.toml; здесь только хуки
setuptools для сборки нативного ядра."""
import os
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from setuptools import Distribution, setup
from setuptools.command.build_py import build_py as _build_py
try:
from setuptools.command.bdist_wheel import bdist_wheel as _bdist_wheel
except ImportError: # setuptools < 70
from wheel.bdist_wheel import bdist_wheel as _bdist_wheel
ROOT = Path(__file__).resolve().parent
_COREBUILD = None
def _load_corebuild():
global _COREBUILD
if _COREBUILD is None:
spec = spec_from_file_location(
"_pyfglair_corebuild", ROOT / "pyfglair" / "_corebuild.py"
)
_COREBUILD = module_from_spec(spec)
spec.loader.exec_module(_COREBUILD)
return _COREBUILD
class build_py(_build_py):
"""Кладёт libfgl-aircon.so внутрь пакета до сборки wheel."""
def run(self):
dest = Path(self.build_lib) / "pyfglair"
dest.mkdir(parents=True, exist_ok=True)
for stale in dest.glob("libfgl-aircon.*"):
stale.unlink()
bundled = os.environ.get("FGL_BUNDLED_MBEDTLS", "1") != "0"
_load_corebuild().build_core(dest=dest, bundled_mbedtls=bundled)
super().run()
class BinaryDistribution(Distribution):
"""Платформенный wheel (внутри — .so), не py3-none-any."""
def has_ext_modules(self):
return True
class bdist_wheel(_bdist_wheel):
"""Тег py3-none-<plat>: .so собран в ABI-режиме cffi (не CPython-ABI).
На musl (Alpine/HA OS) платформа заменяется на musllinux_X_Y_<arch>,
иначе pip поставит glibc-сборку и dlopen упадёт.
"""
def get_tag(self):
_, _, platform = super().get_tag()
musl = _load_corebuild().musl_platform_tag()
if musl is not None and platform.startswith("linux_"):
platform = musl
return "py3", "none", platform
setup(
cmdclass={"build_py": build_py, "bdist_wheel": bdist_wheel},
distclass=BinaryDistribution,
)
-416
View File
@@ -1,416 +0,0 @@
// Реализация extern "C" шейма (cffi/pyfglair). Значения enum'ов C-API
// обязаны совпадать с C++ enum'ами — проверено static_assert'ами.
#include "fgl-aircon/c_api.h"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <new>
#include "fgl-aircon/session.hpp"
#include "fgl-aircon/templates.hpp"
#include "fgl-aircon/version.hpp"
using fgl::aircon::Callbacks;
using fgl::aircon::Config;
using fgl::aircon::Error;
using fgl::aircon::Prop;
using fgl::aircon::PropInfo;
using fgl::aircon::State;
using fgl::aircon::Template;
using fgl::aircon::Value;
static_assert(FGL_STATE_IDLE == static_cast<int>(State::kIdle));
static_assert(FGL_STATE_REGISTERING == static_cast<int>(State::kRegistering));
static_assert(FGL_STATE_ONLINE == static_cast<int>(State::kOnline));
static_assert(FGL_STATE_RECOVERING == static_cast<int>(State::kRecovering));
static_assert(FGL_STATE_OFFLINE == static_cast<int>(State::kOffline));
static_assert(FGL_STATE_KEY_ERROR == static_cast<int>(State::kKeyError));
static_assert(FGL_ERR_NONE == static_cast<int>(Error::kNone));
static_assert(FGL_ERR_NO_SLOT == static_cast<int>(Error::kNoSlot));
static_assert(FGL_ERR_UNREACHABLE == static_cast<int>(Error::kUnreachable));
static_assert(FGL_ERR_KEY_MISMATCH == static_cast<int>(Error::kKeyMismatch));
static_assert(FGL_ERR_BAD_KEY_EXCHANGE == static_cast<int>(Error::kBadKeyExchange));
static_assert(FGL_ERR_ACTIVATION_TIMEOUT == static_cast<int>(Error::kActivationTimeout));
static_assert(FGL_ERR_DECRYPT_FAILED == static_cast<int>(Error::kDecryptFailed));
static_assert(FGL_PROP_OPERATION_MODE == static_cast<int>(Prop::OperationMode));
static_assert(FGL_PROP_FAN_SPEED == static_cast<int>(Prop::FanSpeed));
static_assert(FGL_PROP_ADJUST_TEMPERATURE == static_cast<int>(Prop::AdjustTemperature));
static_assert(FGL_PROP_DISPLAY_TEMPERATURE == static_cast<int>(Prop::DisplayTemperature));
static_assert(FGL_PROP_ECONOMY_MODE == static_cast<int>(Prop::EconomyMode));
static_assert(FGL_PROP_ERROR_CODE == static_cast<int>(Prop::ErrorCode));
static_assert(FGL_PROP_DEVICE_CAPABILITIES == static_cast<int>(Prop::DeviceCapabilities));
static_assert(FGL_PROP_REFRESH == static_cast<int>(Prop::Refresh));
static_assert(FGL_PROP_COUNT == static_cast<int>(Prop::kCount));
static_assert(FGL_TEMPLATE_A == static_cast<int>(Template::kA));
static_assert(FGL_TEMPLATE_B == static_cast<int>(Template::kB));
static_assert(FGL_TEMPLATE_F == static_cast<int>(Template::kF));
static_assert(FGL_VALUE_INT == static_cast<int>(fgl::aircon::ValueKind::kInt));
static_assert(FGL_VALUE_BOOL == static_cast<int>(fgl::aircon::ValueKind::kBool));
static_assert(FGL_VALUE_STRING ==
static_cast<int>(fgl::aircon::ValueKind::kString));
const char* fgl_version(void) { return FGL_AIRCON_VERSION_STRING; }
// ---------------------------------------------------------------------------
// Кольцевые буферы событий/логов (fixed-size, без аллокаций в рантайме)
// ---------------------------------------------------------------------------
namespace {
template <typename T, size_t N>
struct Ring {
std::mutex mtx;
std::condition_variable cv;
T buf[N];
size_t head = 0;
size_t count = 0;
uint64_t dropped = 0;
void push(const T& value) {
std::lock_guard<std::mutex> lock(mtx);
if (count == N) {
buf[head] = value;
head = (head + 1) % N;
dropped++;
} else {
buf[(head + count) % N] = value;
count++;
}
cv.notify_all();
}
int drain(T* out, int max_events) {
std::lock_guard<std::mutex> lock(mtx);
int n = 0;
while (n < max_events && count > 0) {
out[n++] = buf[head];
head = (head + 1) % N;
count--;
}
return n;
}
int wait(T* out, int max_events, int timeout_ms) {
std::unique_lock<std::mutex> lock(mtx);
if (count == 0) {
if (timeout_ms < 0) {
cv.wait(lock, [this] { return count > 0; });
} else {
cv.wait_for(lock, std::chrono::milliseconds(timeout_ms),
[this] { return count > 0; });
}
}
int n = 0;
while (n < max_events && count > 0) {
out[n++] = buf[head];
head = (head + 1) % N;
count--;
}
return n;
}
uint64_t dropped_count() {
std::lock_guard<std::mutex> lock(mtx);
return dropped;
}
};
constexpr size_t kSessionEventCap = 64;
constexpr size_t kLogEventCap = 64;
Ring<fgl_log_event_t, kLogEventCap> g_log_ring;
struct LogWrap {
std::atomic<fgl_log_sink_fn> fn{nullptr};
std::atomic<void*> ctx{nullptr};
};
LogWrap g_log_wrap;
std::once_flag g_log_installed;
void log_wrap_trampoline(int level, const char* msg, size_t len, void*) {
fgl_log_event_t ev{};
ev.level = level;
size_t n = len < sizeof(ev.msg) - 1 ? len : sizeof(ev.msg) - 1;
memcpy(ev.msg, msg, n);
ev.msg[n] = '\0';
g_log_ring.push(ev);
auto fn = g_log_wrap.fn.load(std::memory_order_acquire);
if (fn != nullptr) {
fn(level, msg, len, g_log_wrap.ctx.load(std::memory_order_relaxed));
}
}
void ensure_log_pipe() {
std::call_once(g_log_installed,
[] { fgl::aircon::set_log_sink(log_wrap_trampoline, nullptr); });
}
} // namespace
void fgl_log_set_sink(fgl_log_sink_fn sink, void* ctx) {
g_log_wrap.ctx.store(ctx, std::memory_order_relaxed);
g_log_wrap.fn.store(sink, std::memory_order_release);
// Ядро всегда пишет в кольцевой буфер (его читает pyfglair); sink —
// дополнительный прямой колбэк для C/C++ (только малые стеки!).
ensure_log_pipe();
}
void fgl_log_set_level(int min_level) {
fgl::aircon::set_log_level(min_level);
}
int fgl_log_poll_events(fgl_log_event_t* out, int max_events) {
if (out == nullptr || max_events <= 0) return 0;
return g_log_ring.drain(out, max_events);
}
int fgl_log_wait_events(fgl_log_event_t* out, int max_events,
int timeout_ms) {
if (out == nullptr || max_events <= 0) return 0;
return g_log_ring.wait(out, max_events, timeout_ms);
}
// ---------------------------------------------------------------------------
// Сессия
// ---------------------------------------------------------------------------
struct fgl_session {
fgl::aircon::Session* s;
Ring<fgl_event_t, kSessionEventCap>* events;
fgl_on_state_fn on_state;
fgl_on_property_fn on_property;
void* user;
};
namespace {
void on_state_trampoline(void* ctx, State st, Error err) {
auto* s = static_cast<fgl_session*>(ctx);
if (s->events != nullptr) {
fgl_event_t ev{};
ev.type = FGL_EVENT_STATE;
ev.data.state.state = static_cast<fgl_state_t>(st);
ev.data.state.error = static_cast<fgl_error_t>(err);
s->events->push(ev);
}
if (s->on_state != nullptr) {
s->on_state(s->user, static_cast<fgl_state_t>(st),
static_cast<fgl_error_t>(err));
}
}
void on_property_trampoline(void* ctx,
const fgl::aircon::PropertyEvent& ev) {
auto* s = static_cast<fgl_session*>(ctx);
if (s->events != nullptr) {
fgl_event_t out{};
out.type = FGL_EVENT_PROPERTY;
out.data.property.prop = static_cast<fgl_prop_t>(ev.prop);
out.data.property.cmd_id = ev.cmd_id;
out.data.property.status = ev.status;
out.data.property.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind);
out.data.property.value.i = ev.value.i;
memcpy(out.data.property.value.s, ev.value.s,
sizeof(out.data.property.value.s));
s->events->push(out);
}
if (s->on_property != nullptr) {
fgl_property_event_t out{};
out.prop = static_cast<fgl_prop_t>(ev.prop);
out.cmd_id = ev.cmd_id;
out.status = ev.status;
out.value.kind = static_cast<fgl_value_kind_t>(ev.value.kind);
out.value.i = ev.value.i;
memcpy(out.value.s, ev.value.s, sizeof(out.value.s));
s->on_property(s->user, &out);
}
}
} // namespace
fgl_session_t* fgl_session_create(const fgl_config_t* cfg,
const fgl_callbacks_t* cbs) {
if (cfg == nullptr) return nullptr;
Config cc{};
cc.host = cfg->host;
cc.device_port = cfg->device_port;
cc.dsn = cfg->dsn;
cc.lanip_key = cfg->lanip_key;
cc.lanip_key_id = cfg->lanip_key_id;
cc.tmpl = static_cast<Template>(cfg->tmpl);
cc.listen_port = cfg->listen_port;
cc.keepalive_ms = cfg->keepalive_ms;
cc.max_queue = cfg->max_queue;
// Конверсии-override через C-API не задаются (применяются на стороне
// pyfglair при превью/вычислениях; сессия работает в единицах API).
auto* out = new (std::nothrow) fgl_session{};
if (out == nullptr) return nullptr;
out->events = new (std::nothrow) Ring<fgl_event_t, kSessionEventCap>();
if (out->events == nullptr) {
delete out;
return nullptr;
}
out->on_state = cbs != nullptr ? cbs->on_state : nullptr;
out->on_property = cbs != nullptr ? cbs->on_property : nullptr;
out->user = cbs != nullptr ? cbs->ctx : nullptr;
Callbacks cpp_cbs{};
cpp_cbs.on_state = on_state_trampoline;
cpp_cbs.on_property = on_property_trampoline;
cpp_cbs.ctx = out;
ensure_log_pipe();
out->s = fgl::aircon::Session::create(cc, cpp_cbs);
if (out->s == nullptr) {
delete out->events;
delete out;
return nullptr;
}
return out;
}
void fgl_session_destroy(fgl_session_t* s) {
if (s == nullptr) return;
delete s->s;
delete s->events;
delete s;
}
int fgl_session_start(fgl_session_t* s) {
return (s != nullptr && s->s->start()) ? 1 : 0;
}
void fgl_session_stop(fgl_session_t* s) {
if (s != nullptr) s->s->stop();
}
int fgl_session_poll_events(fgl_session_t* s, fgl_event_t* out,
int max_events) {
if (s == nullptr || out == nullptr || max_events <= 0) return 0;
return s->events->drain(out, max_events);
}
int fgl_session_wait_events(fgl_session_t* s, fgl_event_t* out,
int max_events, int timeout_ms) {
if (s == nullptr || out == nullptr || max_events <= 0) return 0;
return s->events->wait(out, max_events, timeout_ms);
}
uint64_t fgl_session_events_dropped(const fgl_session_t* s) {
return (s != nullptr && s->events != nullptr)
? s->events->dropped_count()
: 0;
}
uint64_t fgl_log_events_dropped(void) { return g_log_ring.dropped_count(); }
fgl_state_t fgl_session_state(const fgl_session_t* s) {
return s != nullptr ? static_cast<fgl_state_t>(s->s->state())
: FGL_STATE_IDLE;
}
fgl_error_t fgl_session_last_error(const fgl_session_t* s) {
return s != nullptr ? static_cast<fgl_error_t>(s->s->last_error())
: FGL_ERR_NONE;
}
int fgl_session_set_int(fgl_session_t* s, fgl_prop_t prop, int64_t value) {
return (s != nullptr && s->s->set_int(static_cast<Prop>(prop), value)) ? 1
: 0;
}
int fgl_session_set_bool(fgl_session_t* s, fgl_prop_t prop, int value) {
return (s != nullptr &&
s->s->set_bool(static_cast<Prop>(prop), value != 0))
? 1
: 0;
}
int fgl_session_set_string(fgl_session_t* s, fgl_prop_t prop,
const char* value) {
return (s != nullptr &&
s->s->set_string(static_cast<Prop>(prop), value))
? 1
: 0;
}
int fgl_session_get_prop(fgl_session_t* s, fgl_prop_t prop) {
return (s != nullptr && s->s->get_prop(static_cast<Prop>(prop))) ? 1 : 0;
}
int fgl_session_batch_begin(fgl_session_t* s) {
return (s != nullptr && s->s->batch_begin()) ? 1 : 0;
}
int fgl_session_batch_commit(fgl_session_t* s) {
return (s != nullptr && s->s->batch_commit()) ? 1 : 0;
}
int fgl_session_batch_abort(fgl_session_t* s) {
return (s != nullptr && s->s->batch_abort()) ? 1 : 0;
}
int fgl_session_cached(const fgl_session_t* s, fgl_prop_t prop,
fgl_value_t* out) {
if (s == nullptr) return 0;
Value v{};
if (!s->s->cached(static_cast<Prop>(prop), &v)) return 0;
if (out != nullptr) {
out->kind = static_cast<fgl_value_kind_t>(v.kind);
out->i = v.i;
memcpy(out->s, v.s, sizeof(out->s));
}
return 1;
}
// ---------------------------------------------------------------------------
// Интроспекция
// ---------------------------------------------------------------------------
int fgl_template_detect(const char* oem_model) {
// Контракт c_api.h: -1 — неизвестная модель (template_detect дал бы kA).
if (!fgl::aircon::template_is_known(oem_model)) return -1;
return static_cast<int>(fgl::aircon::template_detect(oem_model));
}
const char* fgl_prop_name(fgl_template_t t, fgl_prop_t prop) {
const PropInfo* info = fgl::aircon::prop_info_find(static_cast<Template>(t),
static_cast<Prop>(prop));
return info != nullptr ? info->name : nullptr;
}
const char* fgl_prop_base_type(fgl_template_t t, fgl_prop_t prop) {
const PropInfo* info = fgl::aircon::prop_info_find(static_cast<Template>(t),
static_cast<Prop>(prop));
return info != nullptr ? info->base_type : nullptr;
}
int fgl_prop_read_only(fgl_template_t t, fgl_prop_t prop) {
const PropInfo* info = fgl::aircon::prop_info_find(static_cast<Template>(t),
static_cast<Prop>(prop));
return info != nullptr ? (info->read_only ? 1 : 0) : -1;
}
int fgl_prop_raw_range(fgl_template_t t, fgl_prop_t prop, int64_t* min,
int64_t* max) {
const PropInfo* info = fgl::aircon::prop_info_find(static_cast<Template>(t),
static_cast<Prop>(prop));
if (info == nullptr) return -1;
if (info->raw_min == info->raw_max) return 0; // «не определён»
if (min != nullptr) *min = info->raw_min;
if (max != nullptr) *max = info->raw_max;
return 1;
}
int64_t fgl_convert_to_display(fgl_template_t t, fgl_prop_t prop,
int64_t raw) {
return fgl::aircon::convert_to_display(static_cast<Template>(t),
static_cast<Prop>(prop), raw, nullptr);
}
int64_t fgl_convert_from_input(fgl_template_t t, fgl_prop_t prop,
int64_t api_value) {
return fgl::aircon::convert_from_input(static_cast<Template>(t),
static_cast<Prop>(prop), api_value,
nullptr);
}
-326
View File
@@ -1,326 +0,0 @@
// Реализация публичной сессии fgl-aircon поверх ayla-сессии.
#include "fgl-aircon/session.hpp"
#include <cstdio>
#include <cstring>
#include <new>
#include "ayla/log.hpp"
#include "ayla/session.hpp"
namespace fgl::aircon {
namespace {
struct LogCtx {
LogSink fn = nullptr;
void* user = nullptr;
};
LogCtx g_log_ctx;
void log_trampoline(int level, const char* msg, size_t len, void*) {
if (g_log_ctx.fn != nullptr) {
g_log_ctx.fn(level, msg, len, g_log_ctx.user);
}
}
} // namespace
void set_log_sink(LogSink sink, void* ctx) {
g_log_ctx.fn = sink;
g_log_ctx.user = ctx;
ayla::log::set_sink(sink != nullptr ? log_trampoline : nullptr, nullptr);
}
void set_log_level(int min_level) { ayla::log::set_min_level(min_level); }
struct Session::Impl {
Config cfg{};
Callbacks cbs{};
Template tmpl = Template::kA;
ayla::Session* ayla = nullptr;
struct CacheEntry {
Value value;
bool present = false;
};
CacheEntry cache[static_cast<size_t>(Prop::kCount)];
// Спинлок (no-heap): сериализует запись кэша (колбэк ayla) и чтение
// (cached()). Критические секции — копии POD ≤ 80 Б.
volatile int spin = 0;
void lock() {
while (__sync_lock_test_and_set(&spin, 1)) {
}
}
void unlock() { __sync_lock_release(&spin); }
static void on_ayla_state(void* ctx, ayla::SessionState st,
ayla::SessionError err);
static void on_ayla_property(void* ctx, const ayla::PropertyEvent& ev);
const PropOverride* overrides() const { return cfg.overrides; }
};
void Session::Impl::on_ayla_state(void* ctx, ayla::SessionState st,
ayla::SessionError err) {
auto* impl = static_cast<Impl*>(ctx);
if (impl->cbs.on_state != nullptr) {
impl->cbs.on_state(impl->cbs.ctx, static_cast<State>(st),
static_cast<Error>(err));
}
}
void Session::Impl::on_ayla_property(void* ctx,
const ayla::PropertyEvent& ev) {
auto* impl = static_cast<Impl*>(ctx);
const PropInfo* info = prop_info_find_by_name(impl->tmpl, ev.name);
if (info == nullptr) return; // свойство не из шаблона
Value v{};
if (ev.is_int) {
// Прибор шлёт bool-свойства как int 0/1 — коэрсим к типу из таблицы.
v.kind = (info->kind == ValueKind::kBool) ? ValueKind::kBool
: ValueKind::kInt;
v.i = convert_to_display(impl->tmpl, info->prop, ev.int_value,
impl->overrides());
} else if (ev.is_bool) {
v.kind = ValueKind::kBool;
v.i = ev.bool_value ? 1 : 0;
} else {
v.kind = ValueKind::kString;
snprintf(v.s, sizeof(v.s), "%s", ev.str_value);
}
{
impl->lock();
auto& entry = impl->cache[static_cast<size_t>(info->prop)];
entry.value = v;
entry.present = true;
impl->unlock();
}
if (impl->cbs.on_property != nullptr) {
PropertyEvent out{};
out.prop = info->prop;
out.value = v;
out.cmd_id = ev.cmd_id;
out.status = ev.status;
impl->cbs.on_property(impl->cbs.ctx, out);
}
}
Session::Session() : impl_(nullptr) {}
Session::~Session() {
if (impl_ != nullptr) {
stop();
delete impl_->ayla;
delete impl_;
impl_ = nullptr;
}
}
Session* Session::create(const Config& cfg, const Callbacks& cbs) {
if (cfg.host == nullptr || cfg.dsn == nullptr || cfg.lanip_key == nullptr) {
return nullptr;
}
if (cfg.overrides != nullptr) {
// Валидация списка: терминатор Prop::kCount (обязателен, контракт
// документирован в types.hpp); linear без нулевых делителей; custom_fn
// с ненулевым fn. Сканирование ограничено kCount+1 элементами.
bool term = false;
for (ptrdiff_t i = 0;; i++) {
if (i > static_cast<ptrdiff_t>(Prop::kCount)) return nullptr;
const PropOverride& o = cfg.overrides[i];
if (o.prop == Prop::kCount) {
term = true;
break;
}
if (static_cast<uint8_t>(o.prop) >=
static_cast<uint8_t>(Prop::kCount)) {
return nullptr;
}
if (o.to_display.kind == ConvKind::kLinear &&
(o.to_display.linear.num == 0 || o.to_display.linear.den == 0)) {
return nullptr; // деление на ноль
}
if (o.from_input.kind == ConvKind::kLinear &&
(o.from_input.linear.num == 0 || o.from_input.linear.den == 0)) {
return nullptr; // деление на ноль
}
if (o.to_display.kind == ConvKind::kCustomFn &&
o.to_display.fn == nullptr) {
return nullptr;
}
if (o.from_input.kind == ConvKind::kCustomFn &&
o.from_input.fn == nullptr) {
return nullptr;
}
}
if (!term) return nullptr;
}
auto* impl = new (std::nothrow) Session::Impl();
if (impl == nullptr) return nullptr;
impl->cfg = cfg;
impl->cbs = cbs;
impl->tmpl = cfg.tmpl;
ayla::SessionConfig acfg{};
acfg.host = cfg.host;
acfg.device_port = cfg.device_port;
acfg.dsn = cfg.dsn;
acfg.lanip_key = cfg.lanip_key;
acfg.lanip_key_id = cfg.lanip_key_id;
acfg.listen_port = cfg.listen_port;
acfg.keepalive_ms = cfg.keepalive_ms;
acfg.max_queue = cfg.max_queue;
ayla::SessionCallbacks acbs{};
acbs.on_state = &Session::Impl::on_ayla_state;
acbs.on_property = &Session::Impl::on_ayla_property;
acbs.ctx = impl;
impl->ayla = ayla::Session::create(acfg, acbs);
if (impl->ayla == nullptr) {
delete impl;
return nullptr;
}
auto* s = new (std::nothrow) Session();
if (s == nullptr) {
delete impl->ayla;
delete impl;
return nullptr;
}
s->impl_ = impl;
return s;
}
bool Session::start() {
return impl_ != nullptr && impl_->ayla != nullptr && impl_->ayla->start();
}
void Session::stop() {
if (impl_ != nullptr && impl_->ayla != nullptr) {
impl_->ayla->stop();
}
}
State Session::state() const {
if (impl_ == nullptr || impl_->ayla == nullptr) return State::kIdle;
return static_cast<State>(impl_->ayla->state());
}
Error Session::last_error() const {
if (impl_ == nullptr || impl_->ayla == nullptr) return Error::kNone;
return static_cast<Error>(impl_->ayla->last_error());
}
bool Session::set_int(Prop prop, int64_t value) {
if (impl_ == nullptr ||
static_cast<uint8_t>(prop) >= static_cast<uint8_t>(Prop::kCount)) {
return false;
}
const PropInfo* info = prop_info_find(impl_->tmpl, prop);
if (info == nullptr || info->read_only || info->kind == ValueKind::kString) {
return false;
}
int64_t lo = 0, hi = 0;
if (prop_api_range(impl_->tmpl, prop, impl_->overrides(), &lo, &hi)) {
if (value < lo) value = lo;
if (value > hi) value = hi;
}
int64_t raw =
convert_from_input(impl_->tmpl, prop, value, impl_->overrides());
bool ok = impl_->ayla->set_property(info->name, raw, info->base_type);
if (ok) {
// Оптимистичный кэш (эха нет — PROTOCOL §5.3).
impl_->lock();
auto& entry = impl_->cache[static_cast<size_t>(prop)];
entry.value.kind = info->kind;
entry.value.i = value;
entry.present = true;
impl_->unlock();
}
return ok;
}
bool Session::set_bool(Prop prop, bool value) {
if (impl_ == nullptr) return false;
const PropInfo* info = prop_info_find(impl_->tmpl, prop);
if (info == nullptr || info->kind != ValueKind::kBool) return false;
return set_int(prop, value ? 1 : 0);
}
bool Session::set_string(Prop prop, const char* value) {
if (impl_ == nullptr || value == nullptr) return false;
const PropInfo* info = prop_info_find(impl_->tmpl, prop);
if (info == nullptr || info->read_only ||
info->kind != ValueKind::kString) {
return false;
}
bool ok = impl_->ayla->set_property_string(info->name, value);
if (ok) {
impl_->lock();
auto& entry = impl_->cache[static_cast<size_t>(prop)];
snprintf(entry.value.s, sizeof(entry.value.s), "%s", value);
entry.value.kind = ValueKind::kString;
entry.present = true;
impl_->unlock();
}
return ok;
}
bool Session::get_prop(Prop prop) {
if (impl_ == nullptr) return false;
const PropInfo* info = prop_info_find(impl_->tmpl, prop);
if (info == nullptr) return false;
return impl_->ayla->get_property(info->name);
}
bool Session::batch_begin() {
return impl_ != nullptr && impl_->ayla != nullptr &&
impl_->ayla->begin_batch();
}
bool Session::batch_commit() {
return impl_ != nullptr && impl_->ayla != nullptr &&
impl_->ayla->commit_batch();
}
bool Session::batch_abort() {
return impl_ != nullptr && impl_->ayla != nullptr &&
impl_->ayla->abort_batch();
}
bool Session::cached(Prop prop, Value* out) const {
if (impl_ == nullptr ||
static_cast<uint8_t>(prop) >= static_cast<uint8_t>(Prop::kCount)) {
return false;
}
impl_->lock();
const auto& entry = impl_->cache[static_cast<size_t>(prop)];
bool present = entry.present;
if (present && out != nullptr) *out = entry.value;
impl_->unlock();
return present;
}
uint32_t Session::rekey_count() const {
return impl_ != nullptr && impl_->ayla != nullptr
? impl_->ayla->rekey_count()
: 0;
}
uint32_t Session::pushes_ok() const {
return impl_ != nullptr && impl_->ayla != nullptr
? impl_->ayla->pushes_ok()
: 0;
}
uint32_t Session::pushes_bad() const {
return impl_ != nullptr && impl_->ayla != nullptr
? impl_->ayla->pushes_bad()
: 0;
}
uint32_t Session::commands_served() const {
return impl_ != nullptr && impl_->ayla != nullptr
? impl_->ayla->commands_served()
: 0;
}
} // namespace fgl::aircon
-354
View File
@@ -1,354 +0,0 @@
// Таблицы свойств шаблонов A/B/F (docs/PROTOCOL.md §8.1-8.2, по данным APK).
#include "fgl-aircon/templates.hpp"
#include <cstring>
namespace fgl::aircon {
namespace {
// raw-диапазоны: PROTOCOL §8.3 (таблица приложения −10..45 °C; фактические
// ограничения прибора уже; направления — по num_dir, таймеры/прочее — 0..N).
constexpr PropInfo kTemplateA[] = {
{Prop::OperationMode, "operation_mode", "integer", false, ValueKind::kInt, 0, 6},
{Prop::FanSpeed, "fan_speed", "integer", false, ValueKind::kInt, 0, 4},
{Prop::AdjustTemperature, "adjust_temperature", "integer", false, ValueKind::kInt, -100, 450},
{Prop::DisplayTemperature, "display_temperature", "integer", true, ValueKind::kInt, 4000, 9500},
{Prop::AfVerticalDirection, "af_vertical_direction", "integer", false, ValueKind::kInt, 0, 15},
{Prop::AfVerticalSwing, "af_vertical_swing", "integer", false, ValueKind::kBool, 0, 1},
{Prop::AfHorizontalDirection, "af_horizontal_direction", "integer", false, ValueKind::kInt, 0, 15},
{Prop::AfHorizontalSwing, "af_horizontal_swing", "integer", false, ValueKind::kBool, 0, 1},
{Prop::OutdoorLowNoise, "outdoor_low_noise", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::IndoorFanControl, "indoor_fan_control", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::HumanDetAutoSave, "human_det_auto_save", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::MinHeat, "min_heat", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::PowerfulMode, "powerful_mode", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::CoilDryMode, "coil_dry_mode", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::EconomyMode, "economy_mode", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::MasterTimerOnOff1, "master_timer_on_off_1", "integer", false, ValueKind::kInt, 0, 0},
{Prop::MasterTimerOnOff2, "master_timer_on_off_2", "integer", false, ValueKind::kInt, 0, 0},
{Prop::ErrorCode, "error_code", "integer", true, ValueKind::kInt, 0, 4095},
{Prop::DemandControl, "demand_control", "integer", false, ValueKind::kInt, 0, 0},
{Prop::FilterSignResetDisplay, "filter_sign_reset_display", "integer", false, ValueKind::kInt, 0, 1},
{Prop::OpStatus, "op_status", "integer", true, ValueKind::kInt, 0, 0},
{Prop::DeviceName, "device_name", "string", false, ValueKind::kString, 0, 0},
{Prop::BuildingName, "building_name", "string", false, ValueKind::kString, 0, 0},
{Prop::WifiLedEnable, "wifi_led_enable", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::ServiceContactName, "service_contact_name", "string", false, ValueKind::kString, 0, 0},
{Prop::ServiceContactPhone, "service_contact_phone", "string", false, ValueKind::kString, 0, 0},
{Prop::ServiceContactEmail, "service_contact_email", "string", false, ValueKind::kString, 0, 0},
{Prop::AfHorizontalNumDir, "af_horizontal_num_dir", "integer", true, ValueKind::kInt, 0, 15},
{Prop::AfVerticalNumDir, "af_vertical_num_dir", "integer", true, ValueKind::kInt, 0, 15},
{Prop::DeviceCapabilities, "device_capabilities", "integer", true, ValueKind::kInt, 0, 0},
{Prop::GetProp, "get_prop", "integer", false, ValueKind::kInt, 0, 1},
{Prop::HumanDet, "human_det", "integer", false, ValueKind::kInt, 0, 0},
{Prop::Refresh, "refresh", "integer", false, ValueKind::kInt, 0, 1},
};
constexpr PropInfo kTemplateB[] = {
{Prop::OperationMode, "operation_mode", "integer", false, ValueKind::kInt, 0, 6},
{Prop::FanSpeed, "fan_speed", "integer", false, ValueKind::kInt, 0, 4},
{Prop::AdjustTemperature, "adjust_temperature", "integer", false, ValueKind::kInt, -100, 450},
{Prop::AfVerticalMoveStep1, "af_vertical_move_step1", "integer", false, ValueKind::kInt, 0, 0},
{Prop::AfHorizontalMoveStep1, "af_horizontal_move_step1", "integer", false, ValueKind::kInt, 0, 0},
{Prop::EconomyMode, "economy_mode", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::MasterTimerOnOff1, "master_timer_on_off_1", "integer", false, ValueKind::kInt, 0, 0},
{Prop::MasterTimerOnOff2, "master_timer_on_off_2", "integer", false, ValueKind::kInt, 0, 0},
{Prop::ErrorCode, "error_code", "integer", true, ValueKind::kInt, 0, 4095},
{Prop::DemandControl, "demand_control", "integer", false, ValueKind::kInt, 0, 0},
{Prop::FilterSignResetDisplay, "filter_sign_reset_display", "integer", false, ValueKind::kInt, 0, 1},
{Prop::OpStatus, "op_status", "integer", true, ValueKind::kInt, 0, 0},
{Prop::DeviceName, "device_name", "string", false, ValueKind::kString, 0, 0},
{Prop::BuildingName, "building_name", "string", false, ValueKind::kString, 0, 0},
{Prop::WifiLedEnable, "wifi_led_enable", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::ServiceContactName, "service_contact_name", "string", false, ValueKind::kString, 0, 0},
{Prop::ServiceContactPhone, "service_contact_phone", "string", false, ValueKind::kString, 0, 0},
{Prop::ServiceContactEmail, "service_contact_email", "string", false, ValueKind::kString, 0, 0},
{Prop::DeviceCapabilities, "device_capabilities", "integer", true, ValueKind::kInt, 0, 0},
{Prop::Refresh, "refresh", "integer", false, ValueKind::kInt, 0, 1},
};
// F = A + monitor1, filter_sign_reset вместо filter_sign_reset_display.
constexpr PropInfo kTemplateF[] = {
{Prop::OperationMode, "operation_mode", "integer", false, ValueKind::kInt, 0, 6},
{Prop::FanSpeed, "fan_speed", "integer", false, ValueKind::kInt, 0, 4},
{Prop::AdjustTemperature, "adjust_temperature", "integer", false, ValueKind::kInt, -100, 450},
{Prop::DisplayTemperature, "display_temperature", "integer", true, ValueKind::kInt, 4000, 9500},
{Prop::AfVerticalDirection, "af_vertical_direction", "integer", false, ValueKind::kInt, 0, 15},
{Prop::AfVerticalSwing, "af_vertical_swing", "integer", false, ValueKind::kBool, 0, 1},
{Prop::AfHorizontalDirection, "af_horizontal_direction", "integer", false, ValueKind::kInt, 0, 15},
{Prop::AfHorizontalSwing, "af_horizontal_swing", "integer", false, ValueKind::kBool, 0, 1},
{Prop::OutdoorLowNoise, "outdoor_low_noise", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::IndoorFanControl, "indoor_fan_control", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::HumanDetAutoSave, "human_det_auto_save", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::MinHeat, "min_heat", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::PowerfulMode, "powerful_mode", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::CoilDryMode, "coil_dry_mode", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::EconomyMode, "economy_mode", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::MasterTimerOnOff1, "master_timer_on_off_1", "integer", false, ValueKind::kInt, 0, 0},
{Prop::MasterTimerOnOff2, "master_timer_on_off_2", "integer", false, ValueKind::kInt, 0, 0},
{Prop::ErrorCode, "error_code", "integer", true, ValueKind::kInt, 0, 4095},
{Prop::DemandControl, "demand_control", "integer", false, ValueKind::kInt, 0, 0},
{Prop::FilterSignReset, "filter_sign_reset", "integer", false, ValueKind::kInt, 0, 1},
{Prop::OpStatus, "op_status", "integer", true, ValueKind::kInt, 0, 0},
{Prop::DeviceName, "device_name", "string", false, ValueKind::kString, 0, 0},
{Prop::BuildingName, "building_name", "string", false, ValueKind::kString, 0, 0},
{Prop::WifiLedEnable, "wifi_led_enable", "boolean", false, ValueKind::kBool, 0, 1},
{Prop::ServiceContactName, "service_contact_name", "string", false, ValueKind::kString, 0, 0},
{Prop::ServiceContactPhone, "service_contact_phone", "string", false, ValueKind::kString, 0, 0},
{Prop::ServiceContactEmail, "service_contact_email", "string", false, ValueKind::kString, 0, 0},
{Prop::AfHorizontalNumDir, "af_horizontal_num_dir", "integer", true, ValueKind::kInt, 0, 15},
{Prop::AfVerticalNumDir, "af_vertical_num_dir", "integer", true, ValueKind::kInt, 0, 15},
{Prop::DeviceCapabilities, "device_capabilities", "integer", true, ValueKind::kInt, 0, 0},
{Prop::GetProp, "get_prop", "integer", false, ValueKind::kInt, 0, 1},
{Prop::HumanDet, "human_det", "integer", false, ValueKind::kInt, 0, 0},
{Prop::Monitor1, "monitor1", "integer", true, ValueKind::kInt, 0, 0},
{Prop::Refresh, "refresh", "integer", false, ValueKind::kInt, 0, 1},
};
constexpr const char* kOemA[] = {"AP-WA1E", "AP-WA2E", "AP-WA3E", "AP-WA4E",
"AP-WA5E", "AP-WA6E", "AP-WC1E", "AP-WC2E",
"AP-WC3E", "AP-WC4E", "AP-WD1E"};
constexpr const char* kOemB[] = {"AP-WB1E", "AP-WB2E", "AP-WB3E", "AP-WB4E"};
constexpr const char* kOemF[] = {"AP-WF1E", "AP-WF2E", "AP-WF3E", "AP-WF4E"};
bool in_list(const char* const* list, size_t n, const char* model) {
for (size_t i = 0; i < n; i++) {
if (strcmp(list[i], model) == 0) return true;
}
return false;
}
constexpr size_t kOemACount = sizeof(kOemA) / sizeof(kOemA[0]);
constexpr size_t kOemBCount = sizeof(kOemB) / sizeof(kOemB[0]);
constexpr size_t kOemFCount = sizeof(kOemF) / sizeof(kOemF[0]);
const PropInfo* table_begin(Template t) {
switch (t) {
case Template::kA: return kTemplateA;
case Template::kB: return kTemplateB;
case Template::kF: return kTemplateF;
}
return nullptr;
}
size_t table_count(Template t) {
switch (t) {
case Template::kA: return sizeof(kTemplateA) / sizeof(kTemplateA[0]);
case Template::kB: return sizeof(kTemplateB) / sizeof(kTemplateB[0]);
case Template::kF: return sizeof(kTemplateF) / sizeof(kTemplateF[0]);
}
return 0;
}
// Деление int64 с округлением к нулю; b != 0 (валидация в create);
// INT64_MIN/-1 → насыщение (иначе UB).
int64_t div_round_zero(int64_t a, int64_t b) {
// Нативное / уже усекает к нулю; единственный UB-случай — INT64_MIN / -1.
if (a == INT64_MIN && b == -1) return INT64_MAX;
return a / b;
}
const Conversion* pick_override(const PropOverride* overrides, Prop prop,
bool to_display) {
if (overrides == nullptr) return nullptr;
for (const PropOverride* o = overrides; o->prop != Prop::kCount; o++) {
if (o->prop == prop) {
return to_display ? &o->to_display : &o->from_input;
}
}
return nullptr;
}
const PropOverride* find_override(const PropOverride* overrides, Prop prop) {
if (overrides == nullptr) return nullptr;
for (const PropOverride* o = overrides; o->prop != Prop::kCount; o++) {
if (o->prop == prop) return o;
}
return nullptr;
}
} // namespace
bool template_is_known(const char* oem_model) {
if (oem_model == nullptr) return false;
return in_list(kOemA, kOemACount, oem_model) ||
in_list(kOemB, kOemBCount, oem_model) ||
in_list(kOemF, kOemFCount, oem_model);
}
Template template_detect(const char* oem_model) {
if (oem_model == nullptr) {
// Эквивалент kA, чтобы API без модели работал; явная проверка у вызывающего.
return Template::kA;
}
if (in_list(kOemA, kOemACount, oem_model)) return Template::kA;
if (in_list(kOemB, kOemBCount, oem_model)) return Template::kB;
if (in_list(kOemF, kOemFCount, oem_model)) return Template::kF;
return Template::kA; // неизвестные модели трактуем как A (как и legacy)
}
const PropInfo* prop_info_begin(Template t) { return table_begin(t); }
size_t prop_info_count(Template t) { return table_count(t); }
const PropInfo* prop_info_find(Template t, Prop prop) {
for (const PropInfo* p = table_begin(t);
p != table_begin(t) + table_count(t); p++) {
if (p->prop == prop) return p;
}
return nullptr;
}
const PropInfo* prop_info_find_by_name(Template t, const char* name) {
if (name == nullptr) return nullptr;
for (const PropInfo* p = table_begin(t);
p != table_begin(t) + table_count(t); p++) {
if (strcmp(p->name, name) == 0) return p;
}
return nullptr;
}
const char* prop_enum_name(Prop prop) {
switch (prop) {
case Prop::OperationMode: return "OperationMode";
case Prop::FanSpeed: return "FanSpeed";
case Prop::AdjustTemperature: return "AdjustTemperature";
case Prop::DisplayTemperature: return "DisplayTemperature";
case Prop::AfVerticalDirection: return "AfVerticalDirection";
case Prop::AfVerticalSwing: return "AfVerticalSwing";
case Prop::AfHorizontalDirection: return "AfHorizontalDirection";
case Prop::AfHorizontalSwing: return "AfHorizontalSwing";
case Prop::AfVerticalMoveStep1: return "AfVerticalMoveStep1";
case Prop::AfHorizontalMoveStep1: return "AfHorizontalMoveStep1";
case Prop::OutdoorLowNoise: return "OutdoorLowNoise";
case Prop::IndoorFanControl: return "IndoorFanControl";
case Prop::HumanDetAutoSave: return "HumanDetAutoSave";
case Prop::MinHeat: return "MinHeat";
case Prop::PowerfulMode: return "PowerfulMode";
case Prop::CoilDryMode: return "CoilDryMode";
case Prop::EconomyMode: return "EconomyMode";
case Prop::MasterTimerOnOff1: return "MasterTimerOnOff1";
case Prop::MasterTimerOnOff2: return "MasterTimerOnOff2";
case Prop::ErrorCode: return "ErrorCode";
case Prop::DemandControl: return "DemandControl";
case Prop::FilterSignResetDisplay: return "FilterSignResetDisplay";
case Prop::FilterSignReset: return "FilterSignReset";
case Prop::OpStatus: return "OpStatus";
case Prop::DeviceName: return "DeviceName";
case Prop::BuildingName: return "BuildingName";
case Prop::WifiLedEnable: return "WifiLedEnable";
case Prop::ServiceContactName: return "ServiceContactName";
case Prop::ServiceContactPhone: return "ServiceContactPhone";
case Prop::ServiceContactEmail: return "ServiceContactEmail";
case Prop::AfHorizontalNumDir: return "AfHorizontalNumDir";
case Prop::AfVerticalNumDir: return "AfVerticalNumDir";
case Prop::DeviceCapabilities: return "DeviceCapabilities";
case Prop::GetProp: return "GetProp";
case Prop::HumanDet: return "HumanDet";
case Prop::Monitor1: return "Monitor1";
case Prop::Refresh: return "Refresh";
case Prop::kCount: break;
}
return "?";
}
int64_t convert_to_display(Template t, Prop prop, int64_t raw,
const PropOverride* overrides) {
(void)t; // шаблонных не-тождественных конверсий пока нет (кроме общей ниже)
const Conversion* c = pick_override(overrides, prop, /*to_display=*/true);
if (c != nullptr) {
switch (c->kind) {
case ConvKind::kLinear: {
int64_t prod = 0;
if (__builtin_mul_overflow(raw, c->linear.num, &prod)) {
prod = (raw < 0) != (c->linear.num < 0) ? INT64_MIN : INT64_MAX;
}
int64_t q = div_round_zero(prod, c->linear.den);
int64_t out = 0;
if (__builtin_add_overflow(q, c->linear.offset, &out)) {
out = q < 0 ? INT64_MIN : INT64_MAX;
}
return out;
}
case ConvKind::kCustomFn:
return c->fn != nullptr ? c->fn(raw, c->ctx) : raw;
case ConvKind::kTemplateDefault:
break;
}
}
// Шаблонная конверсия по умолчанию.
if (prop == Prop::DisplayTemperature) {
int64_t diff = 0;
if (__builtin_sub_overflow(raw, 5000, &diff)) {
diff = INT64_MIN; // raw < INT64_MIN+5000 — дальше только насыщение
}
return div_round_zero(diff, 10); // raw 0.01°C(+5000) -> API 0.1°C
}
return raw; // остальные свойства — тождественны
}
int64_t convert_from_input(Template t, Prop prop, int64_t api_value,
const PropOverride* overrides) {
(void)t;
const Conversion* c = pick_override(overrides, prop, /*to_display=*/false);
if (c != nullptr) {
switch (c->kind) {
case ConvKind::kLinear: {
// Инверсия: raw = (api - offset) * den / num (с насыщением).
int64_t diff = 0;
// Направление насыщения: по знаку уменьшаемого (offset с экстремумом
// сам бы переполнил любую разность).
if (__builtin_sub_overflow(api_value, c->linear.offset, &diff)) {
diff = api_value < 0 ? INT64_MIN : INT64_MAX;
}
int64_t prod = 0;
if (__builtin_mul_overflow(diff, c->linear.den, &prod)) {
prod = (diff < 0) != (c->linear.den < 0) ? INT64_MIN : INT64_MAX;
}
return div_round_zero(prod, c->linear.num);
}
case ConvKind::kCustomFn:
return c->fn != nullptr ? c->fn(api_value, c->ctx) : api_value;
case ConvKind::kTemplateDefault:
break;
}
}
if (prop == Prop::DisplayTemperature) {
int64_t prod = 0;
if (__builtin_mul_overflow(api_value, 10, &prod)) {
prod = api_value < 0 ? INT64_MIN : INT64_MAX;
}
int64_t out = 0;
if (__builtin_add_overflow(prod, 5000, &out)) {
out = prod < 0 ? INT64_MIN : INT64_MAX;
}
return out; // API 0.1°C -> raw (read-only, но симметрично)
}
return api_value;
}
bool prop_api_range(Template t, Prop prop, const PropOverride* overrides,
int64_t* min, int64_t* max) {
const PropOverride* o = find_override(overrides, prop);
if (o != nullptr && o->has_range) {
if (min != nullptr) *min = o->min;
if (max != nullptr) *max = o->max;
return true;
}
const PropInfo* info = prop_info_find(t, prop);
if (info == nullptr || info->raw_min == info->raw_max) {
return false; // нет диапазона (0..0 в таблице — «не определён»)
}
int64_t lo = convert_to_display(t, prop, info->raw_min, overrides);
int64_t hi = convert_to_display(t, prop, info->raw_max, overrides);
if (lo > hi) {
int64_t tmp = lo;
lo = hi;
hi = tmp;
}
if (min != nullptr) *min = lo;
if (max != nullptr) *max = hi;
return true;
}
} // namespace fgl::aircon
+18 -67
View File
@@ -18,26 +18,16 @@ namespace {
constexpr uint32_t kLoopTickMs = 20; constexpr uint32_t kLoopTickMs = 20;
constexpr size_t kMaxName = 40; constexpr size_t kMaxName = 40;
constexpr size_t kMaxQueue = 48; // >= cfg.max_queue default 40 (полный батч constexpr size_t kMaxQueue = 32;
// шаблона A = 33 свойства) и с запасом
static_assert(kMaxQueue >= 40, "kMaxQueue must cover full template batch");
struct Command { struct Command {
uint8_t type; // 1=GET, 2=SET, 3=DELETE uint8_t type; // 1=GET, 2=SET, 3=DELETE
char name[kMaxName]; char name[kMaxName];
int64_t value; int64_t value;
char base_type[10]; char base_type[10];
char str_value[48]; // строковые SET
int cmd_id; int cmd_id;
}; };
// Безопасная копия строк (без snprintf — избегаем -Wrestrict при инлайне).
void copy_str(char* dst, const char* src, size_t cap) {
size_t i = 0;
for (; i + 1 < cap && src[i] != '\0'; i++) dst[i] = src[i];
dst[i] = '\0';
}
bool gen_random_token(char* out, size_t len) { bool gen_random_token(char* out, size_t len) {
static const char kAlpha[] = static const char kAlpha[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@@ -153,8 +143,6 @@ struct Session::Impl {
strncmp(queue[i].name, cmd.name, kMaxName) == 0) { strncmp(queue[i].name, cmd.name, kMaxName) == 0) {
if (cmd.type == 2) { if (cmd.type == 2) {
queue[i].value = cmd.value; // замещаем queue[i].value = cmd.value; // замещаем
copy_str(queue[i].str_value, cmd.str_value,
sizeof(queue[i].str_value));
return true; return true;
} }
if (cmd.type == 1) return true; // дубликат GET if (cmd.type == 1) return true; // дубликат GET
@@ -177,8 +165,6 @@ struct Session::Impl {
strncmp(batch[i].name, cmd.name, kMaxName) == 0) { strncmp(batch[i].name, cmd.name, kMaxName) == 0) {
if (cmd.type == 2) { if (cmd.type == 2) {
batch[i].value = cmd.value; batch[i].value = cmd.value;
copy_str(batch[i].str_value, cmd.str_value,
sizeof(batch[i].str_value));
return true; return true;
} }
if (cmd.type == 1) return true; if (cmd.type == 1) return true;
@@ -384,8 +370,6 @@ void Session::Impl::build_set_payload(char* out, size_t out_cap,
w.key("value"); w.key("value");
if (strcmp(c.base_type, "boolean") == 0) { if (strcmp(c.base_type, "boolean") == 0) {
w.boolean(c.value != 0); w.boolean(c.value != 0);
} else if (strcmp(c.base_type, "string") == 0) {
w.string(c.str_value);
} else { } else {
w.integer(c.value); w.integer(c.value);
} }
@@ -538,38 +522,26 @@ void Session::Impl::handle_datapoint(const HttpRequest& req,
snprintf(ev.name, sizeof(ev.name), "%s", name); snprintf(ev.name, sizeof(ev.name), "%s", name);
int64_t iv = 0; int64_t iv = 0;
bool bv = false; bool bv = false;
const char* sv = nullptr;
size_t sv_len = 0;
jsmntype_t vt;
if (data_doc.get_int("value", &iv)) { if (data_doc.get_int("value", &iv)) {
ev.is_int = true; ev.is_int = true;
ev.int_value = iv; ev.int_value = iv;
} else if (data_doc.get_bool("value", &bv)) { } else if (data_doc.get_bool("value", &bv)) {
ev.is_bool = true; ev.is_bool = true;
ev.bool_value = bv; ev.bool_value = bv;
} else if (data_doc.find("value", &sv, &sv_len, &vt) &&
vt == JSMN_STRING && sv_len + 1 <= sizeof(ev.str_value)) {
memcpy(ev.str_value, sv, sv_len);
ev.str_value[sv_len] = '\0';
} }
bool value_ok = true; int cmd_id = -1, status = 0;
if (ev.is_int || ev.is_bool) { parse_query(req.query, &cmd_id, &status);
value_ok = true; ev.cmd_id = cmd_id;
} else { ev.status = status;
// Строка: значение должно влезать в буфер события; иначе событие if (cbs.on_property != nullptr) {
// не отправляем (кэш не затираем пустышкой). cbs.on_property(cbs.ctx, ev);
const char* sv2 = nullptr;
size_t sv2_len = 0;
jsmntype_t vt2;
value_ok = data_doc.find("value", &sv2, &sv2_len, &vt2) &&
vt2 == JSMN_STRING &&
sv2_len + 1 <= sizeof(ev.str_value);
if (value_ok) {
memcpy(ev.str_value, sv2, sv2_len);
ev.str_value[sv2_len] = '\0';
}
}
if (value_ok) {
int cmd_id = -1, status = 0;
parse_query(req.query, &cmd_id, &status);
ev.cmd_id = cmd_id;
ev.status = status;
if (cbs.on_property != nullptr) {
cbs.on_property(cbs.ctx, ev);
}
} }
} }
} }
@@ -745,7 +717,7 @@ Session* Session::create(const SessionConfig& cfg, const SessionCallbacks& cbs)
if (impl == nullptr) return nullptr; if (impl == nullptr) return nullptr;
impl->cfg = cfg; impl->cfg = cfg;
impl->cbs = cbs; impl->cbs = cbs;
if (impl->cfg.max_queue == 0) impl->cfg.max_queue = 40; if (impl->cfg.max_queue == 0) impl->cfg.max_queue = 16;
if (impl->cfg.keepalive_ms == 0) impl->cfg.keepalive_ms = 15000; if (impl->cfg.keepalive_ms == 0) impl->cfg.keepalive_ms = 15000;
impl->timings.keepalive_ms = impl->cfg.keepalive_ms; impl->timings.keepalive_ms = impl->cfg.keepalive_ms;
snprintf(impl->host, sizeof(impl->host), "%s", cfg.host); snprintf(impl->host, sizeof(impl->host), "%s", cfg.host);
@@ -888,26 +860,6 @@ void Session::set_timings_for_test(const SessionTimings& t) {
impl_->timings = tmp; impl_->timings = tmp;
} }
bool Session::set_property_string(const char* name, const char* value) {
if (impl_ == nullptr || name == nullptr || value == nullptr ||
strlen(name) >= kMaxName || strlen(value) >= 48) {
return false;
}
Command cmd{};
cmd.type = 2;
snprintf(cmd.name, sizeof(cmd.name), "%s", name);
cmd.value = 0;
snprintf(cmd.base_type, sizeof(cmd.base_type), "string");
snprintf(cmd.str_value, sizeof(cmd.str_value), "%s", value);
{
std::lock_guard<std::mutex> lk(impl_->queue_mu);
cmd.cmd_id = impl_->next_cmd_id++;
}
if (!impl_->submit(cmd)) return false;
impl_->want_notify.store(true, std::memory_order_release);
return true;
}
bool Session::begin_batch() { bool Session::begin_batch() {
std::lock_guard<std::mutex> lk(impl_->queue_mu); std::lock_guard<std::mutex> lk(impl_->queue_mu);
if (impl_->batch_open) return false; if (impl_->batch_open) return false;
@@ -917,20 +869,19 @@ bool Session::begin_batch() {
} }
bool Session::commit_batch() { bool Session::commit_batch() {
bool ok = true;
{ {
std::lock_guard<std::mutex> lk(impl_->queue_mu); std::lock_guard<std::mutex> lk(impl_->queue_mu);
if (!impl_->batch_open) return false; if (!impl_->batch_open) return false;
bool ok = true;
for (uint8_t i = 0; i < impl_->batch_len; i++) { for (uint8_t i = 0; i < impl_->batch_len; i++) {
if (!impl_->enqueue_locked(impl_->batch[i])) ok = false; if (!impl_->enqueue_locked(impl_->batch[i])) ok = false;
} }
impl_->batch_open = false; impl_->batch_open = false;
impl_->batch_len = 0; impl_->batch_len = 0;
if (!ok) return false;
} }
// notify ставится ВСЕГДА: даже при частичном отказе поставленные команды
// должны уйти немедленно, а не ждать keep-alive.
impl_->want_notify.store(true, std::memory_order_release); impl_->want_notify.store(true, std::memory_order_release);
return ok; return true;
} }
bool Session::abort_batch() { bool Session::abort_batch() {
+1 -4
View File
@@ -53,8 +53,7 @@ struct SessionConfig {
uint32_t lanip_key_id = 0; uint32_t lanip_key_id = 0;
uint16_t listen_port = 10275; // 0 — любой свободный uint16_t listen_port = 10275; // 0 — любой свободный
uint32_t keepalive_ms = 15000; uint32_t keepalive_ms = 15000;
uint8_t max_queue = 40; // лимит очереди команд (>= полного uint8_t max_queue = 16; // лимит очереди команд
// батча свойств шаблона, 33)
}; };
struct SessionCallbacks { struct SessionCallbacks {
@@ -108,8 +107,6 @@ class Session {
// SET-команда (integer/boolean как int64). // SET-команда (integer/boolean как int64).
bool set_property(const char* name, int64_t value, bool set_property(const char* name, int64_t value,
const char* base_type = "integer"); const char* base_type = "integer");
// SET-команда строкового свойства (base_type "string").
bool set_property_string(const char* name, const char* value);
// Пакет: собрать несколько команд, один notify на commit. // Пакет: собрать несколько команд, один notify на commit.
bool begin_batch(); bool begin_batch();
bool commit_batch(); bool commit_batch();
-20
View File
@@ -34,29 +34,9 @@ fgl_add_test(ayla_envelope ayla/test_envelope.cpp)
fgl_add_test(ayla_json ayla/test_json.cpp) fgl_add_test(ayla_json ayla/test_json.cpp)
fgl_add_test(ayla_httpc ayla/test_httpc.cpp) fgl_add_test(ayla_httpc ayla/test_httpc.cpp)
# aircon-слой: юнит-тесты таблиц/конверсий + C-API smoke.
fgl_add_test(aircon_tables aircon/test_tables.cpp)
fgl_add_test(aircon_convert aircon/test_convert.cpp)
target_link_libraries(test_aircon_tables PRIVATE fgl-aircon)
target_link_libraries(test_aircon_convert PRIVATE fgl-aircon)
add_executable(aircon_runner aircon/aircon_runner.cpp)
target_compile_features(aircon_runner PRIVATE cxx_std_20)
target_link_libraries(aircon_runner PRIVATE fgl-aircon)
target_include_directories(aircon_runner PRIVATE "${CMAKE_SOURCE_DIR}/src")
# Интеграционные сценарии с mock-модулем (python, stdlib-only). # Интеграционные сценарии с mock-модулем (python, stdlib-only).
find_package(Python3 COMPONENTS Interpreter REQUIRED) find_package(Python3 COMPONENTS Interpreter REQUIRED)
add_test(NAME ayla_session_mock add_test(NAME ayla_session_mock
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/ayla/test_session_mock.py COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/ayla/test_session_mock.py
$<TARGET_FILE:session_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py) $<TARGET_FILE:session_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
set_tests_properties(ayla_session_mock PROPERTIES TIMEOUT 180) set_tests_properties(ayla_session_mock PROPERTIES TIMEOUT 180)
add_test(NAME aircon_session_mock
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/aircon/test_aircon_mock.py
$<TARGET_FILE:aircon_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
set_tests_properties(aircon_session_mock PROPERTIES TIMEOUT 180)
# fglair-discover против мок-облака.
add_test(NAME tools_fglair_discover
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/test_fglair_discover.py)
set_tests_properties(tools_fglair_discover PROPERTIES TIMEOUT 60)
-365
View File
@@ -1,365 +0,0 @@
"""Self-test приёмочного скрипта на моке HA REST API (без железа)."""
from __future__ import annotations
import json
import pathlib
import sys
import threading
import types
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import SimpleNamespace
import pytest
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from test_esphome_ha import ( # noqa: E402
AcceptanceError,
EsphomeChannel,
HaRest,
Step,
build_steps,
main,
)
class MockHa:
def __init__(self) -> None:
self.lock = threading.Lock()
self.fail_at = 0
self.post_count = 0
self.ignore_services = False
self.non_json = False
self.state = {
"entity_id": "climate.test",
"state": "heat",
"attributes": {
"hvac_modes": ["off", "cool", "heat"],
"fan_modes": ["low", "high"],
"swing_modes": ["off", "on"],
"temperature": 22.0,
"min_temp": 16.0,
"max_temp": 30.0,
"fan_mode": "low",
"swing_mode": "off",
},
}
def apply(self, service: str, data: dict) -> None:
attrs = self.state["attributes"]
if service == "set_hvac_mode":
self.state["state"] = data["hvac_mode"]
elif service == "set_fan_mode":
attrs["fan_mode"] = data["fan_mode"]
elif service == "set_swing_mode":
attrs["swing_mode"] = data["swing_mode"]
elif service == "set_temperature":
attrs["temperature"] = data["temperature"]
def handler(self):
mock = self
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def _json(self, status: int, payload) -> None:
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.close_connection = True
self.wfile.write(body)
def _raw(self, status: int, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.close_connection = True
self.wfile.write(body)
def do_GET(self):
if self.path != "/api/states/climate.test":
self._json(404, {"message": "not found"})
return
with mock.lock:
if mock.non_json:
self._raw(200, b"<html>captive portal</html>")
return
self._json(200, json.loads(json.dumps(mock.state)))
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
data = json.loads(self.rfile.read(length) or b"{}")
with mock.lock:
mock.post_count += 1
if mock.fail_at and mock.post_count == mock.fail_at:
self._json(500, {"message": "boom"})
return
if not mock.ignore_services:
mock.apply(self.path.rsplit("/", 1)[-1], data)
self._json(200, [])
return Handler
@pytest.fixture
def mock_ha():
mock = MockHa()
server = ThreadingHTTPServer(("127.0.0.1", 0), mock.handler())
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
port = server.server_address[1]
yield mock, f"http://127.0.0.1:{port}"
server.shutdown()
server.server_close()
thread.join(timeout=5)
QUICK_ARGS = ["quick", "--ha-token", "t", "--entity", "climate.test"]
def test_build_steps_supported_only():
state = {
"state": "heat",
"attributes": {
"hvac_modes": ["off"],
"fan_modes": [],
"temperature": 20.0,
"min_temp": 16.0,
"max_temp": 30.0,
},
}
assert [step.name for step in build_steps(state)] == ["temperature"]
def test_quick_success(mock_ha, capsys, tmp_path):
_, url = mock_ha
report = tmp_path / "quick.csv"
assert main(
[*QUICK_ARGS, "--ha-url", url, "--report", str(report)]
) == 0
out = capsys.readouterr().out
assert "restore: expected=heat observed=heat" in out
assert "ok" in report.read_text()
def test_quick_burst_success(mock_ha):
_, url = mock_ha
assert main([*QUICK_ARGS, "--burst", "--ha-url", url]) == 0
def test_quick_service_error(mock_ha, capsys):
mock, url = mock_ha
mock.fail_at = 1
assert main([*QUICK_ARGS, "--ha-url", url]) == 2
assert "HTTP 500" in capsys.readouterr().err
def test_quick_restores_after_partial_failure(mock_ha):
"""Сбой на втором шаге — исходное состояние возвращается (finally)."""
mock, url = mock_ha
mock.fail_at = 2
assert main([*QUICK_ARGS, "--ha-url", url]) == 2
assert mock.state["state"] == "heat"
assert mock.state["attributes"]["fan_mode"] == "low"
def test_quick_timeout_is_failure(mock_ha, capsys):
mock, url = mock_ha
mock.ignore_services = True
rc = main(
[*QUICK_ARGS, "--ha-url", url, "--settle-timeout", "0.3"]
)
assert rc == 1
assert "ПРОВАЛЕНО" in capsys.readouterr().err
def test_quick_non_json_answer(mock_ha, capsys):
mock, url = mock_ha
mock.non_json = True
assert main([*QUICK_ARGS, "--ha-url", url]) == 2
assert "не JSON" in capsys.readouterr().err
def test_long_report_on_success(mock_ha, tmp_path, capsys):
_, url = mock_ha
report = tmp_path / "acceptance.csv"
rc = main([
"long", "--ha-url", url, "--ha-token", "t",
"--entity", "climate.test",
"--hours", "0.000003", "--interval", "0.01",
"--report", str(report),
])
assert rc == 0
text = report.read_text()
assert "timestamp,step,expected,observed,result" in text
assert "ok" in text
assert "long: ok=1 fail=0" in capsys.readouterr().out
def test_long_failure_restores_and_reports(mock_ha, tmp_path):
mock, url = mock_ha
mock.ignore_services = True # шаг не достигается, восстановление — работает
report = tmp_path / "fail.csv"
rc = main([
"long", "--ha-url", url, "--ha-token", "t",
"--entity", "climate.test",
"--hours", "0.000003", "--interval", "0.01",
"--settle-timeout", "0.3",
"--report", str(report),
])
assert rc == 1
assert "fail" in report.read_text()
assert mock.state["state"] == "heat"
def test_wait_state_timeout(mock_ha):
_, url = mock_ha
ha = HaRest(url, "t", timeout=1)
with pytest.raises(AcceptanceError):
ha.wait_state("climate.test", lambda s: False, timeout=0.2)
def test_esphome_channel_sends_mode_and_temperature(monkeypatch):
instances: list = []
class ClimateInfo:
key = 7
class FakeClient:
def __init__(self, host, port, noise_psk=None):
self.calls: list = []
instances.append(self)
async def connect(self, login=False):
self.login = login
async def list_entities_services(self):
return [ClimateInfo()], []
async def climate_command(self, key=None, mode=None,
target_temperature=None):
self.calls.append((key, mode, target_temperature))
async def disconnect(self):
self.disconnected = True
fake = types.ModuleType("aioesphomeapi")
fake.APIClient = FakeClient
fake.ClimateMode = SimpleNamespace(COOL="COOL", HEAT="HEAT")
fake.ClimateInfo = ClimateInfo
monkeypatch.setitem(sys.modules, "aioesphomeapi", fake)
channel = EsphomeChannel("esphome.local", "key")
try:
channel.connect()
mode_step = Step("hvac_mode", "climate", "set_hvac_mode",
{"hvac_mode": "cool"}, "state", "cool")
temp_step = Step("temperature", "climate", "set_temperature",
{"temperature": 23.5}, "temperature", 23.5)
fan_step = Step("fan_mode", "climate", "set_fan_mode",
{"fan_mode": "low"}, "fan_mode", "low")
assert channel.apply(mode_step) is True
assert channel.apply(temp_step) is True
assert channel.apply(fan_step) is False
finally:
channel.close()
assert instances[0].calls == [(7, "COOL", None), (7, None, 23.5)]
assert instances[0].login is True
assert instances[0].disconnected is True
def _fake_module(client_cls, climate_info=None):
fake = types.ModuleType("aioesphomeapi")
fake.APIClient = client_cls
fake.ClimateMode = SimpleNamespace(COOL="COOL", HEAT="HEAT")
if climate_info is not None:
fake.ClimateInfo = climate_info
return fake
def test_esphome_connect_error_is_acceptance_error(monkeypatch):
class BadClient:
def __init__(self, host, port, noise_psk=None):
pass
async def connect(self, login=False):
raise RuntimeError("connect boom")
async def disconnect(self):
pass
monkeypatch.setitem(sys.modules, "aioesphomeapi",
_fake_module(BadClient))
channel = EsphomeChannel("bad.local", "")
try:
with pytest.raises(AcceptanceError, match="подключение"):
channel.connect()
finally:
channel.close()
assert channel._loop.is_closed()
def test_main_esphome_connect_error_rc2(monkeypatch, capsys, mock_ha):
class BadClient:
def __init__(self, host, port, noise_psk=None):
pass
async def connect(self, login=False):
raise RuntimeError("connect boom")
_, url = mock_ha
monkeypatch.setitem(sys.modules, "aioesphomeapi",
_fake_module(BadClient))
rc = main([*QUICK_ARGS, "--ha-url", url, "--esphome-host", "1.2.3.4"])
assert rc == 2
assert "подключение" in capsys.readouterr().err
def test_main_esphome_missing_dependency_rc2(monkeypatch, capsys, mock_ha):
_, url = mock_ha
monkeypatch.setitem(sys.modules, "aioesphomeapi", None)
rc = main([*QUICK_ARGS, "--ha-url", url, "--esphome-host", "1.2.3.4"])
assert rc == 2
assert "aioesphomeapi" in capsys.readouterr().err
def test_esphome_command_error_is_acceptance_error(monkeypatch):
class ClimateInfo:
key = 3
class BadCommandClient:
def __init__(self, host, port, noise_psk=None):
pass
async def connect(self, login=False):
pass
async def list_entities_services(self):
return [ClimateInfo()], []
async def climate_command(self, **kwargs):
raise RuntimeError("command boom")
async def disconnect(self):
pass
monkeypatch.setitem(sys.modules, "aioesphomeapi",
_fake_module(BadCommandClient, ClimateInfo))
channel = EsphomeChannel("bad.local", "")
try:
channel.connect()
step = Step("hvac_mode", "climate", "set_hvac_mode",
{"hvac_mode": "cool"}, "state", "cool")
with pytest.raises(AcceptanceError, match="команда"):
channel.apply(step)
finally:
channel.close()
-553
View File
@@ -1,553 +0,0 @@
#!/usr/bin/env python3
"""Приёмка HA (и опционально ESPHome) на живом кондиционере.
Полуавтоматический скрипт: третью LAN-сессию НЕ открывает (оба слота заняты
HA + ESPHome); работает через REST API Home Assistant по long-lived token.
Профиль → Security → Long-lived access tokens → создать токен.
Запуск:
python tests/acceptance/test_esphome_ha.py quick \
--ha-url http://homeassistant.local:8123 --ha-token TOKEN \
--entity climate.ac
python tests/acceptance/test_esphome_ha.py long \
--ha-url ... --ha-token ... --entity climate.ac \
--hours 24 --interval 3600 --report acceptance.csv
Режимы:
quick — матрица изменений (hvac/fan/уставка/swing), последовательно и
«burst» (без пауз), каждое действие проверяется по состоянию в
HA и завершается возвратом к исходному состоянию;
long — одно изменение в --interval секунд (по умолчанию 3600) на
протяжении --hours часов, проверка и возврат, CSV-отчёт
дописывается по ходу.
ESPHome-сторона (опционально): --esphome-host/--esphome-key/--esphome-entity
и установленный `aioesphomeapi` — шаги «режим» и «уставка» отправляются
через ESPHome native API, результат сверяется по состоянию в HA (то есть
проверяется сквозная цепочка ESPHome → модуль → HA). Fan/swing через
ESPHome в этой версии не отправляются.
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import datetime as dt
import json
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any, Callable, Optional
DEFAULT_TIMEOUT = 15.0
SETTLE_TIMEOUT = 30.0
class AcceptanceError(RuntimeError):
pass
class HaRest:
"""Минимальный клиент REST API Home Assistant (stdlib)."""
def __init__(self, base_url: str, token: str, timeout: float = DEFAULT_TIMEOUT):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def _request(self, method: str, path: str, payload: Any = None) -> Any:
data = json.dumps(payload).encode() if payload is not None else None
request = urllib.request.Request(
self.base_url + path, data=data, headers=self.headers, method=method
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as resp:
body = resp.read().decode() or "null"
try:
return resp.status, json.loads(body)
except ValueError as err:
raise AcceptanceError(
f"{method} {path}: ответ не JSON: {body[:120]!r}"
) from err
except urllib.error.HTTPError as err:
try:
body = err.read().decode(errors="replace")
finally:
err.close()
raise AcceptanceError(
f"{method} {path}: HTTP {err.code}: {body[:200]}"
) from err
except OSError as err:
raise AcceptanceError(f"{method} {path}: {err}") from err
def state(self, entity_id: str) -> dict:
_, data = self._request("GET", f"/api/states/{entity_id}")
if not isinstance(data, dict):
raise AcceptanceError(f"{entity_id}: неожиданный ответ /api/states")
return data
def call(self, domain: str, service: str, data: dict) -> None:
self._request("POST", f"/api/services/{domain}/{service}", data)
def wait_state(
self,
entity_id: str,
predicate: Callable[[dict], bool],
timeout: float = SETTLE_TIMEOUT,
) -> dict:
deadline = time.monotonic() + timeout
last: Optional[dict] = None
while time.monotonic() < deadline:
last = self.state(entity_id)
if predicate(last):
return last
time.sleep(0.5)
raise AcceptanceError(
f"{entity_id}: состояние не достигнуто за {timeout} с: "
f"{last and last.get('state')}"
)
class EsphomeChannel:
"""Канал ESPHome native API: режим и уставка (best-effort)."""
def __init__(self, host: str, key: str, timeout: float = DEFAULT_TIMEOUT):
self.host = host
self.key = key
self.timeout = timeout
self._loop = asyncio.new_event_loop()
self._aio = None
self._client = None
self._entity_key: Optional[int] = None
def connect(self) -> None:
self._loop.run_until_complete(self._connect())
async def _connect(self) -> None:
try:
import aioesphomeapi # noqa: PLC0415
except ImportError as err:
raise AcceptanceError(
"ESPHome-канал: пакет aioesphomeapi не установлен"
) from err
self._aio = aioesphomeapi
try:
await self._connect_inner()
except AcceptanceError:
raise
except Exception as err: # noqa: BLE001
raise AcceptanceError(
f"ESPHome {self.host}: подключение/список сущностей: {err}"
) from err
async def _connect_inner(self) -> None:
self._client = self._aio.APIClient(
self.host, 6053, noise_psk=self.key or None
)
await asyncio.wait_for(
self._client.connect(login=True), timeout=self.timeout
)
entities, _services = await self._client.list_entities_services()
climate_info = getattr(self._aio, "ClimateInfo", None)
for entity in entities:
if climate_info is not None and isinstance(entity, climate_info):
self._entity_key = entity.key
break
if entity.__class__.__name__ == "ClimateInfo":
self._entity_key = entity.key
break
if self._entity_key is None:
raise AcceptanceError("ESPHome: climate-сущность не найдена")
def apply(self, step: "Step") -> bool:
"""Отправляет шаг через ESPHome; False — шаг не поддержан каналом."""
if step.name not in ("hvac_mode", "temperature"):
return False
self._loop.run_until_complete(self._apply(step))
return True
async def _apply(self, step: "Step") -> None:
mode = None
target = None
if step.name == "hvac_mode":
mode = getattr(self._aio.ClimateMode, str(step.expected).upper())
else:
target = float(step.expected)
try:
await asyncio.wait_for(
self._client.climate_command(
key=self._entity_key, mode=mode, target_temperature=target
),
timeout=self.timeout,
)
except Exception as err: # noqa: BLE001
raise AcceptanceError(f"ESPHome: команда не отправлена: {err}") from err
def close(self) -> None:
try:
if self._client is not None:
try:
self._loop.run_until_complete(
asyncio.wait_for(
self._client.disconnect(), timeout=self.timeout
)
)
except Exception: # noqa: BLE001 - best-effort
pass
finally:
self._loop.close()
@dataclass
class Step:
name: str
domain: str
service: str
service_data: dict
attribute: str
expected: Any
def predicate(self) -> Callable[[dict], bool]:
def check(state: dict) -> bool:
if self.attribute == "state":
return state.get("state") == self.expected
value = state.get("attributes", {}).get(self.attribute)
return _matches(value, self.expected)
return check
def _pick(options: list, preferred: str) -> Optional[str]:
if not options:
return None
if preferred in options:
return preferred
return options[0]
def _matches(observed: Any, expected: Any) -> bool:
if isinstance(expected, (int, float)) and isinstance(observed, (int, float)):
return abs(float(observed) - float(expected)) < 0.11
return observed == expected
def _observed(state: Optional[dict], step: Step) -> Any:
if state is None:
return None
if step.attribute == "state":
return state.get("state")
return state.get("attributes", {}).get(step.attribute)
def build_steps(state: dict) -> list[Step]:
"""Адаптивная матрица: только поддерживаемые режимы/диапазоны."""
attrs = state.get("attributes", {})
steps: list[Step] = []
hvac_modes = [mode for mode in attrs.get("hvac_modes", []) if mode != "off"]
mode = _pick(hvac_modes, "cool")
if mode:
steps.append(
Step("hvac_mode", "climate", "set_hvac_mode",
{"hvac_mode": mode}, "state", mode)
)
fan_mode = _pick(attrs.get("fan_modes", []), "low")
if fan_mode:
steps.append(
Step("fan_mode", "climate", "set_fan_mode",
{"fan_mode": fan_mode}, "fan_mode", fan_mode)
)
swing = _pick(attrs.get("swing_modes", []), "on")
if swing:
steps.append(
Step("swing_mode", "climate", "set_swing_mode",
{"swing_mode": swing}, "swing_mode", swing)
)
temperature = attrs.get("temperature")
low = attrs.get("min_temp", 16.0)
high = attrs.get("max_temp", 30.0)
if temperature is not None and low is not None and high is not None:
target = temperature + 1.0
if target > high:
target = max(low, high - 1.0)
steps.append(
Step("temperature", "climate", "set_temperature",
{"temperature": target}, "temperature", target)
)
return steps
def _original_predicate(original: dict) -> Callable[[dict], bool]:
state = original.get("state")
attrs = original.get("attributes", {})
def check(current: dict) -> bool:
if current.get("state") != state:
return False
current_attrs = current.get("attributes", {})
for key in ("fan_mode", "swing_mode", "temperature"):
if attrs.get(key) is not None and not _matches(
current_attrs.get(key), attrs.get(key)
):
return False
return True
return check
def _restore(
ha: HaRest, entity_id: str, original: dict, *, wait: bool = True
) -> None:
attrs = original.get("attributes", {})
state = original.get("state")
if state == "off" and "off" in attrs.get("hvac_modes", []):
ha.call("climate", "set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": "off"})
elif state and state != "off":
ha.call("climate", "set_hvac_mode",
{"entity_id": entity_id, "hvac_mode": state})
if attrs.get("fan_mode") is not None:
ha.call("climate", "set_fan_mode",
{"entity_id": entity_id, "fan_mode": attrs["fan_mode"]})
if attrs.get("temperature") is not None:
ha.call("climate", "set_temperature",
{"entity_id": entity_id, "temperature": attrs["temperature"]})
if attrs.get("swing_mode") is not None:
ha.call("climate", "set_swing_mode",
{"entity_id": entity_id, "swing_mode": attrs["swing_mode"]})
if wait:
ha.wait_state(entity_id, _original_predicate(original))
def _apply_step(
ha: HaRest, esphome: Optional[EsphomeChannel], entity_id: str, step: Step
) -> None:
if esphome is not None and esphome.apply(step):
return
ha.call(step.domain, step.service,
{"entity_id": entity_id, **step.service_data})
def _write_report(
path: str, rows: list[list], write_header: bool = True
) -> None:
mode = "w" if write_header else "a"
with open(path, mode, newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
if write_header:
writer.writerow(
["timestamp", "step", "expected", "observed", "result"]
)
writer.writerows(rows)
def run_quick(
ha: HaRest,
entity_id: str,
*,
burst: bool = False,
esphome: Optional[EsphomeChannel] = None,
settle_timeout: float = SETTLE_TIMEOUT,
) -> list[dict]:
original = ha.state(entity_id)
steps = build_steps(original)
if not steps:
raise AcceptanceError("climate-сущность не поддерживает ни одного шага")
results: list[dict] = []
try:
if burst and esphome is None:
for step in steps:
_apply_step(ha, esphome, entity_id, step)
for step in steps:
try:
state = ha.wait_state(
entity_id, step.predicate(), settle_timeout
)
observed = _observed(state, step)
except AcceptanceError as err:
observed = str(err)
results.append({
"step": step.name,
"expected": step.expected,
"observed": observed,
})
else:
for step in steps:
_apply_step(ha, esphome, entity_id, step)
try:
state = ha.wait_state(
entity_id, step.predicate(), settle_timeout
)
observed = _observed(state, step)
except AcceptanceError as err:
observed = str(err)
results.append({
"step": step.name,
"expected": step.expected,
"observed": observed,
})
finally:
_restore(ha, entity_id, original, wait=False)
try:
restored = ha.wait_state(
entity_id, _original_predicate(original), settle_timeout
)
except AcceptanceError:
restored = ha.state(entity_id)
results.append({
"step": "restore",
"expected": original.get("state"),
"observed": restored.get("state"),
})
return results
def run_long(
ha: HaRest,
entity_id: str,
*,
hours: float,
interval: float,
report_path: Optional[str],
esphome: Optional[EsphomeChannel] = None,
settle_timeout: float = SETTLE_TIMEOUT,
) -> tuple[int, int]:
original = ha.state(entity_id)
steps = build_steps(original)
if not steps:
raise AcceptanceError("climate-сущность не поддерживает ни одного шага")
iterations = max(1, int(hours * 3600 / interval))
ok = failed = 0
rows: list[list] = []
for index in range(iterations):
step = steps[index % len(steps)]
timestamp = dt.datetime.now(dt.timezone.utc).isoformat()
try:
_apply_step(ha, esphome, entity_id, step)
state = ha.wait_state(
entity_id, step.predicate(), settle_timeout
)
observed = _observed(state, step)
passed = _matches(observed, step.expected)
except AcceptanceError as err:
observed = str(err)
passed = False
finally:
try:
_restore(ha, entity_id, original)
except AcceptanceError as err:
observed = f"restore failed: {err}"
passed = False
ok += 1 if passed else 0
failed += 0 if passed else 1
row = [timestamp, step.name, step.expected, observed,
"ok" if passed else "fail"]
rows.append(row)
if report_path:
_write_report(report_path, [row], write_header=index == 0)
if index + 1 < iterations:
time.sleep(interval)
return ok, failed
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("mode", nargs="?", choices=["quick", "long"],
default="quick")
parser.add_argument("--long", dest="long_mode", action="store_true",
help="алиас режима long")
parser.add_argument("--ha-url", required=True)
parser.add_argument("--ha-token", required=True)
parser.add_argument("--entity", required=True,
help="entity_id climate-сущности FGLair")
parser.add_argument("--burst", action="store_true",
help="quick: без пауз между изменениями (без ESPHome)")
parser.add_argument("--hours", type=float, default=24.0)
parser.add_argument("--interval", type=float, default=3600.0)
parser.add_argument("--report", help="CSV-отчёт (quick и long)")
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
parser.add_argument("--settle-timeout", type=float,
default=SETTLE_TIMEOUT)
parser.add_argument("--esphome-host")
parser.add_argument("--esphome-key", default="")
return parser
def _make_esphome(args: argparse.Namespace) -> Optional[EsphomeChannel]:
if not args.esphome_host:
return None
channel = EsphomeChannel(args.esphome_host, args.esphome_key, args.timeout)
try:
channel.connect()
except AcceptanceError:
channel.close()
raise
except Exception as err: # noqa: BLE001
channel.close()
raise AcceptanceError(
f"ESPHome {args.esphome_host}: подключение не удалось: {err}"
) from err
return channel
def main(argv: Optional[list[str]] = None) -> int:
args = _build_parser().parse_args(argv)
if args.long_mode:
args.mode = "long"
esphome: Optional[EsphomeChannel] = None
try:
esphome = _make_esphome(args)
ha = HaRest(args.ha_url, args.ha_token, args.timeout)
if args.mode == "quick":
results = run_quick(
ha, args.entity, burst=args.burst, esphome=esphome,
settle_timeout=args.settle_timeout,
)
failed = [
row for row in results
if not _matches(row.get("observed"), row.get("expected"))
]
for row in results:
print(f"{row['step']}: expected={row['expected']} "
f"observed={row.get('observed', '-')}")
if args.report:
_write_report(args.report, [
[dt.datetime.now(dt.timezone.utc).isoformat(),
row["step"], row["expected"], row.get("observed"),
"ok" if _matches(row.get("observed"),
row.get("expected")) else "fail"]
for row in results
])
if failed:
print(f"ПРОВАЛЕНО: {len(failed)}", file=sys.stderr)
return 1
return 0
ok, failed = run_long(
ha, args.entity,
hours=args.hours, interval=args.interval,
report_path=args.report, esphome=esphome,
settle_timeout=args.settle_timeout,
)
print(f"long: ok={ok} fail={failed}"
+ (f", отчёт: {args.report}" if args.report else ""))
return 1 if failed else 0
except AcceptanceError as err:
print(f"приёмка: {err}", file=sys.stderr)
return 2
finally:
if esphome is not None:
esphome.close()
if __name__ == "__main__":
sys.exit(main())
-180
View File
@@ -1,180 +0,0 @@
// Тесты публичной сессии (aircon-слой) против mock-модуля: маппинг
// имя↔enum, конверсии в событиях, оптимистичный кэш, batch, C-API.
// Запускается python-оркестратором (tests/aircon/test_aircon_mock.py).
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <thread>
#include "fgl-aircon/c_api.h"
#include "fgl-aircon/session.hpp"
#include "fgl-aircon/templates.hpp"
using fgl::aircon::Prop;
using fgl::aircon::Session;
using fgl::aircon::State;
static const char* state_name(State st) {
switch (st) {
case State::kIdle: return "idle";
case State::kRegistering: return "registering";
case State::kOnline: return "online";
case State::kRecovering: return "recovering";
case State::kOffline: return "offline";
case State::kKeyError: return "key_error";
}
return "?";
}
static void on_state(void*, State st, fgl::aircon::Error err) {
printf("STATE %s %d\n", state_name(st), static_cast<int>(err));
fflush(stdout);
}
static void on_property(void*, const fgl::aircon::PropertyEvent& ev) {
const char* n = fgl::aircon::prop_enum_name(ev.prop);
if (ev.value.kind == fgl::aircon::ValueKind::kString) {
printf("PROP %s %d %d s:%s\n", n, ev.cmd_id, ev.status, ev.value.s);
} else {
printf("PROP %s %d %d %c:%lld\n", n, ev.cmd_id, ev.status,
ev.value.kind == fgl::aircon::ValueKind::kBool ? 'b' : 'i',
static_cast<long long>(ev.value.i));
}
fflush(stdout);
}
static uint32_t env_u32(const char* name, uint32_t def) {
const char* v = getenv(name);
return v != nullptr ? static_cast<uint32_t>(atoi(v)) : def;
}
int main(int argc, char** argv) {
if (argc < 6) {
fprintf(stderr,
"usage: %s <host> <device_port> <listen_port> <dsn> <lanip_key> "
"<key_id> <duration_sec> [prop-enum-name ...]\n",
argv[0]);
return 2;
}
fgl::aircon::Config cfg{};
cfg.host = argv[1];
cfg.device_port = static_cast<uint16_t>(atoi(argv[2]));
cfg.listen_port = static_cast<uint16_t>(atoi(argv[3]));
cfg.dsn = argv[4];
cfg.lanip_key = argv[5];
cfg.lanip_key_id = static_cast<uint32_t>(atoi(argv[6]));
cfg.tmpl = fgl::aircon::Template::kA;
int duration_sec = atoi(argv[7]);
fgl::aircon::Callbacks cbs{};
cbs.on_state = on_state;
cbs.on_property = on_property;
Session* s = Session::create(cfg, cbs);
if (s == nullptr) {
printf("CREATE_FAILED\n");
return 2;
}
// Тест C-API интроспекции.
printf("CVER %s\n", fgl_version());
printf("CNAME %s\n", fgl_prop_name(FGL_TEMPLATE_A, FGL_PROP_FAN_SPEED));
printf("CCONV %lld\n", static_cast<long long>(
fgl_convert_to_display(FGL_TEMPLATE_A,
FGL_PROP_DISPLAY_TEMPERATURE,
7000)));
int64_t range_min = 0, range_max = 0;
int range_ok = fgl_prop_raw_range(FGL_TEMPLATE_A,
FGL_PROP_ADJUST_TEMPERATURE,
&range_min, &range_max);
printf("CRANGE %d %lld %lld\n", range_ok,
static_cast<long long>(range_min), static_cast<long long>(range_max));
printf("CRANGE_NONE %d\n",
fgl_prop_raw_range(FGL_TEMPLATE_A, FGL_PROP_DEVICE_NAME, nullptr,
nullptr));
if (!s->start()) {
printf("START_FAILED\n");
return 2;
}
// Начальная синхронизация: пакет GET всех свойств шаблона A (батч).
if (argc > 8) {
s->batch_begin();
for (int i = 8; i < argc; i++) {
// argv: имена enum'ов ("OperationMode")
for (uint8_t p = 0; p < static_cast<uint8_t>(Prop::kCount); p++) {
if (strcmp(fgl::aircon::prop_enum_name(static_cast<Prop>(p)),
argv[i]) == 0) {
if (!s->get_prop(static_cast<Prop>(p))) {
printf("GET_FAIL %s\n", argv[i]);
}
break;
}
}
}
if (!s->batch_commit()) printf("BATCH_PARTIAL\n");
} else {
// По умолчанию — все int/bool свойства шаблола A одним батчем
// (строковые пропускаются: модуль-мок их не генерирует; на приборе
// строковый push проверялся вручную через aircon-события).
s->batch_begin();
for (auto* info = fgl::aircon::prop_info_begin(cfg.tmpl);
info != fgl::aircon::prop_info_begin(cfg.tmpl) +
fgl::aircon::prop_info_count(cfg.tmpl);
info++) {
if (info->kind != fgl::aircon::ValueKind::kString) {
if (!s->get_prop(info->prop)) {
printf("GET_FAIL %s\n", fgl::aircon::prop_enum_name(info->prop));
}
}
}
if (!s->batch_commit()) printf("BATCH_PARTIAL\n");
}
const char* set_prop = getenv("RUNNER_SET_PROP"); // enum-имя
const char* set_val = getenv("RUNNER_SET_VALUE");
uint32_t set_at = env_u32("RUNNER_SET_AT", 0);
if (set_prop != nullptr && set_val != nullptr && set_at > 0) {
std::this_thread::sleep_for(std::chrono::seconds(set_at));
for (uint8_t p = 0; p < static_cast<uint8_t>(Prop::kCount); p++) {
if (strcmp(fgl::aircon::prop_enum_name(static_cast<Prop>(p)),
set_prop) == 0) {
bool ok = s->set_int(static_cast<Prop>(p), atoll(set_val));
printf("SET_DONE %s=%s ok=%d\n", set_prop, set_val, ok ? 1 : 0);
// Оптимистичный кэш сразу доступен.
fgl::aircon::Value v{};
if (s->cached(static_cast<Prop>(p), &v)) {
printf("CACHED %s %lld\n", set_prop,
static_cast<long long>(v.i));
}
fflush(stdout);
break;
}
}
}
auto deadline = std::chrono::steady_clock::now() +
std::chrono::seconds(duration_sec);
while (std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// Проверки отказов (после duration, до stop):
// - read-only свойство (ErrorCode) — отказ;
// - свойство не из шаблона A (AfVerticalMoveStep1, только B) — отказ;
// - клэмпинг: AdjustTemperature=1000 -> raw max 450.
printf("RO_SET %d\n", s->set_int(Prop::ErrorCode, 5) ? 1 : 0);
printf("NOTMPL_SET %d\n",
s->set_int(Prop::AfVerticalMoveStep1, 1) ? 1 : 0);
printf("CLAMP_SET %d\n", s->set_int(Prop::AdjustTemperature, 1000) ? 1 : 0);
s->stop();
printf("DELETED\n");
printf("STATS rekeys=%u pushes_ok=%u pushes_bad=%u cmds=%u state=%s\n",
s->rekey_count(), s->pushes_ok(), s->pushes_bad(),
s->commands_served(), state_name(s->state()));
fflush(stdout);
delete s;
return 0;
}
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""Интеграционные сценарии aircon-слоя (публичный API) против mock-модуля.
usage: test_aircon_mock.py <aircon_runner> <mock_ac.py>
Проверяет: маппинг enum↔имя, конверсию в событиях (DisplayTemperature!),
оптимистичный кэш, batch=1 notify, RO/не-шаблонные отказы, клэмпинг.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "ayla"))
from test_session_mock import ( # noqa: E402
FAILURES, Proc, check, free_port, LANIP_KEY, KEY_ID, DSN, run_mock)
RUNNER = sys.argv[1]
MOCK = sys.argv[2]
def run_runner(mock_port, listen, duration, props=None, env_extra=None,
timeout=None):
env = dict(os.environ)
if env_extra:
env.update(env_extra)
argv = [RUNNER, "127.0.0.1", str(mock_port), str(listen), DSN, LANIP_KEY,
str(KEY_ID), str(duration)]
if props:
argv += props
p = Proc(argv, env=env)
return p, (timeout or duration + 25)
def scenario_mapping_and_batch():
print("== aircon: маппинг/конверсии, batch=1 notify, полный шаблон A")
mock_port, listen = free_port(), free_port()
mock = run_mock([], mock_port)
runner, timeout = run_runner(mock_port, listen, 5,
props=["OperationMode", "FanSpeed",
"DisplayTemperature"])
check(runner.wait_line("STATE online", 10) is not None, "online")
p1 = runner.wait_line("PROP OperationMode", 10)
check(p1 is not None and " i:6" in p1, f"operation_mode: {p1}")
p2 = runner.wait_line("PROP FanSpeed", 10)
check(p2 is not None and " i:4" in p2, f"fan_speed: {p2}")
# Конверсия: mock шлёт display_temperature=7000? Нет — mock вернёт
# значение из своих props (нет в словаре -> 0) => raw 0 -> api -500.
p3 = runner.wait_line("PROP DisplayTemperature", 10)
check(p3 is not None and " i:-500" in p3, f"display_temp raw0 -> -500: {p3}")
# C-API smoke.
check(runner.wait_line("CVER 0.", 5) is not None, "c_api version")
cname = runner.wait_line("CNAME", 5)
check(cname is not None and "fan_speed" in cname, "c_api prop_name")
conv = runner.wait_line("CCONV 200", 5)
check(conv is not None, "c_api convert 7000 -> 200")
rng = runner.wait_line("CRANGE 1 -100 450", 5)
check(rng is not None, f"c_api raw range adjust_temperature: {rng}")
check(runner.wait_line("CRANGE_NONE 0", 5) is not None,
"c_api: строковое свойство без диапазона")
# batch=1 notify: ждём >=2 REG (второй — асинхронный put после выдачи
# батча), затем ассертим ровно один notify=True.
import time as _time
deadline = _time.time() + 5
while _time.time() < deadline:
regs = [l for l in mock.all_lines() if l.startswith("REG")]
if len(regs) >= 2:
break
_time.sleep(0.2)
notifies = [l for l in regs if "notify=True" in l]
check(len(regs) >= 2 and len(notifies) == 1,
f"один notify=True на батч: regs={regs}")
runner.wait_line("DELETED", timeout)
runner.stop()
mock.stop()
def scenario_set_cache_clamp():
print("== aircon: SET + оптимистичный кэш + клэмпинг + отказы")
mock_port, listen = free_port(), free_port()
mock = run_mock([], mock_port)
runner, timeout = run_runner(mock_port, listen, 5,
env_extra={"RUNNER_SET_PROP": "FanSpeed",
"RUNNER_SET_VALUE": "3",
"RUNNER_SET_AT": "1"})
check(runner.wait_line("STATE online", 10) is not None, "online")
check(runner.wait_line("~SET_DONE FanSpeed=3 ok=1", 10) is not None,
"SET выполнена")
check(runner.wait_line("CACHED FanSpeed 3", 5) is not None,
"оптимистичный кэш")
check(mock.wait_line("~CMD SET fan_speed=3", 10) is not None,
"mock применил SET (имя из таблицы)")
# Отказы и клэмпинг печатаются перед DELETED.
check(runner.wait_line("RO_SET 0", timeout) is not None, "RO отказ")
check(runner.wait_line("NOTMPL_SET 0", 5) is not None,
"не-шаблонное свойство отказ")
check(runner.wait_line("CLAMP_SET 1", 5) is not None, "клэмпинг применён")
check(mock.wait_line("~CMD SET adjust_temperature=450", 15) is not None,
"клэмпинг: 1000 -> raw max 450 на модуле")
runner.wait_line("DELETED", 10)
runner.stop()
mock.stop()
def main():
scenario_mapping_and_batch()
scenario_set_cache_clamp()
print()
if FAILURES:
print("ПРОВАЛЕНО:", len(FAILURES))
for f in FAILURES:
print(" -", f)
sys.exit(1)
print("ВСЕ СЦЕНАРИИ ПРОЙДЕНЫ")
if __name__ == "__main__":
main()
-189
View File
@@ -1,189 +0,0 @@
// Тесты конверсий: шаблонные по умолчанию, linear/custom override, диапазоны.
#include "doctest/doctest.h"
#include <cstring>
#include "fgl-aircon/templates.hpp"
using fgl::aircon::ConvKind;
using fgl::aircon::Conversion;
using fgl::aircon::Linear;
using fgl::aircon::Prop;
using fgl::aircon::PropOverride;
using fgl::aircon::convert_from_input;
using fgl::aircon::convert_to_display;
using fgl::aircon::prop_api_range;
using fgl::aircon::Template;
namespace {
const PropOverride kTerm{Prop::kCount};
}
TEST_CASE("Конверсия по умолчанию: тождество и DisplayTemperature") {
// Большинство свойств тождественны (API == raw).
CHECK(convert_to_display(Template::kA, Prop::OperationMode, 6, &kTerm) == 6);
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 200, &kTerm) == 200);
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 250, &kTerm) == 250);
// DisplayTemperature: raw 0.01°C(+5000) -> API 0.1°C.
// Прибор: 7000 → 20.0°C → 200 (проверено на приборе).
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7000, &kTerm) == 200);
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7300, &kTerm) == 230);
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 4975, &kTerm) == -2);
// Округление к нулю.
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7004, &kTerm) == 200);
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature, 7005, &kTerm) == 200);
// Обратная (симметричная, для тестов; свойство RO).
CHECK(convert_from_input(Template::kA, Prop::DisplayTemperature, 230, &kTerm) == 7300);
}
TEST_CASE("Linear override: to_display и from_input") {
// ТЕ ЖЕ коэффициенты в оба направления (from_input = инверсия):
// api = raw*1/2 (250 -> 125); raw = api*2 (125 -> 250).
PropOverride ov{};
ov.prop = Prop::AdjustTemperature;
ov.to_display.kind = ConvKind::kLinear;
ov.to_display.linear = Linear{1, 2, 0};
ov.from_input.kind = ConvKind::kLinear;
ov.from_input.linear = Linear{1, 2, 0};
const PropOverride list[] = {ov, kTerm};
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 250, list) == 125);
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 125, list) == 250);
// Смещение: Фаренгейт из сырых 0.1°C: F = raw*9/5 + 320 (те же коэфф.).
PropOverride ovF{};
ovF.prop = Prop::AdjustTemperature;
ovF.to_display.kind = ConvKind::kLinear;
ovF.to_display.linear = Linear{9, 5, 320};
ovF.from_input.kind = ConvKind::kLinear;
ovF.from_input.linear = Linear{9, 5, 320};
const PropOverride listF[] = {ovF, kTerm};
// 25.0°C = 77°F: 250*9/5+320 = 770 (0.1°F).
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 250, listF) == 770);
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 770, listF) == 250);
// Отрицательные: -100 (−10°C) → 140 (14.0°F).
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, -100, listF) == 140);
// Override не задет другие свойства.
CHECK(convert_to_display(Template::kA, Prop::FanSpeed, 3, listF) == 3);
}
namespace {
int64_t double_it(int64_t raw, void*) { return raw * 2; }
int64_t half_it(int64_t v, void*) { return v / 2; }
}
TEST_CASE("Custom fn override") {
PropOverride ov{};
ov.prop = Prop::FanSpeed;
ov.to_display.kind = ConvKind::kCustomFn;
ov.to_display.fn = double_it;
ov.from_input.kind = ConvKind::kCustomFn;
ov.from_input.fn = half_it;
const PropOverride list[] = {ov, kTerm};
CHECK(convert_to_display(Template::kA, Prop::FanSpeed, 2, list) == 4);
CHECK(convert_from_input(Template::kA, Prop::FanSpeed, 4, list) == 2);
}
TEST_CASE("Диапазоны API-значений: шаблонные и override") {
int64_t lo = 0, hi = 0;
// adjust_temperature: raw -100..450 → тождественно.
REQUIRE(prop_api_range(Template::kA, Prop::AdjustTemperature, &kTerm, &lo, &hi));
CHECK(lo == -100);
CHECK(hi == 450);
// display_temperature: raw 4000..9500 (таблица приложения −10..45°C)
// → api -100..450.
REQUIRE(prop_api_range(Template::kA, Prop::DisplayTemperature, &kTerm, &lo, &hi));
CHECK(lo == -100);
CHECK(hi == 450);
// error_code 0..4095.
REQUIRE(prop_api_range(Template::kA, Prop::ErrorCode, &kTerm, &lo, &hi));
CHECK(lo == 0);
CHECK(hi == 4095);
// Без диапазона (0..0 в таблице).
CHECK_FALSE(prop_api_range(Template::kA, Prop::DeviceName, &kTerm, &lo, &hi));
CHECK_FALSE(prop_api_range(Template::kA, Prop::OpStatus, &kTerm, &lo, &hi));
// Override-диапазон перекрывает шаблонный.
PropOverride ov{};
ov.prop = Prop::AdjustTemperature;
ov.has_range = true;
ov.min = 160;
ov.max = 300; // 16..30°C — фактические границы прибора
const PropOverride list[] = {ov, kTerm};
REQUIRE(prop_api_range(Template::kA, Prop::AdjustTemperature, list, &lo, &hi));
CHECK(lo == 160);
CHECK(hi == 300);
// Свойство не из шаблона.
CHECK_FALSE(prop_api_range(Template::kB, Prop::AfVerticalDirection, &kTerm,
&lo, &hi));
}
TEST_CASE("Насыщение linear при переполнении int64") {
// to_display: api = raw*num/den; raw=4 * num=INT64_MAX/2 -> переполнение.
PropOverride ov{};
ov.prop = Prop::AdjustTemperature;
ov.to_display.kind = ConvKind::kLinear;
ov.to_display.linear = Linear{INT64_MAX / 2, 1, 0};
// from_input: raw = (api-offset)*den/num; api=4 * den=INT64_MAX/2.
ov.from_input.kind = ConvKind::kLinear;
ov.from_input.linear = Linear{1, INT64_MAX / 2, 0};
const PropOverride list[] = {ov, kTerm};
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, 4, list) ==
INT64_MAX);
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, 4, list) ==
INT64_MAX);
// Смещение с переполнением сложения.
PropOverride ov2{};
ov2.prop = Prop::FanSpeed;
ov2.to_display.kind = ConvKind::kLinear;
ov2.to_display.linear = Linear{1, 1, INT64_MAX};
const PropOverride list2[] = {ov2, kTerm};
CHECK(convert_to_display(Template::kA, Prop::FanSpeed, 2, list2) ==
INT64_MAX);
}
TEST_CASE("template_is_known") {
using fgl::aircon::template_is_known;
CHECK(template_is_known("AP-WC1E"));
CHECK_FALSE(template_is_known("UNKNOWN"));
CHECK_FALSE(template_is_known(nullptr));
}
TEST_CASE("Насыщение linear: негативные экстремумы (UBSan)") {
// raw=-4 * num=INT64_MAX/2 -> INT64_MIN-переполнение -> насыщение MIN.
PropOverride ov{};
ov.prop = Prop::AdjustTemperature;
ov.to_display.kind = ConvKind::kLinear;
ov.to_display.linear = Linear{INT64_MAX / 2, 1, 0};
ov.from_input.kind = ConvKind::kLinear;
ov.from_input.linear = Linear{1, INT64_MAX / 2, 0};
const PropOverride list[] = {ov, kTerm};
CHECK(convert_to_display(Template::kA, Prop::AdjustTemperature, -4, list) ==
INT64_MIN); // (-4)*(MAX/2) -> переполнение в минус
CHECK(convert_from_input(Template::kA, Prop::AdjustTemperature, -4, list) ==
INT64_MIN);
// add-переполнение в минус.
PropOverride ov2{};
ov2.prop = Prop::FanSpeed;
ov2.to_display.kind = ConvKind::kLinear;
ov2.to_display.linear = Linear{1, 1, INT64_MIN};
const PropOverride list2[] = {ov2, kTerm};
// q=2 + offset=INT64_MIN = INT64_MIN+2 — корректно (в диапазоне int64),
// насыщение не требуется; убеждаемся что нет падения/UB.
int64_t v = convert_to_display(Template::kA, Prop::FanSpeed, 2, list2);
CHECK(v == INT64_MIN + 2);
}
TEST_CASE("DisplayTemperature: экстремумы без UB") {
CHECK(convert_to_display(Template::kA, Prop::DisplayTemperature,
INT64_MIN + 1, &kTerm) != 0);
// Значение определяется насыщением, точный результат не важен — нет UB.
int64_t r = convert_from_input(Template::kA, Prop::DisplayTemperature,
INT64_MIN / 10, &kTerm);
CHECK(r != 0); // насыщение или корректное значение — но не падение
}
-133
View File
@@ -1,133 +0,0 @@
// Тесты таблиц шаблонов: состав против PROTOCOL.md §8.2, типы, RO, диапазоны.
#include "doctest/doctest.h"
#include <cstring>
#include <string>
#include "fgl-aircon/templates.hpp"
using fgl::aircon::Prop;
using fgl::aircon::prop_info_count;
using fgl::aircon::prop_info_find;
using fgl::aircon::prop_info_find_by_name;
using fgl::aircon::Template;
using fgl::aircon::template_detect;
namespace {
// Список имён свойств шаблона (для сверки с протоколом).
std::string names_joined(Template t) {
std::string out;
for (auto* p = fgl::aircon::prop_info_begin(t);
p != fgl::aircon::prop_info_begin(t) + prop_info_count(t); p++) {
if (!out.empty()) out += ",";
out += p->name;
}
return out;
}
bool has(Template t, const char* name) {
return prop_info_find_by_name(t, name) != nullptr;
}
} // namespace
TEST_CASE("Шаблон A: состав против PROTOCOL §8.2 (все 33)") {
const char* expected[] = {
"operation_mode", "fan_speed", "adjust_temperature",
"af_vertical_direction", "af_vertical_swing", "af_horizontal_direction",
"af_horizontal_swing", "outdoor_low_noise", "indoor_fan_control",
"human_det_auto_save", "min_heat", "powerful_mode", "coil_dry_mode",
"economy_mode", "master_timer_on_off_1", "master_timer_on_off_2",
"error_code", "demand_control", "filter_sign_reset_display", "op_status",
"device_name", "building_name", "wifi_led_enable",
"service_contact_name", "service_contact_phone", "service_contact_email",
"af_horizontal_num_dir", "af_vertical_num_dir", "device_capabilities",
"display_temperature", "get_prop", "human_det", "refresh"};
for (const char* n : expected) {
CAPTURE(n);
CHECK(has(Template::kA, n));
}
CHECK(prop_info_count(Template::kA) == 33);
}
TEST_CASE("Шаблон B: состав (20) и отличия от A") {
CHECK(prop_info_count(Template::kB) == 20);
CHECK(has(Template::kB, "af_vertical_move_step1"));
CHECK(has(Template::kB, "af_horizontal_move_step1"));
CHECK_FALSE(has(Template::kB, "af_vertical_direction"));
CHECK_FALSE(has(Template::kB, "display_temperature"));
CHECK_FALSE(has(Template::kB, "powerful_mode"));
CHECK_FALSE(has(Template::kB, "min_heat"));
}
TEST_CASE("Шаблон F: = A + monitor1 + filter_sign_reset (без display-варианта)") {
CHECK(prop_info_count(Template::kF) == 34);
CHECK(has(Template::kF, "monitor1"));
CHECK(has(Template::kF, "filter_sign_reset"));
CHECK_FALSE(has(Template::kF, "filter_sign_reset_display"));
CHECK(has(Template::kF, "display_temperature"));
}
TEST_CASE("template_detect: oem_model → шаблон (PROTOCOL §8.1)") {
CHECK(template_detect("AP-WC1E") == Template::kA);
CHECK(template_detect("AP-WA3E") == Template::kA);
CHECK(template_detect("AP-WB2E") == Template::kB);
CHECK(template_detect("AP-WF4E") == Template::kF);
CHECK(template_detect("UNKNOWN-MODEL") == Template::kA); // как legacy
CHECK(template_detect(nullptr) == Template::kA);
}
TEST_CASE("Атрибуты свойств: base_type/read_only/диапазоны") {
auto* om = prop_info_find_by_name(Template::kA, "operation_mode");
REQUIRE(om != nullptr);
CHECK(std::string(om->base_type) == "integer");
CHECK_FALSE(om->read_only);
CHECK(om->raw_min == 0);
CHECK(om->raw_max == 6);
auto* err = prop_info_find_by_name(Template::kA, "error_code");
REQUIRE(err != nullptr);
CHECK(err->read_only);
CHECK(err->raw_max == 4095);
auto* eco = prop_info_find_by_name(Template::kA, "economy_mode");
REQUIRE(eco != nullptr);
CHECK(std::string(eco->base_type) == "boolean");
CHECK(eco->kind == fgl::aircon::ValueKind::kBool);
auto* dn = prop_info_find_by_name(Template::kA, "device_name");
REQUIRE(dn != nullptr);
CHECK(dn->kind == fgl::aircon::ValueKind::kString);
CHECK(std::string(dn->base_type) == "string");
// prop_info_find по enum согласован с find_by_name.
auto* by_enum = prop_info_find(Template::kA, Prop::FanSpeed);
REQUIRE(by_enum != nullptr);
CHECK(std::string(by_enum->name) == "fan_speed");
CHECK(prop_info_find(Template::kB, Prop::AfVerticalDirection) == nullptr);
CHECK(prop_info_find(Template::kA, Prop::kCount) == nullptr);
CHECK(prop_info_find_by_name(Template::kA, "no_such") == nullptr);
CHECK(prop_info_find_by_name(Template::kA, nullptr) == nullptr);
}
TEST_CASE("Таблицы: без дублей имен и enum'ов внутри шаблона") {
for (Template t : {Template::kA, Template::kB, Template::kF}) {
size_t n = prop_info_count(t);
for (size_t i = 0; i < n; i++) {
for (size_t j = i + 1; j < n; j++) {
auto* a = fgl::aircon::prop_info_begin(t) + i;
auto* b = fgl::aircon::prop_info_begin(t) + j;
CHECK_FALSE(std::string(a->name) == std::string(b->name));
CHECK_FALSE(a->prop == b->prop);
}
}
}
}
TEST_CASE("af_*_swing: base_type integer (PROTOCOL §8.3), kind bool в API") {
auto* vs = prop_info_find_by_name(Template::kA, "af_vertical_swing");
REQUIRE(vs != nullptr);
CHECK(std::string(vs->base_type) == "integer");
CHECK(vs->kind == fgl::aircon::ValueKind::kBool);
}
+1 -11
View File
@@ -220,7 +220,6 @@ class Mock:
self.push_seq = 0 self.push_seq = 0
self.reg_count = 0 self.reg_count = 0
self.last_reg_time = None self.last_reg_time = None
self.active = False # сессия активирована (первый poll обслужен)
self.fail_pushes = args.fail_pushes self.fail_pushes = args.fail_pushes
self.garbage_pushes = args.garbage_pushes self.garbage_pushes = args.garbage_pushes
self.miss_response = args.break_outbound self.miss_response = args.break_outbound
@@ -292,9 +291,6 @@ class Mock:
cmd = c.get("cmd", {}) cmd = c.get("cmd", {})
if cmd.get("method") == "DELETE": if cmd.get("method") == "DELETE":
print("DELETE", flush=True) print("DELETE", flush=True)
# Слот освобождён: следующая сессия должна получить KE.
self.crypto = None
self.active = False
return return
res = cmd.get("resource", "") res = cmd.get("resource", "")
name = res.split("name=")[-1] name = res.split("name=")[-1]
@@ -354,12 +350,11 @@ class Mock:
return 202 return 202
if not self.args.no_poll: if not self.args.no_poll:
self.poll_commands() self.poll_commands()
self.active = True # poll обслужен — сессия приложения активна
return 202 return 202
def spontaneous_loop(self): def spontaneous_loop(self):
while not self.stop.wait(self.args.push_every or 10): while not self.stop.wait(self.args.push_every or 10):
if self.crypto and self.active and not self.args.no_poll: if self.crypto and not self.args.no_poll:
with self.lock: with self.lock:
self.props["tick"] += 1 self.props["tick"] += 1
corrupt = self.fail_pushes > 0 corrupt = self.fail_pushes > 0
@@ -385,14 +380,9 @@ def main():
ap.add_argument("--stale-gap", type=float, default=44.0) ap.add_argument("--stale-gap", type=float, default=44.0)
ap.add_argument("--push-every", type=float, default=0) ap.add_argument("--push-every", type=float, default=0)
ap.add_argument("--fail-first-ke", action="store_true") ap.add_argument("--fail-first-ke", action="store_true")
ap.add_argument("--set", action="append", default=[], metavar="NAME=VALUE",
help="предустановить свойство mock-модуля (повторяемо)")
args = ap.parse_args() args = ap.parse_args()
mock = Mock(args) mock = Mock(args)
for item in args.set:
name, _, value = item.partition("=")
mock.props[name] = int(value, 0) # принимает 0b/0x/десятичное
lock = mock.lock lock = mock.lock
class H(http.server.BaseHTTPRequestHandler): class H(http.server.BaseHTTPRequestHandler):
-38
View File
@@ -1,38 +0,0 @@
"""Фикстуры тестов HA-компонента fglair."""
from __future__ import annotations
import pytest
from helpers import DSN, KEY_ID, LANIP_KEY
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.fglair.const import (
CONF_DEVICE_PORT,
CONF_DSN,
CONF_HOST,
CONF_LANIP_KEY,
CONF_LANIP_KEY_ID,
CONF_TEMPLATE,
DOMAIN,
)
@pytest.fixture(autouse=True)
def auto_enable_custom_integrations(enable_custom_integrations):
yield
@pytest.fixture
def entry_data() -> dict:
return {
CONF_HOST: "127.0.0.1",
CONF_DEVICE_PORT: 9,
CONF_DSN: DSN,
CONF_LANIP_KEY: LANIP_KEY,
CONF_LANIP_KEY_ID: KEY_ID,
CONF_TEMPLATE: "A",
}
@pytest.fixture
def config_entry(entry_data) -> MockConfigEntry:
return MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id=DSN)
-16
View File
@@ -1,16 +0,0 @@
"""Общие данные HA-тестов fglair (импортируется как top-level модуль)."""
from __future__ import annotations
import socket
DSN = "AC000W00MOCK0001"
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
KEY_ID = 64201
def free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
-631
View File
@@ -1,631 +0,0 @@
"""Config flow fglair: меню, ручной ввод, импорт, облако (discover замокан)."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, patch
from helpers import DSN, KEY_ID, LANIP_KEY
from homeassistant import config_entries
from homeassistant.config_entries import SOURCE_RECONFIGURE
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from pytest_homeassistant_custom_component.common import MockConfigEntry
import pyfglair
from custom_components.fglair.const import DOMAIN
from custom_components.fglair.trial import TrialResult
from pyfglair import Prop, Template, Value, ValueKind
from pyfglair.provision import Device
MANUAL_INPUT = {
"host": "192.168.1.50",
"device_port": 80,
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"name": "Bedroom",
"model": "AP-WC1E",
}
LIMITS_INPUT = {
"temp_min": 16.0,
"temp_max": 30.0,
"temp_step": 0.5,
"temp_num": 1,
"temp_den": 1,
"temp_offset": 0,
}
TRIAL_OK = (TrialResult(ok=True), Template.A, False)
TRIAL_NETWORK_FAIL = (
TrialResult(ok=False, reason="unreachable"),
Template.A,
False,
)
def _trial_ok(properties=None):
return (
TrialResult(ok=True, properties=properties or {}),
Template.A,
False,
)
async def _menu(hass: HomeAssistant):
return await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
async def _select(hass: HomeAssistant, flow_id: str, option: str):
return await hass.config_entries.flow.async_configure(
flow_id, {"next_step_id": option}
)
async def _configure(hass: HomeAssistant, flow_id: str, user_input: dict):
return await hass.config_entries.flow.async_configure(flow_id, user_input)
async def _pages(
hass: HomeAssistant,
flow_id: str,
*,
template: str = "A",
caps: dict | None = None,
limits: dict | None = None,
):
"""Проходит страницы template → capabilities → limits."""
result = await _configure(hass, flow_id, {"template": template})
assert result["step_id"] == "capabilities", result
result = await _configure(hass, flow_id, caps or {})
assert result["step_id"] == "limits", result
return await _configure(hass, flow_id, limits or LIMITS_INPUT)
def _suggested(schema, field: str):
for key in schema.schema:
if getattr(key, "schema", None) == field:
description = key.description
if isinstance(description, dict):
return description.get("suggested_value")
return None
async def test_user_menu(hass: HomeAssistant):
result = await _menu(hass)
assert result["type"] == "menu"
assert set(result["menu_options"]) == {"cloud", "manual", "import_json"}
async def test_manual_success(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
) as trial:
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
assert result["type"] == "form"
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["title"] == "Bedroom"
assert result["result"].unique_id == DSN
data = result["data"]
assert data["lanip_key_id"] == KEY_ID
assert data["template"] == "A"
assert data["temp_step"] == 5
assert data["overrides"]["adjust_temperature"] == {
"num": 1,
"den": 1,
"offset": 0,
"min": 160,
"max": 300,
}
assert isinstance(data["features"], dict)
assert data["features"]["mode_heat"] is True
trial.assert_called_once()
assert trial.call_args.args[0]["host"] == "192.168.1.50"
async def test_template_change_and_conversion(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _pages(
hass,
result["flow_id"],
template="B",
limits={
**LIMITS_INPUT,
"temp_min": 17.0,
"temp_max": 28.0,
"temp_step": 1.0,
"temp_num": 2,
"temp_den": 4,
"temp_offset": 5,
},
)
assert result["type"] == "create_entry"
assert result["data"]["template"] == "B"
assert result["data"]["temp_step"] == 10
assert result["data"]["overrides"]["adjust_temperature"] == {
"num": 2,
"den": 4,
"offset": 5,
"min": 170,
"max": 280,
}
async def test_limits_invalid_range(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {"template": "B"}
)
result = await _configure(hass, result["flow_id"], {})
assert result["step_id"] == "limits"
result = await _configure(
hass,
result["flow_id"],
{**LIMITS_INPUT, "temp_min": 30.0, "temp_max": 16.0},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_range"}
result = await _configure(hass, result["flow_id"], LIMITS_INPUT)
assert result["type"] == "create_entry"
async def test_limits_unreachable_range(hass: HomeAssistant):
"""Конверсия выводит таблицу за min/max — пустой достижимый диапазон."""
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {"template": "A"}
)
result = await _configure(hass, result["flow_id"], {})
result = await _configure(
hass,
result["flow_id"],
{
**LIMITS_INPUT,
"temp_min": 30.0,
"temp_max": 40.0,
"temp_num": 1,
"temp_den": 2,
},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_range"}
async def test_template_device_values(hass: HomeAssistant):
props = {
Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200),
Prop.OPERATION_MODE: Value(ValueKind.INT, 3),
Prop.FAN_SPEED: Value(ValueKind.INT, 4),
Prop.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011),
Prop.AF_VERTICAL_NUM_DIR: Value(ValueKind.INT, 5),
}
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(
TrialResult(ok=True, properties=props),
Template.A,
False,
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["step_id"] == "template"
values = result["description_placeholders"]["device_values"]
assert "20.0" in values
assert "0x0000001B" in values
assert "cool" in values
assert "направлений заслонок: верт. 5" in values
# capabilities: дефолты из маски, swing выключен
result = await _configure(hass, result["flow_id"], {"template": "A"})
assert result["step_id"] == "capabilities"
assert _suggested(result["data_schema"], "swing_vertical") is False
assert _suggested(result["data_schema"], "mode_cool") is True
assert _suggested(result["data_schema"], "af_vertical_direction") is True
async def test_manual_connect_error(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_NETWORK_FAIL,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["type"] == "form"
assert result["errors"] == {"base": "cannot_connect"}
async def test_manual_key_mismatch_error(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(
TrialResult(ok=False, reason="key_mismatch"),
Template.A,
False,
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["type"] == "form"
assert result["errors"] == {"base": "key_mismatch"}
async def test_manual_already_configured(hass: HomeAssistant):
existing = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DSN)
existing.add_to_hass(hass)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
async def test_manual_error_keeps_input(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_NETWORK_FAIL,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["errors"] == {"base": "cannot_connect"}
assert _suggested(result["data_schema"], "host") == MANUAL_INPUT["host"]
assert _suggested(result["data_schema"], "dsn") == DSN
assert _suggested(result["data_schema"], "lanip_key") is None
async def test_import_json_success(hass: HomeAssistant):
payload = json.dumps(
{
"ip_address": "192.168.1.60",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"model": "AP-WB2E",
"name": "Kitchen",
}
)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(TrialResult(ok=True), Template.B, False),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json")
assert result["type"] == "form"
result = await _configure(
hass, result["flow_id"], {"config_json": payload}
)
assert result["step_id"] == "template"
assert _suggested(result["data_schema"], "template") == "B"
result = await _pages(hass, result["flow_id"], template="B")
assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.60"
assert result["data"]["template"] == "B"
async def test_import_json_invalid(hass: HomeAssistant):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json")
result = await _configure(
hass, result["flow_id"], {"config_json": "not json"}
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_import"}
async def test_cloud_flow_success(hass: HomeAssistant):
device = Device(
name="Bedroom",
model="AP-WC1E",
dsn=DSN,
lan_ip="192.168.1.50",
lanip_key=LANIP_KEY,
lanip_key_id=KEY_ID,
region="eu",
)
with patch(
"pyfglair.discover", AsyncMock(return_value=[device])
), patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud")
assert result["type"] == "form"
result = await _configure(
hass,
result["flow_id"],
{"email": "user@example.com", "password": "secret", "region": "eu"},
)
assert result["step_id"] == "cloud_device"
result = await _configure(hass, result["flow_id"], {"dsn": DSN})
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.50"
assert result["data"]["lanip_key_id"] == KEY_ID
async def test_cloud_no_devices(hass: HomeAssistant):
with patch(
"pyfglair.discover",
AsyncMock(
side_effect=pyfglair.ProvisionError(
"У аккаунта нет устройств", "no_devices"
)
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud")
result = await _configure(
hass,
result["flow_id"],
{"email": "user@example.com", "password": "secret", "region": "eu"},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "no_devices"}
async def test_cloud_auth_error(hass: HomeAssistant):
with patch(
"pyfglair.discover",
AsyncMock(
side_effect=pyfglair.ProvisionError("401 unauthorized", "auth")
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud")
result = await _configure(
hass,
result["flow_id"],
{"email": "user@example.com", "password": "bad", "region": "eu"},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_auth"}
async def test_yaml_import(hass: HomeAssistant):
payload = {
"ip_address": "192.168.1.70",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"model": "AP-WF1E",
}
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(TrialResult(ok=True), Template.F, False),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload})
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].data["template"] == "F"
assert entries[0].data["host"] == "192.168.1.70"
assert isinstance(entries[0].data["features"], dict)
async def test_yaml_import_invalid(hass: HomeAssistant):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: "not-a-dict"})
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN) == []
assert not hass.config_entries.flow.async_progress()
async def test_import_json_null_device_port(hass: HomeAssistant):
payload = json.dumps(
{
"ip_address": "192.168.1.71",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": str(KEY_ID),
"device_port": None,
}
)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
) as trial:
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json")
result = await _configure(
hass, result["flow_id"], {"config_json": payload}
)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["data"]["device_port"] == 80
assert trial.call_args.args[0]["device_port"] == 80
RECONFIGURE_ENTRY_DATA = {
"host": "127.0.0.1",
"device_port": 9,
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"name": "Old name",
"model": "AP-WC1E",
"template": "A",
}
async def test_reconfigure_success(hass: HomeAssistant, mock_ac, socket_enabled):
from test_entities import _setup_entry
proc = mock_ac(["--set", "operation_mode=6"])
entry = await _setup_entry(hass, proc.port)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
)
assert result["type"] == "menu"
result = await _select(hass, result["flow_id"], "manual")
assert result["type"] == "form"
assert _suggested(result["data_schema"], "host") == "127.0.0.1"
result = await _configure(
hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"}
)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "abort"
assert result["reason"] == "reconfigure_successful"
await hass.async_block_till_done()
assert entry.data["name"] == "Renamed"
assert entry.state.name == "LOADED"
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_reconfigure_wrong_device(hass: HomeAssistant):
entry = MockConfigEntry(
domain=DOMAIN, data=RECONFIGURE_ENTRY_DATA, unique_id=DSN
)
entry.add_to_hass(hass)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
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, "dsn": "AC000W00OTHER001"},
)
assert result["type"] == "abort"
assert result["reason"] == "unique_id_mismatch"
async def test_options_flow_shows_and_updates_key(
hass: HomeAssistant, mock_ac, socket_enabled
):
from test_entities import _setup_entry
proc = mock_ac(["--set", "operation_mode=6"])
entry = await _setup_entry(hass, proc.port)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == "form"
assert result["step_id"] == "init"
assert _suggested(result["data_schema"], "lanip_key") == LANIP_KEY
assert result["description_placeholders"]["dsn"] == DSN
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"lanip_key": LANIP_KEY}
)
assert result["type"] == "create_entry"
await hass.async_block_till_done()
assert entry.data["lanip_key"] == LANIP_KEY
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_options_flow_new_key_reloads(
hass: HomeAssistant, mock_ac, socket_enabled
):
from test_entities import _setup_entry
proc = mock_ac(["--set", "operation_mode=6"])
entry = await _setup_entry(hass, proc.port)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"lanip_key": "bmV3LWtleQ=="}
)
assert result["type"] == "create_entry"
await hass.async_block_till_done()
assert entry.data["lanip_key"] == "bmV3LWtleQ=="
assert entry.state.name in ("LOADED", "SETUP_RETRY")
if entry.state.name == "LOADED":
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_run_trial_probes_unknown_model(hass: HomeAssistant):
"""Без oem_model шаблон определяется пробингом A/B/F."""
from custom_components.fglair import config_flow as cf
with patch.object(
cf, "probe_template", return_value=(Template.B, {}, None)
) as probe, patch.object(
cf, "trial_connect", return_value=TrialResult(ok=True)
):
result = await hass.async_add_executor_job(
cf._run_trial,
{
"host": "127.0.0.1",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"model": "",
"template": "A",
},
)
trial_result, template, probed = result
assert trial_result.ok
assert template is Template.B
assert probed is True
probe.assert_called_once()
async def test_reconfigure_prefills_stored_template(
hass: HomeAssistant, mock_ac, socket_enabled
):
"""При reconfigure дефолт — сохранённый шаблон, а не пересчитанный."""
from test_entities import _setup_entry
proc = mock_ac(["--set", "operation_mode=6"])
entry = await _setup_entry(hass, proc.port)
hass.config_entries.async_update_entry(
entry, data={**entry.data, "template": "B"}
)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(TrialResult(ok=True), Template.A, False),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(
hass, result["flow_id"], {**MANUAL_INPUT, "host": "127.0.0.1",
"device_port": proc.port}
)
assert result["step_id"] == "template"
assert _suggested(result["data_schema"], "template") == "B"
await hass.config_entries.async_unload(entry.entry_id)
@@ -1,59 +0,0 @@
"""Диагностика: параметры для ESPHome и маскировка ключа."""
from __future__ import annotations
import asyncio
import time
from helpers import DSN, LANIP_KEY
from homeassistant.core import HomeAssistant
from test_entities import MOCK_SETTINGS, _setup_entry
from custom_components.fglair.diagnostics import (
async_get_config_entry_diagnostics,
)
from pyfglair import Prop, State
async def _wait_for(predicate, timeout: float = 20.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = predicate()
if result:
return result
await asyncio.sleep(0.1)
raise AssertionError("условие не выполнено за отведённое время")
async def test_diagnostics(hass: HomeAssistant, mock_ac, socket_enabled):
proc = mock_ac(MOCK_SETTINGS)
entry = await _setup_entry(hass, proc.port)
runtime = entry.runtime_data
await _wait_for(
lambda: runtime.client.state == State.ONLINE
and runtime.client.cached(Prop.OPERATION_MODE) is not None
and runtime.client.cached(Prop.DISPLAY_TEMPERATURE) is not None
)
diag = await async_get_config_entry_diagnostics(hass, entry)
assert diag["config"]["dsn"] == DSN
assert diag["config"]["host"] == "127.0.0.1"
assert diag["config"]["template"] == "A"
assert diag["esp_home"]["lanip_key"] != LANIP_KEY
assert "REDACTED" in diag["esp_home"]["lanip_key"]
assert diag["esp_home"]["lanip_key_id"] == 64201
assert diag["entry_data"]["lanip_key"] == "**REDACTED**"
assert diag["runtime"]["state"] == "online"
assert diag["runtime"]["events_dropped"] == 0
assert diag["runtime"]["values"]["operation_mode"]["raw"] == 6
assert diag["runtime"]["values"]["operation_mode"]["display"] == 6
assert (
diag["runtime"]["values"]["display_temperature"]["display"] == 200
)
assert await hass.config_entries.async_unload(entry.entry_id)
unloaded = await async_get_config_entry_diagnostics(hass, entry)
assert unloaded["runtime"]["loaded"] is False
assert unloaded["config"]["dsn"] == DSN
-319
View File
@@ -1,319 +0,0 @@
"""Сущности fglair против mock_ac.py: climate/switch/select/sensor/binary."""
from __future__ import annotations
import asyncio
import time
from helpers import DSN, KEY_ID, LANIP_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.fglair.const import DOMAIN
from pyfglair import State
MOCK_SETTINGS = [
"--set", "operation_mode=6",
"--set", "fan_speed=4",
"--set", "adjust_temperature=220",
"--set", "display_temperature=7000",
"--set", "device_capabilities=0b1111111111111111111",
"--set", "af_vertical_num_dir=5",
"--set", "af_horizontal_num_dir=7",
"--set", "af_vertical_direction=2",
"--set", "af_horizontal_direction=3",
"--set", "op_status=0b1000000010000000000000000",
"--set", "economy_mode=0",
"--set", "powerful_mode=0",
]
async def _wait_for(predicate, timeout: float = 20.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = predicate()
if result:
return result
await asyncio.sleep(0.1)
raise AssertionError("условие не выполнено за отведённое время")
def _entity_id(hass: HomeAssistant, platform: str, unique_id: str):
return er.async_get(hass).async_get_entity_id(platform, DOMAIN, unique_id)
async def _setup_entry(hass: HomeAssistant, port: int) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={
"host": "127.0.0.1",
"device_port": port,
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"template": "A",
"name": "Mock AC",
"listen_port": 0,
},
unique_id=DSN,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry
async def test_entities_snapshot(hass: HomeAssistant, mock_ac, socket_enabled):
proc = mock_ac(MOCK_SETTINGS)
entry = await _setup_entry(hass, proc.port)
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
await _wait_for(
lambda: climate_id and hass.states.get(climate_id) is not None
and hass.states.get(climate_id).state == "heat"
and hass.states.get(climate_id).attributes["swing_mode"] == "off"
)
climate = hass.states.get(climate_id)
assert climate.attributes["description"]
assert climate.attributes["current_temperature"] == 20.0
assert climate.attributes["temperature"] == 22.0
assert climate.attributes["fan_mode"] == "auto"
assert climate.attributes["preset_mode"] is None
assert "heat" in climate.attributes["hvac_modes"]
assert "cool" in climate.attributes["hvac_modes"]
assert climate.attributes["swing_mode"] == "off"
room = _entity_id(hass, "sensor", f"{DSN}_display_temperature")
await _wait_for(
lambda: room and hass.states.get(room) is not None
and hass.states.get(room).state == "20.0"
)
connectivity = _entity_id(hass, "binary_sensor", f"{DSN}_connectivity")
assert hass.states.get(connectivity).state == "on"
defrost = _entity_id(hass, "binary_sensor", f"{DSN}_op_status_defrost")
await _wait_for(lambda: hass.states.get(defrost).state == "on")
maintenance = _entity_id(
hass, "binary_sensor", f"{DSN}_op_status_maintenance"
)
assert hass.states.get(maintenance).state == "off"
vertical = _entity_id(
hass, "select", f"{DSN}_af_vertical_direction"
)
await _wait_for(
lambda: hass.states.get(vertical).attributes["options"]
== ["0", "1", "2", "3", "4"]
)
vertical_state = hass.states.get(vertical)
assert vertical_state.state == "2"
economy = _entity_id(hass, "switch", f"{DSN}_economy_mode")
assert hass.states.get(economy).state == "off"
connection = _entity_id(hass, "sensor", f"{DSN}_connection_state")
assert hass.states.get(connection).state == "online"
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_entity_commands(hass: HomeAssistant, mock_ac, socket_enabled):
proc = mock_ac(MOCK_SETTINGS)
entry = await _setup_entry(hass, proc.port)
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
select_id = _entity_id(hass, "select", f"{DSN}_af_vertical_direction")
await _wait_for(
lambda: climate_id and hass.states.get(climate_id) is not None
and hass.states.get(climate_id).state == "heat"
and hass.states.get(select_id).attributes["options"]
== ["0", "1", "2", "3", "4"]
)
await hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": climate_id, "hvac_mode": "cool"},
blocking=True,
)
assert proc.wait_line("CMD SET operation_mode=3", 10)
await hass.services.async_call(
"climate",
"set_temperature",
{"entity_id": climate_id, "temperature": 25.5},
blocking=True,
)
assert proc.wait_line("CMD SET adjust_temperature=255", 10)
await hass.services.async_call(
"climate",
"set_fan_mode",
{"entity_id": climate_id, "fan_mode": "low"},
blocking=True,
)
assert proc.wait_line("CMD SET fan_speed=1", 10)
await hass.services.async_call(
"climate",
"set_preset_mode",
{"entity_id": climate_id, "preset_mode": "eco"},
blocking=True,
)
assert proc.wait_line("CMD SET economy_mode=True", 10)
assert proc.wait_line("CMD SET powerful_mode=False", 10)
await _wait_for(
lambda: hass.states.get(climate_id).state == "cool"
)
await hass.services.async_call(
"select",
"select_option",
{"entity_id": select_id, "option": "4"},
blocking=True,
)
assert proc.wait_line("CMD SET af_vertical_direction=4", 10)
switch_id = _entity_id(hass, "switch", f"{DSN}_economy_mode")
await hass.services.async_call(
"switch", "turn_on", {"entity_id": switch_id}, blocking=True
)
assert proc.wait_line("CMD SET economy_mode=True", 10)
await hass.services.async_call(
"climate", "turn_off", {"entity_id": climate_id}, blocking=True
)
assert proc.wait_line("CMD SET operation_mode=0", 10)
await hass.services.async_call(
"climate", "turn_on", {"entity_id": climate_id}, blocking=True
)
assert proc.wait_line("CMD SET operation_mode=1", 10)
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_reload_online(hass: HomeAssistant, mock_ac, socket_enabled):
"""После unload тот же прибор снова поднимается до ONLINE."""
proc = mock_ac(MOCK_SETTINGS)
entry = await _setup_entry(hass, proc.port)
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
RESTRICTED_SETTINGS = [
"--set", "operation_mode=3",
"--set", "fan_speed=4",
"--set", "device_capabilities=0b100001", # cool (0) + fan auto (5)
]
async def test_capabilities_filter(
hass: HomeAssistant, mock_ac, socket_enabled
):
proc = mock_ac(RESTRICTED_SETTINGS)
entry = await _setup_entry(hass, proc.port)
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
await _wait_for(
lambda: climate_id and hass.states.get(climate_id) is not None
and hass.states.get(climate_id).state == "cool"
and "heat" not in hass.states.get(climate_id).attributes["hvac_modes"]
)
climate = hass.states.get(climate_id)
assert "heat" not in climate.attributes["hvac_modes"]
assert "cool" in climate.attributes["hvac_modes"]
assert climate.attributes["fan_modes"] == ["auto"]
assert climate.attributes.get("preset_modes") is None
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_manual_conversion_applied(
hass: HomeAssistant, mock_ac, socket_enabled
):
settings = [
"--set", "operation_mode=6",
"--set", "adjust_temperature=220",
"--set", "display_temperature=7000",
]
proc = mock_ac(settings)
entry = MockConfigEntry(
domain=DOMAIN,
data={
"host": "127.0.0.1",
"device_port": proc.port,
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"template": "A",
"name": "Mock AC",
"listen_port": 0,
"temp_step": 5,
"overrides": {
"adjust_temperature": {
"num": 1,
"den": 1,
"offset": 5,
"min": 170,
"max": 280,
}
},
},
unique_id=DSN,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
await _wait_for(
lambda: climate_id and hass.states.get(climate_id) is not None
and hass.states.get(climate_id).state == "heat"
and hass.states.get(climate_id).attributes["temperature"] == 22.5
)
assert hass.states.get(climate_id).attributes["temperature"] == 22.5
assert hass.states.get(climate_id).attributes["min_temp"] == 17.0
assert hass.states.get(climate_id).attributes["max_temp"] == 28.0
await hass.services.async_call(
"climate",
"set_temperature",
{"entity_id": climate_id, "temperature": 25.0},
blocking=True,
)
assert proc.wait_line("CMD SET adjust_temperature=245", 10)
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_capabilities_zero_is_unknown(
hass: HomeAssistant, mock_ac, socket_enabled
):
"""device_capabilities=0 трактуем как «не сообщено» — режимы не режем."""
proc = mock_ac([
"--set", "operation_mode=6",
"--set", "device_capabilities=0",
])
entry = await _setup_entry(hass, proc.port)
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
climate_id = _entity_id(hass, "climate", f"{DSN}_climate")
await _wait_for(
lambda: climate_id and hass.states.get(climate_id) is not None
and hass.states.get(climate_id).state == "heat"
)
assert "heat" in hass.states.get(climate_id).attributes["hvac_modes"]
assert await hass.config_entries.async_unload(entry.entry_id)
-122
View File
@@ -1,122 +0,0 @@
"""Дефолты 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]
-120
View File
@@ -1,120 +0,0 @@
"""Setup/unload ConfigEntry: клиент стартует, останавливается, ошибки порта."""
from __future__ import annotations
import asyncio
import socket
from helpers import free_port
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.fglair.const import CONF_LISTEN_PORT, DOMAIN
from pyfglair import Prop, State
async def test_setup_unload(hass: HomeAssistant, config_entry, socket_enabled):
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
runtime = config_entry.runtime_data
assert runtime is not None
assert isinstance(runtime.client.state, State)
assert runtime.coordinator.data is not None
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert runtime.client.cached(Prop.OPERATION_MODE) is None
async def test_two_entries_default_port(hass: HomeAssistant, entry_data, socket_enabled):
"""Две записи без явного listen_port не конфликтуют (порт 0 → эфемерный)."""
first = MockConfigEntry(domain=DOMAIN, data=entry_data, unique_id="DSN-1")
second = MockConfigEntry(
domain=DOMAIN,
data={**entry_data, "dsn": "DSN-2"},
unique_id="DSN-2",
)
first.add_to_hass(hass)
assert await hass.config_entries.async_setup(first.entry_id)
await hass.async_block_till_done()
second.add_to_hass(hass)
assert await hass.config_entries.async_setup(second.entry_id)
await hass.async_block_till_done()
assert first.state.name == "LOADED"
assert second.state.name == "LOADED"
first_runtime = first.runtime_data
assert await hass.config_entries.async_unload(first.entry_id)
assert first_runtime.client.cached(Prop.OPERATION_MODE) is None
assert second.state.name == "LOADED"
assert await hass.config_entries.async_unload(second.entry_id)
async def test_two_entries_same_explicit_port(
hass: HomeAssistant, entry_data, socket_enabled
):
"""Явно заданный один порт на две записи — вторая SETUP_RETRY."""
port = free_port()
first = MockConfigEntry(
domain=DOMAIN,
data={**entry_data, CONF_LISTEN_PORT: port},
unique_id="DSN-1",
)
first.add_to_hass(hass)
assert await hass.config_entries.async_setup(first.entry_id)
await hass.async_block_till_done()
second = MockConfigEntry(
domain=DOMAIN,
data={**entry_data, "dsn": "DSN-2", CONF_LISTEN_PORT: port},
unique_id="DSN-2",
)
second.add_to_hass(hass)
assert not await hass.config_entries.async_setup(second.entry_id)
await hass.async_block_till_done()
assert second.state.name == "SETUP_RETRY"
assert await hass.config_entries.async_unload(first.entry_id)
async def test_unload_does_not_block_loop(
hass: HomeAssistant, config_entry, socket_enabled
):
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
ticks = 0
async def ticker() -> None:
nonlocal ticks
while True:
await asyncio.sleep(0.01)
ticks += 1
task = asyncio.create_task(ticker())
await asyncio.sleep(0.05)
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
task.cancel()
assert ticks >= 3
async def test_setup_port_busy(hass: HomeAssistant, entry_data, socket_enabled):
port = free_port()
sock = socket.socket()
sock.bind(("127.0.0.1", port))
sock.listen(1)
try:
entry = MockConfigEntry(
domain=DOMAIN,
data={**entry_data, CONF_LISTEN_PORT: port},
unique_id=entry_data["dsn"],
)
entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.state.name == "SETUP_RETRY"
finally:
sock.close()
-82
View File
@@ -1,82 +0,0 @@
"""Ручные конверсии/диапазоны (ConversionSet) без HA-рантайма."""
from __future__ import annotations
from custom_components.fglair.overrides import ConversionSet, LinearOverride
from pyfglair import Prop, Template
def test_identity_keeps_api_units():
conversions = ConversionSet(Template.A, {}, 5)
assert conversions.to_display(Prop.DISPLAY_TEMPERATURE, 200) == 200
assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 220) == 220
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 220) == 220
assert conversions.temperature_range() == (160, 300)
def test_linear_override_roundtrip_and_clamp():
override = LinearOverride(
num=1, den=1, offset=5, min_value=170, max_value=280
)
conversions = ConversionSet(
Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5
)
assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 220) == 225
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 225) == 220
assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 100) == 170
assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 999) == 280
assert conversions.temperature_range() == (170, 280)
def test_linear_override_scaled():
override = LinearOverride(num=2, den=4, offset=0)
conversions = ConversionSet(
Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5
)
assert conversions.to_display(Prop.ADJUST_TEMPERATURE, 200) == 100
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 100) == 200
def test_scaled_override_clamped_to_table():
"""display = raw/2: таблица A -100..450 → достижимо -50..225."""
override = LinearOverride(
num=1, den=2, offset=0, min_value=160, max_value=300
)
conversions = ConversionSet(
Template.A, {Prop.ADJUST_TEMPERATURE: override}, 5
)
assert conversions.temperature_range() == (160, 225)
assert conversions.clamp(Prop.ADJUST_TEMPERATURE, 250) == 225
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 250) == 450
assert conversions.from_display(Prop.ADJUST_TEMPERATURE, 225) == 450
def test_from_entry_parses_spec():
conversions = ConversionSet.from_entry(
{
"template": "B",
"temp_step": 10,
"overrides": {
"adjust_temperature": {
"num": 2,
"den": 4,
"offset": 5,
"min": 170,
"max": 280,
}
},
}
)
assert conversions.template == Template.B
assert conversions.step_tenths == 10
# display = raw/2 + 5: таблица -100..450 → -45..230, override 170..280
# → достижимо 170..230.
assert conversions.temperature_range() == (170, 230)
override = conversions.override(Prop.ADJUST_TEMPERATURE)
assert (override.num, override.den, override.offset) == (2, 4, 5)
assert conversions.override(Prop.FAN_SPEED).identity
def test_from_entry_defaults():
conversions = ConversionSet.from_entry({"template": "F"})
assert conversions.step_tenths == 5
assert conversions.temperature_range() == (160, 300)
-113
View File
@@ -1,113 +0,0 @@
"""Repair issue при несовпадении LAN-ключа и его fix-flow."""
from __future__ import annotations
import asyncio
import time
from helpers import DSN, KEY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from test_entities import _setup_entry
from custom_components.fglair.const import DOMAIN
from pyfglair import State
async def _wait_for(predicate, timeout: float = 20.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = predicate()
if result:
return result
await asyncio.sleep(0.1)
raise AssertionError("условие не выполнено за отведённое время")
async def test_key_error_creates_and_fixes_issue(
hass: HomeAssistant, mock_ac, socket_enabled
):
assert await async_setup_component(hass, "repairs", {})
proc = mock_ac([], key_id=KEY_ID + 1)
entry = await _setup_entry(hass, proc.port)
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.KEY_ERROR)
registry = ir.async_get(hass)
issue_id = f"key_mismatch_{DSN}"
issue = registry.async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.is_fixable
assert issue.translation_key == "key_mismatch"
assert issue.translation_placeholders["device"] == "Mock AC"
flow_manager = hass.data["repairs"]["flow_manager"]
result = await flow_manager.async_init(DOMAIN, data={"issue_id": issue_id})
assert result["type"] == "form"
result = await flow_manager.async_configure(result["flow_id"], {})
assert result["type"] == "create_entry"
await hass.async_block_till_done()
# Ключ всё ещё неверен: после reload issue создан снова, а не потерян.
assert registry.async_get_issue(DOMAIN, issue_id) is not None
await hass.config_entries.async_unload(entry.entry_id)
async def test_reconfigure_fixes_key_error(
hass: HomeAssistant, mock_ac, socket_enabled
):
"""Repair-путь целиком: bad key_id → issue → reconfigure → online → issue снят."""
from homeassistant.config_entries import SOURCE_RECONFIGURE
from test_config_flow import LIMITS_INPUT, MANUAL_INPUT
proc = mock_ac(
["--set", "operation_mode=6"], key_id=KEY_ID + 1
)
entry = await _setup_entry(hass, proc.port)
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.KEY_ERROR)
registry = ir.async_get(hass)
issue_id = f"key_mismatch_{DSN}"
assert registry.async_get_issue(DOMAIN, issue_id) is not None
# Mock помнит только один app_addr; останавливаем «залипшую» сессию,
# чтобы пробная сессия config flow получила KE (штатно это делает reload).
await runtime.client.async_stop()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "manual"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
**MANUAL_INPUT,
"host": "127.0.0.1",
"device_port": proc.port,
"lanip_key_id": KEY_ID + 1,
},
)
assert result["step_id"] == "template", result.get("errors")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"template": "A"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {}
)
assert result["step_id"] == "limits"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], LIMITS_INPUT
)
assert result["type"] == "abort"
assert result["reason"] == "reconfigure_successful"
await hass.async_block_till_done()
runtime = entry.runtime_data
await _wait_for(lambda: runtime.client.state == State.ONLINE)
assert registry.async_get_issue(DOMAIN, issue_id) is None
assert entry.data["lanip_key_id"] == KEY_ID + 1
assert await hass.config_entries.async_unload(entry.entry_id)
@@ -1,46 +0,0 @@
"""Согласованность strings.json / translations en/ru."""
from __future__ import annotations
import json
import pathlib
import re
ROOT = pathlib.Path(__file__).resolve().parents[3]
COMPONENT = ROOT / "custom_components" / "fglair"
def _keys(node, prefix: str = "") -> set[str]:
keys: set[str] = set()
if isinstance(node, dict):
for key, value in node.items():
keys.add(prefix + key)
keys |= _keys(value, prefix + key + ".")
return keys
def test_en_matches_strings():
strings = json.loads((COMPONENT / "strings.json").read_text())
english = json.loads((COMPONENT / "translations" / "en.json").read_text())
assert strings == english
def test_ru_covers_all_keys():
strings = json.loads((COMPONENT / "strings.json").read_text())
russian = json.loads((COMPONENT / "translations" / "ru.json").read_text())
assert _keys(strings) == _keys(russian)
def _placeholders(node, prefix: str = "") -> dict[str, set[str]]:
out: dict[str, set[str]] = {}
if isinstance(node, dict):
for key, value in node.items():
out.update(_placeholders(value, prefix + key + "."))
elif isinstance(node, str):
out[prefix] = set(re.findall(r"{(\w+)}", node))
return out
def test_ru_placeholders_match():
strings = json.loads((COMPONENT / "strings.json").read_text())
russian = json.loads((COMPONENT / "translations" / "ru.json").read_text())
assert _placeholders(strings) == _placeholders(russian)
-76
View File
@@ -1,76 +0,0 @@
"""Пробное подключение (trial_connect) против mock_ac.py — без HA-рантайма."""
from __future__ import annotations
import asyncio
from helpers import free_port
from custom_components.fglair.trial import trial_connect
from pyfglair import Prop, State
async def test_trial_online(mock_ac, ac_env, socket_enabled):
proc = mock_ac()
result = await asyncio.to_thread(
trial_connect,
"127.0.0.1",
ac_env["dsn"],
ac_env["key"],
ac_env["key_id"],
device_port=proc.port,
listen_port=free_port(),
timeout=10.0,
)
assert result.ok, result.reason
assert result.state == State.ONLINE
assert result.properties[Prop.OPERATION_MODE].int_value == 6
assert result.properties[Prop.FAN_SPEED].int_value == 4
async def test_trial_key_mismatch(mock_ac, ac_env, socket_enabled):
proc = mock_ac()
result = await asyncio.to_thread(
trial_connect,
"127.0.0.1",
ac_env["dsn"],
ac_env["key"],
ac_env["key_id"] + 1,
device_port=proc.port,
listen_port=free_port(),
timeout=10.0,
)
assert not result.ok
assert result.reason == "key_mismatch"
async def test_trial_unreachable(ac_env, socket_enabled):
dead_port = free_port()
result = await asyncio.to_thread(
trial_connect,
"127.0.0.1",
ac_env["dsn"],
ac_env["key"],
ac_env["key_id"],
device_port=dead_port,
listen_port=free_port(),
timeout=1.0,
)
assert not result.ok
assert result.reason in ("unreachable", "timeout")
assert result.state != State.ONLINE
async def test_trial_no_slots(mock_ac, ac_env, socket_enabled):
proc = mock_ac(["--503"])
result = await asyncio.to_thread(
trial_connect,
"127.0.0.1",
ac_env["dsn"],
ac_env["key"],
ac_env["key_id"],
device_port=proc.port,
listen_port=free_port(),
timeout=3.0,
)
assert not result.ok
assert result.reason == "no_slots"
-2
View File
@@ -1,2 +0,0 @@
pytest-homeassistant-custom-component==0.13.367
aiohttp>=3.9
-169
View File
@@ -1,169 +0,0 @@
"""Общие фикстуры тестов: сборка ядра, mock-облако, mock-модуль."""
from __future__ import annotations
import os
import pathlib
import socket
import subprocess
import sys
import threading
import time
import pytest
from aiohttp import web
ROOT = pathlib.Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
if "FGL_AIRCON_LIB" not in os.environ:
from pyfglair._corebuild import build_core
os.environ["FGL_AIRCON_LIB"] = str(build_core())
MOCK_AC = ROOT / "tests" / "ayla" / "mock_ac.py"
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
KEY_ID = 64201
DSN = "AC000W00MOCK0001"
def free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
class Proc:
"""Subprocess с построчным чтением stdout и ожиданием префиксов."""
def __init__(self, argv):
self.lines: list[str] = []
self.lock = threading.Lock()
self.event = threading.Event()
self.proc = subprocess.Popen(
argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
threading.Thread(target=self._reader, daemon=True).start()
def _reader(self) -> None:
assert self.proc.stdout is not None
for line in self.proc.stdout:
with self.lock:
self.lines.append(line.rstrip("\n"))
self.event.set()
def all_lines(self) -> list[str]:
with self.lock:
return list(self.lines)
def text(self) -> str:
return "\n".join(self.all_lines())
def wait_line(self, needle: str, timeout: float) -> str | None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with self.lock:
for line in self.lines:
if needle in line:
return line
self.event.wait(0.1)
return None
def stop(self) -> None:
self.proc.terminate()
try:
self.proc.wait(timeout=10)
except subprocess.TimeoutExpired:
self.proc.kill()
if self.proc.stdout is not None:
self.proc.stdout.close()
@pytest.fixture(scope="session")
def ac_env() -> dict:
return {"dsn": DSN, "key": LANIP_KEY, "key_id": KEY_ID}
@pytest.fixture
async def cloud():
"""Мок облака Ayla: sign_in / devices / lan.json."""
state = {
"url": "",
"fail_login": False,
"no_key": False,
"bad_devices": False,
"bad_lanip": False,
}
async def sign_in(request):
if state["fail_login"]:
return web.json_response({"error": "invalid"}, status=401)
return web.json_response({"access_token": "tok-123"})
async def devices(request):
assert request.headers["Authorization"] == "auth_token tok-123"
if state["bad_devices"]:
return web.json_response([{"device": "not-an-object"}])
return web.json_response([
{
"device": {
"dsn": DSN,
"product_name": "Bedroom",
"oem_model": "AP-WC1E",
"lan_ip": "192.168.1.50",
"mac": "AA:BB:CC:DD:EE:FF",
}
}
])
async def lan(request):
if state["no_key"]:
return web.json_response({"lanip": {}})
if state["bad_lanip"]:
return web.json_response({"lanip": ["unexpected"]})
return web.json_response({
"lanip": {"lanip_key": "TW9ja0tleQ==", "lanip_key_id": KEY_ID}
})
app = web.Application()
app.router.add_post("/users/sign_in.json", sign_in)
app.router.add_get("/apiv1/devices.json", devices)
app.router.add_get("/apiv1/dsns/{dsn}/lan.json", lan)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
state["url"] = f"http://127.0.0.1:{port}"
yield state
await runner.cleanup()
@pytest.fixture
def mock_ac():
procs: list[Proc] = []
def start(
extra: list[str] | None = None, key_id: int | None = None
) -> Proc:
port = free_port()
proc = Proc([
sys.executable, str(MOCK_AC),
"--port", str(port),
"--lanip-key", LANIP_KEY,
"--key-id", str(KEY_ID if key_id is None else key_id),
*(extra or []),
])
procs.append(proc)
if proc.wait_line("READY", 15) is None:
proc.stop()
raise RuntimeError("mock_ac не стартовал: " + proc.text())
proc.port = port # type: ignore[attr-defined]
return proc
yield start
for proc in procs:
proc.stop()
-95
View File
@@ -1,95 +0,0 @@
"""CLI pyfglair: discover против мок-облака и monitor против mock_ac."""
from __future__ import annotations
import asyncio
import json
from pyfglair.__main__ import main
from conftest import free_port
async def test_cli_discover_json(cloud, capfd):
rc = await asyncio.to_thread(
main,
[
"discover", "--api-base", cloud["url"],
"--email", "user@example.com", "--password", "secret",
],
)
assert rc == 0
out, _ = capfd.readouterr()
config = json.loads(out.splitlines()[0])
assert config["dsn"] == "AC000W00MOCK0001"
assert config["lanip_key_id"] == 64201
assert config["ip_address"] == "192.168.1.50"
async def test_cli_discover_esphome_secrets(cloud, capfd):
rc = await asyncio.to_thread(
main,
[
"discover", "--api-base", cloud["url"],
"--email", "user@example.com", "--password", "secret",
"--format", "esphome-secrets",
],
)
assert rc == 0
out, _ = capfd.readouterr()
assert 'bedroom_dsn: "AC000W00MOCK0001"' in out
assert "bedroom_lanip_key_id: 64201" in out
async def test_cli_discover_error(cloud, capfd):
cloud["fail_login"] = True
rc = await asyncio.to_thread(
main,
[
"discover", "--api-base", cloud["url"],
"--email", "user@example.com", "--password", "bad",
],
)
assert rc == 2
err = capfd.readouterr().err
assert "Ошибка входа" in err
assert "Traceback" not in err
async def test_cli_discover_out_file(cloud, capfd, tmp_path):
out_file = tmp_path / "config_bedroom.json"
rc = await asyncio.to_thread(
main,
[
"discover", "--api-base", cloud["url"],
"--email", "user@example.com", "--password", "secret",
"--out", str(out_file),
],
)
assert rc == 0
capfd.readouterr()
assert out_file.stat().st_mode & 0o777 == 0o600
config = json.loads(out_file.read_text())
assert config["dsn"] == "AC000W00MOCK0001"
async def test_cli_monitor(mock_ac, ac_env, capfd):
proc = mock_ac()
rc = await asyncio.to_thread(
main,
[
"monitor",
"--host", "127.0.0.1",
"--device-port", str(proc.port),
"--dsn", ac_env["dsn"],
"--lanip-key", ac_env["key"],
"--lanip-key-id", str(ac_env["key_id"]),
"--listen-port", str(free_port()),
"--duration", "2",
"--get", "OperationMode",
"--log-level", "3",
],
)
assert rc == 0
out, _ = capfd.readouterr()
assert "STATE online" in out
assert "PROP OPERATION_MODE = 6" in out

Some files were not shown because too many files have changed in this diff Show More