Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfdf22ba64 | ||
|
|
345fe19ca7 | ||
|
|
e91d4605b1 | ||
|
|
b44004627b | ||
|
|
b21817ab9f | ||
|
|
790cee3c78 | ||
|
|
024b290b89 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -4,19 +4,12 @@ docs/apk/*.apk
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
dist/
|
||||
.venv*/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Локальный конфиг устройства (содержит lanip_key)
|
||||
config_kata.json
|
||||
docs/legacy/config_kata.json
|
||||
|
||||
# Сборка
|
||||
build/
|
||||
build-*/
|
||||
|
||||
# Coredump-файлы отладки
|
||||
core
|
||||
core.*
|
||||
|
||||
@@ -15,9 +15,6 @@ if(ESP_PLATFORM)
|
||||
"src/ayla/httpd.cpp"
|
||||
"src/ayla/log.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"
|
||||
INCLUDE_DIRS
|
||||
"include"
|
||||
@@ -43,8 +40,6 @@ else()
|
||||
project(fgl-aircon VERSION 0.1.0 LANGUAGES CXX)
|
||||
|
||||
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)
|
||||
|
||||
# ---- mbedtls ----
|
||||
@@ -72,80 +67,39 @@ else()
|
||||
set(USE_SHARED_MBEDTLS_LIBRARY OFF CACHE BOOL "" FORCE)
|
||||
set(USE_STATIC_MBEDTLS_LIBRARY ON CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(mbedtls)
|
||||
set(FGL_MBEDTLS_TARGET mbedcrypto)
|
||||
set(FGL_MBEDTLS_TARGET mbedcrypto STATIC)
|
||||
set(FGL_MBEDTLS_INCLUDE_DIR "")
|
||||
# Статический mbedcrypto нужен и для shared-библиотеки (pyfglair).
|
||||
set_target_properties(mbedcrypto PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
else()
|
||||
set(FGL_MBEDTLS_TARGET "${MBEDTLS_CRYPTO_LIB}")
|
||||
set(FGL_MBEDTLS_INCLUDE_DIR "${MBEDTLS_INCLUDE_DIR}")
|
||||
endif()
|
||||
|
||||
set(FGL_SOURCES
|
||||
add_library(fgl-aircon STATIC
|
||||
src/ayla/crypto.cpp
|
||||
src/ayla/envelope.cpp
|
||||
src/ayla/httpc.cpp
|
||||
src/ayla/httpd.cpp
|
||||
src/ayla/log.cpp
|
||||
src/ayla/session.cpp
|
||||
src/aircon/c_api.cpp
|
||||
src/aircon/session.cpp
|
||||
src/aircon/tables.cpp
|
||||
src/ayla/platform/posix/platform.cpp
|
||||
)
|
||||
|
||||
function(fgl_configure_target tgt)
|
||||
target_include_directories(${tgt}
|
||||
PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
"${FGL_MBEDTLS_INCLUDE_DIR}"
|
||||
PRIVATE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
||||
)
|
||||
target_link_libraries(${tgt} PUBLIC ${FGL_MBEDTLS_TARGET})
|
||||
target_compile_features(${tgt} PUBLIC cxx_std_20)
|
||||
target_compile_options(${tgt} PRIVATE
|
||||
-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()
|
||||
target_include_directories(fgl-aircon
|
||||
PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
"${FGL_MBEDTLS_INCLUDE_DIR}"
|
||||
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_compile_options(fgl-aircon PRIVATE
|
||||
-Wall -Wextra -Werror
|
||||
-fno-exceptions -fno-rtti
|
||||
)
|
||||
|
||||
if(FGL_BUILD_TESTS)
|
||||
enable_testing()
|
||||
add_subdirectory(tests)
|
||||
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()
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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**.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: диалог добавления custom repository с заполненным URL
|
||||
и выбранной категорией Integration.*
|
||||
|
||||
2. Найдите **FGLair** в HACS → **Download** → перезапустите Home Assistant.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: страница загрузки интеграции с кнопкой Download и
|
||||
версией.*
|
||||
|
||||
### Вариант B: вручную (любой хостинг)
|
||||
|
||||
Скопируйте каталог `custom_components/fglair/` в
|
||||
`<config>/custom_components/fglair/` вашего HA и перезапустите Home
|
||||
Assistant. Убедитесь, что `pyfglair` установлен в python-окружение HA
|
||||
(см. «Требования»).
|
||||
|
||||
## Добавление устройства
|
||||
|
||||
3. Settings → Devices & Services → **Add Integration** → «FGLair».
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: диалог поиска интеграции с введённым «FGLair» и
|
||||
выделенным результатом.*
|
||||
|
||||
4. **Вход в облако**: e-mail/пароль FGLair и регион. Облако
|
||||
используется один раз — получить статический LAN-ключ модуля.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: форма с заполненными регионом EU и e-mail, пароль
|
||||
скрыт.*
|
||||
|
||||
5. **Выбор устройства**: список найденных кондиционеров (имя, модель, IP).
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: список с одним устройством (имя, модель, host).*
|
||||
|
||||
6. **Три страницы настройки** (все значения уже подставлены по данным
|
||||
устройства — можно просто нажимать «Далее»):
|
||||
1. **Шаблон модуля** — A/B/F с пояснениями; определяется по модели
|
||||
модуля, а для неизвестных моделей — проверкой свойств. Рядом
|
||||
показаны прочитанные с устройства значения.
|
||||
2. **Возможности прибора** — тумблеры по «человекочитаемым» фичам
|
||||
(режимы, скорости, swing, пресеты, флаги, заслонки) с описаниями;
|
||||
включены/выключены по `device_capabilities` и ответам устройства.
|
||||
Сущности создаются только для включённых возможностей.
|
||||
3. **Уставка** — диапазон/шаг (у A/F 16–30 °C шаг 0.5, у B шаг 1.0) и,
|
||||
при необходимости, ручная конверсия коэффициентами.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: страница «Возможности прибора» с тумблерами и
|
||||
описаниями, часть выключена по данным устройства.*
|
||||
|
||||
7. **Готово**: карточка устройства с сущностями climate/switch/select/
|
||||
sensor/binary_sensor.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: страница устройства с созданными сущностями.*
|
||||
|
||||
## Где взять ключ для 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.
|
||||
|
||||

|
||||
|
||||
*Должно быть видно: страница 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
|
||||
```
|
||||
@@ -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, "")))
|
||||
)
|
||||
@@ -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)
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -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()
|
||||
@@ -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, "")),
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,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-ключ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -296,9 +296,9 @@ HA-превью (PLAN_HOME_ASSISTANT §4) и тестами.
|
||||
| M0 ✅ | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 |
|
||||
| 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) |
|
||||
| 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 — приёмочный шаг перед релизом |
|
||||
| M5 | **ВНЕ СКОУПА core-релиза** (по решению владельца): `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | — |
|
||||
| M3 | `src/aircon`: шаблоны, конверсии+override, публичный API, batch | `tests/aircon` зелёные; на приборе: чтение всех свойств, batch=1 notify |
|
||||
| M4 | fglctl-пример, `tools/fglair-discover` (в т.ч. `--format esphome-secrets`), README библиотеки (сборка IDF/POSIX, тесты) | 24 ч на приборе: 0 рассинхронов; README готов |
|
||||
| M5 | (Опция) `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | Два устройства одновременно |
|
||||
|
||||
README.md библиотеки (после M4, для людей): сборка в ESP-IDF (как
|
||||
компонент), сборка POSIX (cmake), запуск тестов (ctest + mock), краткий
|
||||
|
||||
@@ -135,7 +135,7 @@ ESPHome-лямбды компилируются в C++-функции и пер
|
||||
2. **CLI-дискавери** (облако Ayla, без установки HA):
|
||||
```bash
|
||||
# печатает блок для 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_lanip_key: "<base64>"
|
||||
# ac_lanip_key_id: 62999
|
||||
|
||||
@@ -31,17 +31,14 @@ Fallback, если сборка wheel станет блокером: сборк
|
||||
* `pyfglair/_corebuild.py` — cmake-сборка при упаковке wheel;
|
||||
* `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl-aircon/c_api.h`;
|
||||
* `pyfglair/session.py` — обёртка `Session(cfg)`; колбэки ядра → asyncio
|
||||
через `loop.call_soon_threadsafe`. Транспорт событий — C-очередь
|
||||
(`fgl_session_wait_events`) и выделенный python-поток: прямые cffi-колбэки
|
||||
из потоков ядра небезопасны (у session-потока стек 8 КБ — CPython
|
||||
падает по stack overflow);
|
||||
через `loop.call_soon_threadsafe`;
|
||||
* `pyfglair/templates.py` — интроспекция шаблонов через cffi-вызовы
|
||||
`fgl_template_info_get` / `fgl_convert_to_display` (для превью в
|
||||
config flow; единый источник данных — C-таблицы, дублей нет);
|
||||
* `pyfglair/provision.py` — облачный discovery (перенос логики
|
||||
`docs/legacy/aircon/discovery.py` на современный aiohttp): e-mail/
|
||||
пароль/регион → 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).
|
||||
2. **`custom_components/fglair/`**:
|
||||
```
|
||||
@@ -50,7 +47,7 @@ Fallback, если сборка wheel станет блокером: сборк
|
||||
fglair_client.py # фоновый поток с FglSession
|
||||
coordinator.py # push-driven coordinator
|
||||
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
|
||||
```
|
||||
|
||||
@@ -87,11 +84,9 @@ Repair (теоретический): `key_error` → «Ключ устройст
|
||||
перепровижинируйте» (повторный облако-вход по требованию). Облако в рантайме
|
||||
не используется, ключ статичен.
|
||||
|
||||
**Диагностика**: device-страница показывает `dsn`, `lanip_key_id`, `host` и
|
||||
маску `lanip_key` (дамп прикладывают в issues — секретов в нём нет). Полный
|
||||
ключ для secrets ESPHome берётся явным действием: reconfigure («Настроить
|
||||
заново») на карточке интеграции или CLI `python -m pyfglair discover` на
|
||||
хосте HA.
|
||||
**Диагностика**: device-страница показывает `dsn`, `lanip_key`, `lanip_key_id`,
|
||||
`host` — источник для копирования в secrets ESPHome. В redacted-дампе ключ
|
||||
маскируется.
|
||||
|
||||
## 4. Сущности
|
||||
|
||||
@@ -99,15 +94,8 @@ Repair (теоретический): `key_error` → «Ключ устройст
|
||||
display_temperature; sensors: room temp, error_code, op_status-флаги;
|
||||
switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/
|
||||
human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки,
|
||||
demand_control; binary_sensor: connectivity). capabilities и выбранные в мастере
|
||||
feature-тумблеры фильтруют сущности (`features.py`,
|
||||
`entry.data["features"]`): то, что прибор не умеет или отключено
|
||||
пользователем, не создаётся. Записи — один `batch_commit()` на действие
|
||||
пользователя. У сущностей есть атрибут `description` (HA не поддерживает
|
||||
тултипы).
|
||||
Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована
|
||||
(§10) — сущность не создаётся до уточнения; в H3 вместо неё
|
||||
диагностический sensor состояния связи.
|
||||
demand_control; binary_sensor: connectivity). capabilities фильтруют
|
||||
режимы/пресеты. Записи — один `batch_commit()` на действие пользователя.
|
||||
|
||||
## 5. Runtime
|
||||
|
||||
@@ -122,9 +110,8 @@ asyncio → push-coordinator. Состояния: `online` → available;
|
||||
Структура (`screenshots/step-N.png` — заглушки-плейсхолдеры, владелец заменит
|
||||
реальными скриншотами; рядом с каждой — описание что должно быть видно):
|
||||
|
||||
1. **Установка через HACS** (нужно публичное GitHub-зеркало — HACS не
|
||||
поддерживает GitLab/Gitea; либо ручное копирование `custom_components/`):
|
||||
* HACS → ⋮ → Custom repositories → URL GitHub-зеркала, категория
|
||||
1. **Установка через HACS**:
|
||||
* HACS → ⋮ → Custom repositories → URL репозитория, категория
|
||||
Integration → Add. Скриншот: диалог добавления custom repository с
|
||||
заполненным URL и выбранной категорией Integration.
|
||||
* FGLair → Download → перезапуск HA. Скриншот: страница загрузки
|
||||
@@ -142,7 +129,7 @@ asyncio → push-coordinator. Состояния: `online` → available;
|
||||
6. **Готово**: карточка устройства со списком сущностей. Скриншот: страница
|
||||
устройства с созданными climate/sensor/switch сущностями.
|
||||
7. **Где взять ключ для ESPHome**: диагностика устройства. Скриншот: страница
|
||||
Diagnostics с полями dsn/lanip_key_id/host и маской lanip_key.
|
||||
Diagnostics с полями dsn/lanip_key/lanip_key_id.
|
||||
8. Troubleshooting: 503 (оба слота заняты — телефон+ESP?), key_error,
|
||||
недоступность.
|
||||
|
||||
@@ -157,18 +144,15 @@ Security → Long-lived access tokens) и REST API: вызов сервисов
|
||||
(OAuth-флоу) не нужна — пользователь просто создаёт токен и передаёт
|
||||
скрипту (`--ha-url`, `--ha-token`).
|
||||
Режимы: quick (матрица burst/не-burst, обе стороны, с возвратом) и `--long`
|
||||
(24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт дописывается
|
||||
по ходу). Скрипт НЕ открывает собственную сессию к кондиционеру (оба слота
|
||||
заняты HA+ESP). ESPHome-канал (опционально, `--esphome-host`): через native
|
||||
API отправляются шаги «режим» и «уставка», проверка — по состоянию в HA
|
||||
(сквозная цепочка ESPHome → модуль → HA); fan/swing — только со стороны HA.
|
||||
(24 ч, ежечасное изменение с проверкой и возвратом, CSV-отчёт). Скрипт НЕ
|
||||
открывает собственную сессию к кондиционеру (оба слота заняты HA+ESP).
|
||||
|
||||
## 8. Этапы
|
||||
|
||||
| # | Содержимое |
|
||||
|---|-----------|
|
||||
| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
||||
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
||||
| H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
||||
| H4 ✅ | repair, диагностика (ключ для ESPHome), translations |
|
||||
| H5 ✅ | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), hacs.json; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64) — релизное действие, чек-лист `docs/RELEASE_HA.md` |
|
||||
| H1 | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
||||
| H2 | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
||||
| H3 | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
||||
| H4 | repair, диагностика (ключ для ESPHome), translations |
|
||||
| H5 | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), HACS-релиз |
|
||||
|
||||
@@ -32,10 +32,6 @@ docs/ # документация (ниже) + матер
|
||||
| `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). |
|
||||
| `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 рассинхронов). |
|
||||
|
||||
## Краткая выжимка протокола
|
||||
|
||||
|
||||
@@ -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 зеркал).
|
||||
@@ -8,7 +8,7 @@ python /opt/aircon/main.py \
|
||||
run \
|
||||
--port 3355 \
|
||||
--mqtt_host 192.168.88.1 \
|
||||
--mqtt_user "${MQTT_USER:-user:password}" \
|
||||
--mqtt_user "aircon:ic6ahrahbaijaVe3eTip" \
|
||||
--local_ip 192.168.88.1 \
|
||||
--config /opt/aircon/config_kata.json \
|
||||
"$@"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "FGLair",
|
||||
"homeassistant": "2025.1.0",
|
||||
"render_readme": true
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/README.md`](custom_components/fglair/README.md).
|
||||
@@ -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])
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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"]
|
||||
@@ -1,5 +0,0 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests/pyfglair
|
||||
filterwarnings =
|
||||
error::ResourceWarning
|
||||
@@ -27,11 +27,4 @@ if command -v clang++ >/dev/null 2>&1; then
|
||||
else
|
||||
echo "clang++ не найден — сборка пропущена"
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,26 +18,16 @@ namespace {
|
||||
|
||||
constexpr uint32_t kLoopTickMs = 20;
|
||||
constexpr size_t kMaxName = 40;
|
||||
constexpr size_t kMaxQueue = 48; // >= cfg.max_queue default 40 (полный батч
|
||||
// шаблона A = 33 свойства) и с запасом
|
||||
static_assert(kMaxQueue >= 40, "kMaxQueue must cover full template batch");
|
||||
constexpr size_t kMaxQueue = 32;
|
||||
|
||||
struct Command {
|
||||
uint8_t type; // 1=GET, 2=SET, 3=DELETE
|
||||
char name[kMaxName];
|
||||
int64_t value;
|
||||
char base_type[10];
|
||||
char str_value[48]; // строковые SET
|
||||
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) {
|
||||
static const char kAlpha[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
@@ -153,8 +143,6 @@ struct Session::Impl {
|
||||
strncmp(queue[i].name, cmd.name, kMaxName) == 0) {
|
||||
if (cmd.type == 2) {
|
||||
queue[i].value = cmd.value; // замещаем
|
||||
copy_str(queue[i].str_value, cmd.str_value,
|
||||
sizeof(queue[i].str_value));
|
||||
return true;
|
||||
}
|
||||
if (cmd.type == 1) return true; // дубликат GET
|
||||
@@ -177,8 +165,6 @@ struct Session::Impl {
|
||||
strncmp(batch[i].name, cmd.name, kMaxName) == 0) {
|
||||
if (cmd.type == 2) {
|
||||
batch[i].value = cmd.value;
|
||||
copy_str(batch[i].str_value, cmd.str_value,
|
||||
sizeof(batch[i].str_value));
|
||||
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");
|
||||
if (strcmp(c.base_type, "boolean") == 0) {
|
||||
w.boolean(c.value != 0);
|
||||
} else if (strcmp(c.base_type, "string") == 0) {
|
||||
w.string(c.str_value);
|
||||
} else {
|
||||
w.integer(c.value);
|
||||
}
|
||||
@@ -538,38 +522,26 @@ void Session::Impl::handle_datapoint(const HttpRequest& req,
|
||||
snprintf(ev.name, sizeof(ev.name), "%s", name);
|
||||
int64_t iv = 0;
|
||||
bool bv = false;
|
||||
const char* sv = nullptr;
|
||||
size_t sv_len = 0;
|
||||
jsmntype_t vt;
|
||||
if (data_doc.get_int("value", &iv)) {
|
||||
ev.is_int = true;
|
||||
ev.int_value = iv;
|
||||
} else if (data_doc.get_bool("value", &bv)) {
|
||||
ev.is_bool = true;
|
||||
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;
|
||||
if (ev.is_int || ev.is_bool) {
|
||||
value_ok = true;
|
||||
} else {
|
||||
// Строка: значение должно влезать в буфер события; иначе событие
|
||||
// не отправляем (кэш не затираем пустышкой).
|
||||
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);
|
||||
}
|
||||
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;
|
||||
impl->cfg = cfg;
|
||||
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;
|
||||
impl->timings.keepalive_ms = impl->cfg.keepalive_ms;
|
||||
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;
|
||||
}
|
||||
|
||||
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() {
|
||||
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||
if (impl_->batch_open) return false;
|
||||
@@ -917,20 +869,19 @@ bool Session::begin_batch() {
|
||||
}
|
||||
|
||||
bool Session::commit_batch() {
|
||||
bool ok = true;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
||||
if (!impl_->batch_open) return false;
|
||||
bool ok = true;
|
||||
for (uint8_t i = 0; i < impl_->batch_len; i++) {
|
||||
if (!impl_->enqueue_locked(impl_->batch[i])) ok = false;
|
||||
}
|
||||
impl_->batch_open = false;
|
||||
impl_->batch_len = 0;
|
||||
if (!ok) return false;
|
||||
}
|
||||
// notify ставится ВСЕГДА: даже при частичном отказе поставленные команды
|
||||
// должны уйти немедленно, а не ждать keep-alive.
|
||||
impl_->want_notify.store(true, std::memory_order_release);
|
||||
return ok;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Session::abort_batch() {
|
||||
|
||||
@@ -53,8 +53,7 @@ struct SessionConfig {
|
||||
uint32_t lanip_key_id = 0;
|
||||
uint16_t listen_port = 10275; // 0 — любой свободный
|
||||
uint32_t keepalive_ms = 15000;
|
||||
uint8_t max_queue = 40; // лимит очереди команд (>= полного
|
||||
// батча свойств шаблона, 33)
|
||||
uint8_t max_queue = 16; // лимит очереди команд
|
||||
};
|
||||
|
||||
struct SessionCallbacks {
|
||||
@@ -108,8 +107,6 @@ class Session {
|
||||
// SET-команда (integer/boolean как int64).
|
||||
bool set_property(const char* name, int64_t value,
|
||||
const char* base_type = "integer");
|
||||
// SET-команда строкового свойства (base_type "string").
|
||||
bool set_property_string(const char* name, const char* value);
|
||||
// Пакет: собрать несколько команд, один notify на commit.
|
||||
bool begin_batch();
|
||||
bool commit_batch();
|
||||
|
||||
@@ -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_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).
|
||||
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||
add_test(NAME ayla_session_mock
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/ayla/test_session_mock.py
|
||||
$<TARGET_FILE:session_runner> ${CMAKE_CURRENT_SOURCE_DIR}/ayla/mock_ac.py)
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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); // насыщение или корректное значение — но не падение
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -220,7 +220,6 @@ class Mock:
|
||||
self.push_seq = 0
|
||||
self.reg_count = 0
|
||||
self.last_reg_time = None
|
||||
self.active = False # сессия активирована (первый poll обслужен)
|
||||
self.fail_pushes = args.fail_pushes
|
||||
self.garbage_pushes = args.garbage_pushes
|
||||
self.miss_response = args.break_outbound
|
||||
@@ -292,9 +291,6 @@ class Mock:
|
||||
cmd = c.get("cmd", {})
|
||||
if cmd.get("method") == "DELETE":
|
||||
print("DELETE", flush=True)
|
||||
# Слот освобождён: следующая сессия должна получить KE.
|
||||
self.crypto = None
|
||||
self.active = False
|
||||
return
|
||||
res = cmd.get("resource", "")
|
||||
name = res.split("name=")[-1]
|
||||
@@ -354,12 +350,11 @@ class Mock:
|
||||
return 202
|
||||
if not self.args.no_poll:
|
||||
self.poll_commands()
|
||||
self.active = True # poll обслужен — сессия приложения активна
|
||||
return 202
|
||||
|
||||
def spontaneous_loop(self):
|
||||
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:
|
||||
self.props["tick"] += 1
|
||||
corrupt = self.fail_pushes > 0
|
||||
@@ -385,14 +380,9 @@ def main():
|
||||
ap.add_argument("--stale-gap", type=float, default=44.0)
|
||||
ap.add_argument("--push-every", type=float, default=0)
|
||||
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()
|
||||
|
||||
mock = Mock(args)
|
||||
for item in args.set:
|
||||
name, _, value = item.partition("=")
|
||||
mock.props[name] = int(value, 0) # принимает 0b/0x/десятичное
|
||||
lock = mock.lock
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -1,2 +0,0 @@
|
||||
pytest-homeassistant-custom-component==0.13.367
|
||||
aiohttp>=3.9
|
||||
@@ -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()
|
||||
@@ -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
|
||||