Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9ce65a11 | ||
|
|
157df4e795 |
@@ -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,8 @@ docs/apk/*.apk
|
|||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.egg-info/
|
.venv/
|
||||||
dist/
|
|
||||||
.venv*/
|
|
||||||
venv/
|
venv/
|
||||||
|
|
||||||
# Локальный конфиг устройства (содержит lanip_key)
|
# Локальный конфиг устройства (содержит lanip_key)
|
||||||
config_kata.json
|
|
||||||
docs/legacy/config_kata.json
|
docs/legacy/config_kata.json
|
||||||
|
|
||||||
# Сборка
|
|
||||||
build/
|
|
||||||
build-*/
|
|
||||||
|
|
||||||
# Coredump-файлы отладки
|
|
||||||
core
|
|
||||||
core.*
|
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
|
||||||
|
|
||||||
if(ESP_PLATFORM)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Сборка как компонента ESP-IDF (корень репозитория = компонент; имя
|
|
||||||
# компонента = имя каталога, из которого он подключён).
|
|
||||||
# ВАЖНО: cmake_minimum_required должен остаться до этой ветки, project()
|
|
||||||
# для IDF-сборки не вызывается.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
idf_component_register(
|
|
||||||
SRCS
|
|
||||||
"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/esp-idf/platform.cpp"
|
|
||||||
INCLUDE_DIRS
|
|
||||||
"include"
|
|
||||||
PRIV_INCLUDE_DIRS
|
|
||||||
"."
|
|
||||||
"src"
|
|
||||||
PRIV_REQUIRES
|
|
||||||
lwip
|
|
||||||
esp_timer
|
|
||||||
esp_hw_support
|
|
||||||
pthread
|
|
||||||
mbedtls
|
|
||||||
)
|
|
||||||
target_compile_options(${COMPONENT_LIB} PRIVATE
|
|
||||||
-Wall -Wextra -Werror
|
|
||||||
-fno-exceptions -fno-rtti
|
|
||||||
)
|
|
||||||
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20)
|
|
||||||
else()
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Обычная (POSIX) сборка: статическая библиотека + тесты.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
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 ----
|
|
||||||
if(NOT FGL_BUNDLED_MBEDTLS)
|
|
||||||
find_path(MBEDTLS_INCLUDE_DIR mbedtls/aes.h
|
|
||||||
HINTS /usr/include/mbedtls3 /usr/include
|
|
||||||
)
|
|
||||||
find_library(MBEDTLS_CRYPTO_LIB
|
|
||||||
NAMES mbedcrypto-3 mbedcrypto
|
|
||||||
)
|
|
||||||
if(NOT MBEDTLS_INCLUDE_DIR OR NOT MBEDTLS_CRYPTO_LIB)
|
|
||||||
message(STATUS "System Mbed TLS not found - falling back to bundled")
|
|
||||||
set(FGL_BUNDLED_MBEDTLS ON)
|
|
||||||
endif()
|
|
||||||
endif()
|
|
||||||
if(FGL_BUNDLED_MBEDTLS)
|
|
||||||
include(FetchContent)
|
|
||||||
FetchContent_Declare(mbedtls
|
|
||||||
GIT_REPOSITORY https://github.com/Mbed-TLS/mbedtls.git
|
|
||||||
GIT_TAG v3.6.2
|
|
||||||
GIT_SHALLOW TRUE
|
|
||||||
)
|
|
||||||
set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE)
|
|
||||||
set(ENABLE_TESTING OFF CACHE BOOL "" FORCE)
|
|
||||||
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_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
|
|
||||||
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()
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -30,14 +30,13 @@
|
|||||||
### 2.1. [ГЛАВНАЯ ПРИЧИНА «РАССИНХРОНИЗАЦИИ»] Keep-alive 1200 с вместо 10–15 с
|
### 2.1. [ГЛАВНАЯ ПРИЧИНА «РАССИНХРОНИЗАЦИИ»] Keep-alive 1200 с вместо 10–15 с
|
||||||
|
|
||||||
`notifier.py:_KEEP_ALIVE_INTERVAL = 1200.0`. APK: 10 с (или `lan.json:keepAlive/3`).
|
`notifier.py:_KEEP_ALIVE_INTERVAL = 1200.0`. APK: 10 с (или `lan.json:keepAlive/3`).
|
||||||
Проверено на приборе: **модуль игнорирует 400/401 на свои POST; переkey
|
Проверено на приборе: **единственный механизм восстановления после расхождения
|
||||||
происходит при `local_reg` после зазора ≥ ~44–50 с от предыдущего** (при
|
CBC-цепочек — принудительный re-key, который модуль делает при получении
|
||||||
keep-alive 10–15 с re-key вообще не происходит). Следствие для legacy: любая
|
`local_reg` для сессии старше ≈44 с. Ответы 400/401 модуль игнорирует.**
|
||||||
потерянная пара запрос-ответ → обе стороны «глохнут» до следующего local_reg
|
Следствие для legacy: любая потерянная пара запрос-ответ/обрыв соединения →
|
||||||
(до 20 минут), который завершится re-key — наблюдаемый симптом «перестаёт
|
обе стороны «глохнут» на срок до 20 минут (до следующего local_reg). Наблюдаемый
|
||||||
понимать кондиционер, потом сам чинится» — именно это. Корректная стратегия
|
симптом «перестаёт понимать кондиционер» с самопроизвольным восстановлением —
|
||||||
для новой реализации: при ошибке расшифровки — пауза ~50 с, затем local_reg
|
именно это.
|
||||||
(re-key гарантирован).
|
|
||||||
|
|
||||||
Дополнительно: длинные паузы между local_reg держат сессию «полуживой»
|
Дополнительно: длинные паузы между local_reg держат сессию «полуживой»
|
||||||
(модуль не видит keep-alive, но слот может удерживаться), и конфликт за
|
(модуль не видит keep-alive, но слот может удерживаться), и конфликт за
|
||||||
|
|||||||
@@ -1,92 +1,79 @@
|
|||||||
# План: базовая библиотека `fgl-aircon` (C++20, Linux + ESP-IDF) в монорепозитории
|
# План: кросс-платформенная библиотека `fglair-core` (C++20, Linux + ESP-IDF)
|
||||||
|
|
||||||
Целевая аудитория — агенты-реализаторы. Протокольные детали — в
|
Целевая аудитория документа — агенты-реализаторы. Протокольные детали — в
|
||||||
`PROTOCOL.md` (ссылки вида «§N»; факты помечены [ПРОВЕРЕНО НА ПРИБОРЕ]).
|
`PROTOCOL.md` (ссылки вида «§N», факты помечены [ПРОВЕРЕНО НА ПРИБОРЕ]).
|
||||||
Причины проектных решений — `LEGACY_ANALYSIS.md`.
|
Причины проектных решений — `LEGACY_ANALYSIS.md`.
|
||||||
|
|
||||||
## 0. Монорепозиторий
|
## 1. Цели и не-цели
|
||||||
|
|
||||||
Все три компонента живут в одном репозитории:
|
|
||||||
|
|
||||||
```
|
|
||||||
/ # CMakeLists.txt базовой библиотеки — в корне
|
|
||||||
include/fgl-aircon/ # публичный API (уровень aircon)
|
|
||||||
src/
|
|
||||||
ayla/ # реализация протокола + главный цикл сообщений
|
|
||||||
platform/
|
|
||||||
posix/ # сокеты/std::thread/timerfd/getrandom
|
|
||||||
esp-idf/ # lwip-сокеты/esp_timer/esp_random
|
|
||||||
aircon/ # конверсии, шаблоны, реализация публичного API
|
|
||||||
third_party/jsmn/ # вендоренный JSON-парсер (MIT)
|
|
||||||
components/fglair/ # ESPHome external component (см. PLAN_ESPHOME)
|
|
||||||
custom_components/fglair/ # HA custom component (см. PLAN_HOME_ASSISTANT)
|
|
||||||
tests/
|
|
||||||
ayla/ # тесты протокола (KDF, envelope, http, сессия+mock)
|
|
||||||
aircon/ # тесты конверсий и шаблонов
|
|
||||||
acceptance/ # скрипты приёмки ESPHome<->HA (см. §11)
|
|
||||||
tools/ # probe_reference.py, probe_mdns.py, fglair-discover
|
|
||||||
docs/
|
|
||||||
```
|
|
||||||
|
|
||||||
Подключение к сборке:
|
|
||||||
* **ESP-IDF**: корень репозитория регистрируется как компонент
|
|
||||||
(`idf_component.yml`/`CMakeLists.txt` с `idf_component_register`); ESPHome-
|
|
||||||
компонент (`components/fglair`) подключает его через `EXTRA_COMPONENT_DIRS`
|
|
||||||
/ relative path.
|
|
||||||
* **POSIX**: `cmake -S . -B build && cmake --build build` — статическая
|
|
||||||
библиотека `fgl-aircon` + цели тестов.
|
|
||||||
|
|
||||||
## 1. Слои библиотеки (логическое разделение, сборка — одна)
|
|
||||||
|
|
||||||
```
|
|
||||||
приложение (HA / ESPHome / fglctl)
|
|
||||||
│ include/fgl-aircon/*.hpp — публичный API
|
|
||||||
▼
|
|
||||||
src/aircon — свойства и их семантика: таблицы шаблонов A/B/F,
|
|
||||||
конверсии (шаблонные/линейные/функцией), кэш значений,
|
|
||||||
адаптация к публичному API. Не знает про сеть.
|
|
||||||
│ src/ayla/session.hpp (внутренний интерфейс)
|
|
||||||
▼
|
|
||||||
src/ayla — протокол Ayla LAN: crypto/KDF, envelope, HTTP-сервер/клиент
|
|
||||||
(mini-httpd/httpc), очередь команд с coalescing и pacing,
|
|
||||||
главный цикл сообщений (один поток/задача), re-key, слоты,
|
|
||||||
keep-alive. Не знает про свойства кондиционера.
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
src/ayla/platform/* — сокеты, таймеры, CSPRNG, лог (posix | esp-idf)
|
|
||||||
```
|
|
||||||
|
|
||||||
Правила слоёв:
|
|
||||||
* `aircon` → `ayla` → `platform`, зависимости только вниз.
|
|
||||||
* `ayla` оперирует «непрозрачными» именами свойств (строки) и целыми —
|
|
||||||
вся семантика (°C, режимы, флаги, битмаски) — в `aircon`.
|
|
||||||
* Публичный API — только `include/fgl-aircon/`; внутренние заголовки лежат
|
|
||||||
рядом с реализациями (`src/ayla/*.hpp`, `src/aircon/*.hpp`).
|
|
||||||
|
|
||||||
## 2. Цели и не-цели
|
|
||||||
|
|
||||||
Цели:
|
Цели:
|
||||||
1. Реализовать LAN-протокол FGLair/Ayla (сторона «приложения») по
|
1. Реализовать LAN-протокол FGLair/Ayla (сторона «приложения») по спецификации
|
||||||
`PROTOCOL.md`, поведение — как у APK и подтверждено живыми тестами.
|
`PROTOCOL.md` с поведением, максимально близким к официальному APK и
|
||||||
2. Портативность: ESP-IDF (esp32/esp32s3/esp32c3, только IDF-фреймворк) и
|
подтверждённому живыми тестами прибора.
|
||||||
POSIX (Linux) из одной кодовой базы.
|
2. Портативность: сборка как C++20-библиотека (Linux, CMake) и как компонент
|
||||||
3. Малый footprint: без heap после `init()`, без исключений/RTTI наружу,
|
ESP-IDF (esp32/esp32s3/esp32c3 — везде ESP-IDF, Arduino-фреймворк не
|
||||||
логирование через callback, статические буферы.
|
поддерживаем и не тестируем).
|
||||||
4. Интеграция «как у climate-модулей ESPHome»: асинхронный API, колбэки.
|
3. Малый footprint и детерминированное использование памяти: без heap после
|
||||||
5. Устойчивость: re-key (модуль ротирует ключи каждые ≈44–60 с), перезагрузка
|
инициализации (все буферы — члены/статические), никаких исключений наружу
|
||||||
модуля, слоты/503, режим «KE без активации»; жёсткий rate-control.
|
(внутри — `expected`/коды), логирование через callback.
|
||||||
|
4. Интеграция «как у climate-модулей ESPHome»: простой асинхронный API
|
||||||
|
(set/get свойства, колбэки обновлений, статус сессии).
|
||||||
|
5. Устойчивость: переживать re-key (модуль сам ротирует ключи каждые ≈44–60 с),
|
||||||
|
перезагрузку модуля, конфликт слотов (503), режим «KE без активации»;
|
||||||
|
жёсткий rate-control, чтобы не «завалить» модуль.
|
||||||
|
|
||||||
Не-цели (первая версия):
|
Не-цели (первая версия):
|
||||||
* Облако в рантайме. `lanip_key` статичен (зашит в модуль, 5 лет без ротаций);
|
* Облако внутри C++-библиотеки. **lanip_key считается статичным, зашитым в
|
||||||
провижининг — Python CLI `tools/fglair-discover`. Несовпадение `key_id` →
|
модуль** (5 лет эксплуатации без ротаций). Провижининг — отдельный Python
|
||||||
устойчивое состояние ошибки до правки конфига вручную.
|
CLI (`fglair-discover`), см. §8. При несовпадении `key_id` библиотека
|
||||||
* Узловые устройства, setup-режим (RSA), OTA.
|
переходит в устойчивое состояние ошибки и ждёт смены конфига вручную.
|
||||||
* Hisense-свойства (`t_power` и пр.) — только шаблоны A/B/F.
|
* Узловые устройства (`node/*`), setup-режим (RSA `sec`), OTA.
|
||||||
|
* Hisense-свойства (`t_power` и пр.) — только FGLair-шаблоны A/B/F.
|
||||||
|
|
||||||
## 3. Публичный API (эскиз `include/fgl-aircon/`)
|
## 2. Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ Приложение: HA-интеграция / ESPHome-компонент / CLI │
|
||||||
|
└───────────────▲────────────────────────────────────────────┘
|
||||||
|
│ include/fgl/*.h — публичный API
|
||||||
|
│ C++-классы + тонкий extern "C"-шейм (для cffi/HA)
|
||||||
|
┌───────────────┴────────────────────────────────────────────┐
|
||||||
|
│ core (портативный C++20, без исключений/RTTI/heap): │
|
||||||
|
│ session — машина состояний, re-key, keep-alive, слоты │
|
||||||
|
│ crypto — KDF, AES-256-CBC (цепочка!), HMAC-SHA256 │
|
||||||
|
│ envelope — pack/unpack {"enc","sign"}, seq_no, паддинг │
|
||||||
|
│ property — таблицы свойств шаблонов A/B/F, конверсии │
|
||||||
|
│ cmdq — очередь команд с coalescing + pacing │
|
||||||
|
│ json — минимальный streaming JSON reader/writer │
|
||||||
|
│ httpd — минимальный HTTP/1.1 server (роутинг по IP) │
|
||||||
|
│ httpc — клиент local_reg │
|
||||||
|
├────────────────────────────────────────────────────────────┤
|
||||||
|
│ platform layer (интерфейс fgl/platform.h, 2 реализации): │
|
||||||
|
│ posix : sockets, std::thread, timerfd, getrandom │
|
||||||
|
│ esp-idf: lwip sockets, esp_timer/FreeRTOS, esp_random │
|
||||||
|
├────────────────────────────────────────────────────────────┤
|
||||||
|
│ crypto backend: mbedtls (в ESP-IDF встроен; на Linux — │
|
||||||
|
│ системный или vendored) │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
* Ядро не знает про ОС: сокеты/таймеры/логи/случайность — через тонкие
|
||||||
|
платформенные заголовки, реализуемые слоем ниже.
|
||||||
|
* Ядро однопоточное: один внутренний поток/задача владеет сессией и шифрами
|
||||||
|
(CBC-цепочка требует строгой сериализации). Вызовы API извне — через
|
||||||
|
потокобезопасный mailbox (lock-free SPSC или мьютекс). Все колбэки
|
||||||
|
исполняются из этого потока.
|
||||||
|
* C++20 разрешён и приветствуется (enum class, span, chrono, concepts),
|
||||||
|
но: без исключений, RTTI, виртуальных иерархий в горячем пути и heap после
|
||||||
|
`init()`. `std::function` в API не использовать (функция+user-data).
|
||||||
|
* Запрет на `printf`; логирование через injectable `fgl_log_fn`.
|
||||||
|
|
||||||
|
## 3. Публичный API (эскиз, `include/fgl/`)
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
// types.hpp
|
// fgl/types.h
|
||||||
enum class fgl_state { idle, registering, online, recovering, offline, key_error };
|
enum class fgl_state { idle, registering, online, recovering, offline, key_error };
|
||||||
enum class fgl_prop { operation_mode, fan_speed, adjust_temperature,
|
enum class fgl_prop { operation_mode, fan_speed, adjust_temperature,
|
||||||
display_temperature, af_vertical_direction, af_vertical_swing,
|
display_temperature, af_vertical_direction, af_vertical_swing,
|
||||||
@@ -94,37 +81,19 @@ enum class fgl_prop { operation_mode, fan_speed, adjust_temperature,
|
|||||||
powerful_mode, coil_dry_mode, min_heat, outdoor_low_noise,
|
powerful_mode, coil_dry_mode, min_heat, outdoor_low_noise,
|
||||||
indoor_fan_control, human_det_auto_save, wifi_led_enable,
|
indoor_fan_control, human_det_auto_save, wifi_led_enable,
|
||||||
op_status, error_code, device_capabilities, demand_control,
|
op_status, error_code, device_capabilities, demand_control,
|
||||||
get_prop, device_name, building_name /* ...PROTOCOL §8.2 */ };
|
get_prop, device_name, building_name, /* ...по PROTOCOL.md §8.2 */ };
|
||||||
enum class fgl_template { A, B, F };
|
|
||||||
struct fgl_value { enum kind { boolean, integer, string } type;
|
struct fgl_value { enum kind { boolean, integer, string } type;
|
||||||
bool b; int32_t i; const char* s; };
|
union { bool b; int32_t i; }; const char* s; };
|
||||||
|
|
||||||
// --- конверсии: шаблон по умолчанию, коэффициенты или функция (см. §4) ---
|
|
||||||
enum class fgl_conv_kind { template_default, linear, custom_fn };
|
|
||||||
struct fgl_linear { int32_t num, den, offset; }; // disp = raw*num/den + offset
|
|
||||||
struct fgl_conversion {
|
|
||||||
fgl_conv_kind kind = fgl_conv_kind::template_default;
|
|
||||||
fgl_linear linear{}; // при kind == linear
|
|
||||||
int32_t (*fn)(int32_t raw, void* ctx) = nullptr; // при kind == custom_fn
|
|
||||||
void* ctx = nullptr;
|
|
||||||
};
|
|
||||||
struct fgl_prop_override { // точечная настройка одного свойства
|
|
||||||
fgl_prop prop;
|
|
||||||
bool has_range = false; int32_t min = 0, max = 0;
|
|
||||||
fgl_conversion to_display{}; // raw -> инженерные единицы
|
|
||||||
fgl_conversion from_input{}; // ввод -> raw
|
|
||||||
};
|
|
||||||
|
|
||||||
struct fgl_config {
|
struct fgl_config {
|
||||||
const char* host; // DNS-имя или IP ("ac.local" / "192.168.0.42")
|
const char* device_ip; // "192.168.88.3"
|
||||||
const char* dsn; // "AC000W00XXXXXXX"
|
const char* dsn; // "AC000W002879281"
|
||||||
const char* lanip_key; // base64-строка как есть
|
const char* lanip_key; // base64-строка как есть
|
||||||
uint32_t lanip_key_id;
|
uint32_t lanip_key_id; // 62888
|
||||||
fgl_template template_;
|
fgl_template template_; // A / B / F
|
||||||
uint16_t listen_port; // 0 => 10275
|
uint16_t listen_port; // 0 => 10275
|
||||||
uint32_t keepalive_ms; // 0 => 15000
|
uint32_t keepalive_ms; // 0 => 15000 (рекомендация; APK: 10с)
|
||||||
uint8_t max_queue; // 0 => 16
|
uint8_t max_queue; // 0 => 16
|
||||||
const fgl_prop_override* overrides = nullptr; // nullptr-terminated
|
|
||||||
};
|
};
|
||||||
struct fgl_callbacks {
|
struct fgl_callbacks {
|
||||||
void (*on_state)(void* user, fgl_state st, int err);
|
void (*on_state)(void* user, fgl_state st, int err);
|
||||||
@@ -133,192 +102,172 @@ struct fgl_callbacks {
|
|||||||
void* user;
|
void* user;
|
||||||
};
|
};
|
||||||
|
|
||||||
// session.hpp (C++-классы; c_api.h — extern "C" шейм для cffi/HA)
|
// fgl/session.h (C++-класс; в fgl/c_api.h — extern "C" шейм для cffi)
|
||||||
class FglSession {
|
class FglSession {
|
||||||
public:
|
public:
|
||||||
static FglSession* create(const fgl_config&, const fgl_callbacks&);
|
static FglSession* create(const fgl_config&, const fgl_callbacks&);
|
||||||
int start(); int stop(); // stop() шлёт delete_session (≤2с)
|
int start(); int stop(); // stop() шлёт delete_session, ждёт ≤2с
|
||||||
fgl_state state() const;
|
fgl_state state() const;
|
||||||
|
// Управление (ставится в очередь с coalescing):
|
||||||
int set_bool(fgl_prop, bool); int set_int(fgl_prop, int32_t);
|
int set_bool(fgl_prop, bool); int set_int(fgl_prop, int32_t);
|
||||||
int get_prop(fgl_prop);
|
int get_prop(fgl_prop); // запросить refresh
|
||||||
int batch_begin(); int batch_commit();
|
int batch_begin(); int batch_commit(); // атомарный пакет команд
|
||||||
bool cached(fgl_prop, fgl_value* out) const;
|
bool cached(fgl_prop, fgl_value* out) const;
|
||||||
};
|
};
|
||||||
|
|
||||||
// templates.hpp — интроспекция шаблонов (для превью в HA, тестов, CLI):
|
|
||||||
// список свойств шаблона, base_type, RO, диапазоны, примеры конверсии
|
|
||||||
const fgl_template_info* fgl_template_info_get(fgl_template);
|
|
||||||
int32_t fgl_convert_to_display(fgl_template, fgl_prop, int32_t raw,
|
|
||||||
const fgl_prop_override* ov);
|
|
||||||
int32_t fgl_convert_from_input(fgl_template, fgl_prop, int32_t disp,
|
|
||||||
const fgl_prop_override* ov);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. Конверсии (требование: задаются руками при необходимости)
|
Таблица свойств — `const` массивы в rodata по шаблонам (имя, base_type,
|
||||||
|
read-only, диапазоны), см. PROTOCOL.md §8.
|
||||||
|
|
||||||
Каждое числовое свойство имеет конверсию по умолчанию из таблицы шаблона
|
## 4. Поведенческие требования (обязательны к точной реализации)
|
||||||
(`adjust_temperature`: ×0.1 °C; `display_temperature`: (v−5000)/100;
|
|
||||||
направления: 0..N−1; перечисления — словари). Конфиг сессии может переопределить
|
|
||||||
её для любого свойства одним из способов:
|
|
||||||
|
|
||||||
1. **Коэффициенты** `fgl_linear {num, den, offset}` — для линейных величин
|
Ссылки на PROTOCOL.md; всё, что ниже, согласовано с живыми тестами прибора.
|
||||||
(температуры, проценты). Без кода, доступно из HA UI и YAML.
|
|
||||||
2. **Функция-указатель** `int32_t (*)(int32_t raw, void* ctx)` — произвольная
|
|
||||||
логика; `ctx` для захваченных данных. ESPHome-лямбды компилируются в
|
|
||||||
функции и передаются напрямую (пример — PLAN_ESPHOME §2); HA ограничен
|
|
||||||
коэффициентами (и выбором шаблона).
|
|
||||||
3. Диапазоны (`min/max`) переопределяются независимо от конверсии.
|
|
||||||
|
|
||||||
Конверсия применяется в `src/aircon` на границе API: наружу — «инженерные»
|
### 4.1. Установка сессии
|
||||||
единицы (0.1 °C уже умножено? — НЕТ: наружу отдаётся значение в единицах
|
1. `start()`: HTTP-сервер слушает `listen_port` (по умолчанию 10275).
|
||||||
конверсии, см. ниже), в протокол — raw. Договорённость о единицах наружу
|
2. Отправить `POST /local_reg.json?dsn=<DSN>` с `notify=0` (§4.1). Ответы:
|
||||||
фиксируется в README: наружу отдаётся результат `to_display` (для шаблона A
|
202 — ок; **503 — нет свободных слотов** (2 заняты, например телефоном и
|
||||||
температуры — °C×1 float-friendly int? — принимаем: наружу int32 в
|
другим сервером) → состояние `offline` с ошибкой `FGL_E_NO_SLOT`, повтор
|
||||||
«инженерных» единицах, кратность задаёт конверсия; для HA cffi этого
|
с backoff 30–60 с; таймаут/отказ соединения → `offline`, backoff 1→60 с.
|
||||||
достаточно, ESPHome при желании делит сам через лямбду).
|
3. Дождаться `POST /local_lan/key_exchange.json` (обычно <1 с). Проверить
|
||||||
|
`ver==1, proto==1, sec пустой` (иначе 426/400), сверить `key_id`
|
||||||
|
(несовпадение → **412** + состояние `key_error` до смены конфига вручную;
|
||||||
|
облако НЕ дёргается — ключ статичен). Сгенерировать `random_2` (16 симв.
|
||||||
|
`[A-Za-z0-9]`), `time_2` (любое int64, напр. наносекунды аптайма),
|
||||||
|
вывести ключи (§3.2), ответить 200 `{"random_2":...,"time_2":...}`.
|
||||||
|
4. После ответа модуль в течение ~0.5 с делает «пустой» опрос commands.json —
|
||||||
|
это сигнал активации. **Если в течение 5 с опроса нет — сессия не
|
||||||
|
активировалась** (наблюдавшийся режим зависания модуля): закрыть серверную
|
||||||
|
сторону молча, уйти в `recovering` с паузой 30–60 с (НЕ долбить
|
||||||
|
повторными local_reg — ухудшает состояние модуля).
|
||||||
|
5. После активации: поставить пакет GET-команд нужных свойств (подписанное
|
||||||
|
подмножество таблицы, по умолчанию — все состояния + capabilities) и
|
||||||
|
отправить ОДИН `local_reg` с `notify=1`. Значения придут push'ами.
|
||||||
|
|
||||||
## 5. Поведенческие требования (обязательны к точной реализации)
|
### 4.2. Keep-alive и re-key (ядро надёжности)
|
||||||
|
* Таймер `keepalive_ms` (по умолчанию **15000**). По истечении — `PUT local_reg`
|
||||||
|
с `notify = (очередь непуста)`.
|
||||||
|
* **Модуль сам ротирует ключи**: очередной `local_reg` при возрасте сессии
|
||||||
|
≥ ~44 с приходит вместе с новым key exchange. Обработать его как обычный
|
||||||
|
KE (перегенерация шифров, цепочки сбрасываются), НЕ пересоздавая сессию;
|
||||||
|
seq_no приложения продолжает глобальный счётчик. После re-key НЕ нужна
|
||||||
|
повторная начальная синхронизация (значения уже в кэше).
|
||||||
|
* Каждый обслуженный `GET /commands.json` перезапускает таймер keep-alive
|
||||||
|
(APK-поведение). Анти-спам: не более одного `local_reg` в ~1 с; notify=1
|
||||||
|
отправляется один раз на пакет команд.
|
||||||
|
* Модель времени: хранить `last_ke_time`; при `local_reg` предсказывать,
|
||||||
|
будет ли re-key (age ≥ 44 c) — для телеметрии/диагностики.
|
||||||
|
|
||||||
### 5.1. Установка сессии
|
### 4.3. Очередь команд и pacing
|
||||||
1. `start()`: HTTP-сервер слушает `listen_port` (10275). Разрешение `host`:
|
* Ограничение очереди `max_queue` (16). Coalescing: новый write того же
|
||||||
`getaddrinfo` (lwip DNS); для `*.local` — Ayla-mDNS запрос A-записи на
|
свойства замещает предыдущий незабранный; GET-дубликаты отбрасываются.
|
||||||
`224.0.0.251:10276` (модуль НЕ отвечает на :5353 — проверено). Ретраи
|
* `commands.json`: отдать **одну** команду из головы; 206, если очередь
|
||||||
разрешения при недоступности.
|
непуста, иначе 200. Шифрование строго последовательно (CBC-цепочка),
|
||||||
2. `POST /local_reg.json?dsn=<DSN>` с `notify=0` (PROTOCOL §4.1). Ответы:
|
паддинг — Java-вариант (≥1 NUL).
|
||||||
202 — ок; **503 — нет слотов** (2 заняты) → `offline`/`FGL_E_NO_SLOT`,
|
* Записи не эхируются (проверено): после выдачи write обновить кэш
|
||||||
повтор 30–60 с; отказ соединения → `offline`, backoff 1→60 с.
|
оптимистично; опционально (по конфигу) подтвердить GET-ом через 1–2 с.
|
||||||
3. Дождаться key exchange (<1 с): `ver/proto==1, sec==""` (иначе 426),
|
|
||||||
сверка `key_id` (несовпадение → **412** + `key_error` до правки конфига).
|
|
||||||
`random_2` — 16 симв. `[A-Za-z0-9]`, `time_2` — наносекунды аптайма;
|
|
||||||
вывести ключи (§3.2 PROTOCOL), ответить 200.
|
|
||||||
4. Активация = «пустой» опрос commands.json в течение ~0.5 с. **Нет опроса
|
|
||||||
5 с → сессия не активировалась** (известный режим зависания модуля):
|
|
||||||
тишина + `recovering` с паузой 30–60 с (НЕ долбить local_reg).
|
|
||||||
5. После активации — пакет GET-команд свойств + ОДИН `local_reg notify=1`.
|
|
||||||
|
|
||||||
### 5.2. Keep-alive и re-key
|
### 4.4. Обработка сообщений модуля
|
||||||
* Таймер `keepalive_ms` (default **15000**); по истечении — `PUT local_reg`
|
* Datapoint push: расшифровать, проверить подпись; **seq_no не проверять**.
|
||||||
c `notify=(очередь непуста)`. Каждый `commands.json` перезапускает таймер.
|
Парсить query `?cmd_id=N&status=200` для сопоставления с ожиданиями GET.
|
||||||
* Re-key — событие по инициативе модуля (при зазоре local_reg ≥ ~44–50 с,
|
При ошибке расшифровки ответить **401** (APK-совместимость; модуль это
|
||||||
[ПРОВЕРЕНО НА ПРИБОРЕ]; при штатном keep-alive НЕ происходит): обработать
|
игнорирует, но таковы интерфейсные контракты) и пометить сессию
|
||||||
как обычный KE (перегенерация шифров/цепочек), сессию не пересоздавать,
|
`recovering` — ждать ближайшего re-key по keep-alive (≤15 с).
|
||||||
начальную синхронизацию не повторять; seq_no приложения продолжает
|
* Повторный key exchange на живой сессии — штатное событие (§4.2), не ошибка.
|
||||||
глобальный счётчик.
|
|
||||||
* Восстановление при ошибке расшифровки: тишина > порога (50 с по умолчанию)
|
|
||||||
и возврат — модуль гарантированно ре-кает [ПРОВЕРЕНО НА ПРИБОРЕ].
|
|
||||||
* Анти-спам: ≤1 local_reg/с; notify=1 — один на пакет команд.
|
|
||||||
|
|
||||||
### 5.3. Очередь команд
|
### 4.5. Завершение
|
||||||
* Лимит `max_queue` (16); coalescing (write замещает write, GET-дубликаты
|
* `stop()`: поставить команду DELETE `local_reg.json`/`delete_session`,
|
||||||
отбрасываются). `commands.json`: одна команда из головы; 206/200;
|
дождаться выдачи (≤2 с), закрыть сервер. Освобождает слот немедленно
|
||||||
шифрование строго последовательно; паддинг Java-вариант (≥1 NUL).
|
(проверено) — важно из-за лимита в 2 сессии.
|
||||||
* Записи не эхируются (проверено): оптимистичное обновление кэша;
|
|
||||||
опционально GET-подтверждение через 1–2 с.
|
|
||||||
|
|
||||||
### 5.4. Входящие сообщения
|
### 4.6. HTTP-сервер
|
||||||
* Datapoint push: расшифровка, подпись; **seq_no не проверяется**. Query
|
* Минимальный HTTP/1.1: GET/POST, `Content-Length`, keep-alive, query-параметры.
|
||||||
`?cmd_id=N&status=200` — сопоставление GET-ожиданий. Ошибка расшифровки →
|
Один поток, последовательная обработка, RST-обрывы от модуля — норма.
|
||||||
**401** (APK-совместимость) + `recovering` (самолечение — ближайший
|
* Роутинг на сессию по IP клиента (как `deviceWithLanIP` в APK). Мульти-сессии
|
||||||
keep-alive/re-key, ≤15 с).
|
(несколько устройств) — через `FglHub` (M6).
|
||||||
* Ответы на GET также доставляются через datapoint push (в `ayla` они
|
|
||||||
прозрачны наверх как обновления свойства).
|
|
||||||
|
|
||||||
### 5.5. Завершение
|
## 5. Криптография
|
||||||
* `stop()`: DELETE `local_reg.json`/`delete_session`, выдача ≤2 с, закрыть
|
|
||||||
сервер. Слот освобождается немедленно (проверено).
|
|
||||||
|
|
||||||
## 6. Криптография
|
* mbedtls: AES-256-CBC c сохранением IV-состояния между сообщениями
|
||||||
|
(mbedtls_aes_crypt_cbc обновляет iv-буфер на месте — использовать его же
|
||||||
|
как персистентное состояние), HMAC-SHA256 через `mbedtls_md`.
|
||||||
|
* KDF по §3.2 PROTOCOL. Тестовые векторы — эталон `tools/probe_reference.py`
|
||||||
|
(проверен на приборе) + генератор векторов на Python.
|
||||||
|
* `random_2`/`id` — из CSPRNG платформы. `time_2` — наносекунды аптайма.
|
||||||
|
|
||||||
mbedtls (в ESP-IDF встроен; POSIX — системный или FetchContent):
|
## 6. Таблица свойств
|
||||||
AES-256-CBC с персистентным IV-состоянием между сообщениями
|
|
||||||
(`mbedtls_aes_crypt_cbc` обновляет iv-буфер на месте — использовать его как
|
|
||||||
состояние цепочки), HMAC-SHA256 через `mbedtls_md`. KDF — PROTOCOL §3.2;
|
|
||||||
тестовые векторы — эталон `tools/probe_reference.py` (проверен на приборе).
|
|
||||||
|
|
||||||
## 7. HTTP и JSON: оценка готовых библиотек (решение)
|
`src/property.cpp` + `include/fgl/props.def`: на каждый шаблон — `constexpr`
|
||||||
|
массив `{enum, имя, base_type, RO, min/max}`; конверсии `adjust_temperature`
|
||||||
|
(×0.1 °C), `display_temperature` ((v−5000)/100), направления 0..num_dir−1;
|
||||||
|
битовые декодеры `op_status` / `device_capabilities` (§8.4–8.5).
|
||||||
|
|
||||||
Требования: no-heap после init, один код для ESP-IDF и POSIX, точный
|
## 7. Структура репозитория
|
||||||
контроль поведения (keep-alive/RST-quirks модуля измерены на приборе),
|
|
||||||
малый footprint.
|
|
||||||
|
|
||||||
**JSON (парсер):**
|
```
|
||||||
| Кандидат | Оценка |
|
fglair-core/
|
||||||
|----------|--------|
|
include/fgl/ # публичные заголовки (C++20 + c_api.h extern "C")
|
||||||
| ESP-IDF `cJSON`/`esp_json` | DOM + malloc на узлы → конфликтует с no-heap; IDF-only |
|
src/ # ядро (портативное)
|
||||||
| ArduinoJson (есть в ESPHome) | v7 лишился zero-alloc-режима (StaticJsonDocument удалён); тянет зависимость ESPHome |
|
platform/posix/ # sockets/std::thread/timerfd/getrandom
|
||||||
| RapidJSON (SAX + custom allocator) | Подходит технически, но тяжеловат (~10k строк) для наших 5 форматов сообщений |
|
platform/esp-idf/ # lwip/esp_timer/esp_random (+ idf_component CMakeLists)
|
||||||
| **jsmn (MIT, 2 файла, ~300 строк)** | **Принято**: токеновый парсер без аллокаций, стандарт де-факто embedded, раньше входил в ESP-IDF. Вендорим в `third_party/jsmn/` — единый код для обеих платформ |
|
test/unit/ # KDF, envelope, json, property, cmdq (doctest/catch2)
|
||||||
|
test/integration/ # python mock-модуль (эталон probe_reference.py) + runner
|
||||||
|
tools/probe_reference.py# эталонный клиент, проверенный на приборе
|
||||||
|
tools/fglair-discover # CLI: облачный discovery -> печать/сохранение конфига
|
||||||
|
examples/cli/ # fglctl (linux): status/set/monitor
|
||||||
|
CMakeLists.txt # linux build + tests
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
Writer — собственный, с фиксированным буфером (~100 строк; наши данные не
|
Зависимости: mbedtls (IDF встроен; Linux — системный или FetchContent 3.x),
|
||||||
требуют сложного экранирования).
|
Python 3 (тесты/инструменты). Оценка ресурсов (ESP32, IDF): RAM < 20 КБ на
|
||||||
|
сессию, код ядра ~35–50 КБ + mbedtls.
|
||||||
|
|
||||||
**HTTP-сервер (входящие от модуля) и HTTP-клиент (local_reg):**
|
## 8. Провижининг (вне библиотеки)
|
||||||
| Кандидат | Оценка |
|
|
||||||
|----------|--------|
|
|
||||||
| ESP-IDF `esp_http_server` | Есть, но: IDF-only (нужна вторая реализация для POSIX); роутинг по IP клиента — хак (`httpd_req_to_sockfd` + getpeername); меньше контроля над keep-alive/RK-quirks |
|
|
||||||
| cpp-httplib (POSIX) | Удобен, но heap + вторая ветка кода; LGPL/MIT ок |
|
|
||||||
| mongoose / civetweb / libmicrohttpd | Лицензии/вес избыточны |
|
|
||||||
|
|
||||||
**Решение:** собственный мини-httpd + мини-httpc в `src/ayla` (~300+100
|
* `fglair-discover` (Python): вход e-mail/пароль/регион → облачные endpoints
|
||||||
строк) поверх BSD-сокетов — lwip на ESP32 и glibc дают идентичный API, одна
|
(PROTOCOL.md §7) → печать `dsn, ip, oem_model, lanip_key, lanip_key_id` и
|
||||||
реализация, полный контроль. `esp_http_server`/третьилицевые httpd НЕ
|
сохранение json-конфига (формат `config_kata.json`). Тот же код кладётся в
|
||||||
используем. HTTP-клиент (один POST/PUT с Content-Length) — тривиален.
|
HA-интеграцию (config flow) и используется standalone для ESPHome-пользователей.
|
||||||
|
* Рантайм-обновления ключа НЕТ. Несовпадение `key_id` = `key_error`,
|
||||||
|
лечение — редактирование конфига вручную (для HA — repair-флоу с повторным
|
||||||
|
облаком; для ESPHome — копирование ключа из диагностики HA или повторный
|
||||||
|
запуск CLI).
|
||||||
|
|
||||||
**Conan:** не нужен — единственная внешняя зависимость (jsmn) вендорится.
|
## 9. Тестирование
|
||||||
Если позже появятся POSIX-only зависимости (например, TLS для облачного
|
|
||||||
инструмента) — подключить conan только для POSIX-ветки.
|
|
||||||
|
|
||||||
## 8. Таблицы свойств и конверсии (src/aircon)
|
1. **Unit**: KDF-векторы; envelope roundtrip (оба варианта паддинга); CBC-
|
||||||
|
цепочка (3 сообщения подряд); JSON writer/reader fuzz; coalescing; таблицы.
|
||||||
|
2. **Mock-модуль** (`test/integration/mock_ac.py`, развитие probe_reference.py):
|
||||||
|
сценарии: обычная сессия; re-key по возрасту 44 с (ускоренный таймер);
|
||||||
|
503-слоты; «KE без poll»; потеря сообщения → 401 → восстановление на
|
||||||
|
следующем keep-alive; delete_session.
|
||||||
|
3. **On-device** (чек-лист, фактически повторяет проведённые пробы):
|
||||||
|
старт/активация ≤5 с; GET всех свойств; запись + оптимистичное обновление;
|
||||||
|
24 ч uptime с keep-alive 15 с (лог: re-key каждые 45–60 с, 0 потерь);
|
||||||
|
параллельно телефон; перезапуск прибора питанием.
|
||||||
|
4. CI: gcc+clang -Wall -Werror, asan/ubsan (unit+mock), idf-сборка esp32.
|
||||||
|
|
||||||
`constexpr`-массивы на шаблон: `{enum, имя, base_type, RO, диапазон raw,
|
## 10. Этапы (milestones)
|
||||||
конверсия по умолчанию, словари перечислений}`. Битовые декодеры
|
|
||||||
`op_status`/`device_capabilities` (PROTOCOL §8.4–8.5). Точка расширения —
|
|
||||||
`fgl_prop_override` (§4). Интроспекция `fgl_template_info_get` используется
|
|
||||||
HA-превью (PLAN_HOME_ASSISTANT §4) и тестами.
|
|
||||||
|
|
||||||
## 9. Тесты (разделение как у src)
|
|
||||||
|
|
||||||
* `tests/ayla/` — протокол: KDF-векторы; envelope roundtrip (оба паддинга);
|
|
||||||
CBC-цепочка (3 сообщения); мини-httpd/httpc (запросы модуля, keep-alive,
|
|
||||||
RST); очередь/coalescing/pacing; машина состояний с mock-модулем
|
|
||||||
(`tests/ayla/mock_ac.py` — развитие probe_reference.py): обычная сессия,
|
|
||||||
re-key по возрасту, 503-слоты, «KE без poll», потеря сообщения → 401 →
|
|
||||||
восстановление, delete_session.
|
|
||||||
* `tests/aircon/` — конверсии и шаблоны: все свойства всех шаблонов;
|
|
||||||
линейные/функциональные override; диапазоны; битмаски; согласованность
|
|
||||||
таблиц с PROTOCOL §8 (значения из APK).
|
|
||||||
* CI: gcc+clang `-Wall -Werror`, asan/ubsan, ctest; idf-сборка esp32.
|
|
||||||
|
|
||||||
## 10. Этапы
|
|
||||||
|
|
||||||
| # | Содержимое | Критерии приёмки |
|
| # | Содержимое | Критерии приёмки |
|
||||||
|---|------------|------------------|
|
|---|------------|------------------|
|
||||||
| M0 ✅ | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 |
|
| M0 | Каркас, платслой, логирование, CMake+IDF, CI | Собирается на linux и esp-idf; пустой HTTP-сервер отвечает 404 |
|
||||||
| M1 ✅ | `src/ayla`: crypto+envelope, мини-httpd/httpc, jsmn-вендор | Векторы зелёные; httpd-тесты; совместимость с probe_reference.py |
|
| M1 | Крипто: KDF + envelope + векторы | Векторы зелёные; совместимость с probe_reference.py |
|
||||||
| M2 ✅ | `src/ayla`: сессия (установка/активация/keep-alive/re-key/слоты/503/delete) с mock-модулем | Все сценарии mock; на приборе: активация ≤5 с; семантика re-key: при зазоре local_reg ≥ ~44–50 с (при честном keep-alive 15 с — 0 re-key за 100 с; при 50 с — 3 re-key) |
|
| M2 | Сессия с mock-модулем: local_reg→KE→активация (poll)→GET→push; keep-alive | Mock-сценарий «обычная сессия»; на приборе: активация ≤5 с, свойства читаются |
|
||||||
| M3 ✅ | `src/aircon`: шаблоны, конверсии+override, публичный API, batch | `tests/aircon` зелёные; на приборе: чтение всех свойств, batch=1 notify |
|
| M3 | Очередь с coalescing, 206/200, batch, записи+оптимистичный кэш | На приборе: batch из 5 команд = 1 local_reg notify; значения применяются |
|
||||||
| M4 ✅ | fglctl-пример, `tools/fglair-discover` (в т.ч. `--format esphome-secrets`), README библиотеки (сборка IDF/POSIX, тесты) | README готов; fglctl/дискавери проверены (мок-облако + прибор). 60-мин soak на приборе: 0 рассинхронов (docs/reports/M4_SOAK.md); полный 24-ч soak — приёмочный шаг перед релизом |
|
| M4 | re-key по возрасту, 401-обработка, 503/слоты, режим «KE без poll» (backoff), delete_session | Mock-сценарии + 2 ч на приборе без рассинхрона; stop() освобождает слот |
|
||||||
| M5 | **ВНЕ СКОУПА core-релиза** (по решению владельца): `FglHub` N устройств; mDNS-резолвер как опция host-разрешения | — |
|
| M5 | Таблицы A/B/F + конверсии; fglctl; fglair-discover; probe_reference.py в tools/ | 24 ч на приборе: 0 рассинхронов, re-key каждые 45–60 с |
|
||||||
|
| M6 | (Опционально) mDNS-обнаружение (запрос на :10276), FglHub на N устройств | Устройство найдено без статического IP |
|
||||||
|
|
||||||
README.md библиотеки (после M4, для людей): сборка в ESP-IDF (как
|
## 11. Риски и открытые вопросы
|
||||||
компонент), сборка POSIX (cmake), запуск тестов (ctest + mock), краткий
|
|
||||||
пример API. Максимально коротко.
|
|
||||||
|
|
||||||
## 11. Приёмка ESPHome↔HA (скрипт в `tests/acceptance/`)
|
* Режим «KE без poll» (PROTOCOL.md §4.4 п.6) — причина не идентифицирована;
|
||||||
|
стратегия (пауза 30–60 с) подобрана эмпирически; заложить телеметрию для
|
||||||
Полуавтоматизированный тест сквозной согласованности двух интеграций,
|
уточнения.
|
||||||
работающих с одним кондиционером (занимают оба слота модуля — скрипт сам
|
* `display_temperature` → °C: формула (v−5000)/100 c округлением к 0.25;
|
||||||
третью сессию НЕ открывает). Детали и авторизация — PLAN_ESPHOME §8 /
|
расхождение с таблицей приложения ≤ 0.25 °C.
|
||||||
PLAN_HOME_ASSISTANT §7 (HA long-lived access token + REST API — проверено,
|
* Порог re-key ≈44 с измерен в границах 39–44 с — для надёжности опираться
|
||||||
стандартный механизм; ESPHome — официальный `aioesphomeapi`).
|
не на точное значение, а на факт «KE может прийти с любым local_reg».
|
||||||
Режимы: `quick` (матрица изменений burst/не-burst, обе стороны, с
|
* В ESPHome/Arduino-сборках esp_http_server может быть занят портом 80 —
|
||||||
возвратом) и `--long` (24 ч, раз в час одно изменение с проверкой и
|
ядро использует собственный мини-httpd на lwip-сокетах, конфликтов нет.
|
||||||
возвратом; CSV-отчёт). Запускается вручную; входит в чек-лист релиза.
|
|
||||||
|
|
||||||
## 12. Риски
|
|
||||||
|
|
||||||
* Режим «KE без poll» (PROTOCOL §4.4 п.6) — причина не идентифицирована;
|
|
||||||
стратегия (пауза 30–60 с) эмпирическая; заложить телеметрию.
|
|
||||||
* Порог re-key ≈44 с (границы 39–44 с) — не опираться на точное значение.
|
|
||||||
* Конверсии через `int32_t num/den` — следить за переполнением (int64
|
|
||||||
промежуточно).
|
|
||||||
|
|||||||
@@ -1,235 +1,153 @@
|
|||||||
# План: компонент ESPHome `fglair` (components/fglair)
|
# План: внешний компонент ESPHome (`fglair`)
|
||||||
|
|
||||||
Аудитория — агенты-реализатели. Протокол — `docs/PROTOCOL.md`; ядро —
|
Аудитория — агенты-реализаторы. Протокол — `docs/PROTOCOL.md`; ядро —
|
||||||
`docs/PLAN_CORE_LIBRARY.md` (`fgl-aircon`, C++20, монорепо: библиотека в
|
`docs/PLAN_CORE_LIBRARY.md` (`fglair-core`, C++20). Компонент строится по
|
||||||
корне, компонент здесь). Требования к среде: **только ESP-IDF framework**
|
образцу штатных climate-модулей ESPHome (midea, hisense-ac, tuya), но протокол
|
||||||
(Arduino-фреймворк ESPHome не поддерживаем).
|
вынесен в переиспользуемое C++-ядро.
|
||||||
|
|
||||||
## 1. Структура
|
Требования к среде: **только ESP-IDF framework** (Arduino-фреймворк ESPHome
|
||||||
|
считается устаревшим и не поддерживается). Ядро — C++20 без исключений/RTTI,
|
||||||
|
что совместимо с дефолтными флагами сборки ESPHome для IDF.
|
||||||
|
|
||||||
|
## 1. Распределение кода
|
||||||
|
|
||||||
```
|
```
|
||||||
components/fglair/ # ESPHome external component
|
esphome-fglair/ (external component, установка через
|
||||||
__init__.py # FglairHub : Component — владеет FglSession
|
components/fglair/ external_components: - source: github://...)
|
||||||
|
__init__.py # FglairHub: Component; владеет FglSession ядра
|
||||||
climate.py # FglairClimate : climate::Climate
|
climate.py # FglairClimate : climate::Climate
|
||||||
sensor.py switch.py select.py binary_sensor.py
|
sensor.py # комнатная температура, error_code, op_status-флаги
|
||||||
config_validation.py const.py
|
switch.py # economy/powerful/coil_dry/min_heat/...
|
||||||
|
select.py # положения заслонок (v2)
|
||||||
|
binary_sensor.py # connectivity
|
||||||
|
config_validation.py
|
||||||
|
const.py
|
||||||
|
core/ # git-subtree/symlink fglair-core (src+include+platform/esp-idf)
|
||||||
|
CMakeLists.txt # STATIC LIBRARY; REQUIRES lwip esp_timer mbedtls
|
||||||
translations/
|
translations/
|
||||||
CMakeLists.txt # подключает библиотеку из корня репозитория
|
|
||||||
# (EXTRA_COMPONENT_DIRS / relative),
|
|
||||||
# REQUIRES lwip esp_timer mbedtls
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Установка пользователем:
|
Ядро компилируется как статическая библиотека через CMakeLists компонента;
|
||||||
```yaml
|
платформенный слой — `platform/esp-idf` (lwip-сокеты, esp_timer, esp_random).
|
||||||
external_components:
|
|
||||||
- source: github://<user>/aircon@main
|
|
||||||
components: [fglair]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. YAML-конфигурация
|
## 2. YAML-конфигурация
|
||||||
|
|
||||||
Базовый пример (такой же войдёт в README, с комментариями на английском;
|
|
||||||
реальные значения — в secrets, см. §5):
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
external_components:
|
external_components:
|
||||||
- source: github://<user>/aircon@main
|
- source: github://<user>/esphome-fglair@main
|
||||||
components: [fglair]
|
components: [fglair]
|
||||||
|
|
||||||
fglair:
|
fglair:
|
||||||
devices:
|
devices:
|
||||||
- id: ac_living
|
- id: ac_living
|
||||||
host: ac.local # DNS-имя (или IP); .local — mDNS :10276
|
ip_address: 192.168.88.3 # либо dsn + mdns: true (запрос на :10276)
|
||||||
dsn: !secret ac_dsn
|
dsn: AC000W002879281
|
||||||
lanip_key: !secret ac_lanip_key
|
lanip_key: 1uWOP3nGbSz2ysEfjJJVu+P0fxAQTg==
|
||||||
lanip_key_id: !secret ac_lanip_key_id
|
lanip_key_id: 62888
|
||||||
template: A # A | B | F
|
template: A # A|B|F
|
||||||
# keepalive: 15s # по умолчанию 15s
|
# port: 10275 # локальный порт сервера (default)
|
||||||
# port: 10275 # локальный порт сервера (по умолчанию)
|
# keepalive: 15s
|
||||||
|
|
||||||
climate:
|
climate:
|
||||||
- platform: fglair
|
- platform: fglair
|
||||||
device_id: ac_living
|
device_id: ac_living
|
||||||
name: "Living Room AC"
|
name: "Кондиционер"
|
||||||
|
# swing: both # off|vertical|horizontal|both
|
||||||
|
|
||||||
sensor:
|
sensor:
|
||||||
- platform: fglair
|
- platform: fglair
|
||||||
device_id: ac_living
|
device_id: ac_living
|
||||||
room_temperature: { name: "Room Temperature" }
|
room_temperature: {name: "Температура в комнате"}
|
||||||
error_code: { name: "AC Error Code" }
|
error_code: {name: "Код ошибки"}
|
||||||
|
|
||||||
switch:
|
switch:
|
||||||
- platform: fglair
|
- platform: fglair
|
||||||
device_id: ac_living
|
device_id: ac_living
|
||||||
economy: { name: "Economy" }
|
economy: {name: "Эко"}
|
||||||
powerful: { name: "Powerful" }
|
powerful: {name: "Мощный"}
|
||||||
coil_dry: { name: "Coil Dry" }
|
coil_dry: {name: "Осушка змеевика"}
|
||||||
min_heat: { name: "Minimum Heat" }
|
min_heat: {name: "Мин. обогрев"}
|
||||||
outdoor_low_noise:{ name: "Outdoor Low Noise" }
|
outdoor_low_noise: {name: "Тихий наружный блок"}
|
||||||
wifi_led: { name: "Wi-Fi LED" }
|
wifi_led: {name: "LED Wi-Fi"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.1. `host` вместо IP (требование)
|
**Откуда брать ключ**: у пользователя обычно уже есть HA-интеграция (или
|
||||||
|
запускается `fglair-discover` CLI из репозитория ядра). HA-диагностика
|
||||||
|
устройства показывает `lanip_key`/`lanip_key_id` — значения копируются в YAML
|
||||||
|
вручную. Ключ статичен (зашит в модуль), автоматическая синхронизация не
|
||||||
|
предусмотрена.
|
||||||
|
|
||||||
* Валидатор — `cv.string` (имя или IP). Разрешение выполняет ядро
|
**Поведение при смене ключа**: ядро отвечает 412 и переходит в `key_error`;
|
||||||
(`fgl_config.host`, PLAN_CORE §3): `getaddrinfo` (lwip DNS); для имён
|
компонент логирует ошибку с текстом «lanip_key устарел, обновите конфиг» и
|
||||||
`*.local` — Ayla-mDNS A-запрос на `224.0.0.251:10276` (модуль не отвечает
|
останавливает сессию (не долбит модуль). Обновление — правка YAML вручную.
|
||||||
на :5353 — проверено). Ретраи разрешения при потере связи, mDNS-кэш TTL.
|
|
||||||
* В YAML планах/примерах использовать `ac.local`-стиль имён, никаких
|
|
||||||
реальных IP.
|
|
||||||
|
|
||||||
### 2.2. Кастомная конверсия через лямбду
|
## 3. Компонент `fglair` (hub, `__init__.py`)
|
||||||
|
|
||||||
Переопределение конверсии свойства (вместо шаблонной) — передаётся в ядро
|
* `FglairHub : public Component` — на каждое `device` создаёт `FglSession`
|
||||||
как `fgl_conversion{custom_fn}` (PLAN_CORE §4):
|
(API ядра) при `setup()`; колбэки ядра приходят из его внутренней задачи —
|
||||||
|
мост в main-loop ESPHome через `Component::defer()`.
|
||||||
```yaml
|
* `dump_config()`: версия ядра, состояние, статистика (re-keys, команды,
|
||||||
fglair:
|
lost-push), измеренный возраст re-key.
|
||||||
devices:
|
* `loop()`: поллинг mailbox (транзакции из main-loop в ядро — тоже через
|
||||||
- id: ac_living
|
mailbox ядра, ядро однопоточное внутри).
|
||||||
host: ac.local
|
* Зависимости: `network`; старт сессии только после `network::is_connected()`;
|
||||||
# ...
|
при смене IP самой ESP ядро перерегистрируется само (local_reg с новым ip).
|
||||||
convert:
|
* Доступность: таймаут watchdog 60 с без push и без успешного local_reg →
|
||||||
- property: adjust_temperature
|
entities в NaN/unavailable; восстановление ядром (backoff) возвращает.
|
||||||
to_display: !lambda "return x * 0.1;" # raw -> display
|
|
||||||
from_input: !lambda "return (int32_t)(x * 10);" # display -> raw
|
|
||||||
# range: [16, 30]
|
|
||||||
```
|
|
||||||
|
|
||||||
ESPHome-лямбды компилируются в C++-функции и передаются в ядро напрямую
|
|
||||||
(capture недоступен — если нужен контекст, использовать глобальные
|
|
||||||
конфиг-переменные; задокументировать).
|
|
||||||
|
|
||||||
## 3. Компонент `fglair` (hub)
|
|
||||||
|
|
||||||
* `FglairHub : Component` — создаёт `FglSession` на каждое `device` в
|
|
||||||
`setup()` (после `network::is_connected()`); колбэки ядра приходят из его
|
|
||||||
задачи — мост в main-loop через `Component::defer()`.
|
|
||||||
* `dump_config()`: версия ядра, состояние, статистика (re-key счётчик,
|
|
||||||
команды, потерянные push), измеренный возраст re-key.
|
|
||||||
* Состояния ядра → диагностика: `online/recovering/offline/key_error`
|
|
||||||
(key_error логирует «lanip_key устарел, обновите secrets» и останавливает
|
|
||||||
сессию — обновление только правкой YAML, см. §5).
|
|
||||||
* Watchdog: 60 с без push и без успешного local_reg → entities NaN.
|
|
||||||
|
|
||||||
## 4. `climate.py`
|
## 4. `climate.py`
|
||||||
|
|
||||||
* `traits()`: modes OFF/COOL/HEAT/DRY/FAN_ONLY/AUTO (фильтр по
|
* `FglairClimate : public climate::Climate, public Component`:
|
||||||
|
* `traits()`: modes OFF/COOL/HEAT/DRY/FAN_ONLY/AUTO (фильтр по
|
||||||
`device_capabilities`); fan quiet/low/medium/high/auto; swing off/vertical/
|
`device_capabilities`); fan quiet/low/medium/high/auto; swing off/vertical/
|
||||||
horizontal/both; step 0.5 °C (шаблон B — 1.0 °C); min/max из шаблона/override.
|
horizontal/both; step 0.5 °C (шаблон B — 1.0 °C); min/max 16–30 °C;
|
||||||
* `control(const ClimateCall&)`: все изменения вызова — в ОДИН
|
* `control(const ClimateCall&)`: все изменения вызова — в ОДИН
|
||||||
`batch_begin()/batch_commit()`; OFF → `operation_mode=0`; turn_on → `=1`.
|
`batch_begin()/batch_commit()` ядра (один local_reg notify на действие);
|
||||||
* `current_temperature` ← `display_temperature`; значения из кэша ядра;
|
OFF → `operation_mode=0`; turn_on → `operation_mode=1`;
|
||||||
push-колбэк → `publish_state()`; записи — optimistic (эха нет,
|
* `current_temperature` ← `display_temperature`; остальные значения из кэша
|
||||||
PROTOCOL §5.3).
|
ядра; push-колбэк ядра → `publish_state()`;
|
||||||
* Presets: `ECO`/`BOOST` → economy_mode/powerful_mode.
|
* записи не эхируются (PROTOCOL.md §5.3) — optimistic update в кэше ядра,
|
||||||
* `sensor.py`: room temp (°C, точность 0.25), error_code, op_status-флаги.
|
state публикуется сразу;
|
||||||
* `switch.py`: bool-свойства. `select.py` (v2): положения заслонок.
|
* presets: `CLIMATE_PRESET_ECO` / `CLIMATE_PRESET_BOOST` → economy_mode /
|
||||||
`binary_sensor.py`: connectivity.
|
powerful_mode.
|
||||||
|
|
||||||
## 5. Откуда брать ключ (для README)
|
## 5. Остальные платформы
|
||||||
|
|
||||||
1. **Из Home Assistant** (если интеграция уже настроена): настройки
|
* `sensor.py`: `room_temperature` (°C, точность 0.25), `error_code`,
|
||||||
устройства → диагностика — там показаны `lanip_key`, `lanip_key_id`,
|
`op_status` (text-флаги defrost/oil_recovery/pump_down/maintenance/
|
||||||
`dsn`; скопировать в `secrets.yaml`.
|
check_operation/запреты — по битовой маске PROTOCOL.md §8.4).
|
||||||
2. **CLI-дискавери** (облако Ayla, без установки HA):
|
* `switch.py`: bool-свойства; `write_state` → `set_bool` ядра.
|
||||||
```bash
|
* `select.py` (v2): `af_vertical_direction`/`af_horizontal_direction`, N опций
|
||||||
# печатает блок для secrets.yaml
|
из `af_*_num_dir`.
|
||||||
python tools/fglair-discover --region eu --email <email> --format esphome-secrets
|
* `binary_sensor.py`: `connectivity` = ONLINE.
|
||||||
# ac_dsn: "AC000W00XXXXXXX"
|
|
||||||
# ac_lanip_key: "<base64>"
|
|
||||||
# ac_lanip_key_id: 62999
|
|
||||||
```
|
|
||||||
3. Существующий `config_*.json` от legacy-скрипта — поля переносятся в
|
|
||||||
secrets вручную.
|
|
||||||
Ключ статичен; при несовпадении `key_id` — правка secrets вручную.
|
|
||||||
|
|
||||||
## 6. README компонента (после реализации; для людей, коротко)
|
## 6. Ограничения и требования
|
||||||
|
|
||||||
Разделы:
|
* Платы: esp32/esp32s3/esp32c3 (lwip + ≥ 25–30 КБ свободной RAM под сессию
|
||||||
1. **Quick start** — минимальный YAML (§2) с комментариями на английском;
|
и буферы ядра; для c3 проверить стек задачи ядра).
|
||||||
все чувствительные значения через `!secret`.
|
* Порт 10275 (или настраиваемый) должен быть свободен; конфликт с `api`
|
||||||
2. **Where to get the key** — §5 (HA-диагностика / fglair-discover CLI /
|
(6053)/`ota` исключён.
|
||||||
legacy-конфиг).
|
* **Одно устройство на ESP** в v1 (мульти-устройства — M6 ядра/FglHub).
|
||||||
3. **Custom conversions** — пример с лямбдами (§2.2).
|
* Колбэки ядра — не в main-loop; мост через `defer()` обязателен.
|
||||||
4. **Advanced** — пример «с действиями и событиями»: переключение режимов по
|
* OTA-обновление ESPHome поверх живой сессии: `stop()` в `on_shutdown`-
|
||||||
внешнему триггеру + вывод данных на дисплей. Схема примера (LVGL-часть —
|
триггере не гарантируется — слот на модуле освободится сам по таймауту
|
||||||
с пропусками несущественных секций, помеченными `# ...`):
|
(< 2 мин); ядро переживает это штатно (проверено на приборе).
|
||||||
```yaml
|
|
||||||
# External trigger: switch the AC to powerful cool mode on demand
|
|
||||||
binary_sensor:
|
|
||||||
- platform: gpio
|
|
||||||
id: hot_day_trigger
|
|
||||||
on_press:
|
|
||||||
then:
|
|
||||||
- climate.control:
|
|
||||||
id: ac_living
|
|
||||||
hvac_mode: COOL
|
|
||||||
preset: BOOST
|
|
||||||
- logger.log: "Hot day: powerful cooling enabled"
|
|
||||||
|
|
||||||
schedule: # rotate operation modes by time of day
|
## 7. Тесты и приёмка
|
||||||
- platform: time
|
|
||||||
on_time:
|
|
||||||
- hours: 7
|
|
||||||
then:
|
|
||||||
- climate.control: { id: ac_living, hvac_mode: AUTO }
|
|
||||||
|
|
||||||
display: # LVGL dashboard (relevant fragments only)
|
1. CI: `esphome compile` для тестовых конфигов (esp32-idf, esp32s3-idf).
|
||||||
lvgl:
|
2. Mock-модуль ядра + локальная сборка компонента — smoke: старт, online,
|
||||||
# ... widget definitions omitted ...
|
одна запись, публикация state.
|
||||||
- label:
|
3. На приборе: чек-лист M5 ядра + OTA-ребут поверх сессии + 24 ч uptime
|
||||||
id: room_temp_label
|
под управлением из HA через native API.
|
||||||
text:
|
4. Приёмка: 24 ч без рассинхронов; параллельно телефон (2 слота); изменение
|
||||||
format: "%.1f°C"
|
с пульта отражается в HA < 1 с.
|
||||||
# bound via lambda to id(ac_living).current_temperature
|
|
||||||
- label:
|
|
||||||
id: mode_label
|
|
||||||
# ... omitted ...
|
|
||||||
# bound to id(ac_living).mode via lambda
|
|
||||||
script:
|
|
||||||
- id: push_mode_to_display
|
|
||||||
# called on climate state change (on_state trigger), omitted
|
|
||||||
```
|
|
||||||
5. **Troubleshooting** — 503 (оба слота заняты), key_error, «KE без
|
|
||||||
активации» → подождать/перезапустить.
|
|
||||||
|
|
||||||
## 7. Ограничения
|
## 8. Этапы
|
||||||
|
|
||||||
* esp32/esp32s3/esp32c3; ≥25–30 КБ свободной RAM; порт 10275 свободен.
|
|
||||||
* Одно устройство на ESP в v1 (FglHub — M5 ядра).
|
|
||||||
* OTA-ребут поверх живой сессии: слот освободится сам (<2 мин), ядро
|
|
||||||
переживает штатно (проверено).
|
|
||||||
|
|
||||||
## 8. Приёмка (полуавтоматическая, `tests/acceptance/`)
|
|
||||||
|
|
||||||
Топология: один кондиционер, HA-интеграция (на сервере HA) + ESPHome-устройство
|
|
||||||
(ESP32) — занимают оба слота модуля. Топология обязательна для приёмки и
|
|
||||||
заодно проверяет совместное владение.
|
|
||||||
|
|
||||||
Скрипт `tests/acceptance/test_esphome_ha.py` (python):
|
|
||||||
* **ESPHome-сторона**: официальный `aioesphomeapi` — подключение к устройству
|
|
||||||
по имени, `subscribe_states`, `climate_command(...)` для изменений.
|
|
||||||
* **HA-сторона**: **long-lived access token** (Создаётся пользователем:
|
|
||||||
Profile → Security → Long-lived access tokens) + REST API
|
|
||||||
(`/api/services/climate/set_*`, `/api/states/<entity_id>`) — стандартный,
|
|
||||||
документированный механизм, отдельной авторизации не требуется.
|
|
||||||
* **Режим quick (~5 мин)**: матрица {параметр: hvac_mode, target_temp,
|
|
||||||
fan_mode, swing} × {направление: HA→ESP, ESP→HA} × {одиночное изменение,
|
|
||||||
burst из 10}. Каждый шаг: изменение на стороне A → ожидание отражения на
|
|
||||||
стороне B (таймаут 10 с) → возврат → проверка возврата. Отдельно: после
|
|
||||||
burst — проверка согласованности финальных состояний и отсутствия ошибок
|
|
||||||
в логах обеих сторон.
|
|
||||||
* **Режим `--long` (24 ч)**: раз в час — одно псевдослучайное изменение
|
|
||||||
(ротация по списку параметров), проверка на другой стороне, возврат,
|
|
||||||
проверка. CSV-лог + итоговый отчёт (успехи/провалы/задержки).
|
|
||||||
* Запуск вручную; входит в релизный чек-лист компонента (E4).
|
|
||||||
|
|
||||||
## 9. Этапы
|
|
||||||
|
|
||||||
| # | Содержимое |
|
| # | Содержимое |
|
||||||
|---|-----------|
|
|---|-----------|
|
||||||
| E1 | каркас компонента, компиляция ядра (esp-idf), hub + connectivity |
|
| E1 | каркас external component, компиляция ядра (esp-idf), hub + connectivity |
|
||||||
| E2 | climate (mode/fan/temp/swing, batch, optimistic), host-резолвер |
|
| E2 | climate (mode/fan/temp/swing, batch-запись, optimistic state) |
|
||||||
| E3 | sensor/switch, capabilities, кастомные конверсии (лямбды), translations |
|
| E3 | sensor/switch, capabilities-фильтры, translations, README с YAML |
|
||||||
| E4 | README (§6), скрипт приёмки (§8), CI, релиз |
|
| E4 | select (заслонки), mdns-опция (:10276), CI, релиз |
|
||||||
|
|||||||
@@ -1,174 +1,146 @@
|
|||||||
# План: интеграция Home Assistant (`custom_components/fglair`)
|
# План: интеграция для Home Assistant (`fglair` custom component)
|
||||||
|
|
||||||
Аудитория — агенты-реализаторы. Протокол — `docs/PROTOCOL.md`; ядро —
|
Аудитория — агенты-реализаторы. Протокол — `docs/PROTOCOL.md`, библиотека —
|
||||||
`docs/PLAN_CORE_LIBRARY.md` (`fgl-aircon`, C++20, монорепо: библиотека в
|
`docs/PLAN_CORE_LIBRARY.md` (`fglair-core`, C++20).
|
||||||
корне, компонент HA здесь).
|
|
||||||
|
|
||||||
## 1. Архитектура
|
## 1. Архитектура
|
||||||
|
|
||||||
```
|
```
|
||||||
Home Assistant (custom component `fglair`)
|
Home Assistant (custom component `fglair`)
|
||||||
│ использует python-пакет pyfglair (cffi-bindings к libfgl-aircon.so)
|
│ использует python-пакет pyfglair (cffi-bindings к libfglair-core.so)
|
||||||
▼
|
▼
|
||||||
pyfglair (wheel: linux x86_64/aarch64; cffi; собирает библиотеку из корня
|
pyfglair (wheel: linux x86_64/aarch64; cffi; собирает fglair-core через cmake)
|
||||||
репозитория через cmake)
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
fgl-aircon (C++, корень монорепо) ←—— тот же код, что и на ESP32 (ESP-IDF)
|
fglair-core (C++) ←—— mock-тесты; тот же код, что и на ESP32 (ESP-IDF)
|
||||||
```
|
```
|
||||||
|
|
||||||
Компонент — тонкий: переводит API библиотеки в сущности HA. Вся протокольная
|
Компонент — тонкий: переводит API библиотеки в сущности HA. Вся протокольная
|
||||||
логика (сессия, шифрование, pacing, re-key, восстановление) — в C-ядре.
|
логика (сессия, шифрование, pacing, re-key, восстановление) — в C-ядре.
|
||||||
|
|
||||||
Обоснование: переиспользование библиотеки (требование владельца); cffi-wheel
|
Обоснование: пользователь требует переиспользования библиотеки из п.2;
|
||||||
— рабочий путь (HA-контейнеры x86_64/aarch64 debian; cibuildwheel).
|
cffi-wheel — рабочий путь (HA-контейнеры: x86_64/aarch64 debian; cibuildwheel
|
||||||
Fallback, если сборка wheel станет блокером: сборка .so при старте в docker
|
покрывает). Fallback (если сборка wheel станет блокером): libfglair собирается
|
||||||
(dev-режим). Чисто-Python повторная реализация протокола — запрещена.
|
в docker при старте интеграции один раз (dev-mode) — не рекомендуется для
|
||||||
|
продакшна; чисто-Python повторная реализация протокола — запрещена (дубль
|
||||||
|
логики, расползание с ESP32-веткой).
|
||||||
|
|
||||||
## 2. Состав
|
## 2. Состав репозиториев
|
||||||
|
|
||||||
1. **`pyfglair`** (python-пакет):
|
1. **`pyfglair`** (python-пакет):
|
||||||
* `pyfglair/_corebuild.py` — cmake-сборка при упаковке wheel;
|
* `pyfglair/_corebuild.py` — cmake-сборка при упаковке wheel;
|
||||||
* `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl-aircon/c_api.h`;
|
* `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl/c_api.h`
|
||||||
* `pyfglair/session.py` — обёртка `Session(cfg)`; колбэки ядра → asyncio
|
(extern "C"-шейм ядра);
|
||||||
через `loop.call_soon_threadsafe`. Транспорт событий — C-очередь
|
* `pyfglair/session.py` — pythonic-обёртка: `Session(cfg)`, колбэки ядра
|
||||||
(`fgl_session_wait_events`) и выделенный python-поток: прямые cffi-колбэки
|
мостятся в `asyncio` через `loop.call_soon_threadsafe`;
|
||||||
из потоков ядра небезопасны (у session-потока стек 8 КБ — CPython
|
* `pyfglair/provision.py` — облачный discovery (перенос `docs/legacy/aircon/discovery.py`
|
||||||
падает по stack overflow);
|
на современный aiohttp): вход e-mail/пароль/регион → dsn, ip, oem_model,
|
||||||
* `pyfglair/templates.py` — интроспекция шаблонов через cffi-вызовы
|
lanip_key, lanip_key_id;
|
||||||
`fgl_template_info_get` / `fgl_convert_to_display` (для превью в
|
* CLI: `python -m pyfglair discover` / `python -m pyfglair monitor`.
|
||||||
config flow; единый источник данных — C-таблицы, дублей нет);
|
2. **`ha-fglair`** (custom component, HACS-совместимый):
|
||||||
* `pyfglair/provision.py` — облачный discovery (перенос логики
|
* `custom_components/fglair/…` (см. §4);
|
||||||
`docs/legacy/aircon/discovery.py` на современный aiohttp): e-mail/
|
* `manifest.json`: `requirements: ["pyfglair>=1.0.0"]`, `domain: fglair`,
|
||||||
пароль/регион → dsn, host/ip, oem_model, lanip_key, lanip_key_id;
|
`config_flow: true`, `iot_class: local_push`.
|
||||||
* CLI: `python -m pyfglair discover` / `monitor` / `--format esphome-secrets`
|
|
||||||
(печать блока для secrets.yaml ESPHome).
|
|
||||||
2. **`custom_components/fglair/`**:
|
|
||||||
```
|
|
||||||
manifest.json # requirements: ["pyfglair>=1.0.0"], config_flow: true
|
|
||||||
config_flow.py # flow + options + repair
|
|
||||||
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
|
|
||||||
translations/{en,ru}.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Config flow
|
## 3. Конфигурация (config flow)
|
||||||
|
|
||||||
Шаг 1 — **подключение** (один из вариантов):
|
Вариант A — облачный (удобный):
|
||||||
* A (облачный): e-mail/пароль FGLair + регион → список устройств
|
1. Пользователь вводит e-mail/пароль FGLair + регион EU/US/CN.
|
||||||
(name, model, host) → выбор.
|
2. Интеграция через `pyfglair.provision` получает список устройств
|
||||||
* B (ручной): host/dsn/lanip_key/lanip_key_id по полям, либо импорт
|
(name, model, ip, dsn, lanip_key, lanip_key_id) и показывает их.
|
||||||
`config_*.json` (миграция с legacy).
|
3. Выбор устройства → `ConfigEntry` (те же поля, что `config_kata.json`)
|
||||||
|
→ шаг «пробное подключение» (старт сессии, ожидание ONLINE ≤ 10 с).
|
||||||
|
|
||||||
Шаг 2 — **пробное подключение**: старт сессии, ожидание ONLINE ≤10 с,
|
Вариант B — ручной: ввод ip/dsn/lanip_key/lanip_key_id по полям или импорт
|
||||||
чтение базовых свойств. Ошибка → назад с сообщением.
|
существующего `config_*.json` (миграция с legacy-скрипта).
|
||||||
|
|
||||||
Шаг 3 — **выбор шаблона С ПРЕВЬЮ РЕЗУЛЬТАТОВ** (требование): после выбора
|
Ключ считается **статичным** (см. PLAN_CORE_LIBRARY §8): облако используется
|
||||||
шаблона (обычно определён по oem_model автоматически) — форма-предпросмотр
|
только здесь, при настройке. В рантайме — никогда.
|
||||||
рассчитанных значений через `pyfglair.templates` (вызовы в C-ядро):
|
|
||||||
|
|
||||||
| Поле превью | Пример значения |
|
Repair-флоу (редкий, теоретический):
|
||||||
|---|---|
|
* Состояние ядра `key_error` (несовпадение `key_id`) → repair «Ключ устройства
|
||||||
| hvac-режимы | off, cool, dry, fan, heat, auto (operation_mode 0–6) |
|
изменён — перепровижинируйте». Повторный вход в облако по требованию
|
||||||
| fan-режимы | quiet/low/medium/high/auto (0–4) |
|
пользователя, обновление полей ConfigEntry. Никаких автоматических
|
||||||
| Диапазон уставки | 16.0–30.0 °C, шаг 0.5 (adjust_temperature raw 160–300, ×0.1) |
|
перечитываний облака.
|
||||||
| Текущая температура | display_temperature 7000 → 20.0 °C |
|
|
||||||
| Заслонки | vertical 0–4 (af_vertical_num_dir), horizontal 0–6 |
|
|
||||||
| Битмаска capabilities | heat, cool, economy, powerful, min_heat, swing … |
|
|
||||||
|
|
||||||
Пользователь оценивает корректность (сверяет с приложением FGLair) и
|
**Диагностика**: Device-страница HA показывает `lanip_key`, `lanip_key_id`,
|
||||||
подтверждает → создаётся `ConfigEntry`. При расхождении — возможность
|
`dsn`, ip — чтобы пользователь мог скопировать их в конфиг ESPHome (см.
|
||||||
выбрать другой шаблон или задать конверсию коэффициентами (linear:
|
PLAN_ESPHOME). В redacted-дамп lanip_key маскируется, в полном — виден.
|
||||||
num/den/offset) и диапазон вручную на этом же шаге.
|
|
||||||
|
|
||||||
Repair (теоретический): `key_error` → «Ключ устройства изменён —
|
## 4. Структура компонента
|
||||||
перепровижинируйте» (повторный облако-вход по требованию). Облако в рантайме
|
|
||||||
не используется, ключ статичен.
|
|
||||||
|
|
||||||
**Диагностика**: device-страница показывает `dsn`, `lanip_key_id`, `host` и
|
```
|
||||||
маску `lanip_key` (дамп прикладывают в issues — секретов в нём нет). Полный
|
custom_components/fglair/
|
||||||
ключ для secrets ESPHome берётся явным действием: reconfigure («Настроить
|
manifest.json
|
||||||
заново») на карточке интеграции или CLI `python -m pyfglair discover` на
|
config_flow.py # flow + options flow + repair
|
||||||
хосте HA.
|
const.py
|
||||||
|
fglair_client.py # FglairClient: владеет FglSession (фоновый поток)
|
||||||
|
coordinator.py # push-driven coordinator (polling-интервал только watchdog)
|
||||||
|
climate.py # ClimateEntity
|
||||||
|
sensor.py # комнатная температура, error_code, op_status-флаги
|
||||||
|
switch.py # economy, powerful, coil_dry, min_heat, outdoor_low_noise,
|
||||||
|
# human_det_auto_save, wifi_led, indoor_fan_control
|
||||||
|
select.py # af_vertical/horizontal_direction (если N>1), demand_control
|
||||||
|
binary_sensor.py # connectivity
|
||||||
|
diagnostics.py
|
||||||
|
translations/{en,ru,...}.json
|
||||||
|
```
|
||||||
|
|
||||||
## 4. Сущности
|
## 5. Маппинг сущностей (шаблон A; B/F — по capabilities)
|
||||||
|
|
||||||
Как раньше (climate: hvac/fan/swing/preset ECO-BOOST; current_temperature ←
|
**Climate**:
|
||||||
display_temperature; sensors: room temp, error_code, op_status-флаги;
|
* HVAC: `OFF`↔`operation_mode=0`; `COOL/HEAT/DRY/FAN_ONLY/AUTO`↔3/6/4/5/2;
|
||||||
switch: economy/powerful/coil_dry/min_heat/outdoor_low_noise/
|
`turn_on` → `operation_mode=1`.
|
||||||
human_det_auto_save/wifi_led/indoor_fan_control; select: заслонки,
|
* `target_temperature` ↔ `adjust_temperature` (×0.1 °C, шаг 0.5 °C);
|
||||||
demand_control; binary_sensor: connectivity). capabilities и выбранные в мастере
|
* `current_temperature` ← `display_temperature` ((v−5000)/100);
|
||||||
feature-тумблеры фильтруют сущности (`features.py`,
|
* `fan_mode` ↔ `fan_speed` (quiet/low/medium/high/auto);
|
||||||
`entry.data["features"]`): то, что прибор не умеет или отключено
|
* `swing_mode` ↔ `af_vertical_swing`+`af_horizontal_swing` (off/vertical/
|
||||||
пользователем, не создаётся. Записи — один `batch_commit()` на действие
|
horizontal/both);
|
||||||
пользователя. У сущностей есть атрибут `description` (HA не поддерживает
|
* presets: `ECO`→economy_mode, `BOOST`→powerful_mode (взаимоисключающие);
|
||||||
тултипы).
|
* capabilities из `device_capabilities` скрывают недоступные режимы.
|
||||||
Demand_control: семантика значений в PROTOCOL §8.3 не зафиксирована
|
|
||||||
(§10) — сущность не создаётся до уточнения; в H3 вместо неё
|
|
||||||
диагностический sensor состояния связи.
|
|
||||||
|
|
||||||
## 5. Runtime
|
**Sensors**: room temp, error_code (с текстами), op_status (флаги defrost /
|
||||||
|
oil recovery / pump down / maintenance / check operation / запреты).
|
||||||
|
**Binary sensor**: connectivity (ONLINE). **Switch/Select** — по списку §4.
|
||||||
|
|
||||||
Один `FglairClient` на `ConfigEntry` (daemon-thread, сессия ядра); колбэки →
|
Записи идут batch'ем на действие пользователя (одно `batch_commit()` на вызов
|
||||||
asyncio → push-coordinator. Состояния: `online` → available;
|
`set_temperature`/`set_hvac_mode`/…) — ядро само склеит в один local_reg notify.
|
||||||
`recovering` → доступны (последние значения) + diagnostic-сенсор;
|
|
||||||
`offline` → unavailable; `key_error` → unavailable + repair. Keep-alive 15 с
|
|
||||||
(самолечение десинхрона ≤15 с). Выгрузка: `stop()` (delete_session).
|
|
||||||
|
|
||||||
## 6. README компонента (после реализации; для людей, коротко)
|
## 6. Runtime-модель
|
||||||
|
|
||||||
Структура (`screenshots/step-N.png` — заглушки-плейсхолдеры, владелец заменит
|
* Один `FglairClient` на `ConfigEntry`: фоновый daemon-thread с сессией ядра;
|
||||||
реальными скриншотами; рядом с каждой — описание что должно быть видно):
|
колбэки → `asyncio` → coordinator push-update.
|
||||||
|
* Состояния ядра транслируются: `online` → entities available; `recovering` —
|
||||||
|
доступны (последние значения), помечено diagnostic-сенсором; `offline` →
|
||||||
|
unavailable; `key_error` → unavailable + repair.
|
||||||
|
* Keep-alive/rotация ключей/переподключение — полностью в ядре (15 с;
|
||||||
|
самолечение десинхрона ≤15 с — PROTOCOL.md §4.4).
|
||||||
|
* Выгрузка: `stop()` (delete_session, освобождает слот на модуле).
|
||||||
|
|
||||||
1. **Установка через HACS** (нужно публичное GitHub-зеркало — HACS не
|
## 7. Требования к надёжности (acceptance)
|
||||||
поддерживает GitLab/Gitea; либо ручное копирование `custom_components/`):
|
|
||||||
* HACS → ⋮ → Custom repositories → URL GitHub-зеркала, категория
|
|
||||||
Integration → Add. Скриншот: диалог добавления custom repository с
|
|
||||||
заполненным URL и выбранной категорией Integration.
|
|
||||||
* FGLair → Download → перезапуск HA. Скриншот: страница загрузки
|
|
||||||
интеграции с кнопкой Download (версия видна).
|
|
||||||
2. **Добавление устройства**: Settings → Devices & Services → Add
|
|
||||||
Integration → «FGLair». Скриншот: диалог поиска интеграции с введённым
|
|
||||||
«FGLair» и выделенным результатом.
|
|
||||||
3. **Вход в облако** (шаг 1A): e-mail/пароль/регион. Скриншот: форма с
|
|
||||||
заполненными регионом EU и e-mail (пароль скрыт).
|
|
||||||
4. **Выбор устройства**: список найденных кондиционеров. Скриншот: список
|
|
||||||
с одним устройством (имя, модель, host).
|
|
||||||
5. **Проверка шаблона с превью** (шаг 3): Скриншот: форма превью — таблица
|
|
||||||
рассчитанных значений (режимы, диапазон температур, пример конверсии
|
|
||||||
температуры), кнопки Confirm/Change template.
|
|
||||||
6. **Готово**: карточка устройства со списком сущностей. Скриншот: страница
|
|
||||||
устройства с созданными climate/sensor/switch сущностями.
|
|
||||||
7. **Где взять ключ для ESPHome**: диагностика устройства. Скриншот: страница
|
|
||||||
Diagnostics с полями dsn/lanip_key_id/host и маской lanip_key.
|
|
||||||
8. Troubleshooting: 503 (оба слота заняты — телефон+ESP?), key_error,
|
|
||||||
недоступность.
|
|
||||||
|
|
||||||
## 7. Приёмка (полуавтоматическая, `tests/acceptance/test_esphome_ha.py`)
|
1. 24 ч непрерывной работы: 0 рассинхронов; в логе ядра — штатные re-key
|
||||||
|
каждые 45–60 с; сущности обновляются < 1 с после изменений с пульта.
|
||||||
|
2. Параллельная работа с приложением на телефоне: обе сессии живут (2 слота);
|
||||||
|
без взаимных сбоев.
|
||||||
|
3. Вкл/выкл питания модуля: восстановление ≤ 120 с (backoff).
|
||||||
|
4. Burst 10 изменений уставки из UI: ≤ 2 local_reg notify.
|
||||||
|
5. `key_error` (симуляция смены lanip_key_id): repair отрабатывает.
|
||||||
|
6. Перезапуск HA: корректный delete_session, повторная регистрация.
|
||||||
|
|
||||||
Совместно с ESPHome-компонентом (топология и детали — PLAN_ESPHOME §8).
|
## 8. Тесты
|
||||||
Со стороны HA скрипт использует **long-lived access token** (профиль →
|
|
||||||
Security → Long-lived access tokens) и REST API: вызов сервисов
|
|
||||||
`/api/services/climate/set_temperature|set_hvac_mode|set_fan_mode|set_swing_mode`
|
|
||||||
и чтение `/api/states/<entity_id>`. Возможность подтверждена: это
|
|
||||||
стандартный документированный механизм HA 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.
|
|
||||||
|
|
||||||
## 8. Этапы
|
* `pytest` + mock `pyfglair` (fake session, scripted callbacks): flow, entities,
|
||||||
|
repair, unload.
|
||||||
|
* Интеграционный тест с mock-модулем (`test/integration/mock_ac.py` ядра) через
|
||||||
|
настоящий wheel: полный цикл в CI.
|
||||||
|
* Ручной чек-лист на реальном приборе (совпадает с M5 ядра).
|
||||||
|
|
||||||
|
## 9. Этапы
|
||||||
|
|
||||||
| # | Содержимое |
|
| # | Содержимое |
|
||||||
|---|-----------|
|
|---|-----------|
|
||||||
| H1 ✅ | wheel `pyfglair` (сборка из корня монорепо), cffi-обёртки, CLI discover/monitor |
|
| H1 | wheel `pyfglair`: сборка, cffi-bindings, обёртка Session, CLI discover/monitor |
|
||||||
| H2 ✅ | компонент: manifest, config flow (облако/ручной/импорт) + пробное подключение |
|
| H2 | компонент: manifest, config_flow (облако + ручной + импорт), client/coordinator, climate |
|
||||||
| H3 ✅ | шаг «превью шаблона» с ручными конверсиями; climate + сущности |
|
| H3 | sensor/switch/select/binary_sensor, capabilities, translations, диагностика с lanip_key |
|
||||||
| H4 ✅ | repair, диагностика (ключ для ESPHome), translations |
|
| H4 | repair-флоу, unload, тесты, публикация в HACS (custom) |
|
||||||
| H5 ✅ | README с HACS-инструкцией и заглушками скриншотов (§6), скрипт приёмки (§7), hacs.json; публикация `pyfglair` на PyPI (manylinux x86_64/aarch64) — релизное действие, чек-лист `docs/RELEASE_HA.md` |
|
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ SDK `com.aylanetworks.aylasdk`, см. `AylaLanModule.java`, `AylaEncryption.java
|
|||||||
`lan_ip` каждого устройства. Плюс `GET /apiv1/dsns/<DSN>/lan.json` отдаёт
|
`lan_ip` каждого устройства. Плюс `GET /apiv1/dsns/<DSN>/lan.json` отдаёт
|
||||||
`{ "lanip": { "lanip_key": ..., "lanip_key_id": ..., "keepAlive": ..., "autoSync": ... } }`. **[APK]**
|
`{ "lanip": { "lanip_key": ..., "lanip_key_id": ..., "keepAlive": ..., "autoSync": ... } }`. **[APK]**
|
||||||
2. **mDNS**: приложение опрашивает A-запись `<DSN>.local` (например
|
2. **mDNS**: приложение опрашивает A-запись `<DSN>.local` (например
|
||||||
`<DSN>.local`, например `AC000W00ABCD1234.local`), отправляя DNS-query на `224.0.0.251:5353` **и на
|
`AC000W002879281.local`), отправляя DNS-query на `224.0.0.251:5353` **и на
|
||||||
`224.0.0.251:10276`** (нестандартный порт Ayla). **[ПРОВЕРЕНО НА ПРИБОРЕ:
|
`224.0.0.251:10276`** (нестандартный порт Ayla). **[ПРОВЕРЕНО НА ПРИБОРЕ:
|
||||||
модуль отвечает ТОЛЬКО на :10276, на :5353 — нет.** A-ответ, TTL 10,
|
модуль отвечает ТОЛЬКО на :10276, на :5353 — нет.** A-ответ, TTL 10,
|
||||||
имя `<DSN>.local` → IP модуля. Проба: `tools/probe_mdns.py`.]
|
имя `AC000W002879281.local` → IP модуля. Проба: `tools/probe_mdns.py`.]
|
||||||
3. **Кэш** приложения хранит последние lan_ip/lanip_key. **[APK]**
|
3. **Кэш** приложения хранит последние lan_ip/lanip_key. **[APK]**
|
||||||
|
|
||||||
Для библиотеки минимумом является статическая конфигурация вида `config_kata.json`
|
Для библиотеки минимумом является статическая конфигурация вида `config_kata.json`
|
||||||
@@ -61,7 +61,7 @@ SDK `com.aylanetworks.aylasdk`, см. `AylaLanModule.java`, `AylaEncryption.java
|
|||||||
|
|
||||||
```
|
```
|
||||||
POST /local_lan/key_exchange.json
|
POST /local_lan/key_exchange.json
|
||||||
{"key_exchange":{"ver":1,"proto":1,"key_id":62999,"random_1":"<16 алфанум. симв.>","time_1":<int>,"sec":""}}
|
{"key_exchange":{"ver":1,"proto":1,"key_id":62888,"random_1":"<16 алфанум. симв.>","time_1":<int>,"sec":""}}
|
||||||
```
|
```
|
||||||
|
|
||||||
* `ver` и `proto` обязаны быть `1` (AES-256-CBC + HMAC-SHA256). Иначе — **426 Upgrade Required**. **[APK]**
|
* `ver` и `proto` обязаны быть `1` (AES-256-CBC + HMAC-SHA256). Иначе — **426 Upgrade Required**. **[APK]**
|
||||||
@@ -216,21 +216,15 @@ GET http://<ip приложения>:<порт>/local_lan/commands.json
|
|||||||
один «пустой» опрос `commands.json` — это признак принятой сессии.
|
один «пустой» опрос `commands.json` — это признак принятой сессии.
|
||||||
2. `local_reg` от endpoint'а с живой сессией **моложе ~40 с** → только
|
2. `local_reg` от endpoint'а с живой сессией **моложе ~40 с** → только
|
||||||
keep-alive, без key exchange.
|
keep-alive, без key exchange.
|
||||||
3. `local_reg` при **зазоре ≥ ~44–50 с** от предыдущего local_reg →
|
3. `local_reg` от endpoint'а с сессией **старше ~44 с** → модуль принудительно
|
||||||
модуль принудительно инициирует новый key exchange («вернувшееся»
|
инициирует новый key exchange (ротация сессионных ключей). Т.е. при штатном
|
||||||
приложение получает свежие ключи). При штатном keep-alive каждые 10–15 с
|
keep-alive каждые 10–15 с ключи ротируются примерно каждые 45–60 с.
|
||||||
re-key НЕ происходит — сессия живёт сколь угодно долго (проверено:
|
`time_1` модуля — тикающий счётчик с шагом ≈10 нс (аптайм); порог,
|
||||||
100 с при 15 с keep-alive — 0 re-key; 125 с при 50 с keep-alive — 3 re-key,
|
вероятно, 44 с в этих единицах либо просто 4.4e9 тиков.
|
||||||
оба без потерь). `time_1` модуля — тикающий счётчик с шагом ≈10 нс (аптайм);
|
|
||||||
порог, вероятно, 4.4e9 тиков (~44 с) от последнего local_reg.
|
|
||||||
4. **Ответы 401/400 на POST модуля игнорируются**: сессия продолжает работать,
|
4. **Ответы 401/400 на POST модуля игнорируются**: сессия продолжает работать,
|
||||||
re-key не вызывается. Восстановление после расхождения CBC-цепочек —
|
re-key не вызывается. Единственный механизм восстановления после расхождения
|
||||||
намеренная «тишина» приложения на > порога из п. 3 с последующим
|
CBC-цепочек — принудительный re-key по `local_reg` (п. 3). Поэтому интервал
|
||||||
`local_reg`: модуль сочтёт приложение вернувшимся и ре-кает. Т.е. стратегия
|
keep-alive = интервал потенциального «зависания» при десинхроне.
|
||||||
самовосстановления: при ошибке расшифровки — пауза keep-alive ~50–60 с,
|
|
||||||
затем возобновить (проверено на приборе). Отдельный случай — бракованная
|
|
||||||
подпись при живой цепочке (сообщение расшифровано, подпись не сошлась):
|
|
||||||
цепочка НЕ расходится, следующий push восстанавливает работу без re-key.
|
|
||||||
5. `delete_session` освобождает слот немедленно; следующий `local_reg` того же
|
5. `delete_session` освобождает слот немедленно; следующий `local_reg` того же
|
||||||
endpoint'а создаёт новую сессию.
|
endpoint'а создаёт новую сессию.
|
||||||
6. Наблюдавшийся (не воспроизведённый повторно) режим отказа: модуль отвечает
|
6. Наблюдавшийся (не воспроизведённый повторно) режим отказа: модуль отвечает
|
||||||
@@ -322,12 +316,11 @@ data: {"id":"<id команды>","ack_status":200,"ack_message":0,"dsn":"..."}
|
|||||||
|
|
||||||
* **Потеря CBC-цепочки** (§3.4): модуль не может расшифровать ответ приложения /
|
* **Потеря CBC-цепочки** (§3.4): модуль не может расшифровать ответ приложения /
|
||||||
приложение не может расшифровать push модуля. Ответы 401/400 на POST модуля
|
приложение не может расшифровать push модуля. Ответы 401/400 на POST модуля
|
||||||
**игнорируются** — модуль продолжает слать в «сломанный» канал. Восстановление:
|
**игнорируются** — модуль продолжает слать в «сломанный» канал. Восстановление
|
||||||
приложение замолкает на > ~44–50 с (порог «возврата» из п. 4.4.3) и шлёт
|
происходит только когда очередной `local_reg` (по возрасту ≥ ~44 с или от
|
||||||
`local_reg` — модуль переkey'ается. Реализация ядра: при ошибке расшифровки
|
нового endpoint'а) вызовет новый key exchange. Следствие: **интервал
|
||||||
пауза keep-alive ~50 с, затем возобновление. В legacy-скрипте пауза получалась
|
keep-alive = максимальное время «мёртвой» сессии при десинхроне**
|
||||||
«бесплатно» из-за keep-alive 1200 с: каждый цикл завершался re-key при
|
(10–15 с — незаметно; 1200 с как в legacy-скрипте — 20 минут глухоты).
|
||||||
возврате — потому рассинхрон «сам чинился» через ~20 минут.
|
|
||||||
* **Смена lanip_key** (`key_id` не совпал): теоретический путь по APK — 412 +
|
* **Смена lanip_key** (`key_id` не совпал): теоретический путь по APK — 412 +
|
||||||
`refreshLanConfig()` из облака. За 5 лет эксплуатации прибора ротации ключа
|
`refreshLanConfig()` из облака. За 5 лет эксплуатации прибора ротации ключа
|
||||||
не наблюдалось ни разу; ключ, по-видимому, зашит в модуль, облако лишь хранит
|
не наблюдалось ни разу; ключ, по-видимому, зашит в модуль, облако лишь хранит
|
||||||
|
|||||||
@@ -1,41 +1,20 @@
|
|||||||
# aircon / FGLair local control
|
# aircon / FGLair local control — документация
|
||||||
|
|
||||||
Реконструкция LAN-протокола FGLair (Fujitsu General, платформа Ayla) и планы
|
Реконструкция LAN-протокола FGLair (Fujitsu General, платформа Ayla) и планы
|
||||||
монорепозитория стека локального управления кондиционером: базовая C++
|
реализации стека локального управления кондиционером.
|
||||||
библиотека + интеграции ESPHome и Home Assistant.
|
|
||||||
|
|
||||||
## Состав репозитория (целевая структура)
|
## Состав
|
||||||
|
|
||||||
```
|
|
||||||
CMakeLists.txt # базовая библиотека (корень)
|
|
||||||
include/fgl-aircon/ # публичный API (уровень aircon: конверсии, шаблоны)
|
|
||||||
src/ayla/ # протокол Ayla LAN + главный цикл сообщений
|
|
||||||
platform/{posix,esp-idf}/ # платформенный слой
|
|
||||||
src/aircon/ # конверсии, шаблоны A/B/F, реализация API
|
|
||||||
third_party/jsmn/ # вендоренный JSON-парсер (MIT)
|
|
||||||
components/fglair/ # ESPHome external component
|
|
||||||
custom_components/fglair/ # Home Assistant custom component
|
|
||||||
tests/{ayla,aircon}/ # тесты протокола / конверсий и шаблонов
|
|
||||||
tests/acceptance/ # полуавтоматическая приёмка ESPHome<->HA
|
|
||||||
tools/ # probe_reference.py, probe_mdns.py, fglair-discover
|
|
||||||
docs/ # документация (ниже) + материалы анализа
|
|
||||||
```
|
|
||||||
|
|
||||||
## Документация
|
|
||||||
|
|
||||||
| Файл | Назначение |
|
| Файл | Назначение |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| `PROTOCOL.md` | Спецификация LAN-протокола: шифрование, эндпоинты, машина состояний, тайминги, свойства FGLair. Для людей и агентов. Факты помечены `[APK]` / `[LEGACY]` / `[ПРОВЕРЕНО НА ПРИБОРЕ]` / `[HYP]`. |
|
| `PROTOCOL.md` | Спецификация LAN-протокола: шифрование, эндпоинты, машина состояний, тайминги, свойства FGLair. Для людей и агентов. Факты помечены `[APK]` / `[LEGACY]` / `[ПРОВЕРЕНО НА ПРИБОРЕ]` / `[HYP]`. |
|
||||||
| `LEGACY_ANALYSIS.md` | Разбор legacy-скрипта: что верно, баги, причины «рассинхронизации ключей» и перегрузки модуля. |
|
| `LEGACY_ANALYSIS.md` | Разбор legacy-скрипта: что верно, баги, причины «рассинхронизации ключей» и перегрузки модуля. |
|
||||||
| `PLAN_CORE_LIBRARY.md` | План C++20-библиотеки `fgl-aircon` (Linux + ESP-IDF): слои ayla/aircon, конверсии, оценка httpd/json-библиотек, монорепо-структура. |
|
| `PLAN_CORE_LIBRARY.md` | План C++20-библиотеки `fglair-core` (Linux + ESP-IDF). |
|
||||||
| `PLAN_HOME_ASSISTANT.md` | План HA-интеграции: pyfglair (cffi wheel), config flow с превью шаблона, HACS-README со скриншотами. |
|
| `PLAN_HOME_ASSISTANT.md` | План HA-интеграции (`pyfglair` wheel + custom component). |
|
||||||
| `PLAN_ESPHOME.md` | План ESPHome-компонента: host+DNS, секреты, кастомные конверсии-лямбды, advanced-пример, приёмка. |
|
| `PLAN_ESPHOME.md` | План external component для ESPHome (только ESP-IDF framework). |
|
||||||
| `legacy/` | Изменённый legacy-скрипт (форк [gyro-labs/AirCon](https://github.com/gyro-labs/AirCon) / hisense_ac): `main.py` + пакет `aircon/`. Локальный `config_kata.json` не версионируется (содержит lanip_key). |
|
| `../tools/probe_mdns.py` | mDNS-проба (`<DSN>.local`, порт 10276). |
|
||||||
| `apk/` | Материалы анализа APK FGLair 3.4.3 (`manifest.json`; .apk-бинарники лежат локально, не версионируются). |
|
| `legacy/` | Изменённый legacy-скрипт (форк [gyro-labs/AirCon](https://github.com/gyro-labs/AirCon) / hisense_ac): `main.py` + пакет `aircon/` + рабочий конфиг `config_kata.json`. |
|
||||||
| `../README.md` | README библиотеки: сборка POSIX/ESP-IDF, тесты, fglctl, `tools/fglair-discover`. |
|
| `apk/` | Материалы анализа APK FGLair 3.4.3 (`manifest.json`; сами .apk лежат локально, не версионируются). |
|
||||||
| `../tools/fglair-discover` | Облачный provisioning (ключ устройства, `--format esphome-secrets`). |
|
|
||||||
| `../examples/cli/fglctl.cpp` | Пример CLI на публичном API. |
|
|
||||||
| `reports/M4_SOAK.md` | Отчёт приёмочного soak-прогона на приборе (60 мин, 0 рассинхронов). |
|
|
||||||
|
|
||||||
## Краткая выжимка протокола
|
## Краткая выжимка протокола
|
||||||
|
|
||||||
@@ -60,35 +39,21 @@ docs/ # документация (ниже) + матер
|
|||||||
|
|
||||||
## Ключевые решения (по уточнениям владельца)
|
## Ключевые решения (по уточнениям владельца)
|
||||||
|
|
||||||
* Монорепозиторий: библиотека в корне, `components/fglair` (ESPHome),
|
|
||||||
`custom_components/fglair` (HA); тесты зеркалят слои (`tests/ayla`,
|
|
||||||
`tests/aircon`); библиотека логически разделена на `src/ayla` (протокол)
|
|
||||||
и `src/aircon` (конверсии/шаблоны/API), публичный интерфейс —
|
|
||||||
`include/fgl-aircon/`.
|
|
||||||
* Конверсии свойств задаются шаблоном по умолчанию, коэффициентами
|
|
||||||
(linear) или функцией-указателем (ESPHome — лямбды, HA — коэффициенты
|
|
||||||
+ превью рассчитанных значений при настройке).
|
|
||||||
* `lanip_key` **статичен** (зашит в модуль; за 5 лет ротаций не было).
|
* `lanip_key` **статичен** (зашит в модуль; за 5 лет ротаций не было).
|
||||||
Облако — только provisioning (HA config flow, CLI `fglair-discover`);
|
Облако используется только для первового provisioning'а (HA config flow или
|
||||||
для ESPHome ключ копируется из диагностики HA или CLI. При несовпадении
|
CLI `fglair-discover`); при несовпадении `key_id` — устойчивая ошибка,
|
||||||
`key_id` — устойчивая ошибка, лечение правкой конфига вручную.
|
лечение правкой конфига вручную. Для ESPHome ключ копируется из диагностики
|
||||||
* ESPHome: везде ESP-IDF framework, подключение по `host` (DNS/mDNS), все
|
HA или получается CLI-той.
|
||||||
чувствительные значения — через `!secret`.
|
* ESP-IDF везде (Arduino-фреймворк ESPHome не поддерживаем), язык ядра — C++20
|
||||||
* HTTP/JSON: собственный мини-httpd/httpc на BSD-сокетах (одна реализация
|
(без исключений/RTTI/heap после init), public API — C++-классы + extern "C"
|
||||||
для lwip/posix) + вендоренный jsmn; esp_http_server/cJSON/ArduinoJson
|
шейм для cffi-bindings HA.
|
||||||
отвергнуты (обоснование — PLAN_CORE §7). Conan не нужен.
|
|
||||||
* Приёмка: полуавтоматический скрипт `tests/acceptance/` (HA long-lived
|
|
||||||
token + REST, ESPHome через aioesphomeapi; quick/burst и 24-часовой
|
|
||||||
режимы).
|
|
||||||
|
|
||||||
## Порядок реализации
|
## Порядок реализации
|
||||||
|
|
||||||
1. `fgl-aircon` (M0–M4) — ядро (ayla → aircon), mock-тесты; эталон
|
1. `fglair-core` (M0–M5) — ядро, mock-тесты, эталон уже проверен на приборе.
|
||||||
`tools/probe_reference.py` уже проверен на приборе.
|
2. `pyfglair` + HA-интеграция (H1–H4) — параллельно с E1–E2.
|
||||||
2. `pyfglair` + HA-интеграция (H1–H5) — параллельно с E1–E2.
|
|
||||||
3. ESPHome-компонент (E1–E4).
|
3. ESPHome-компонент (E1–E4).
|
||||||
4. Приёмочные прогоны (quick + 24 ч), README компонентов.
|
4. Уточнение оставшихся неизвестных (PROTOCOL.md §10) по мере эксплуатации.
|
||||||
5. Уточнение оставшихся неизвестных (PROTOCOL.md §10) по мере эксплуатации.
|
|
||||||
|
|
||||||
## Источники
|
## Источники
|
||||||
|
|
||||||
@@ -97,4 +62,5 @@ docs/ # документация (ниже) + матер
|
|||||||
`com.cafbit.netlib.dns.NetThread`, JS-бандл `assets/www/dist/build.js`.
|
`com.cafbit.netlib.dns.NetThread`, JS-бандл `assets/www/dist/build.js`.
|
||||||
* Legacy-скрипт (`legacy/`) — изменённый форк gyro-labs/AirCon (hisense_ac).
|
* Legacy-скрипт (`legacy/`) — изменённый форк gyro-labs/AirCon (hisense_ac).
|
||||||
* Живые эксперименты на AP-WC1E (сентябрь 2026): сессии, re-key, 401/400,
|
* Живые эксперименты на AP-WC1E (сентябрь 2026): сессии, re-key, 401/400,
|
||||||
слоты/503, delete_session, записи, mDNS. Рабочие артефакты — `tools/`.
|
слоты/503, delete_session, записи, mDNS. Пробы: `tools/probe_*.py`
|
||||||
|
(история — сессия анализа; рабочие артефакты оставлены в tools/).
|
||||||
|
|||||||
@@ -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 \
|
run \
|
||||||
--port 3355 \
|
--port 3355 \
|
||||||
--mqtt_host 192.168.88.1 \
|
--mqtt_host 192.168.88.1 \
|
||||||
--mqtt_user "${MQTT_USER:-user:password}" \
|
--mqtt_user "aircon:ic6ahrahbaijaVe3eTip" \
|
||||||
--local_ip 192.168.88.1 \
|
--local_ip 192.168.88.1 \
|
||||||
--config /opt/aircon/config_kata.json \
|
--config /opt/aircon/config_kata.json \
|
||||||
"$@"
|
"$@"
|
||||||
|
|||||||
@@ -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,7 +0,0 @@
|
|||||||
// Версия библиотеки fgl-aircon.
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#define FGL_AIRCON_VERSION_MAJOR 0
|
|
||||||
#define FGL_AIRCON_VERSION_MINOR 1
|
|
||||||
#define FGL_AIRCON_VERSION_PATCH 0
|
|
||||||
#define FGL_AIRCON_VERSION_STRING "0.1.0"
|
|
||||||
@@ -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
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# CI: сборка и тесты POSIX-платформы.
|
|
||||||
# ESP-IDF-сборка проверяется отдельно (см. README):
|
|
||||||
# . $IDF_PATH/export.sh && idf.py build (в тестовом проекте-компоненте)
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
BUILD_DIR="${BUILD_DIR:-build-ci}"
|
|
||||||
cmake -S . -B "$BUILD_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Release
|
|
||||||
|
|
||||||
cmake --build "$BUILD_DIR"
|
|
||||||
ctest --test-dir "$BUILD_DIR" --output-on-failure
|
|
||||||
|
|
||||||
# Санитайзеры (gcc): ловим OOB/UB в ядре и тестах.
|
|
||||||
SAN_DIR="${SAN_DIR:-build-san}"
|
|
||||||
cmake -S . -B "$SAN_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer"
|
|
||||||
cmake --build "$SAN_DIR"
|
|
||||||
ctest --test-dir "$SAN_DIR" --output-on-failure
|
|
||||||
|
|
||||||
# clang (если доступен): отдельный компилятор = отдельные диагностики.
|
|
||||||
if command -v clang++ >/dev/null 2>&1; then
|
|
||||||
CLANG_DIR="${CLANG_DIR:-build-clang}"
|
|
||||||
cmake -S . -B "$CLANG_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Release \
|
|
||||||
-DCMAKE_CXX_COMPILER=clang++
|
|
||||||
cmake --build "$CLANG_DIR"
|
|
||||||
ctest --test-dir "$CLANG_DIR" --output-on-failure
|
|
||||||
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,86 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# Регенерация tests/ayla/kdf_vectors.hpp. Эталон — APK AylaEncryption
|
|
||||||
# (совпадает с tools/probe_reference.py). Зависимость: pycryptodome.
|
|
||||||
# Запуск: python scripts/gen_kdf_vectors.py > tests/ayla/kdf_vectors.hpp
|
|
||||||
import base64, hmac, json, random, string
|
|
||||||
from Crypto.Cipher import AES
|
|
||||||
|
|
||||||
random.seed(20260922)
|
|
||||||
|
|
||||||
def rand_token(n=16, alpha=string.ascii_letters + string.digits):
|
|
||||||
return "".join(random.choice(alpha) for _ in range(n))
|
|
||||||
|
|
||||||
def cstr(s):
|
|
||||||
return json.dumps(s)
|
|
||||||
|
|
||||||
vectors = []
|
|
||||||
for i in range(4):
|
|
||||||
lanip_key = base64.b64encode(random.randbytes(15 + i)).decode()
|
|
||||||
rnd1 = rand_token(16, string.ascii_letters + string.digits + "+/")
|
|
||||||
rnd2 = rand_token(16)
|
|
||||||
t1 = random.randrange(10**12, 10**15)
|
|
||||||
t2 = random.randrange(10**12, 10**15)
|
|
||||||
k = lanip_key.encode(); b1, b2 = rnd1.encode(), rnd2.encode()
|
|
||||||
s1, s2 = str(t1).encode(), str(t2).encode()
|
|
||||||
|
|
||||||
def m(msg, suf):
|
|
||||||
msg = msg + bytes([suf])
|
|
||||||
return hmac.digest(k, hmac.digest(k, msg, "sha256") + msg, "sha256")
|
|
||||||
|
|
||||||
A, D = b1 + b2 + s1 + s2, b2 + b1 + s2 + s1
|
|
||||||
app_sign, dev_sign = m(A, 0x30), m(D, 0x30)
|
|
||||||
e = AES.new(m(A, 0x31), AES.MODE_CBC, m(A, 0x32)[:16])
|
|
||||||
msgs = [
|
|
||||||
'{"seq_no":0,"data":{}}',
|
|
||||||
'{"seq_no":1,"data":{"cmds":[{"cmd":{"method":"GET","resource":"property.json?name=fan_speed","uri":"/local_lan/property/datapoint.json","data":"","cmd_id":3}}]}}',
|
|
||||||
'{"seq_no":2,"data":{"properties":[{"property":{"base_type":"integer","name":"fan_speed","value":3,"id":"ab12cd34"}}]}}',
|
|
||||||
]
|
|
||||||
encs = []
|
|
||||||
for mtext in msgs:
|
|
||||||
raw = mtext.encode()
|
|
||||||
n = ((len(raw) + 1 + 15) // 16) * 16 # Java-паддинг (>=1 NUL)
|
|
||||||
encs.append(base64.b64encode(e.encrypt(raw.ljust(n, b"\x00"))).decode())
|
|
||||||
legacy_msg = '{"seq_no":3,"data":{"pad":"XX"}}' # 32 байта, без NUL
|
|
||||||
assert len(legacy_msg) % 16 == 0 and legacy_msg[-1] != "\x00"
|
|
||||||
encs.append(base64.b64encode(e.encrypt(legacy_msg.encode())).decode())
|
|
||||||
msgs.append(legacy_msg)
|
|
||||||
|
|
||||||
vectors.append(dict(
|
|
||||||
lanip_key=lanip_key, random_1=rnd1, random_2=rnd2,
|
|
||||||
time_1=t1, time_2=t2, plaintexts=msgs, enc_base64=encs,
|
|
||||||
app_sign_msg0=base64.b64encode(hmac.digest(app_sign, msgs[0].encode(), "sha256")).decode(),
|
|
||||||
dev_sign_msg1=base64.b64encode(hmac.digest(dev_sign, msgs[1].encode(), "sha256")).decode()))
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
lines.append("// СГЕНЕРИРОВАНО scripts/gen_kdf_vectors.py (эталон = APK AylaEncryption).")
|
|
||||||
lines.append("// Не редактировать руками; регенерация:")
|
|
||||||
lines.append("// python scripts/gen_kdf_vectors.py > tests/ayla/kdf_vectors.hpp")
|
|
||||||
lines.append("#pragma once")
|
|
||||||
lines.append("#include <cstddef>")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("namespace fgl::ayla::test {")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("struct KdfVector {")
|
|
||||||
lines.append(" const char* lanip_key;")
|
|
||||||
lines.append(" const char* random_1;")
|
|
||||||
lines.append(" const char* random_2;")
|
|
||||||
lines.append(" long long time_1;")
|
|
||||||
lines.append(" long long time_2;")
|
|
||||||
lines.append(" const char* plaintexts[4];")
|
|
||||||
lines.append(" const char* enc_base64[4]; // 0..2: Java-паддинг; 3: legacy без NUL")
|
|
||||||
lines.append(" const char* app_sign_msg0_b64;")
|
|
||||||
lines.append(" const char* dev_sign_msg1_b64;")
|
|
||||||
lines.append("};")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("inline constexpr KdfVector kKdfVectors[] = {")
|
|
||||||
for v in vectors:
|
|
||||||
pt = ", ".join(cstr(m) for m in v["plaintexts"])
|
|
||||||
en = ", ".join(cstr(e) for e in v["enc_base64"])
|
|
||||||
lines.append(f" {{{cstr(v['lanip_key'])}, {cstr(v['random_1'])}, {cstr(v['random_2'])}, "
|
|
||||||
f"{v['time_1']}LL, {v['time_2']}LL, {{{pt}}}, {{{en}}}, "
|
|
||||||
f"{cstr(v['app_sign_msg0'])}, {cstr(v['dev_sign_msg1'])}}},")
|
|
||||||
lines.append("};")
|
|
||||||
lines.append("inline constexpr size_t kKdfVectorCount = sizeof(kKdfVectors) / sizeof(kKdfVectors[0]);")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("} // namespace fgl::ayla::test")
|
|
||||||
print("\n".join(lines))
|
|
||||||
@@ -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
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
#include "ayla/crypto.hpp"
|
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
#include "mbedtls/platform_util.h"
|
|
||||||
|
|
||||||
namespace fgl::ayla {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
void hmac_sha256(const uint8_t* key, size_t key_len, const uint8_t* data,
|
|
||||||
size_t data_len, uint8_t out[kSha256Len]) {
|
|
||||||
const mbedtls_md_info_t* md = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
|
||||||
mbedtls_md_hmac(md, key, key_len, data, data_len, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool DirectionCrypto::derive(const uint8_t* key, size_t key_len,
|
|
||||||
const uint8_t* msg, size_t msg_len,
|
|
||||||
uint8_t out[kSha256Len]) {
|
|
||||||
// key = HMAC_k( HMAC_k(msg) || msg )
|
|
||||||
if (msg_len > sizeof(uint8_t) * (kSha256Len + 192) - kSha256Len) {
|
|
||||||
return false; // невозможно по протоколу (<=73 байт)
|
|
||||||
}
|
|
||||||
uint8_t inner[kSha256Len];
|
|
||||||
hmac_sha256(key, key_len, msg, msg_len, inner);
|
|
||||||
uint8_t buf[kSha256Len + 192];
|
|
||||||
memcpy(buf, inner, kSha256Len);
|
|
||||||
memcpy(buf + kSha256Len, msg, msg_len);
|
|
||||||
hmac_sha256(key, key_len, buf, kSha256Len + msg_len, out);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool DirectionCrypto::init(const char* lanip_key, const char* rnd_a,
|
|
||||||
const char* rnd_b, int64_t t_a, int64_t t_b) {
|
|
||||||
if (lanip_key == nullptr || rnd_a == nullptr || rnd_b == nullptr) return false;
|
|
||||||
initialized_ = false;
|
|
||||||
mode_ = 0;
|
|
||||||
// затираем предыдущий материал
|
|
||||||
mbedtls_platform_zeroize(sign_key_, sizeof(sign_key_));
|
|
||||||
mbedtls_platform_zeroize(aes_key_, sizeof(aes_key_));
|
|
||||||
mbedtls_platform_zeroize(chain_iv_, sizeof(chain_iv_));
|
|
||||||
|
|
||||||
uint8_t msg[128];
|
|
||||||
size_t off = 0;
|
|
||||||
auto append = [&](const char* s) {
|
|
||||||
size_t n = strlen(s);
|
|
||||||
if (off + n > sizeof(msg)) return false;
|
|
||||||
memcpy(msg + off, s, n);
|
|
||||||
off += n;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
char t_a_buf[24], t_b_buf[24];
|
|
||||||
snprintf(t_a_buf, sizeof(t_a_buf), "%lld", static_cast<long long>(t_a));
|
|
||||||
snprintf(t_b_buf, sizeof(t_b_buf), "%lld", static_cast<long long>(t_b));
|
|
||||||
if (!append(rnd_a) || !append(rnd_b) || !append(t_a_buf) || !append(t_b_buf) ||
|
|
||||||
off + 1 > sizeof(msg)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uint8_t* k = reinterpret_cast<const uint8_t*>(lanip_key);
|
|
||||||
size_t klen = strlen(lanip_key);
|
|
||||||
|
|
||||||
uint8_t block[kSha256Len];
|
|
||||||
msg[off] = 0x30; // sign key
|
|
||||||
if (!derive(k, klen, msg, off + 1, block)) return false;
|
|
||||||
memcpy(sign_key_, block, kHmacKeyLen);
|
|
||||||
|
|
||||||
msg[off] = 0x31; // aes key
|
|
||||||
if (!derive(k, klen, msg, off + 1, block)) return false;
|
|
||||||
memcpy(aes_key_, block, kAesKeyLen);
|
|
||||||
|
|
||||||
msg[off] = 0x32; // iv seed (первые 16 байт)
|
|
||||||
if (!derive(k, klen, msg, off + 1, block)) return false;
|
|
||||||
memcpy(chain_iv_, block, kAesBlockLen);
|
|
||||||
|
|
||||||
mbedtls_aes_init(&aes_enc_);
|
|
||||||
mbedtls_aes_init(&aes_dec_);
|
|
||||||
if (mbedtls_aes_setkey_enc(&aes_enc_, aes_key_, 256) != 0) return false;
|
|
||||||
if (mbedtls_aes_setkey_dec(&aes_dec_, aes_key_, 256) != 0) return false;
|
|
||||||
|
|
||||||
initialized_ = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
long DirectionCrypto::encrypt(uint8_t* out, size_t out_cap, const uint8_t* in,
|
|
||||||
size_t in_len) {
|
|
||||||
if (!initialized_) return -1;
|
|
||||||
if (mode_ == 0) mode_ = 1;
|
|
||||||
if (mode_ != 1) return -1; // направление уже в режиме расшифровки
|
|
||||||
// Java-паддинг: >=1 NUL, кратно 16.
|
|
||||||
size_t padded = ((in_len + 1 + kAesBlockLen - 1) / kAesBlockLen) * kAesBlockLen;
|
|
||||||
if (padded > out_cap) return -1;
|
|
||||||
uint8_t buf[kAesBlockLen * 128]; // до 2048 байт (kHttpdMaxBody)
|
|
||||||
if (padded > sizeof(buf)) return -1;
|
|
||||||
memcpy(buf, in, in_len);
|
|
||||||
memset(buf + in_len, 0, padded - in_len);
|
|
||||||
// mbedtls_aes_crypt_cbc использует и обновляет chain_iv_ на месте —
|
|
||||||
// это и есть состояние цепочки.
|
|
||||||
if (mbedtls_aes_crypt_cbc(&aes_enc_, MBEDTLS_AES_ENCRYPT, padded, chain_iv_,
|
|
||||||
buf, out) != 0) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return static_cast<long>(padded);
|
|
||||||
}
|
|
||||||
|
|
||||||
long DirectionCrypto::decrypt(uint8_t* out, size_t out_cap, const uint8_t* in,
|
|
||||||
size_t in_len) {
|
|
||||||
if (!initialized_) return -1;
|
|
||||||
if (mode_ == 0) mode_ = 2;
|
|
||||||
if (mode_ != 2) return -1; // направление уже в режиме шифрования
|
|
||||||
if (in_len == 0 || in_len % kAesBlockLen != 0 || in_len > out_cap) return -1;
|
|
||||||
if (mbedtls_aes_crypt_cbc(&aes_dec_, MBEDTLS_AES_DECRYPT, in_len, chain_iv_,
|
|
||||||
in, out) != 0) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
// Снять завершающие NUL (по крайней мере один в Java-паддинге, но
|
|
||||||
// принимаем и строго-кратные длины без NUL).
|
|
||||||
size_t len = in_len;
|
|
||||||
while (len > 0 && out[len - 1] == 0) len--;
|
|
||||||
return static_cast<long>(len);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DirectionCrypto::sign(uint8_t out[kSha256Len], const uint8_t* data,
|
|
||||||
size_t len) const {
|
|
||||||
hmac_sha256(sign_key_, kHmacKeyLen, data, len, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool SessionCrypto::init(const char* lanip_key, const char* random_1,
|
|
||||||
const char* random_2, int64_t time_1, int64_t time_2) {
|
|
||||||
return app.init(lanip_key, random_1, random_2, time_1, time_2) &&
|
|
||||||
dev.init(lanip_key, random_2, random_1, time_2, time_1);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgl::ayla
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Сессионная криптография Ayla LAN (docs/PROTOCOL.md §3).
|
|
||||||
// KDF: key = HMAC-SHA256_k(HMAC-SHA256_k(msg) || msg), msg = rnd_a|rnd_b|t_a|t_b|X,
|
|
||||||
// X: 0x30 sign / 0x31 crypto / 0x32 iv(первые 16 байт).
|
|
||||||
// Направления: app (rnd1,rnd2,t1,t2) — исходящие; dev (rnd2,rnd1,t2,t1) — входящие.
|
|
||||||
// ВАЖНО: AES-CBC-цепочка непрерывна в рамках сессии (каждое сообщение
|
|
||||||
// продолжает цепочку с последнего шифроблока предыдущего).
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
#include "mbedtls/aes.h"
|
|
||||||
#include "mbedtls/md.h"
|
|
||||||
|
|
||||||
namespace fgl::ayla {
|
|
||||||
|
|
||||||
constexpr size_t kSha256Len = 32;
|
|
||||||
constexpr size_t kAesKeyLen = 32; // AES-256
|
|
||||||
constexpr size_t kAesBlockLen = 16;
|
|
||||||
constexpr size_t kHmacKeyLen = 32;
|
|
||||||
|
|
||||||
class DirectionCrypto {
|
|
||||||
public:
|
|
||||||
// Выводит ключи направления. rnd_a/rnd_b — 16-символьные токены,
|
|
||||||
// t_a/t_b — 64-битные «времена» (материал KDF, значение произвольно).
|
|
||||||
// app: (random_1, random_2, time_1, time_2); dev: (random_2, random_1, time_2, time_1).
|
|
||||||
bool init(const char* lanip_key, const char* rnd_a, const char* rnd_b,
|
|
||||||
int64_t t_a, int64_t t_b);
|
|
||||||
|
|
||||||
// Шифрует in (любая длина) с zero-паддингом (>=1 NUL, кратно 16).
|
|
||||||
// out_cap должен быть >= padded_len = ((in_len+1)/16+1)*16.
|
|
||||||
// Возвращает длину шифротекста или -1. Продолжает CBC-цепочку.
|
|
||||||
// ВАЖНО: у направления ОДНА цепочка; первый вызов фиксирует режим
|
|
||||||
// (шифрование либо расшифровка) — смешение режимов возвращает -1.
|
|
||||||
long encrypt(uint8_t* out, size_t out_cap, const uint8_t* in, size_t in_len);
|
|
||||||
|
|
||||||
// Расшифровывает in (длина кратна 16), снимает завершающие NUL.
|
|
||||||
// Возвращает длину открытого текста или -1. Продолжает CBC-цепочку
|
|
||||||
// (даже если далее подпись не сойдётся — состояние обязано двигаться).
|
|
||||||
long decrypt(uint8_t* out, size_t out_cap, const uint8_t* in, size_t in_len);
|
|
||||||
|
|
||||||
// HMAC-SHA256(sign_key, data) — подпись/проверка сообщений направления.
|
|
||||||
void sign(uint8_t out[kSha256Len], const uint8_t* data, size_t len) const;
|
|
||||||
|
|
||||||
bool initialized() const { return initialized_; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
static bool derive(const uint8_t* key, size_t key_len, const uint8_t* msg,
|
|
||||||
size_t msg_len, uint8_t out[kSha256Len]);
|
|
||||||
|
|
||||||
bool initialized_ = false;
|
|
||||||
uint8_t mode_ = 0; // 0 не зафиксирован, 1 шифрование, 2 расшифровка
|
|
||||||
uint8_t sign_key_[kHmacKeyLen];
|
|
||||||
uint8_t aes_key_[kAesKeyLen];
|
|
||||||
uint8_t chain_iv_[kAesBlockLen]; // текущее состояние CBC-цепочки
|
|
||||||
mbedtls_aes_context aes_enc_;
|
|
||||||
mbedtls_aes_context aes_dec_;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Пара направлений одной сессии.
|
|
||||||
struct SessionCrypto {
|
|
||||||
DirectionCrypto app; // приложение -> модуль
|
|
||||||
DirectionCrypto dev; // модуль -> приложение
|
|
||||||
|
|
||||||
// lanip_key — строка base64 КАК ЕСТЬ (utf-8 байты, не декодировать).
|
|
||||||
bool init(const char* lanip_key, const char* random_1, const char* random_2,
|
|
||||||
int64_t time_1, int64_t time_2);
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace fgl::ayla
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
#include "ayla/envelope.hpp"
|
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
#include "mbedtls/base64.h"
|
|
||||||
|
|
||||||
|
|
||||||
namespace fgl::ayla {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
bool b64_encode(const uint8_t* in, size_t in_len, char* out, size_t out_cap) {
|
|
||||||
size_t olen = 0;
|
|
||||||
if (mbedtls_base64_encode(reinterpret_cast<unsigned char*>(out), out_cap,
|
|
||||||
&olen, in, in_len) != 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true; // mbedtls пишет NUL-терминатор
|
|
||||||
}
|
|
||||||
|
|
||||||
bool b64_decode(const char* in, uint8_t* out, size_t out_cap, size_t* out_len) {
|
|
||||||
size_t olen = 0;
|
|
||||||
if (mbedtls_base64_decode(out, out_cap, &olen,
|
|
||||||
reinterpret_cast<const unsigned char*>(in),
|
|
||||||
strlen(in)) != 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
*out_len = olen;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Извлекает seq_no верхнего уровня из '{"seq_no":N,"data":...}'.
|
|
||||||
// Ручной depth-1 сканер (без ограничений на размер data; вход уже
|
|
||||||
// проверен подписью). Понимает пробелы между лексемами.
|
|
||||||
bool extract_seq_no(const char* json, int64_t* seq_no) {
|
|
||||||
const char* p = json;
|
|
||||||
while (*p == ' ' || *p == '\t') p++;
|
|
||||||
if (*p != '{') return false;
|
|
||||||
p++;
|
|
||||||
for (;;) {
|
|
||||||
while (*p == ' ' || *p == ',' || *p == '\t') p++;
|
|
||||||
if (*p == '}' || *p == '\0') return false;
|
|
||||||
// ключ
|
|
||||||
if (*p != '"') return false;
|
|
||||||
const char* k = ++p;
|
|
||||||
while (*p != '"' && *p != '\0') p++;
|
|
||||||
if (*p != '"') return false;
|
|
||||||
size_t klen = static_cast<size_t>(p - k);
|
|
||||||
p++;
|
|
||||||
while (*p == ' ' || *p == '\t') p++;
|
|
||||||
if (*p != ':') return false;
|
|
||||||
p++;
|
|
||||||
while (*p == ' ' || *p == '\t') p++;
|
|
||||||
if (klen == 6 && strncmp(k, "seq_no", 6) == 0) {
|
|
||||||
if (*p == '-' || (*p >= '0' && *p <= '9')) {
|
|
||||||
*seq_no = strtoll(p, nullptr, 10);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// пропустить значение другого ключа
|
|
||||||
if (*p == '{' || *p == '[') {
|
|
||||||
int depth = 0;
|
|
||||||
bool in_str = false;
|
|
||||||
for (; *p != '\0'; p++) {
|
|
||||||
if (in_str) {
|
|
||||||
if (*p == '\\') {
|
|
||||||
p++;
|
|
||||||
if (*p == '\0') return false; // обрыв после escape
|
|
||||||
} else if (*p == '"') {
|
|
||||||
in_str = false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (*p == '"') {
|
|
||||||
in_str = true;
|
|
||||||
} else if (*p == '{' || *p == '[') {
|
|
||||||
depth++;
|
|
||||||
} else if (*p == '}' || *p == ']') {
|
|
||||||
depth--;
|
|
||||||
if (depth == 0) {
|
|
||||||
p++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (*p == '\0') return false;
|
|
||||||
} else if (*p == '"') {
|
|
||||||
p++;
|
|
||||||
while (*p != '"' && *p != '\0') {
|
|
||||||
if (*p == '\\') {
|
|
||||||
p++;
|
|
||||||
if (*p == '\0') return false; // обрыв после escape
|
|
||||||
}
|
|
||||||
p++;
|
|
||||||
}
|
|
||||||
if (*p != '"') return false;
|
|
||||||
p++;
|
|
||||||
} else {
|
|
||||||
while (*p != ',' && *p != '}' && *p != '\0') p++;
|
|
||||||
if (*p == '\0') return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сравнение за константное время (аналог mbedtls_ct_memcmp, без зависимости
|
|
||||||
// от версии библиотеки).
|
|
||||||
int ct_memcmp(const uint8_t* a, const uint8_t* b, size_t n) {
|
|
||||||
uint8_t diff = 0;
|
|
||||||
for (size_t i = 0; i < n; i++) diff |= static_cast<uint8_t>(a[i] ^ b[i]);
|
|
||||||
return diff != 0 ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool envelope_pack(DirectionCrypto& dir, int64_t seq_no,
|
|
||||||
const char* data_json, char* out, size_t out_cap) {
|
|
||||||
if (data_json == nullptr || out == nullptr) return false;
|
|
||||||
|
|
||||||
// Открытый текст: {"seq_no":N,"data":<data_json>}
|
|
||||||
char plain[kEnvelopeMaxPlain];
|
|
||||||
int n = snprintf(plain, sizeof(plain), "{\"seq_no\":%lld,\"data\":%s}",
|
|
||||||
static_cast<long long>(seq_no), data_json);
|
|
||||||
if (n <= 0 || static_cast<size_t>(n) >= sizeof(plain)) return false;
|
|
||||||
size_t plain_len = static_cast<size_t>(n);
|
|
||||||
|
|
||||||
// Подпись — по байтам открытого текста без паддинга.
|
|
||||||
uint8_t mac[kSha256Len];
|
|
||||||
dir.sign(mac, reinterpret_cast<const uint8_t*>(plain), plain_len);
|
|
||||||
char sign_b64[64];
|
|
||||||
if (!b64_encode(mac, sizeof(mac), sign_b64, sizeof(sign_b64))) return false;
|
|
||||||
|
|
||||||
// Шифрование с zero-паддингом (>=1 NUL, кратно 16).
|
|
||||||
uint8_t ct[kEnvelopeMaxPlain + kAesBlockLen];
|
|
||||||
long ct_len = dir.encrypt(ct, sizeof(ct),
|
|
||||||
reinterpret_cast<const uint8_t*>(plain), plain_len);
|
|
||||||
if (ct_len < 0) return false;
|
|
||||||
char enc_b64[kEnvelopeMaxB64];
|
|
||||||
if (!b64_encode(ct, static_cast<size_t>(ct_len), enc_b64, sizeof(enc_b64))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int m = snprintf(out, out_cap, "{\"enc\":\"%s\",\"sign\":\"%s\"}", enc_b64,
|
|
||||||
sign_b64);
|
|
||||||
return m > 0 && static_cast<size_t>(m) < out_cap;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool envelope_unpack(DirectionCrypto& dir, const char* enc_b64,
|
|
||||||
const char* sign_b64, char* json_out, size_t out_cap,
|
|
||||||
int64_t* seq_no) {
|
|
||||||
if (enc_b64 == nullptr || sign_b64 == nullptr || json_out == nullptr) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
uint8_t ct[kEnvelopeMaxPlain + kAesBlockLen];
|
|
||||||
size_t ct_len = 0;
|
|
||||||
if (!b64_decode(enc_b64, ct, sizeof(ct), &ct_len)) return false;
|
|
||||||
|
|
||||||
// Расшифровка: CBC-цепочка двигается независимо от исхода проверки подписи.
|
|
||||||
uint8_t plain[kEnvelopeMaxPlain + kAesBlockLen];
|
|
||||||
long plain_len = dir.decrypt(plain, sizeof(plain), ct, ct_len);
|
|
||||||
if (plain_len < 0 || static_cast<size_t>(plain_len) >= out_cap) return false;
|
|
||||||
|
|
||||||
// Проверка подписи по расшифрованным байтам (без паддинга);
|
|
||||||
// сравнение декодированных байт — константное время.
|
|
||||||
uint8_t mac[kSha256Len];
|
|
||||||
dir.sign(mac, plain, static_cast<size_t>(plain_len));
|
|
||||||
uint8_t given[kSha256Len];
|
|
||||||
size_t given_len = 0;
|
|
||||||
if (!b64_decode(sign_b64, given, sizeof(given), &given_len)) return false;
|
|
||||||
if (given_len != kSha256Len) return false;
|
|
||||||
if (ct_memcmp(mac, given, kSha256Len) != 0) return false;
|
|
||||||
|
|
||||||
memcpy(json_out, plain, static_cast<size_t>(plain_len));
|
|
||||||
json_out[plain_len] = '\0';
|
|
||||||
return extract_seq_no(json_out, seq_no);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgl::ayla
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// Защищённый конверт Ayla LAN: {"enc":"<b64 AES-CBC>","sign":"<b64 HMAC>"},
|
|
||||||
// открытый текст: {"seq_no":N,"data":<json-объект>} (docs/PROTOCOL.md §3.3).
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
#include "ayla/crypto.hpp"
|
|
||||||
|
|
||||||
namespace fgl::ayla {
|
|
||||||
|
|
||||||
constexpr size_t kEnvelopeMaxPlain = 1024; // открытый текст конверта
|
|
||||||
constexpr size_t kEnvelopeMaxB64 = 1800; // base64 шифротекста с запасом
|
|
||||||
|
|
||||||
// Собирает открытый текст '{"seq_no":<seq>,"data":<data_json>}' и упаковывает
|
|
||||||
// в конверт (envelope). dir — направление ОТПРАВИТЕЛЯ (app); состояние
|
|
||||||
// CBC-цепочки направления ДВИГАЕТСЯ. out — строка NUL-terminated.
|
|
||||||
// data_json — готовый JSON-объект (обычно построен json::Writer).
|
|
||||||
bool envelope_pack(DirectionCrypto& dir, int64_t seq_no,
|
|
||||||
const char* data_json, char* out, size_t out_cap);
|
|
||||||
|
|
||||||
// Распаковывает конверт (enc_b64/sign_b64 — NUL-terminated) направлением dev:
|
|
||||||
// расшифровывает (цепочка движется даже при последующей ошибке подписи!),
|
|
||||||
// проверяет подпись. При успехе json_out содержит ОТКРЫТЫЙ ТЕКСТ
|
|
||||||
// ('{"seq_no":..,"data":..}') NUL-terminated, seq_no записан в *seq_no.
|
|
||||||
// Возвращает false при ошибке base64/расшифровки/подписи.
|
|
||||||
bool envelope_unpack(DirectionCrypto& dir, const char* enc_b64,
|
|
||||||
const char* sign_b64, char* json_out, size_t out_cap,
|
|
||||||
int64_t* seq_no);
|
|
||||||
|
|
||||||
} // namespace fgl::ayla
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
#include "ayla/httpc.hpp"
|
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
#include "ayla/log.hpp"
|
|
||||||
#include "ayla/platform/platform.hpp"
|
|
||||||
|
|
||||||
namespace fgl::ayla {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Читает статус-строку "HTTP/1.x NNN ..."; возвращает код или -1.
|
|
||||||
int read_status(int fd) {
|
|
||||||
char line[64];
|
|
||||||
size_t len = 0;
|
|
||||||
for (;;) {
|
|
||||||
if (len + 1 >= sizeof(line)) return -1;
|
|
||||||
uint8_t ch;
|
|
||||||
long n = fgl::plat::tcp_recv(fd, &ch, 1);
|
|
||||||
if (n <= 0) return -1;
|
|
||||||
if (ch == '\n') break;
|
|
||||||
line[len++] = static_cast<char>(ch);
|
|
||||||
}
|
|
||||||
// "HTTP/1.1 202 Accepted"
|
|
||||||
if (len < 12 || strncmp(line, "HTTP/", 5) != 0) return -1;
|
|
||||||
const char* sp = strchr(line, ' ');
|
|
||||||
if (sp == nullptr) return -1;
|
|
||||||
int code = atoi(sp + 1);
|
|
||||||
return (code >= 200 && code <= 599) ? code : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Дочитывает заголовки до пустой строки и тело по Content-Length (до лимита).
|
|
||||||
bool drain_response(int fd) {
|
|
||||||
char line[256];
|
|
||||||
long content_length = 0;
|
|
||||||
for (;;) {
|
|
||||||
size_t i = 0;
|
|
||||||
for (;;) {
|
|
||||||
if (i + 1 >= sizeof(line)) return false;
|
|
||||||
uint8_t ch;
|
|
||||||
long n = fgl::plat::tcp_recv(fd, &ch, 1);
|
|
||||||
if (n <= 0) return false;
|
|
||||||
if (ch == '\n') break;
|
|
||||||
line[i++] = static_cast<char>(ch);
|
|
||||||
}
|
|
||||||
if (i > 0 && line[i - 1] == '\r') i--;
|
|
||||||
line[i] = '\0';
|
|
||||||
if (i == 0) break; // конец заголовков
|
|
||||||
if ((line[0] | 0x20) == 'c' && strncasecmp(line, "Content-Length:", 15) == 0) {
|
|
||||||
content_length = strtol(line + 15, nullptr, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (content_length > 0) {
|
|
||||||
uint8_t sink[256];
|
|
||||||
long remaining = content_length;
|
|
||||||
if (remaining > 4096) remaining = 4096; // тела local_reg нет — ограничим
|
|
||||||
while (remaining > 0) {
|
|
||||||
size_t chunk = remaining < static_cast<long>(sizeof(sink))
|
|
||||||
? static_cast<size_t>(remaining)
|
|
||||||
: sizeof(sink);
|
|
||||||
long n = fgl::plat::tcp_recv(fd, sink, chunk);
|
|
||||||
if (n <= 0) break;
|
|
||||||
remaining -= n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool httpc_perform(const HttpcRequest& req, HttpcResponse* resp) {
|
|
||||||
if (resp == nullptr || req.host == nullptr) return false;
|
|
||||||
resp->status = 0;
|
|
||||||
resp->transport_ok = false;
|
|
||||||
|
|
||||||
int fd = fgl::plat::tcp_connect(req.host, req.port, req.timeout_ms);
|
|
||||||
if (fd < 0) {
|
|
||||||
FGL_LOGD("httpc: connect %s:%u failed", req.host,
|
|
||||||
static_cast<unsigned>(req.port));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
fgl::plat::tcp_set_timeout(fd, req.timeout_ms, req.timeout_ms);
|
|
||||||
|
|
||||||
char head[256];
|
|
||||||
int n = snprintf(head, sizeof(head),
|
|
||||||
"%s %s%s%s HTTP/1.1\r\n"
|
|
||||||
"Host: %s\r\n"
|
|
||||||
"Accept: application/json\r\n"
|
|
||||||
"Connection: close\r\n"
|
|
||||||
"Content-Type: %s\r\n"
|
|
||||||
"Content-Length: %u\r\n"
|
|
||||||
"\r\n",
|
|
||||||
req.method, req.path, req.query != nullptr ? "?" : "",
|
|
||||||
req.query != nullptr ? req.query : "", req.host,
|
|
||||||
req.content_type, static_cast<unsigned>(req.body_len));
|
|
||||||
if (n <= 0 || static_cast<size_t>(n) >= sizeof(head)) {
|
|
||||||
fgl::plat::tcp_close(fd);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
bool sent = fgl::plat::tcp_send(fd, head, static_cast<size_t>(n)) == n;
|
|
||||||
if (sent && req.body != nullptr && req.body_len > 0) {
|
|
||||||
sent = fgl::plat::tcp_send(fd, req.body, req.body_len) ==
|
|
||||||
static_cast<long>(req.body_len);
|
|
||||||
}
|
|
||||||
if (!sent) {
|
|
||||||
FGL_LOGD("httpc: send failed");
|
|
||||||
fgl::plat::tcp_close(fd);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int status = read_status(fd);
|
|
||||||
if (status < 0) {
|
|
||||||
fgl::plat::tcp_close(fd);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
drain_response(fd);
|
|
||||||
fgl::plat::tcp_shutdown(fd);
|
|
||||||
fgl::plat::tcp_close(fd);
|
|
||||||
|
|
||||||
resp->status = status;
|
|
||||||
resp->transport_ok = true;
|
|
||||||
FGL_LOGD("httpc: %s %s -> %d", req.method, req.path, status);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgl::ayla
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Минимальный HTTP-клиент для исходящих запросов к модулю (local_reg).
|
|
||||||
// Один запрос — одно соединение; достаточно для keep-alive-цикла протокола
|
|
||||||
// (проверено на приборе: модуль принимает local_reg на новых соединениях).
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
namespace fgl::ayla {
|
|
||||||
|
|
||||||
struct HttpcRequest {
|
|
||||||
const char* method = "PUT"; // "POST" | "PUT"
|
|
||||||
const char* host = nullptr; // dotted-quad или DNS-имя
|
|
||||||
uint16_t port = 80;
|
|
||||||
const char* path = "/local_reg.json";
|
|
||||||
const char* query = nullptr; // без '?', может быть nullptr
|
|
||||||
const char* content_type = "application/json";
|
|
||||||
const uint8_t* body = nullptr;
|
|
||||||
size_t body_len = 0;
|
|
||||||
uint32_t timeout_ms = 10000;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct HttpcResponse {
|
|
||||||
int status = 0; // HTTP-код или 0 при ошибке транспорта
|
|
||||||
bool transport_ok = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Выполняет запрос блокирующе. Тело ответа не читается (у local_reg оно
|
|
||||||
// пусто), но соединение закрывается корректно (shutdown перед close).
|
|
||||||
bool httpc_perform(const HttpcRequest& req, HttpcResponse* resp);
|
|
||||||
|
|
||||||
} // namespace fgl::ayla
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
#include "ayla/httpd.hpp"
|
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
#include <new>
|
|
||||||
|
|
||||||
#include "ayla/log.hpp"
|
|
||||||
|
|
||||||
namespace fgl::ayla {
|
|
||||||
|
|
||||||
struct ThreadCtx {
|
|
||||||
HttpServer* self;
|
|
||||||
};
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Статусные строки для используемых кодов.
|
|
||||||
struct StatusText { int code; const char* text; };
|
|
||||||
constexpr StatusText kStatusTexts[] = {
|
|
||||||
{200, "OK"}, {206, "Partial Content"}, {400, "Bad Request"},
|
|
||||||
{401, "Unauthorized"}, {404, "Not Found"}, {412, "Precondition Failed"},
|
|
||||||
{426, "Upgrade Required"}, {500, "Internal Server Error"},
|
|
||||||
};
|
|
||||||
|
|
||||||
const char* status_text(int code) {
|
|
||||||
for (const auto& st : kStatusTexts) {
|
|
||||||
if (st.code == code) return st.text;
|
|
||||||
}
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
bool recv_line(int fd, char* buf, size_t buflen, size_t* out_len) {
|
|
||||||
size_t len = 0;
|
|
||||||
for (;;) {
|
|
||||||
if (len + 1 >= buflen) return false; // слишком длинно
|
|
||||||
uint8_t ch;
|
|
||||||
long n = fgl::plat::tcp_recv(fd, &ch, 1);
|
|
||||||
if (n <= 0) return false;
|
|
||||||
if (ch == '\n') {
|
|
||||||
if (len > 0 && buf[len - 1] == '\r') len--; // CRLF
|
|
||||||
buf[len] = '\0';
|
|
||||||
*out_len = len;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
buf[len++] = static_cast<char>(ch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Регистронезависимое сравнение первых n символов.
|
|
||||||
bool ieq_prefix(const char* a, const char* b, size_t n) {
|
|
||||||
for (size_t i = 0; i < n; i++) {
|
|
||||||
char ca = a[i] | 0x20, cb = b[i] | 0x20;
|
|
||||||
if (ca != cb) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Читает заголовки до пустой строки; суммарный объём ограничен
|
|
||||||
// kHttpdMaxHeadersTotal (защита от бесконечного стрима заголовков).
|
|
||||||
bool recv_headers(int fd, long* content_length, bool* connection_close) {
|
|
||||||
char line[kHttpdMaxHeaderLine];
|
|
||||||
size_t len;
|
|
||||||
size_t total = 2; // завершающий CRLF
|
|
||||||
*content_length = 0;
|
|
||||||
*connection_close = false;
|
|
||||||
for (;;) {
|
|
||||||
if (!recv_line(fd, line, sizeof(line), &len)) return false;
|
|
||||||
if (len == 0) return true; // конец заголовков
|
|
||||||
total += len + 2;
|
|
||||||
if (total > kHttpdMaxHeadersTotal) return false;
|
|
||||||
if (ieq_prefix(line, "Content-Length:", 15)) {
|
|
||||||
*content_length = strtol(line + 15, nullptr, 10);
|
|
||||||
} else if (ieq_prefix(line, "Connection:", 11)) {
|
|
||||||
const char* v = line + 11;
|
|
||||||
while (*v == ' ') v++;
|
|
||||||
if (ieq_prefix(v, "close", 5)) *connection_close = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// close_after — добавить Connection: close и не ждать продолжения.
|
|
||||||
bool send_response(int fd, const HttpResponse& resp, bool close_after) {
|
|
||||||
char head[192];
|
|
||||||
int n = snprintf(head, sizeof(head),
|
|
||||||
"HTTP/1.1 %d %s\r\n"
|
|
||||||
"Content-Type: %s\r\n"
|
|
||||||
"Content-Length: %u\r\n"
|
|
||||||
"Connection: %s\r\n"
|
|
||||||
"\r\n",
|
|
||||||
resp.status, status_text(resp.status), resp.content_type,
|
|
||||||
static_cast<unsigned>(resp.body_len),
|
|
||||||
close_after ? "close" : "keep-alive");
|
|
||||||
if (n <= 0 || static_cast<size_t>(n) >= sizeof(head)) return false;
|
|
||||||
if (fgl::plat::tcp_send(fd, head, static_cast<size_t>(n)) !=
|
|
||||||
static_cast<long>(n)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (resp.body != nullptr && resp.body_len > 0) {
|
|
||||||
if (fgl::plat::tcp_send(fd, resp.body, resp.body_len) !=
|
|
||||||
static_cast<long>(resp.body_len)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void parse_target(const char* full_target, HttpRequest* req) {
|
|
||||||
const char* q = strchr(full_target, '?');
|
|
||||||
size_t path_len = (q != nullptr) ? static_cast<size_t>(q - full_target)
|
|
||||||
: strlen(full_target);
|
|
||||||
if (path_len >= sizeof(req->target)) path_len = sizeof(req->target) - 1;
|
|
||||||
memcpy(req->target, full_target, path_len);
|
|
||||||
req->target[path_len] = '\0';
|
|
||||||
req->query[0] = '\0';
|
|
||||||
if (q != nullptr && q[1] != '\0') {
|
|
||||||
size_t qlen = strlen(q + 1);
|
|
||||||
if (qlen >= sizeof(req->query)) qlen = sizeof(req->query) - 1;
|
|
||||||
memcpy(req->query, q + 1, qlen);
|
|
||||||
req->query[qlen] = '\0';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constexpr uint32_t kHttpdThreadStack = 24576; // commands-путь: envelope+crypto ~10КБ поверх буферов запроса
|
|
||||||
constexpr uint32_t kAcceptPollMs = 100;
|
|
||||||
constexpr uint32_t kClientRxTimeoutMs = 30000;
|
|
||||||
constexpr uint32_t kClientTxTimeoutMs = 10000;
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
HttpServer::~HttpServer() { stop(); }
|
|
||||||
|
|
||||||
bool HttpServer::start(uint16_t port, HttpHandler handler, void* ctx,
|
|
||||||
const char* thread_name) {
|
|
||||||
if (running_.load(std::memory_order_acquire)) return false;
|
|
||||||
handler_ = handler;
|
|
||||||
ctx_ = ctx;
|
|
||||||
int fd = fgl::plat::tcp_listen(port);
|
|
||||||
if (fd < 0) {
|
|
||||||
FGL_LOGE("httpd: tcp_listen(%u) failed", static_cast<unsigned>(port));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
listen_fd_ = fd;
|
|
||||||
port_ = fgl::plat::tcp_local_port(fd);
|
|
||||||
|
|
||||||
auto* tc = new (std::nothrow) ThreadCtx{this};
|
|
||||||
if (tc == nullptr) {
|
|
||||||
fgl::plat::tcp_close(listen_fd_);
|
|
||||||
listen_fd_ = -1;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
thread_ctx_ = tc;
|
|
||||||
running_.store(true, std::memory_order_release);
|
|
||||||
if (!fgl::plat::thread_create(
|
|
||||||
[](void* p) {
|
|
||||||
static_cast<ThreadCtx*>(p)->self->run();
|
|
||||||
},
|
|
||||||
tc, thread_name, kHttpdThreadStack, &thread_)) {
|
|
||||||
running_.store(false, std::memory_order_release);
|
|
||||||
delete tc;
|
|
||||||
thread_ctx_ = nullptr;
|
|
||||||
fgl::plat::tcp_close(listen_fd_);
|
|
||||||
listen_fd_ = -1;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
FGL_LOGI("httpd: listening on port %u", static_cast<unsigned>(port_));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void HttpServer::stop() {
|
|
||||||
if (!running_.exchange(false, std::memory_order_acq_rel)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Прерываем активное соединение, чтобы поток не ждал rx-таймаута.
|
|
||||||
int active = active_fd_.exchange(-1, std::memory_order_acq_rel);
|
|
||||||
if (active >= 0) {
|
|
||||||
fgl::plat::tcp_shutdown(active);
|
|
||||||
}
|
|
||||||
// Гарантированно ждём завершения потока; poll-цикл замечает running_
|
|
||||||
// в пределах kAcceptPollMs.
|
|
||||||
if (thread_ != nullptr) {
|
|
||||||
fgl::plat::thread_join(thread_);
|
|
||||||
thread_ = nullptr;
|
|
||||||
}
|
|
||||||
delete thread_ctx_;
|
|
||||||
thread_ctx_ = nullptr;
|
|
||||||
if (listen_fd_ >= 0) {
|
|
||||||
fgl::plat::tcp_close(listen_fd_);
|
|
||||||
listen_fd_ = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void HttpServer::run() {
|
|
||||||
while (running_.load(std::memory_order_acquire)) {
|
|
||||||
if (!fgl::plat::tcp_poll_readable(listen_fd_, kAcceptPollMs)) {
|
|
||||||
continue; // таймаут поллинга — перепроверяем running_
|
|
||||||
}
|
|
||||||
uint32_t peer_ip = 0;
|
|
||||||
uint16_t peer_port = 0;
|
|
||||||
int fd = fgl::plat::tcp_accept(listen_fd_, &peer_ip, &peer_port);
|
|
||||||
if (fd < 0) {
|
|
||||||
if (!running_.load(std::memory_order_acquire)) break;
|
|
||||||
fgl::plat::sleep_ms(20);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
fgl::plat::tcp_set_timeout(fd, kClientRxTimeoutMs, kClientTxTimeoutMs);
|
|
||||||
active_fd_.store(fd, std::memory_order_release);
|
|
||||||
if (!running_.load(std::memory_order_acquire)) {
|
|
||||||
// stop() мог произойти в зазоре accept->store: закрываем и выходим.
|
|
||||||
// (Порядок store(fd)->load(running_) против exchange-ов в stop()
|
|
||||||
// закрывает все интерливинги: либо stop() увидит fd и shutdown'нет.)
|
|
||||||
active_fd_.store(-1, std::memory_order_release);
|
|
||||||
fgl::plat::tcp_close(fd);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
handle_connection(fd, peer_ip, peer_port);
|
|
||||||
active_fd_.store(-1, std::memory_order_release);
|
|
||||||
fgl::plat::tcp_close(fd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void HttpServer::handle_connection(int fd, uint32_t peer_ip, uint16_t peer_port) {
|
|
||||||
while (running_.load(std::memory_order_acquire)) { // keep-alive
|
|
||||||
char line[kHttpdMaxTarget + kHttpdMaxQuery + 16];
|
|
||||||
size_t len;
|
|
||||||
if (!recv_line(fd, line, sizeof(line), &len)) return;
|
|
||||||
|
|
||||||
HttpRequest req {};
|
|
||||||
req.peer_ip = peer_ip;
|
|
||||||
req.peer_port = peer_port;
|
|
||||||
|
|
||||||
// "<METHOD> <target> HTTP/x.y"
|
|
||||||
char* sp1 = strchr(line, ' ');
|
|
||||||
if (sp1 == nullptr) return;
|
|
||||||
*sp1 = '\0';
|
|
||||||
if (strlen(line) >= sizeof(req.method)) return;
|
|
||||||
strcpy(req.method, line);
|
|
||||||
char* target = sp1 + 1;
|
|
||||||
char* sp2 = strrchr(target, ' ');
|
|
||||||
if (sp2 == nullptr) return;
|
|
||||||
*sp2 = '\0';
|
|
||||||
parse_target(target, &req);
|
|
||||||
|
|
||||||
long content_length = 0;
|
|
||||||
bool connection_close = false;
|
|
||||||
if (!recv_headers(fd, &content_length, &connection_close)) return;
|
|
||||||
if (content_length < 0 ||
|
|
||||||
static_cast<size_t>(content_length) > kHttpdMaxBody) {
|
|
||||||
HttpResponse resp;
|
|
||||||
resp.status = 400;
|
|
||||||
send_response(fd, resp, /*close_after=*/true);
|
|
||||||
// shutdown до close предотвращает RST, обгоняющий ответ
|
|
||||||
// (recv после SHUT_RDWR сразу вернёт 0, дренаж не нужен).
|
|
||||||
fgl::plat::tcp_shutdown(fd);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (content_length > 0) {
|
|
||||||
size_t need = static_cast<size_t>(content_length);
|
|
||||||
size_t done = 0;
|
|
||||||
while (done < need) {
|
|
||||||
long n = fgl::plat::tcp_recv(fd, req.body + done, need - done);
|
|
||||||
if (n <= 0) return;
|
|
||||||
done += static_cast<size_t>(n);
|
|
||||||
}
|
|
||||||
req.body_len = done;
|
|
||||||
}
|
|
||||||
|
|
||||||
FGL_LOGD("httpd: %s %s?%s from %u.%u.%u.%u", req.method, req.target,
|
|
||||||
req.query, static_cast<unsigned>((peer_ip >> 24) & 0xffu),
|
|
||||||
static_cast<unsigned>((peer_ip >> 16) & 0xffu),
|
|
||||||
static_cast<unsigned>((peer_ip >> 8) & 0xffu),
|
|
||||||
static_cast<unsigned>(peer_ip & 0xffu));
|
|
||||||
|
|
||||||
HttpResponse resp; // по умолчанию 404 — «пустой httpd»
|
|
||||||
resp.status = 404;
|
|
||||||
bool keep = true;
|
|
||||||
if (handler_ != nullptr) {
|
|
||||||
keep = handler_(req, resp, ctx_);
|
|
||||||
}
|
|
||||||
if (!send_response(fd, resp, /*close_after=*/!keep)) return;
|
|
||||||
if (!keep || connection_close) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgl::ayla
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// Мини-HTTP/1.1 сервер для входящих запросов модуля кондиционера.
|
|
||||||
// Один поток, последовательная обработка соединений, keep-alive.
|
|
||||||
// Поведенческие требования — docs/PROTOCOL.md §4-5: пути /local_lan/*,
|
|
||||||
// коды 200/206/401/412/426, Content-Length-фрейминг, RST-обрывы — норма.
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
#include "ayla/platform/platform.hpp"
|
|
||||||
|
|
||||||
namespace fgl::ayla {
|
|
||||||
|
|
||||||
constexpr size_t kHttpdMaxTarget = 128; // путь без query
|
|
||||||
constexpr size_t kHttpdMaxQuery = 96; // query-строка без '?'
|
|
||||||
constexpr size_t kHttpdMaxHeaderLine = 256; // одна строка заголовка
|
|
||||||
constexpr size_t kHttpdMaxBody = 2048; // тело запроса (envelope ~0.5КБ)
|
|
||||||
constexpr size_t kHttpdMaxHeadersTotal = 1024; // суммарный лимит заголовков
|
|
||||||
|
|
||||||
struct HttpRequest {
|
|
||||||
char method[8];
|
|
||||||
char target[kHttpdMaxTarget]; // путь без query
|
|
||||||
char query[kHttpdMaxQuery]; // query-строка без '?'
|
|
||||||
uint8_t body[kHttpdMaxBody];
|
|
||||||
size_t body_len;
|
|
||||||
uint32_t peer_ip; // host byte order
|
|
||||||
uint16_t peer_port;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct HttpResponse {
|
|
||||||
int status = 200; // HTTP-код
|
|
||||||
const char* content_type = "application/json";
|
|
||||||
const uint8_t* body = nullptr;
|
|
||||||
size_t body_len = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Обработчик: заполняет resp. Возвращает true — соединение продолжается
|
|
||||||
// (keep-alive), false — закрыть после ответа.
|
|
||||||
using HttpHandler = bool (*)(const HttpRequest& req, HttpResponse& resp,
|
|
||||||
void* ctx);
|
|
||||||
|
|
||||||
class HttpServer {
|
|
||||||
public:
|
|
||||||
HttpServer() = default;
|
|
||||||
~HttpServer();
|
|
||||||
|
|
||||||
HttpServer(const HttpServer&) = delete;
|
|
||||||
HttpServer& operator=(const HttpServer&) = delete;
|
|
||||||
|
|
||||||
// Запускает сервер (собственный поток). port==0 — любой свободный
|
|
||||||
// (узнать выбранный: port()).
|
|
||||||
// Возвращает false при ошибке bind/listen.
|
|
||||||
bool start(uint16_t port, HttpHandler handler, void* ctx,
|
|
||||||
const char* thread_name = "fgl_httpd");
|
|
||||||
// Останавливает сервер и гарантированно завершает его поток
|
|
||||||
// (активное соединение прерывается shutdown'ом).
|
|
||||||
// КОНТРАКТ: start()/stop() вызываются из одного (управляющего) потока.
|
|
||||||
void stop();
|
|
||||||
|
|
||||||
uint16_t port() const { return port_; }
|
|
||||||
bool is_running() const { return running_.load(std::memory_order_acquire); }
|
|
||||||
|
|
||||||
private:
|
|
||||||
void run();
|
|
||||||
void handle_connection(int fd, uint32_t peer_ip, uint16_t peer_port);
|
|
||||||
|
|
||||||
HttpHandler handler_ = nullptr;
|
|
||||||
void* ctx_ = nullptr;
|
|
||||||
int listen_fd_ = -1;
|
|
||||||
uint16_t port_ = 0;
|
|
||||||
fgl::plat::ThreadId thread_ = nullptr; // владелец-поток, join в stop()
|
|
||||||
struct ThreadCtx* thread_ctx_ = nullptr;
|
|
||||||
std::atomic<bool> running_{false};
|
|
||||||
std::atomic<int> active_fd_{-1}; // fd обрабатываемого соединения
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace fgl::ayla
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
// Минимальный JSON: writer с фиксированным буфером + парсер (jsmn).
|
|
||||||
// Никаких аллокаций; экранирование строк — достаточное для протокола.
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
#define JSMN_STATIC
|
|
||||||
#include "third_party/jsmn/jsmn.h"
|
|
||||||
|
|
||||||
namespace fgl::ayla::json {
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Writer. Стек глубин <= 8; состояние контейнеров (были ли элементы).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
class Writer {
|
|
||||||
public:
|
|
||||||
Writer(char* buf, size_t cap) : buf_(buf), cap_(cap) {
|
|
||||||
buf_[0] = '\0';
|
|
||||||
}
|
|
||||||
|
|
||||||
void begin_object() { value_comma(); put('{'); push('{'); }
|
|
||||||
void end_object() { pop(); put('}'); }
|
|
||||||
void begin_array() { value_comma(); put('['); push('['); }
|
|
||||||
void end_array() { pop(); put(']'); }
|
|
||||||
|
|
||||||
// Ключ объекта; за ним должно следовать значение.
|
|
||||||
void key(const char* name) {
|
|
||||||
if (items_[depth_]) put(',');
|
|
||||||
items_[depth_] = true;
|
|
||||||
put('"');
|
|
||||||
str_escape(name);
|
|
||||||
put_raw("\":", 2);
|
|
||||||
await_value_ = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void string(const char* v) {
|
|
||||||
value_comma();
|
|
||||||
put('"');
|
|
||||||
str_escape(v);
|
|
||||||
put('"');
|
|
||||||
}
|
|
||||||
void integer(int64_t v) {
|
|
||||||
value_comma();
|
|
||||||
char tmp[24];
|
|
||||||
int n = snprintf(tmp, sizeof(tmp), "%lld", static_cast<long long>(v));
|
|
||||||
put_raw(tmp, static_cast<size_t>(n));
|
|
||||||
}
|
|
||||||
void boolean(bool v) { value_comma(); put_raw(v ? "true" : "false", v ? 4 : 5); }
|
|
||||||
void literal(const char* v) { value_comma(); put_raw(v, strlen(v)); }
|
|
||||||
void raw_json(const char* v) { value_comma(); put_raw(v, strlen(v)); }
|
|
||||||
|
|
||||||
const char* c_str() const { return buf_; }
|
|
||||||
size_t size() const { return pos_; }
|
|
||||||
bool ok() const { return ok_; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
static constexpr int kMaxDepth = 8;
|
|
||||||
|
|
||||||
// Запятая/маркер перед ЗНАЧЕНИЕМ (не ключом, не закрытием).
|
|
||||||
void value_comma() {
|
|
||||||
if (await_value_) {
|
|
||||||
await_value_ = false;
|
|
||||||
return; // ключ уже поставил ':' — запятая не нужна
|
|
||||||
}
|
|
||||||
if (items_[depth_]) put(',');
|
|
||||||
items_[depth_] = true;
|
|
||||||
}
|
|
||||||
void push(char c) {
|
|
||||||
if (depth_ + 1 >= kMaxDepth) {
|
|
||||||
ok_ = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
depth_++;
|
|
||||||
items_[depth_] = false;
|
|
||||||
(void)c;
|
|
||||||
}
|
|
||||||
void pop() {
|
|
||||||
if (depth_ > 0) depth_--;
|
|
||||||
await_value_ = false;
|
|
||||||
}
|
|
||||||
void put(char c) {
|
|
||||||
if (!ok_ || pos_ + 1 >= cap_) {
|
|
||||||
ok_ = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buf_[pos_++] = c;
|
|
||||||
buf_[pos_] = '\0';
|
|
||||||
}
|
|
||||||
void put_raw(const char* s, size_t n) {
|
|
||||||
if (!ok_ || pos_ + n + 1 > cap_) {
|
|
||||||
ok_ = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
memcpy(buf_ + pos_, s, n);
|
|
||||||
pos_ += n;
|
|
||||||
buf_[pos_] = '\0';
|
|
||||||
}
|
|
||||||
void str_escape(const char* s) {
|
|
||||||
for (const char* p = s; *p != '\0'; p++) {
|
|
||||||
char c = *p;
|
|
||||||
if (c == '"' || c == '\\') {
|
|
||||||
put('\\');
|
|
||||||
put(c);
|
|
||||||
} else if (c == '\n') {
|
|
||||||
put_raw("\\n", 2);
|
|
||||||
} else if (c == '\t') {
|
|
||||||
put_raw("\\t", 2);
|
|
||||||
} else if (c == '\r') {
|
|
||||||
put_raw("\\r", 2);
|
|
||||||
} else if (c == '\b') {
|
|
||||||
put_raw("\\b", 2);
|
|
||||||
} else if (c == '\f') {
|
|
||||||
put_raw("\\f", 2);
|
|
||||||
} else if (static_cast<unsigned char>(c) < 0x20) {
|
|
||||||
char tmp[8];
|
|
||||||
snprintf(tmp, sizeof(tmp), "\\u%04x", c);
|
|
||||||
put_raw(tmp, 6);
|
|
||||||
} else {
|
|
||||||
put(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
char* buf_;
|
|
||||||
size_t cap_;
|
|
||||||
size_t pos_ = 0;
|
|
||||||
bool ok_ = true;
|
|
||||||
int depth_ = 0;
|
|
||||||
bool items_[kMaxDepth + 1] = {};
|
|
||||||
bool await_value_ = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Parser (jsmn): токены поверх исходного буфера, без копирований.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
constexpr int kMaxTokens = 64;
|
|
||||||
|
|
||||||
class Doc {
|
|
||||||
public:
|
|
||||||
// Разбирает buf (NUL-terminated, будет модифицироваться при unescape).
|
|
||||||
// После вызова buf должен жить, пока используется Doc.
|
|
||||||
bool parse(char* buf) {
|
|
||||||
buf_ = buf;
|
|
||||||
jsmn_init(&p_);
|
|
||||||
count_ = jsmn_parse(&p_, buf, strlen(buf), tokens_, kMaxTokens);
|
|
||||||
return count_ >= 1 && tokens_[0].type == JSMN_OBJECT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Значение ключа по имени (первое вхождение в порядке документа —
|
|
||||||
// глубина не различается; для форматов протокора ключи уникальны).
|
|
||||||
// Для строки возвращает указатель внутрь buf_ (БЕЗ кавычек,
|
|
||||||
// НЕ NUL-terminated) и длину.
|
|
||||||
bool find(const char* name, const char** out_start, size_t* out_len,
|
|
||||||
jsmntype_t* out_type = nullptr) const {
|
|
||||||
for (int i = 1; i + 1 < count_; i++) {
|
|
||||||
if (tokens_[i].type == JSMN_STRING && tokens_[i].size == 1) {
|
|
||||||
int len = tokens_[i].end - tokens_[i].start;
|
|
||||||
if (len == static_cast<int>(strlen(name)) &&
|
|
||||||
strncmp(buf_ + tokens_[i].start, name, len) == 0) {
|
|
||||||
const jsmntok_t& v = tokens_[i + 1];
|
|
||||||
*out_start = buf_ + v.start;
|
|
||||||
*out_len = static_cast<size_t>(v.end - v.start);
|
|
||||||
if (out_type != nullptr) *out_type = v.type;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Строка в out (снятие кавычек; поддержка \" \\ \/ \n \t \r \b \f).
|
|
||||||
bool get_string(const char* name, char* out, size_t out_cap) const {
|
|
||||||
const char* s;
|
|
||||||
size_t len;
|
|
||||||
jsmntype_t type;
|
|
||||||
if (!find(name, &s, &len, &type) || type != JSMN_STRING) return false;
|
|
||||||
size_t o = 0;
|
|
||||||
for (size_t i = 0; i < len; i++) {
|
|
||||||
if (o + 1 >= out_cap) return false;
|
|
||||||
char c = s[i];
|
|
||||||
if (c == '\\' && i + 1 < len) {
|
|
||||||
char e = s[++i];
|
|
||||||
switch (e) {
|
|
||||||
case '"': out[o++] = '"'; break;
|
|
||||||
case '\\': out[o++] = '\\'; break;
|
|
||||||
case '/': out[o++] = '/'; break;
|
|
||||||
case 'n': out[o++] = '\n'; break;
|
|
||||||
case 't': out[o++] = '\t'; break;
|
|
||||||
case 'r': out[o++] = '\r'; break;
|
|
||||||
case 'b': out[o++] = '\b'; break;
|
|
||||||
case 'f': out[o++] = '\f'; break;
|
|
||||||
default: return false; // \uXXXX не требуется протоколом
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out[o++] = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out[o] = '\0';
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get_int(const char* name, int64_t* out) const {
|
|
||||||
const char* s;
|
|
||||||
size_t len;
|
|
||||||
jsmntype_t type;
|
|
||||||
if (!find(name, &s, &len, &type) || type != JSMN_PRIMITIVE) return false;
|
|
||||||
int64_t v = 0;
|
|
||||||
bool neg = false;
|
|
||||||
size_t i = 0;
|
|
||||||
if (i < len && (s[i] == '-' || s[i] == '+')) {
|
|
||||||
neg = s[i] == '-';
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
if (i >= len) return false;
|
|
||||||
for (; i < len; i++) {
|
|
||||||
if (s[i] < '0' || s[i] > '9') return false;
|
|
||||||
int64_t digit = s[i] - '0';
|
|
||||||
if (v > (INT64_MAX - digit) / 10) return false; // переполнение
|
|
||||||
v = v * 10 + digit;
|
|
||||||
}
|
|
||||||
*out = neg ? -v : v;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get_bool(const char* name, bool* out) const {
|
|
||||||
const char* s;
|
|
||||||
size_t len;
|
|
||||||
jsmntype_t type;
|
|
||||||
if (!find(name, &s, &len, &type) || type != JSMN_PRIMITIVE) return false;
|
|
||||||
if (len == 4 && strncmp(s, "true", 4) == 0) {
|
|
||||||
*out = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (len == 5 && strncmp(s, "false", 5) == 0) {
|
|
||||||
*out = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
int tokens_used() const { return count_; }
|
|
||||||
|
|
||||||
private:
|
|
||||||
char* buf_ = nullptr;
|
|
||||||
jsmn_parser p_;
|
|
||||||
jsmntok_t tokens_[kMaxTokens];
|
|
||||||
int count_ = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace fgl::ayla::json
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#include "log.hpp"
|
|
||||||
|
|
||||||
#include <cstdarg>
|
|
||||||
#include <cstdio>
|
|
||||||
|
|
||||||
namespace fgl::ayla::log {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
Sink g_sink = nullptr;
|
|
||||||
void* g_sink_ctx = nullptr;
|
|
||||||
int g_min_level = kInfo;
|
|
||||||
constexpr size_t kMaxLine = 256;
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void set_sink(Sink sink, void* ctx) {
|
|
||||||
g_sink = sink;
|
|
||||||
g_sink_ctx = ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
int min_level() { return g_min_level; }
|
|
||||||
void set_min_level(int level) { g_min_level = level; }
|
|
||||||
|
|
||||||
void write(int level, const char* fmt, ...) {
|
|
||||||
if (g_sink == nullptr || level < g_min_level) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
char buf[kMaxLine];
|
|
||||||
va_list args;
|
|
||||||
va_start(args, fmt);
|
|
||||||
int n = vsnprintf(buf, sizeof(buf), fmt, args);
|
|
||||||
va_end(args);
|
|
||||||
if (n < 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
size_t len = static_cast<size_t>(n);
|
|
||||||
if (len >= sizeof(buf)) {
|
|
||||||
len = sizeof(buf) - 1; // обрезано
|
|
||||||
}
|
|
||||||
g_sink(level, buf, len, g_sink_ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgl::ayla::log
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// Логирование ядра: без printf в stdout, вывод через настраиваемый sink.
|
|
||||||
// Вызовы из внутреннего потока сессии; sink должен быть быстрым и реентерабельным.
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
|
|
||||||
namespace fgl::ayla::log {
|
|
||||||
|
|
||||||
enum Level : int {
|
|
||||||
kDebug = 0,
|
|
||||||
kInfo = 1,
|
|
||||||
kWarn = 2,
|
|
||||||
kError = 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
using Sink = void (*)(int level, const char* msg, size_t len, void* ctx);
|
|
||||||
|
|
||||||
// Устанавливает приёмник логов (глобально). nullptr — логирование отключено.
|
|
||||||
void set_sink(Sink sink, void* ctx);
|
|
||||||
|
|
||||||
// Форматирует в фиксированный буфер (без кучи) и отправляет в sink.
|
|
||||||
void write(int level, const char* fmt, ...) __attribute__((format(printf, 2, 3)));
|
|
||||||
|
|
||||||
int min_level(); // по умолчанию kInfo
|
|
||||||
void set_min_level(int level);
|
|
||||||
|
|
||||||
} // namespace fgl::ayla::log
|
|
||||||
|
|
||||||
#define FGL_LOGD(...) ::fgl::ayla::log::write(::fgl::ayla::log::kDebug, __VA_ARGS__)
|
|
||||||
#define FGL_LOGI(...) ::fgl::ayla::log::write(::fgl::ayla::log::kInfo, __VA_ARGS__)
|
|
||||||
#define FGL_LOGW(...) ::fgl::ayla::log::write(::fgl::ayla::log::kWarn, __VA_ARGS__)
|
|
||||||
#define FGL_LOGE(...) ::fgl::ayla::log::write(::fgl::ayla::log::kError, __VA_ARGS__)
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
// ESP-IDF-реализация платформенного слоя (lwip + esp_timer + esp_random).
|
|
||||||
// Потоки — через pthread-слой ESP-IDF (поддерживается pthread_join).
|
|
||||||
#ifdef ESP_PLATFORM
|
|
||||||
|
|
||||||
#include "ayla/platform/platform.hpp"
|
|
||||||
|
|
||||||
#include <arpa/inet.h>
|
|
||||||
#include <errno.h>
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <lwip/sockets.h>
|
|
||||||
#include <netdb.h>
|
|
||||||
#include <pthread.h>
|
|
||||||
|
|
||||||
#include <sys/select.h>
|
|
||||||
|
|
||||||
#include "esp_random.h"
|
|
||||||
#include "esp_timer.h"
|
|
||||||
#include "freertos/FreeRTOS.h"
|
|
||||||
#include "freertos/task.h"
|
|
||||||
|
|
||||||
#include <new>
|
|
||||||
|
|
||||||
namespace fgl::plat {
|
|
||||||
|
|
||||||
uint64_t now_ms() {
|
|
||||||
return static_cast<uint64_t>(esp_timer_get_time()) / 1000ull;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool random(uint8_t* buf, size_t len) {
|
|
||||||
esp_fill_random(buf, len);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
struct ThreadStart {
|
|
||||||
void (*fn)(void*);
|
|
||||||
void* ctx;
|
|
||||||
};
|
|
||||||
|
|
||||||
void* thread_trampoline(void* arg) {
|
|
||||||
auto* start = static_cast<ThreadStart*>(arg);
|
|
||||||
ThreadStart tmp = *start;
|
|
||||||
delete start;
|
|
||||||
tmp.fn(tmp.ctx);
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool thread_create(void (*fn)(void*), void* ctx, const char* name,
|
|
||||||
uint32_t stack_bytes, ThreadId* out_id) {
|
|
||||||
auto* start = new (std::nothrow) ThreadStart{fn, ctx};
|
|
||||||
if (start == nullptr) return false;
|
|
||||||
pthread_t tid;
|
|
||||||
pthread_attr_t attr;
|
|
||||||
pthread_attr_init(&attr);
|
|
||||||
// Стек ESP-IDF по умолчанию мал для httpd; задаём явно.
|
|
||||||
if (stack_bytes > 0) pthread_attr_setstacksize(&attr, stack_bytes);
|
|
||||||
int rc = pthread_create(&tid, &attr, thread_trampoline, start);
|
|
||||||
pthread_attr_destroy(&attr);
|
|
||||||
if (rc != 0) {
|
|
||||||
delete start;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
pthread_setname_np(tid, name != nullptr ? name : "fgl");
|
|
||||||
if (out_id != nullptr) {
|
|
||||||
*out_id = reinterpret_cast<ThreadId>(tid);
|
|
||||||
} else {
|
|
||||||
pthread_detach(tid);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void thread_join(ThreadId id) {
|
|
||||||
pthread_join(reinterpret_cast<pthread_t>(id), nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
void sleep_ms(uint32_t ms) { vTaskDelay(pdMS_TO_TICKS(ms)); }
|
|
||||||
|
|
||||||
int tcp_listen(uint16_t port) {
|
|
||||||
int fd = lwip_socket(AF_INET, SOCK_STREAM, 0);
|
|
||||||
if (fd < 0) return -1;
|
|
||||||
int one = 1;
|
|
||||||
lwip_setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
|
||||||
struct sockaddr_in addr {};
|
|
||||||
addr.sin_family = AF_INET;
|
|
||||||
addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
|
||||||
addr.sin_port = htons(port);
|
|
||||||
if (lwip_bind(fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) < 0 ||
|
|
||||||
lwip_listen(fd, 4) < 0) {
|
|
||||||
lwip_close(fd);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t tcp_local_port(int fd) {
|
|
||||||
struct sockaddr_in addr {};
|
|
||||||
socklen_t addrlen = sizeof(addr);
|
|
||||||
if (lwip_getsockname(fd, reinterpret_cast<struct sockaddr*>(&addr), &addrlen) != 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return ntohs(addr.sin_port);
|
|
||||||
}
|
|
||||||
|
|
||||||
int tcp_accept(int listen_fd, uint32_t* peer_ip, uint16_t* peer_port) {
|
|
||||||
struct sockaddr_in addr {};
|
|
||||||
socklen_t addrlen = sizeof(addr);
|
|
||||||
int fd = lwip_accept(listen_fd, reinterpret_cast<struct sockaddr*>(&addr), &addrlen);
|
|
||||||
if (fd < 0) return -1;
|
|
||||||
int one = 1;
|
|
||||||
lwip_setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
|
||||||
if (peer_ip != nullptr) *peer_ip = ntohl(addr.sin_addr.s_addr);
|
|
||||||
if (peer_port != nullptr) *peer_port = ntohs(addr.sin_port);
|
|
||||||
return fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
int tcp_connect(const char* host, uint16_t port, uint32_t timeout_ms) {
|
|
||||||
struct addrinfo hints {};
|
|
||||||
hints.ai_family = AF_INET;
|
|
||||||
hints.ai_socktype = SOCK_STREAM;
|
|
||||||
struct addrinfo* list = nullptr;
|
|
||||||
if (lwip_getaddrinfo(host, nullptr, &hints, &list) != 0 || list == nullptr) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
int fd = -1;
|
|
||||||
for (struct addrinfo* ai = list; ai != nullptr; ai = ai->ai_next) {
|
|
||||||
auto* addr = reinterpret_cast<struct sockaddr_in*>(ai->ai_addr);
|
|
||||||
addr->sin_port = htons(port);
|
|
||||||
fd = lwip_socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
|
|
||||||
if (fd < 0) continue;
|
|
||||||
int flags = lwip_fcntl(fd, F_GETFL, 0);
|
|
||||||
lwip_fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
|
||||||
int rc = lwip_connect(fd, ai->ai_addr, ai->ai_addrlen);
|
|
||||||
if (rc == 0) {
|
|
||||||
lwip_fcntl(fd, F_SETFL, flags);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (errno == EINPROGRESS) {
|
|
||||||
fd_set wfds;
|
|
||||||
FD_ZERO(&wfds);
|
|
||||||
FD_SET(fd, &wfds);
|
|
||||||
struct timeval tv {};
|
|
||||||
tv.tv_sec = timeout_ms / 1000;
|
|
||||||
tv.tv_usec = static_cast<long>(timeout_ms % 1000) * 1000L;
|
|
||||||
if (lwip_select(fd + 1, nullptr, &wfds, nullptr, &tv) > 0) {
|
|
||||||
int soerr = 0;
|
|
||||||
socklen_t slen = sizeof(soerr);
|
|
||||||
lwip_getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &slen);
|
|
||||||
if (soerr == 0) {
|
|
||||||
lwip_fcntl(fd, F_SETFL, flags);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lwip_close(fd);
|
|
||||||
fd = -1;
|
|
||||||
}
|
|
||||||
lwip_freeaddrinfo(list);
|
|
||||||
return fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool local_ip_for(const char* host, char* out, size_t out_cap) {
|
|
||||||
struct addrinfo hints {};
|
|
||||||
hints.ai_family = AF_INET;
|
|
||||||
hints.ai_socktype = SOCK_DGRAM;
|
|
||||||
struct addrinfo* list = nullptr;
|
|
||||||
if (lwip_getaddrinfo(host, "80", &hints, &list) != 0 || list == nullptr) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
int fd = lwip_socket(AF_INET, SOCK_DGRAM, 0);
|
|
||||||
if (fd < 0) {
|
|
||||||
lwip_freeaddrinfo(list);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
bool ok = lwip_connect(fd, list->ai_addr, list->ai_addrlen) == 0;
|
|
||||||
struct sockaddr_in local {};
|
|
||||||
socklen_t slen = sizeof(local);
|
|
||||||
if (ok && lwip_getsockname(fd, reinterpret_cast<struct sockaddr*>(&local),
|
|
||||||
&slen) == 0) {
|
|
||||||
const char* s = inet_ntop(AF_INET, &local.sin_addr, out,
|
|
||||||
static_cast<socklen_t>(out_cap));
|
|
||||||
ok = s != nullptr;
|
|
||||||
} else {
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
lwip_freeaddrinfo(list);
|
|
||||||
lwip_close(fd);
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
long tcp_send(int fd, const void* buf, size_t len) {
|
|
||||||
const uint8_t* p = static_cast<const uint8_t*>(buf);
|
|
||||||
size_t done = 0;
|
|
||||||
while (done < len) {
|
|
||||||
long n = lwip_send(fd, p + done, len - done, 0);
|
|
||||||
if (n < 0) {
|
|
||||||
if (errno == EINTR) continue;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
done += static_cast<size_t>(n);
|
|
||||||
}
|
|
||||||
return static_cast<long>(done);
|
|
||||||
}
|
|
||||||
|
|
||||||
long tcp_recv(int fd, void* buf, size_t len) {
|
|
||||||
for (;;) {
|
|
||||||
long n = lwip_recv(fd, buf, len, 0);
|
|
||||||
if (n < 0 && errno == EINTR) continue;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool tcp_set_timeout(int fd, uint32_t rx_ms, uint32_t tx_ms) {
|
|
||||||
struct timeval tv {};
|
|
||||||
tv.tv_sec = rx_ms / 1000;
|
|
||||||
tv.tv_usec = static_cast<long>(rx_ms % 1000) * 1000L;
|
|
||||||
if (lwip_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) return false;
|
|
||||||
tv.tv_sec = tx_ms / 1000;
|
|
||||||
tv.tv_usec = static_cast<long>(tx_ms % 1000) * 1000L;
|
|
||||||
return lwip_setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool tcp_poll_readable(int fd, uint32_t timeout_ms) {
|
|
||||||
fd_set rfds;
|
|
||||||
FD_ZERO(&rfds);
|
|
||||||
FD_SET(fd, &rfds);
|
|
||||||
struct timeval tv {};
|
|
||||||
tv.tv_sec = timeout_ms / 1000;
|
|
||||||
tv.tv_usec = static_cast<long>(timeout_ms % 1000) * 1000L;
|
|
||||||
return lwip_select(fd + 1, &rfds, nullptr, nullptr, &tv) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool tcp_shutdown(int fd) {
|
|
||||||
return lwip_shutdown(fd, SHUT_RDWR) == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void tcp_close(int fd) {
|
|
||||||
if (fd >= 0) lwip_close(fd);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgl::plat
|
|
||||||
|
|
||||||
#endif // ESP_PLATFORM
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
// Платформенный слой: сокеты, таймеры, CSPRNG, потоки.
|
|
||||||
// Одна реализация на платформу: platform/posix (Linux) и platform/esp-idf.
|
|
||||||
// Все функции — блокирующие; коды ошибок через возвращаемое значение (-1),
|
|
||||||
// без исключений. IP-адреса — uint32_t в host byte order.
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
namespace fgl::plat {
|
|
||||||
|
|
||||||
// ---- время ----
|
|
||||||
// Монотонные миллисекунды с неопределённой точки отсчёта.
|
|
||||||
uint64_t now_ms();
|
|
||||||
|
|
||||||
// ---- CSPRNG ----
|
|
||||||
// Заполняет buf cryptographically-secure случайными байтами.
|
|
||||||
bool random(uint8_t* buf, size_t len);
|
|
||||||
|
|
||||||
// ---- потоки ----
|
|
||||||
// Идентификатор потока (непрозрачный). Валиден между thread_create и
|
|
||||||
// завершением thread_join.
|
|
||||||
using ThreadId = void*;
|
|
||||||
// Создаёт поток с именем name (для отладки) и стеком stack_bytes (0 — по
|
|
||||||
// умолчанию платформы; для ESP-IDF задавать явно, >= 4096).
|
|
||||||
// out_id может быть nullptr (тогда присоединиться нельзя).
|
|
||||||
bool thread_create(void (*fn)(void*), void* ctx, const char* name,
|
|
||||||
uint32_t stack_bytes, ThreadId* out_id);
|
|
||||||
// Блокирующе ждёт завершения потока. Гарантирует, что код потока больше не
|
|
||||||
// исполняется (объекты потока можно освобождать после возврата).
|
|
||||||
void thread_join(ThreadId id);
|
|
||||||
// Мягкая блокирующая задержка текущего потока.
|
|
||||||
void sleep_ms(uint32_t ms);
|
|
||||||
|
|
||||||
// ---- TCP (клиент и сервер) ----
|
|
||||||
// Создаёт слушающий сокет; возвращает fd или -1. port==0 — любой свободный
|
|
||||||
// (узнать выбранный: tcp_local_port).
|
|
||||||
int tcp_listen(uint16_t port);
|
|
||||||
// Локальный порт сокета (для ephemeral-слушателем).
|
|
||||||
uint16_t tcp_local_port(int fd);
|
|
||||||
// Принимает соединение (блокирующе); возвращает fd клиента или -1.
|
|
||||||
// peer_ip (host byte order) / peer_port могут быть nullptr.
|
|
||||||
int tcp_accept(int listen_fd, uint32_t* peer_ip, uint16_t* peer_port);
|
|
||||||
// Подключается к host:port (host — DNS-имя или dotted-quad) с таймаутом.
|
|
||||||
int tcp_connect(const char* host, uint16_t port, uint32_t timeout_ms);
|
|
||||||
// Локальный IP-адрес (dotted) интерфейса, которым достигается host
|
|
||||||
// (без реального трафика: UDP connect). Для local_reg.
|
|
||||||
bool local_ip_for(const char* host, char* out, size_t out_cap);
|
|
||||||
long tcp_send(int fd, const void* buf, size_t len); // >0 / -1
|
|
||||||
// Блокирующее чтение; 0 — EOF, -1 — ошибка/таймаут.
|
|
||||||
long tcp_recv(int fd, void* buf, size_t len);
|
|
||||||
// Таймауты на последующие recv/send (0 — без таймаута).
|
|
||||||
bool tcp_set_timeout(int fd, uint32_t rx_ms, uint32_t tx_ms);
|
|
||||||
// Ожидание читаемости fd (для выходa из блокирующего accept). Таймаут в мс.
|
|
||||||
bool tcp_poll_readable(int fd, uint32_t timeout_ms);
|
|
||||||
// Прерывает блокированные recv/send на сокете (для остановки сервера).
|
|
||||||
bool tcp_shutdown(int fd);
|
|
||||||
void tcp_close(int fd);
|
|
||||||
|
|
||||||
} // namespace fgl::plat
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
// POSIX-реализация платформенного слоя (Linux).
|
|
||||||
#include "ayla/platform/platform.hpp"
|
|
||||||
|
|
||||||
#include <arpa/inet.h>
|
|
||||||
#include <errno.h>
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <netinet/tcp.h>
|
|
||||||
#include <netdb.h>
|
|
||||||
#include <poll.h>
|
|
||||||
#include <pthread.h>
|
|
||||||
#include <sys/random.h>
|
|
||||||
#include <sys/socket.h>
|
|
||||||
#include <sys/types.h>
|
|
||||||
#include <time.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
#include <new>
|
|
||||||
|
|
||||||
namespace fgl::plat {
|
|
||||||
|
|
||||||
uint64_t now_ms() {
|
|
||||||
struct timespec ts;
|
|
||||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
|
||||||
return static_cast<uint64_t>(ts.tv_sec) * 1000u +
|
|
||||||
static_cast<uint64_t>(ts.tv_nsec) / 1000000u;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool random(uint8_t* buf, size_t len) {
|
|
||||||
size_t done = 0;
|
|
||||||
while (done < len) {
|
|
||||||
ssize_t n = ::getrandom(buf + done, len - done, 0);
|
|
||||||
if (n < 0) {
|
|
||||||
if (errno == EINTR) continue;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
done += static_cast<size_t>(n);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
struct ThreadStart {
|
|
||||||
void (*fn)(void*);
|
|
||||||
void* ctx;
|
|
||||||
};
|
|
||||||
|
|
||||||
void* thread_trampoline(void* arg) {
|
|
||||||
auto* start = static_cast<ThreadStart*>(arg);
|
|
||||||
ThreadStart tmp = *start;
|
|
||||||
delete start;
|
|
||||||
tmp.fn(tmp.ctx);
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
bool thread_create(void (*fn)(void*), void* ctx, const char* name,
|
|
||||||
uint32_t stack_bytes, ThreadId* out_id) {
|
|
||||||
auto* start = new (std::nothrow) ThreadStart{fn, ctx};
|
|
||||||
if (start == nullptr) return false;
|
|
||||||
pthread_t tid;
|
|
||||||
pthread_attr_t attr;
|
|
||||||
pthread_attr_init(&attr);
|
|
||||||
if (stack_bytes > 0) {
|
|
||||||
if (pthread_attr_setstacksize(&attr, stack_bytes) != 0) {
|
|
||||||
// glibc отвергает < PTHREAD_STACK_MIN; остаётся дефолт (больше — не меньше)
|
|
||||||
// логируем только: ядро запрашивает >= PTHREAD_STACK_MIN.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
int rc = pthread_create(&tid, &attr, thread_trampoline, start);
|
|
||||||
pthread_attr_destroy(&attr);
|
|
||||||
if (rc != 0) {
|
|
||||||
delete start;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
pthread_setname_np(tid, name != nullptr ? name : "fgl");
|
|
||||||
if (out_id != nullptr) {
|
|
||||||
*out_id = reinterpret_cast<ThreadId>(tid);
|
|
||||||
} else {
|
|
||||||
pthread_detach(tid);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void thread_join(ThreadId id) {
|
|
||||||
auto tid = reinterpret_cast<pthread_t>(id);
|
|
||||||
pthread_join(tid, nullptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
void sleep_ms(uint32_t ms) {
|
|
||||||
struct timespec req;
|
|
||||||
req.tv_sec = ms / 1000;
|
|
||||||
req.tv_nsec = static_cast<long>(ms % 1000) * 1000000L;
|
|
||||||
while (nanosleep(&req, &req) == -1 && errno == EINTR) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int tcp_listen(uint16_t port) {
|
|
||||||
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
|
||||||
if (fd < 0) return -1;
|
|
||||||
int one = 1;
|
|
||||||
::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
|
||||||
struct sockaddr_in addr {};
|
|
||||||
addr.sin_family = AF_INET;
|
|
||||||
addr.sin_addr.s_addr = htonl(INADDR_ANY);
|
|
||||||
addr.sin_port = htons(port);
|
|
||||||
if (::bind(fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) < 0 ||
|
|
||||||
::listen(fd, 4) < 0) {
|
|
||||||
::close(fd);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t tcp_local_port(int fd) {
|
|
||||||
struct sockaddr_in addr {};
|
|
||||||
socklen_t addrlen = sizeof(addr);
|
|
||||||
if (::getsockname(fd, reinterpret_cast<struct sockaddr*>(&addr), &addrlen) != 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return ntohs(addr.sin_port);
|
|
||||||
}
|
|
||||||
|
|
||||||
int tcp_accept(int listen_fd, uint32_t* peer_ip, uint16_t* peer_port) {
|
|
||||||
struct sockaddr_in addr {};
|
|
||||||
socklen_t addrlen = sizeof(addr);
|
|
||||||
int fd = ::accept(listen_fd, reinterpret_cast<struct sockaddr*>(&addr), &addrlen);
|
|
||||||
if (fd < 0) return -1;
|
|
||||||
int one = 1;
|
|
||||||
::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
|
||||||
if (peer_ip != nullptr) *peer_ip = ntohl(addr.sin_addr.s_addr);
|
|
||||||
if (peer_port != nullptr) *peer_port = ntohs(addr.sin_port);
|
|
||||||
return fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
int tcp_connect(const char* host, uint16_t port, uint32_t timeout_ms) {
|
|
||||||
struct addrinfo hints {};
|
|
||||||
hints.ai_family = AF_INET;
|
|
||||||
hints.ai_socktype = SOCK_STREAM;
|
|
||||||
struct addrinfo* list = nullptr;
|
|
||||||
if (::getaddrinfo(host, nullptr, &hints, &list) != 0 || list == nullptr) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
int fd = -1;
|
|
||||||
for (struct addrinfo* ai = list; ai != nullptr; ai = ai->ai_next) {
|
|
||||||
auto* addr = reinterpret_cast<struct sockaddr_in*>(ai->ai_addr);
|
|
||||||
addr->sin_port = htons(port);
|
|
||||||
fd = ::socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
|
|
||||||
if (fd < 0) continue;
|
|
||||||
// Неблокирующее подключение + poll для таймаута.
|
|
||||||
int flags = ::fcntl(fd, F_GETFL, 0);
|
|
||||||
::fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
|
||||||
int rc = ::connect(fd, ai->ai_addr, ai->ai_addrlen);
|
|
||||||
if (rc == 0) {
|
|
||||||
::fcntl(fd, F_SETFL, flags);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (errno == EINPROGRESS) {
|
|
||||||
struct pollfd pfd {fd, POLLOUT, 0};
|
|
||||||
if (::poll(&pfd, 1, static_cast<int>(timeout_ms)) > 0) {
|
|
||||||
int soerr = 0;
|
|
||||||
socklen_t slen = sizeof(soerr);
|
|
||||||
::getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &slen);
|
|
||||||
if (soerr == 0) {
|
|
||||||
::fcntl(fd, F_SETFL, flags);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
::close(fd);
|
|
||||||
fd = -1;
|
|
||||||
}
|
|
||||||
::freeaddrinfo(list);
|
|
||||||
return fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool local_ip_for(const char* host, char* out, size_t out_cap) {
|
|
||||||
struct addrinfo hints {};
|
|
||||||
hints.ai_family = AF_INET;
|
|
||||||
hints.ai_socktype = SOCK_DGRAM;
|
|
||||||
struct addrinfo* list = nullptr;
|
|
||||||
if (::getaddrinfo(host, "80", &hints, &list) != 0 || list == nullptr) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
int fd = ::socket(AF_INET, SOCK_DGRAM, 0);
|
|
||||||
if (fd < 0) {
|
|
||||||
::freeaddrinfo(list);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
bool ok = ::connect(fd, list->ai_addr, list->ai_addrlen) == 0;
|
|
||||||
struct sockaddr_in local {};
|
|
||||||
socklen_t slen = sizeof(local);
|
|
||||||
if (ok && ::getsockname(fd, reinterpret_cast<struct sockaddr*>(&local),
|
|
||||||
&slen) == 0) {
|
|
||||||
const char* s = inet_ntop(AF_INET, &local.sin_addr, out,
|
|
||||||
static_cast<socklen_t>(out_cap));
|
|
||||||
ok = s != nullptr;
|
|
||||||
} else {
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
::freeaddrinfo(list);
|
|
||||||
::close(fd);
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
long tcp_send(int fd, const void* buf, size_t len) {
|
|
||||||
const uint8_t* p = static_cast<const uint8_t*>(buf);
|
|
||||||
size_t done = 0;
|
|
||||||
while (done < len) {
|
|
||||||
long n = ::send(fd, p + done, len - done, MSG_NOSIGNAL);
|
|
||||||
if (n < 0) {
|
|
||||||
if (errno == EINTR) continue;
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
done += static_cast<size_t>(n);
|
|
||||||
}
|
|
||||||
return static_cast<long>(done);
|
|
||||||
}
|
|
||||||
|
|
||||||
long tcp_recv(int fd, void* buf, size_t len) {
|
|
||||||
for (;;) {
|
|
||||||
long n = ::recv(fd, buf, len, 0);
|
|
||||||
if (n < 0 && errno == EINTR) continue;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool tcp_set_timeout(int fd, uint32_t rx_ms, uint32_t tx_ms) {
|
|
||||||
struct timeval tv {};
|
|
||||||
tv.tv_sec = rx_ms / 1000;
|
|
||||||
tv.tv_usec = static_cast<long>(rx_ms % 1000) * 1000L;
|
|
||||||
if (::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) return false;
|
|
||||||
tv.tv_sec = tx_ms / 1000;
|
|
||||||
tv.tv_usec = static_cast<long>(tx_ms % 1000) * 1000L;
|
|
||||||
return ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool tcp_poll_readable(int fd, uint32_t timeout_ms) {
|
|
||||||
struct pollfd pfd {fd, POLLIN, 0};
|
|
||||||
return ::poll(&pfd, 1, static_cast<int>(timeout_ms)) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool tcp_shutdown(int fd) {
|
|
||||||
return ::shutdown(fd, SHUT_RDWR) == 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void tcp_close(int fd) {
|
|
||||||
if (fd >= 0) ::close(fd);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgl::plat
|
|
||||||
@@ -1,958 +0,0 @@
|
|||||||
#include "ayla/session.hpp"
|
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
#include <mutex>
|
|
||||||
#include <new>
|
|
||||||
|
|
||||||
#include "ayla/envelope.hpp"
|
|
||||||
#include "ayla/httpc.hpp"
|
|
||||||
#include "ayla/json.hpp"
|
|
||||||
#include "ayla/log.hpp"
|
|
||||||
#include "ayla/platform/platform.hpp"
|
|
||||||
|
|
||||||
namespace fgl::ayla {
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
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";
|
|
||||||
uint8_t rnd[32];
|
|
||||||
size_t need = len <= sizeof(rnd) ? len : sizeof(rnd);
|
|
||||||
if (!plat::random(rnd, need)) return false;
|
|
||||||
for (size_t i = 0; i < len; i++) {
|
|
||||||
out[i] = kAlpha[rnd[i % need] % 62];
|
|
||||||
}
|
|
||||||
out[len] = '\0';
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "cmd_id=5&status=200" -> значения; отсутствующие остаются прежними.
|
|
||||||
void parse_query(const char* query, int* cmd_id, int* status) {
|
|
||||||
const char* p = query;
|
|
||||||
while (*p != '\0') {
|
|
||||||
const char* eq = strchr(p, '=');
|
|
||||||
const char* amp = strchr(p, '&');
|
|
||||||
const char* end = (amp != nullptr) ? amp : p + strlen(p);
|
|
||||||
if (eq != nullptr && eq < end) {
|
|
||||||
size_t klen = static_cast<size_t>(eq - p);
|
|
||||||
int v = atoi(eq + 1);
|
|
||||||
if (klen == 6 && strncmp(p, "cmd_id", 6) == 0) *cmd_id = v;
|
|
||||||
if (klen == 6 && strncmp(p, "status", 6) == 0) *status = v;
|
|
||||||
}
|
|
||||||
if (amp == nullptr) break;
|
|
||||||
p = amp + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
struct Session::Impl {
|
|
||||||
SessionConfig cfg{};
|
|
||||||
SessionTimings timings{};
|
|
||||||
SessionCallbacks cbs{};
|
|
||||||
|
|
||||||
char host[64] = {};
|
|
||||||
char dsn[40] = {};
|
|
||||||
char lanip_key[64] = {};
|
|
||||||
|
|
||||||
// ---- httpd-поток (используется только из httpd-потока) ----
|
|
||||||
HttpServer httpd;
|
|
||||||
SessionCrypto crypto;
|
|
||||||
bool crypto_ready = false;
|
|
||||||
int64_t out_seq = 0;
|
|
||||||
char ke_random2[17] = {};
|
|
||||||
// Рабочие буферы datapoint-пути (httpd однопоточен; вынесены из стека —
|
|
||||||
// экономия ~7КБ стека потока httpd).
|
|
||||||
char dp_body[kHttpdMaxBody + 1];
|
|
||||||
char dp_enc_b64[kEnvelopeMaxB64];
|
|
||||||
char dp_sign_b64[96];
|
|
||||||
char dp_plain[kEnvelopeMaxPlain];
|
|
||||||
char dp_data[kEnvelopeMaxPlain];
|
|
||||||
|
|
||||||
// ---- разделяемое ----
|
|
||||||
std::mutex queue_mu;
|
|
||||||
Command queue[kMaxQueue] = {};
|
|
||||||
uint8_t queue_len = 0;
|
|
||||||
int next_cmd_id = 1;
|
|
||||||
bool batch_open = false;
|
|
||||||
Command batch[kMaxQueue] = {};
|
|
||||||
uint8_t batch_len = 0;
|
|
||||||
|
|
||||||
std::atomic<uint8_t> state{static_cast<uint8_t>(SessionState::kIdle)};
|
|
||||||
std::atomic<int> last_error{static_cast<int>(SessionError::kNone)};
|
|
||||||
std::atomic<uint64_t> ke_time_ms{0}; // время ответа на KE (0 — не было)
|
|
||||||
std::atomic<uint64_t> last_local_reg_ms{0};
|
|
||||||
std::atomic<uint64_t> quiet_until_ms{0}; // пауза local_reg (восстановление)
|
|
||||||
std::atomic<uint64_t> retry_at_ms{0};
|
|
||||||
std::atomic<bool> had_poll_since_ke{false};
|
|
||||||
std::atomic<bool> ever_active{false}; // был online хотя бы раз
|
|
||||||
std::atomic<bool> want_notify{false}; // после batch commit / перехода online
|
|
||||||
std::atomic<bool> delete_pending{false};
|
|
||||||
std::atomic<bool> delete_served{false};
|
|
||||||
std::atomic<bool> decrypt_failed{false};
|
|
||||||
std::atomic<bool> running{false};
|
|
||||||
|
|
||||||
std::atomic<uint32_t> rekeys{0};
|
|
||||||
std::atomic<uint32_t> pushes_ok{0};
|
|
||||||
std::atomic<uint32_t> pushes_bad{0};
|
|
||||||
std::atomic<uint32_t> cmds_served{0};
|
|
||||||
|
|
||||||
plat::ThreadId thread = nullptr;
|
|
||||||
uint8_t backoff_attempts = 0;
|
|
||||||
uint32_t backoff_ms = 0;
|
|
||||||
bool reg_ok = false; // последний local_reg принят (202/200)
|
|
||||||
|
|
||||||
uint16_t listen_port_actual = 0;
|
|
||||||
|
|
||||||
// ---------- helpers (вызывается из обоих потоков) ----------
|
|
||||||
void set_state(SessionState st, SessionError err) {
|
|
||||||
uint8_t prev = state.exchange(static_cast<uint8_t>(st),
|
|
||||||
std::memory_order_acq_rel);
|
|
||||||
last_error.store(static_cast<int>(err), std::memory_order_release);
|
|
||||||
if (prev != static_cast<uint8_t>(st) && cbs.on_state != nullptr) {
|
|
||||||
cbs.on_state(cbs.ctx, st, err); // без дублирования одинаковых состояний
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- очередь (mutex) ----------
|
|
||||||
bool enqueue_locked(const Command& cmd) {
|
|
||||||
// coalescing: SET замещает незабранный SET того же свойства;
|
|
||||||
// GET-дубликат отбрасывается; DELETE — единственный.
|
|
||||||
if (cmd.type == 3) {
|
|
||||||
for (uint8_t i = 0; i < queue_len; i++) {
|
|
||||||
if (queue[i].type == 3) return true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (uint8_t i = 0; i < queue_len; i++) {
|
|
||||||
if (queue[i].type == cmd.type &&
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (queue_len >= cfg.max_queue || queue_len >= kMaxQueue) return false;
|
|
||||||
queue[queue_len++] = cmd;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool submit(Command cmd) {
|
|
||||||
std::lock_guard<std::mutex> lk(queue_mu);
|
|
||||||
bool ok;
|
|
||||||
if (batch_open && cmd.type != 3) {
|
|
||||||
if (batch_len >= kMaxQueue) return false;
|
|
||||||
// coalescing внутри batch
|
|
||||||
for (uint8_t i = 0; i < batch_len; i++) {
|
|
||||||
if (batch[i].type == cmd.type &&
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
batch[batch_len++] = cmd;
|
|
||||||
ok = true;
|
|
||||||
} else {
|
|
||||||
ok = enqueue_locked(cmd);
|
|
||||||
}
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- httpd-обработчики (httpd-поток) ----------
|
|
||||||
static bool http_handler(const HttpRequest& req, HttpResponse& resp,
|
|
||||||
void* ctx);
|
|
||||||
|
|
||||||
void handle_key_exchange(const HttpRequest& req, HttpResponse& resp);
|
|
||||||
void handle_commands(HttpResponse& resp);
|
|
||||||
void handle_datapoint(const HttpRequest& req, HttpResponse& resp);
|
|
||||||
|
|
||||||
void build_get_payload(char* out, size_t out_cap, const Command& c,
|
|
||||||
int* seq_out);
|
|
||||||
void build_set_payload(char* out, size_t out_cap, const Command& c);
|
|
||||||
|
|
||||||
// ---------- session-поток ----------
|
|
||||||
static void session_thread_trampoline(void* ctx) {
|
|
||||||
static_cast<Impl*>(ctx)->session_loop();
|
|
||||||
}
|
|
||||||
void session_loop();
|
|
||||||
bool send_local_reg(bool notify, bool first);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// httpd
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
bool Session::Impl::http_handler(const HttpRequest& req, HttpResponse& resp,
|
|
||||||
void* ctx) {
|
|
||||||
auto* impl = static_cast<Impl*>(ctx);
|
|
||||||
if (strcmp(req.method, "POST") == 0) {
|
|
||||||
if (strcmp(req.target, "/local_lan/key_exchange.json") == 0) {
|
|
||||||
impl->handle_key_exchange(req, resp);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (strcmp(req.target, "/local_lan/property/datapoint.json") == 0) {
|
|
||||||
impl->handle_datapoint(req, resp);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (strcmp(req.target, "/local_lan/property/datapoint/ack.json") == 0 ||
|
|
||||||
strcmp(req.target, "/local_lan/node/property/datapoint.json") == 0 ||
|
|
||||||
strcmp(req.target, "/local_lan/node/property/datapoint/ack.json") == 0) {
|
|
||||||
FGL_LOGD("session: ack/node datapoint (пусто ok)");
|
|
||||||
resp.status = 200;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (strcmp(req.method, "GET") == 0) {
|
|
||||||
if (strcmp(req.target, "/local_lan/commands.json") == 0) {
|
|
||||||
if (!impl->crypto_ready) {
|
|
||||||
resp.status = 401;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
impl->handle_commands(resp);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp.status = 404;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Session::Impl::handle_key_exchange(const HttpRequest& req,
|
|
||||||
HttpResponse& resp) {
|
|
||||||
// Тело: {"key_exchange":{"ver":1,"proto":1,"key_id":N,"random_1":..,"time_1":N,"sec":""}}
|
|
||||||
char body[kHttpdMaxBody + 1];
|
|
||||||
size_t n = req.body_len < kHttpdMaxBody ? req.body_len : kHttpdMaxBody;
|
|
||||||
memcpy(body, req.body, n);
|
|
||||||
body[n] = '\0';
|
|
||||||
|
|
||||||
json::Doc doc;
|
|
||||||
if (!doc.parse(body)) {
|
|
||||||
resp.status = 400;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int64_t ver = 0, proto = 0, key_id = 0, time_1 = 0;
|
|
||||||
char random_1[32] = {}, sec[8] = {};
|
|
||||||
bool have_r1 = doc.get_string("random_1", random_1, sizeof(random_1));
|
|
||||||
bool have_t1 = doc.get_int("time_1", &time_1);
|
|
||||||
bool have_sec = doc.get_string("sec", sec, sizeof(sec));
|
|
||||||
bool have_ver = doc.get_int("ver", &ver);
|
|
||||||
bool have_proto = doc.get_int("proto", &proto);
|
|
||||||
bool have_kid = doc.get_int("key_id", &key_id);
|
|
||||||
if (!have_r1 || !have_t1 || !have_ver || !have_proto || !have_kid) {
|
|
||||||
FGL_LOGW("session: key_exchange неполный");
|
|
||||||
resp.status = 400;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ver != 1 || proto != 1 || (have_sec && sec[0] != '\0')) {
|
|
||||||
FGL_LOGW("session: key_exchange ver/proto/sec не поддержаны");
|
|
||||||
set_state(SessionState::kKeyError, SessionError::kBadKeyExchange);
|
|
||||||
resp.status = 426;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
// sec длиннее буфера -> get_string=false, но поле есть: setup-режим
|
|
||||||
// не поддерживаем — тоже 426 (PROTOCOL §3.1).
|
|
||||||
const char* sec_start = nullptr;
|
|
||||||
size_t sec_len = 0;
|
|
||||||
jsmntype_t sec_type;
|
|
||||||
if (doc.find("sec", &sec_start, &sec_len, &sec_type) && sec_type == JSMN_STRING &&
|
|
||||||
sec_len >= sizeof(sec)) {
|
|
||||||
set_state(SessionState::kKeyError, SessionError::kBadKeyExchange);
|
|
||||||
resp.status = 426;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (static_cast<uint32_t>(key_id) != cfg.lanip_key_id) {
|
|
||||||
FGL_LOGE("session: key_id %lld != %u — ротация ключа?",
|
|
||||||
static_cast<long long>(key_id),
|
|
||||||
static_cast<unsigned>(cfg.lanip_key_id));
|
|
||||||
set_state(SessionState::kKeyError, SessionError::kKeyMismatch);
|
|
||||||
resp.status = 412;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!gen_random_token(ke_random2, 16)) {
|
|
||||||
resp.status = 500;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int64_t time_2 = static_cast<int64_t>(plat::now_ms()) * 1000000ll;
|
|
||||||
crypto_ready = crypto.init(lanip_key, random_1, ke_random2, time_1,
|
|
||||||
time_2); // цепочки сбрасываются тут же
|
|
||||||
if (!crypto_ready) {
|
|
||||||
resp.status = 500;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
had_poll_since_ke.store(false, std::memory_order_release);
|
|
||||||
ke_time_ms.store(plat::now_ms(), std::memory_order_release);
|
|
||||||
bool rekey = ever_active.load(std::memory_order_acquire);
|
|
||||||
rekeys.fetch_add(1, std::memory_order_relaxed);
|
|
||||||
if (!rekey && state.load(std::memory_order_acquire) !=
|
|
||||||
static_cast<uint8_t>(SessionState::kRegistering)) {
|
|
||||||
set_state(SessionState::kRegistering, SessionError::kNone);
|
|
||||||
}
|
|
||||||
FGL_LOGI("session: key exchange #%u (rekey=%d)",
|
|
||||||
static_cast<unsigned>(rekeys.load(std::memory_order_relaxed)), rekey);
|
|
||||||
|
|
||||||
static thread_local char out[128];
|
|
||||||
json::Writer w(out, sizeof(out));
|
|
||||||
w.begin_object();
|
|
||||||
w.key("random_2");
|
|
||||||
w.string(ke_random2);
|
|
||||||
w.key("time_2");
|
|
||||||
w.integer(time_2);
|
|
||||||
w.end_object();
|
|
||||||
resp.status = 200;
|
|
||||||
resp.body = reinterpret_cast<const uint8_t*>(out);
|
|
||||||
resp.body_len = strlen(out);
|
|
||||||
// буфер out живёт до конца ответа (send_response в handle_connection
|
|
||||||
// выполняется синхронно в том же кадре стека httpd-потока).
|
|
||||||
}
|
|
||||||
|
|
||||||
void Session::Impl::build_get_payload(char* out, size_t out_cap,
|
|
||||||
const Command& c, int* seq_out) {
|
|
||||||
(void)seq_out;
|
|
||||||
json::Writer w(out, out_cap);
|
|
||||||
w.begin_object();
|
|
||||||
w.key("cmds");
|
|
||||||
w.begin_array();
|
|
||||||
w.begin_object();
|
|
||||||
w.key("cmd");
|
|
||||||
w.begin_object();
|
|
||||||
w.key("cmd_id");
|
|
||||||
w.integer(c.cmd_id);
|
|
||||||
w.key("method");
|
|
||||||
w.string("GET");
|
|
||||||
w.key("resource");
|
|
||||||
char res[80];
|
|
||||||
snprintf(res, sizeof(res), "property.json?name=%s", c.name);
|
|
||||||
w.string(res);
|
|
||||||
w.key("data");
|
|
||||||
w.string("");
|
|
||||||
w.key("uri");
|
|
||||||
w.string("/local_lan/property/datapoint.json");
|
|
||||||
w.end_object();
|
|
||||||
w.end_object();
|
|
||||||
w.end_array();
|
|
||||||
w.end_object();
|
|
||||||
}
|
|
||||||
|
|
||||||
void Session::Impl::build_set_payload(char* out, size_t out_cap,
|
|
||||||
const Command& c) {
|
|
||||||
json::Writer w(out, out_cap);
|
|
||||||
w.begin_object();
|
|
||||||
w.key("properties");
|
|
||||||
w.begin_array();
|
|
||||||
w.begin_object();
|
|
||||||
w.key("property");
|
|
||||||
w.begin_object();
|
|
||||||
w.key("base_type");
|
|
||||||
w.string(c.base_type);
|
|
||||||
w.key("name");
|
|
||||||
w.string(c.name);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
w.key("id");
|
|
||||||
char id[9];
|
|
||||||
gen_random_token(id, 8);
|
|
||||||
w.string(id);
|
|
||||||
w.end_object();
|
|
||||||
w.end_object();
|
|
||||||
w.end_array();
|
|
||||||
w.end_object();
|
|
||||||
}
|
|
||||||
|
|
||||||
void Session::Impl::handle_commands(HttpResponse& resp) {
|
|
||||||
Command head{};
|
|
||||||
bool have = false;
|
|
||||||
uint8_t remaining = 0;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lk(queue_mu);
|
|
||||||
if (queue_len > 0) {
|
|
||||||
head = queue[0];
|
|
||||||
have = true;
|
|
||||||
queue_len--;
|
|
||||||
memmove(queue, queue + 1, queue_len * sizeof(Command));
|
|
||||||
remaining = queue_len;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
char payload[512];
|
|
||||||
if (!have) {
|
|
||||||
payload[0] = '{';
|
|
||||||
payload[1] = '}';
|
|
||||||
payload[2] = '\0';
|
|
||||||
} else if (head.type == 1) {
|
|
||||||
build_get_payload(payload, sizeof(payload), head, nullptr);
|
|
||||||
} else if (head.type == 2) {
|
|
||||||
build_set_payload(payload, sizeof(payload), head);
|
|
||||||
} else { // DELETE session
|
|
||||||
json::Writer w(payload, sizeof(payload));
|
|
||||||
w.begin_object();
|
|
||||||
w.key("cmds");
|
|
||||||
w.begin_array();
|
|
||||||
w.begin_object();
|
|
||||||
w.key("cmd");
|
|
||||||
w.begin_object();
|
|
||||||
w.key("cmd_id");
|
|
||||||
w.integer(0);
|
|
||||||
w.key("method");
|
|
||||||
w.string("DELETE");
|
|
||||||
w.key("resource");
|
|
||||||
w.string("local_reg.json");
|
|
||||||
w.key("data");
|
|
||||||
w.string("delete_session");
|
|
||||||
w.key("uri");
|
|
||||||
w.string("/local_lan");
|
|
||||||
w.end_object();
|
|
||||||
w.end_object();
|
|
||||||
w.end_array();
|
|
||||||
w.end_object();
|
|
||||||
delete_served.store(true, std::memory_order_release);
|
|
||||||
delete_pending.store(false, std::memory_order_release);
|
|
||||||
FGL_LOGI("session: delete_session выдан модулю");
|
|
||||||
}
|
|
||||||
|
|
||||||
static thread_local char envelope[kEnvelopeMaxB64];
|
|
||||||
int64_t seq = out_seq++;
|
|
||||||
if (!envelope_pack(crypto.app, seq, payload, envelope, sizeof(envelope))) {
|
|
||||||
resp.status = 500;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cmds_served.fetch_add(1, std::memory_order_relaxed);
|
|
||||||
had_poll_since_ke.store(true, std::memory_order_release);
|
|
||||||
uint8_t st8 = state.load(std::memory_order_acquire);
|
|
||||||
if (st8 == static_cast<uint8_t>(SessionState::kRegistering) ||
|
|
||||||
st8 == static_cast<uint8_t>(SessionState::kRecovering) ||
|
|
||||||
st8 == static_cast<uint8_t>(SessionState::kOffline)) {
|
|
||||||
// Опрос команд = сессия жива (в т.ч. после re-key при recovering/offline).
|
|
||||||
ever_active.store(true, std::memory_order_release);
|
|
||||||
decrypt_failed.store(false, std::memory_order_release);
|
|
||||||
set_state(SessionState::kOnline, SessionError::kNone);
|
|
||||||
want_notify.store(true, std::memory_order_release);
|
|
||||||
}
|
|
||||||
resp.status = remaining > 0 ? 206 : 200;
|
|
||||||
resp.body = reinterpret_cast<const uint8_t*>(envelope);
|
|
||||||
resp.body_len = strlen(envelope);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Session::Impl::handle_datapoint(const HttpRequest& req,
|
|
||||||
HttpResponse& resp) {
|
|
||||||
size_t n = req.body_len < kHttpdMaxBody ? req.body_len : kHttpdMaxBody;
|
|
||||||
memcpy(dp_body, req.body, n);
|
|
||||||
dp_body[n] = '\0';
|
|
||||||
json::Doc wrap;
|
|
||||||
if (!wrap.parse(dp_body)) {
|
|
||||||
resp.status = 400;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!wrap.get_string("enc", dp_enc_b64, sizeof(dp_enc_b64)) ||
|
|
||||||
!wrap.get_string("sign", dp_sign_b64, sizeof(dp_sign_b64))) {
|
|
||||||
resp.status = 400;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int64_t seq_no = -1;
|
|
||||||
if (!envelope_unpack(crypto.dev, dp_enc_b64, dp_sign_b64, dp_plain,
|
|
||||||
sizeof(dp_plain), &seq_no)) {
|
|
||||||
pushes_bad.fetch_add(1, std::memory_order_relaxed);
|
|
||||||
FGL_LOGW("session: push не расшифрован/подпись (401); пауза и re-key");
|
|
||||||
if (state.load(std::memory_order_acquire) ==
|
|
||||||
static_cast<uint8_t>(SessionState::kOnline)) {
|
|
||||||
decrypt_failed.store(true, std::memory_order_release);
|
|
||||||
set_state(SessionState::kRecovering, SessionError::kDecryptFailed);
|
|
||||||
// Стратегия восстановления (PROTOCOL §4.4): замолчать на > порога
|
|
||||||
// «возврата» (~44-50с) — следующий local_reg заставит модуль re-key.
|
|
||||||
quiet_until_ms.store(plat::now_ms() + timings.recovering_quiet_ms,
|
|
||||||
std::memory_order_release);
|
|
||||||
}
|
|
||||||
resp.status = 401;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Восстановление после 401 с живой цепочкой (бракованная подпись):
|
|
||||||
// сообщение расшифровано — отменяем тишину и возвращаем online.
|
|
||||||
if (decrypt_failed.exchange(false, std::memory_order_acq_rel)) {
|
|
||||||
quiet_until_ms.store(0, std::memory_order_release);
|
|
||||||
set_state(SessionState::kOnline, SessionError::kNone);
|
|
||||||
FGL_LOGI("session: цепочка восстановлена (успешный push после 401)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// {"seq_no":N,"data":{"name":..,"value":..}}
|
|
||||||
json::Doc top;
|
|
||||||
if (!top.parse(dp_plain)) {
|
|
||||||
pushes_ok.fetch_add(1, std::memory_order_relaxed);
|
|
||||||
resp.status = 200;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const char* data_start = nullptr;
|
|
||||||
size_t data_len = 0;
|
|
||||||
jsmntype_t data_type;
|
|
||||||
PropertyEvent ev{};
|
|
||||||
ev.seq_no = seq_no;
|
|
||||||
if (top.find("data", &data_start, &data_len, &data_type) &&
|
|
||||||
data_type == JSMN_OBJECT && data_len < sizeof(dp_data)) {
|
|
||||||
memcpy(dp_data, data_start, data_len);
|
|
||||||
dp_data[data_len] = '\0';
|
|
||||||
{
|
|
||||||
json::Doc data_doc;
|
|
||||||
if (data_doc.parse(dp_data)) {
|
|
||||||
char name[kMaxName];
|
|
||||||
if (data_doc.get_string("name", name, sizeof(name))) {
|
|
||||||
snprintf(ev.name, sizeof(ev.name), "%s", name);
|
|
||||||
int64_t iv = 0;
|
|
||||||
bool bv = false;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pushes_ok.fetch_add(1, std::memory_order_relaxed);
|
|
||||||
resp.status = 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// session-поток: local_reg / keep-alive / backoff / таймауты
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
bool Session::Impl::send_local_reg(bool notify, bool first) {
|
|
||||||
char path[96];
|
|
||||||
if (first) {
|
|
||||||
snprintf(path, sizeof(path), "/local_reg.json?dsn=%s", dsn);
|
|
||||||
} else {
|
|
||||||
snprintf(path, sizeof(path), "/local_reg.json");
|
|
||||||
}
|
|
||||||
char local_ip[24];
|
|
||||||
if (!plat::local_ip_for(host, local_ip, sizeof(local_ip))) {
|
|
||||||
FGL_LOGW("session: local_ip_for(%s) failed", host);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
char body[160];
|
|
||||||
json::Writer w(body, sizeof(body));
|
|
||||||
w.begin_object();
|
|
||||||
w.key("local_reg");
|
|
||||||
w.begin_object();
|
|
||||||
w.key("ip");
|
|
||||||
w.string(local_ip);
|
|
||||||
w.key("notify");
|
|
||||||
w.boolean(notify);
|
|
||||||
w.key("port");
|
|
||||||
w.integer(listen_port_actual);
|
|
||||||
w.key("uri");
|
|
||||||
w.string("/local_lan");
|
|
||||||
w.end_object();
|
|
||||||
w.end_object();
|
|
||||||
if (!w.ok()) return false;
|
|
||||||
|
|
||||||
HttpcRequest req;
|
|
||||||
req.method = first ? "POST" : "PUT";
|
|
||||||
req.host = host;
|
|
||||||
req.port = cfg.device_port;
|
|
||||||
req.path = path;
|
|
||||||
req.body = reinterpret_cast<const uint8_t*>(body);
|
|
||||||
req.body_len = strlen(body);
|
|
||||||
req.timeout_ms = 5000;
|
|
||||||
HttpcResponse resp;
|
|
||||||
if (!httpc_perform(req, &resp)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
last_local_reg_ms.store(plat::now_ms(), std::memory_order_release);
|
|
||||||
if (resp.status == 503) {
|
|
||||||
set_state(SessionState::kOffline, SessionError::kNoSlot);
|
|
||||||
retry_at_ms.store(plat::now_ms() + timings.no_slot_retry_ms,
|
|
||||||
std::memory_order_release);
|
|
||||||
FGL_LOGW("session: local_reg -> 503 (нет слотов)");
|
|
||||||
backoff_attempts = 0;
|
|
||||||
backoff_ms = 0;
|
|
||||||
reg_ok = false; // сессия не активировалась — следующий local_reg POST?dsn
|
|
||||||
return true; // транспорт ок — это протокольный ответ
|
|
||||||
}
|
|
||||||
if (resp.status != 202 && resp.status != 200) {
|
|
||||||
FGL_LOGW("session: local_reg -> %d", resp.status);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (state.load(std::memory_order_acquire) ==
|
|
||||||
static_cast<uint8_t>(SessionState::kIdle)) {
|
|
||||||
set_state(SessionState::kRegistering, SessionError::kNone);
|
|
||||||
}
|
|
||||||
backoff_attempts = 0;
|
|
||||||
backoff_ms = 0;
|
|
||||||
reg_ok = true;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Session::Impl::session_loop() {
|
|
||||||
bool first_reg = true;
|
|
||||||
while (running.load(std::memory_order_acquire)) {
|
|
||||||
uint64_t now = plat::now_ms();
|
|
||||||
SessionState st =
|
|
||||||
static_cast<SessionState>(state.load(std::memory_order_acquire));
|
|
||||||
|
|
||||||
// kKeyError — устойчивая ошибка: конфиг менять вручную, не дёргаем модуль.
|
|
||||||
if (st == SessionState::kKeyError) {
|
|
||||||
plat::sleep_ms(kLoopTickMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Тишина (восстановление после «KE без poll»).
|
|
||||||
if (now < quiet_until_ms.load(std::memory_order_acquire)) {
|
|
||||||
plat::sleep_ms(kLoopTickMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Активация: KE отвечен, но опроса нет.
|
|
||||||
uint64_t ke_time = ke_time_ms.load(std::memory_order_acquire);
|
|
||||||
if (st == SessionState::kRegistering && ke_time != 0 &&
|
|
||||||
!had_poll_since_ke.load(std::memory_order_acquire) &&
|
|
||||||
now - ke_time > timings.activation_timeout_ms) {
|
|
||||||
FGL_LOGW("session: активация не наступила (KE без poll) — пауза %ums",
|
|
||||||
static_cast<unsigned>(timings.recovering_quiet_ms));
|
|
||||||
ke_time_ms.store(0, std::memory_order_release);
|
|
||||||
quiet_until_ms.store(now + timings.recovering_quiet_ms,
|
|
||||||
std::memory_order_release);
|
|
||||||
set_state(SessionState::kRecovering, SessionError::kActivationTimeout);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backoff / отложенный повтор.
|
|
||||||
if (now < retry_at_ms.load(std::memory_order_acquire)) {
|
|
||||||
plat::sleep_ms(kLoopTickMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// local_reg: по keep-alive, по notify (batch/online) или первичный.
|
|
||||||
bool queue_nonempty;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lk(queue_mu);
|
|
||||||
queue_nonempty = queue_len > 0;
|
|
||||||
}
|
|
||||||
bool notify = queue_nonempty || want_notify.exchange(false,
|
|
||||||
std::memory_order_acq_rel);
|
|
||||||
uint64_t last_reg = last_local_reg_ms.load(std::memory_order_acquire);
|
|
||||||
bool due = notify || last_reg == 0 ||
|
|
||||||
now - last_reg >= timings.keepalive_ms;
|
|
||||||
if (!due) {
|
|
||||||
plat::sleep_ms(kLoopTickMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool first = first_reg || !reg_ok;
|
|
||||||
if (!send_local_reg(queue_nonempty, first)) {
|
|
||||||
// Транспортная ошибка: backoff.
|
|
||||||
backoff_ms = backoff_ms == 0 ? timings.backoff_base_ms
|
|
||||||
: (backoff_ms * 8) / 5; // x1.6
|
|
||||||
if (backoff_ms > timings.backoff_max_ms) {
|
|
||||||
backoff_ms = timings.backoff_max_ms;
|
|
||||||
}
|
|
||||||
backoff_attempts++;
|
|
||||||
if (backoff_attempts >= timings.backoff_attempts) {
|
|
||||||
set_state(SessionState::kOffline, SessionError::kUnreachable);
|
|
||||||
retry_at_ms.store(now + timings.backoff_max_ms,
|
|
||||||
std::memory_order_release);
|
|
||||||
backoff_attempts = 0;
|
|
||||||
backoff_ms = 0;
|
|
||||||
} else {
|
|
||||||
retry_at_ms.store(now + backoff_ms, std::memory_order_release);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (reg_ok) first_reg = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Session (публичный класс)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
Session* Session::create(const SessionConfig& cfg, const SessionCallbacks& cbs) {
|
|
||||||
if (cfg.host == nullptr || cfg.dsn == nullptr || cfg.lanip_key == nullptr) {
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
if (strlen(cfg.lanip_key) >= sizeof(Impl::lanip_key) ||
|
|
||||||
strlen(cfg.dsn) >= sizeof(Impl::dsn) ||
|
|
||||||
strlen(cfg.host) >= sizeof(Impl::host)) {
|
|
||||||
return nullptr; // не помещается во внутренние копии
|
|
||||||
}
|
|
||||||
auto* impl = new (std::nothrow) Impl();
|
|
||||||
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.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);
|
|
||||||
snprintf(impl->dsn, sizeof(impl->dsn), "%s", cfg.dsn);
|
|
||||||
snprintf(impl->lanip_key, sizeof(impl->lanip_key), "%s", cfg.lanip_key);
|
|
||||||
auto* s = new (std::nothrow) Session(cfg, cbs);
|
|
||||||
if (s == nullptr) {
|
|
||||||
delete impl;
|
|
||||||
return nullptr;
|
|
||||||
}
|
|
||||||
s->impl_ = impl;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
Session::Session(const SessionConfig&, const SessionCallbacks&) : impl_(nullptr) {}
|
|
||||||
|
|
||||||
SessionError Session::last_error() const {
|
|
||||||
if (impl_ == nullptr) return SessionError::kNone;
|
|
||||||
return static_cast<SessionError>(impl_->last_error.load(std::memory_order_acquire));
|
|
||||||
}
|
|
||||||
|
|
||||||
SessionState Session::state() const {
|
|
||||||
if (impl_ == nullptr) return SessionState::kIdle;
|
|
||||||
return static_cast<SessionState>(impl_->state.load(std::memory_order_acquire));
|
|
||||||
}
|
|
||||||
|
|
||||||
uint16_t Session::listen_port() const {
|
|
||||||
return impl_ != nullptr ? impl_->listen_port_actual : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t Session::rekey_count() const {
|
|
||||||
return impl_ != nullptr ? impl_->rekeys.load(std::memory_order_relaxed) : 0;
|
|
||||||
}
|
|
||||||
uint32_t Session::pushes_ok() const {
|
|
||||||
return impl_ != nullptr ? impl_->pushes_ok.load(std::memory_order_relaxed) : 0;
|
|
||||||
}
|
|
||||||
uint32_t Session::pushes_bad() const {
|
|
||||||
return impl_ != nullptr ? impl_->pushes_bad.load(std::memory_order_relaxed) : 0;
|
|
||||||
}
|
|
||||||
uint32_t Session::commands_served() const {
|
|
||||||
return impl_ != nullptr ? impl_->cmds_served.load(std::memory_order_relaxed) : 0;
|
|
||||||
}
|
|
||||||
bool Session::had_activity() const {
|
|
||||||
return impl_ != nullptr &&
|
|
||||||
impl_->had_poll_since_ke.load(std::memory_order_acquire);
|
|
||||||
}
|
|
||||||
Session::~Session() {
|
|
||||||
stop();
|
|
||||||
delete impl_;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Session::start() {
|
|
||||||
if (impl_ == nullptr || impl_->running.load(std::memory_order_acquire)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!impl_->httpd.start(impl_->cfg.listen_port, Impl::http_handler, impl_,
|
|
||||||
"fgl_session")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
impl_->listen_port_actual = impl_->httpd.port();
|
|
||||||
impl_->running.store(true, std::memory_order_release);
|
|
||||||
if (!plat::thread_create(Impl::session_thread_trampoline, impl_,
|
|
||||||
"fgl_sess", 8192, &impl_->thread)) {
|
|
||||||
impl_->running.store(false, std::memory_order_release);
|
|
||||||
impl_->httpd.stop();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Session::stop() {
|
|
||||||
if (impl_ == nullptr || !impl_->running.load(std::memory_order_acquire)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Штатное завершение: DELETE-команда + notify local_reg. Сессионный поток
|
|
||||||
// ещё работает и доставит notify; ждём выдачи команды модулю.
|
|
||||||
if (impl_->state.load(std::memory_order_acquire) !=
|
|
||||||
static_cast<uint8_t>(SessionState::kKeyError)) {
|
|
||||||
delete_session();
|
|
||||||
uint64_t deadline = plat::now_ms() + impl_->timings.delete_wait_ms;
|
|
||||||
while (plat::now_ms() < deadline &&
|
|
||||||
!impl_->delete_served.load(std::memory_order_acquire)) {
|
|
||||||
plat::sleep_ms(10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl_->running.store(false, std::memory_order_release);
|
|
||||||
if (impl_->thread != nullptr) {
|
|
||||||
plat::thread_join(impl_->thread);
|
|
||||||
impl_->thread = nullptr;
|
|
||||||
}
|
|
||||||
impl_->httpd.stop();
|
|
||||||
// Сброс для возможного рестарта.
|
|
||||||
impl_->delete_served.store(false, std::memory_order_release);
|
|
||||||
impl_->delete_pending.store(false, std::memory_order_release);
|
|
||||||
impl_->quiet_until_ms.store(0, std::memory_order_release);
|
|
||||||
impl_->retry_at_ms.store(0, std::memory_order_release);
|
|
||||||
impl_->ke_time_ms.store(0, std::memory_order_release);
|
|
||||||
impl_->set_state(SessionState::kIdle, SessionError::kNone);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Session::get_property(const char* name) {
|
|
||||||
if (impl_ == nullptr || name == nullptr || strlen(name) >= kMaxName) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Command cmd{};
|
|
||||||
cmd.type = 1;
|
|
||||||
snprintf(cmd.name, sizeof(cmd.name), "%s", name);
|
|
||||||
{
|
|
||||||
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::set_property(const char* name, int64_t value,
|
|
||||||
const char* base_type) {
|
|
||||||
if (impl_ == nullptr || name == nullptr || strlen(name) >= kMaxName) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
Command cmd{};
|
|
||||||
cmd.type = 2;
|
|
||||||
snprintf(cmd.name, sizeof(cmd.name), "%s", name);
|
|
||||||
cmd.value = value;
|
|
||||||
snprintf(cmd.base_type, sizeof(cmd.base_type), "%s", base_type);
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
void Session::set_timings_for_test(const SessionTimings& t) {
|
|
||||||
if (impl_ == nullptr) return;
|
|
||||||
SessionTimings tmp = t;
|
|
||||||
if (tmp.keepalive_ms < 100) tmp.keepalive_ms = 100; // анти-спам
|
|
||||||
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;
|
|
||||||
impl_->batch_open = true;
|
|
||||||
impl_->batch_len = 0;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Session::commit_batch() {
|
|
||||||
bool ok = true;
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
|
||||||
if (!impl_->batch_open) return false;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// notify ставится ВСЕГДА: даже при частичном отказе поставленные команды
|
|
||||||
// должны уйти немедленно, а не ждать keep-alive.
|
|
||||||
impl_->want_notify.store(true, std::memory_order_release);
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Session::abort_batch() {
|
|
||||||
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
|
||||||
if (!impl_->batch_open) return false;
|
|
||||||
impl_->batch_open = false;
|
|
||||||
impl_->batch_len = 0;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Session::delete_session() {
|
|
||||||
if (impl_ == nullptr) return false;
|
|
||||||
Command cmd{};
|
|
||||||
cmd.type = 3;
|
|
||||||
snprintf(cmd.name, sizeof(cmd.name), "local_reg.json");
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lk(impl_->queue_mu);
|
|
||||||
if (!impl_->enqueue_locked(cmd)) return false;
|
|
||||||
}
|
|
||||||
impl_->delete_pending.store(true, std::memory_order_release);
|
|
||||||
impl_->want_notify.store(true, std::memory_order_release);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace fgl::ayla
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
// Сессия Ayla LAN (сторона «приложения»). docs/PROTOCOL.md §4-6, §4.4.
|
|
||||||
// Потоки: httpd (входящие от модуля: key exchange/commands/datapoint) и
|
|
||||||
// session (исходящие local_reg, таймеры, backoff). Крипто-цепочки и выдача
|
|
||||||
// команд — только в httpd-потоке; session-поток читает очередь под mutex.
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
#include <cstddef>
|
|
||||||
#include <cstdint>
|
|
||||||
|
|
||||||
#include "ayla/crypto.hpp"
|
|
||||||
#include "ayla/httpd.hpp"
|
|
||||||
|
|
||||||
namespace fgl::ayla {
|
|
||||||
|
|
||||||
enum class SessionState : uint8_t {
|
|
||||||
kIdle = 0, // создан, не запущен
|
|
||||||
kRegistering, // local_reg отправлен, ждём key exchange
|
|
||||||
kOnline, // сессия активна
|
|
||||||
kRecovering, // ожидание самолечения (re-key по keep-alive / активация)
|
|
||||||
kOffline, // модуль недоступен (backoff) или нет слотов
|
|
||||||
kKeyError, // lanip_key_id не совпал — требуется смена конфига
|
|
||||||
};
|
|
||||||
|
|
||||||
enum class SessionError : int {
|
|
||||||
kNone = 0,
|
|
||||||
kNoSlot = 1, // 503: оба слота модуля заняты
|
|
||||||
kUnreachable = 2, // transport/backoff
|
|
||||||
kKeyMismatch = 3, // key_id != lanip_key_id (state = kKeyError)
|
|
||||||
kBadKeyExchange = 4, // ver/proto/sec не поддержаны
|
|
||||||
kActivationTimeout = 5,// KE прошёл, опроса commands.json нет (>5 c)
|
|
||||||
kDecryptFailed = 6, // подпись/расшифровка push не сошлись (ждём re-key)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Событие обновления свойства (push модуля). Значение — какой-то один тип.
|
|
||||||
struct PropertyEvent {
|
|
||||||
char name[40];
|
|
||||||
bool is_int = false;
|
|
||||||
int64_t int_value = 0;
|
|
||||||
bool is_bool = false;
|
|
||||||
bool bool_value = false;
|
|
||||||
char str_value[64]; // используется, если !is_int && !is_bool
|
|
||||||
int cmd_id = -1; // из ?cmd_id=N (ответ на GET), иначе -1
|
|
||||||
int status = 0; // из ?status=200
|
|
||||||
int64_t seq_no = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct SessionConfig {
|
|
||||||
const char* host = nullptr; // DNS-имя или IP модуля
|
|
||||||
uint16_t device_port = 80; // порт local_reg модуля
|
|
||||||
const char* dsn = nullptr; // "AC000W00XXXXXXX"
|
|
||||||
const char* lanip_key = nullptr; // base64-строка как есть
|
|
||||||
uint32_t lanip_key_id = 0;
|
|
||||||
uint16_t listen_port = 10275; // 0 — любой свободный
|
|
||||||
uint32_t keepalive_ms = 15000;
|
|
||||||
uint8_t max_queue = 40; // лимит очереди команд (>= полного
|
|
||||||
// батча свойств шаблона, 33)
|
|
||||||
};
|
|
||||||
|
|
||||||
struct SessionCallbacks {
|
|
||||||
// КОНТРАКТ: колбэки приходят из потоков ядра (httpd и/или session),
|
|
||||||
// возможно перекрытие во времени; обязаны быть быстрыми и реентерабельными.
|
|
||||||
// Вызывать stop() из колбэка запрещено (deadlock на join).
|
|
||||||
void (*on_state)(void* ctx, SessionState st, SessionError err);
|
|
||||||
void (*on_property)(void* ctx, const PropertyEvent& ev);
|
|
||||||
void* ctx = nullptr;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Тайминги поведения (PROTOCOL §4.3-4.4; проверено на приборе).
|
|
||||||
struct SessionTimings {
|
|
||||||
uint32_t keepalive_ms = 15000; // период local_reg
|
|
||||||
uint32_t activation_timeout_ms = 5000; // нет poll после KE
|
|
||||||
uint32_t recovering_quiet_ms = 50000; // пауза при десинке/«KE без poll»
|
|
||||||
// (> порога возврата модуля ~44-50с)
|
|
||||||
uint32_t no_slot_retry_ms = 60000; // повтор после 503
|
|
||||||
uint32_t backoff_base_ms = 1000; // transport backoff, шаг x1.6
|
|
||||||
uint32_t backoff_max_ms = 60000;
|
|
||||||
uint8_t backoff_attempts = 6;
|
|
||||||
uint32_t delete_wait_ms = 2000;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Session {
|
|
||||||
public:
|
|
||||||
static Session* create(const SessionConfig& cfg, const SessionCallbacks& cbs);
|
|
||||||
~Session();
|
|
||||||
|
|
||||||
Session(const Session&) = delete;
|
|
||||||
Session& operator=(const Session&) = delete;
|
|
||||||
|
|
||||||
bool start();
|
|
||||||
// Штатное завершение: DELETE-команда + local_reg notify, ожидание выдачи,
|
|
||||||
// остановка потоков. state -> kIdle.
|
|
||||||
void stop();
|
|
||||||
|
|
||||||
SessionState state() const;
|
|
||||||
SessionError last_error() const;
|
|
||||||
uint16_t listen_port() const;
|
|
||||||
// Телеметрия (диагностика).
|
|
||||||
uint32_t rekey_count() const;
|
|
||||||
uint32_t pushes_ok() const;
|
|
||||||
uint32_t pushes_bad() const;
|
|
||||||
uint32_t commands_served() const;
|
|
||||||
bool had_activity() const;
|
|
||||||
|
|
||||||
// ---- Команды (потокобезопасны; кладутся в очередь с coalescing) ----
|
|
||||||
// GET-команда: свойство придёт on_property (cmd_id совпадает).
|
|
||||||
bool get_property(const char* name);
|
|
||||||
// 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();
|
|
||||||
bool abort_batch();
|
|
||||||
// DELETE local_reg.json/delete_session (для stop() и ручного завершения).
|
|
||||||
bool delete_session();
|
|
||||||
|
|
||||||
// Тест-хук: тайминги. ТОЛЬКО до start() (после — читаются потоками ядра).
|
|
||||||
void set_timings_for_test(const SessionTimings& t);
|
|
||||||
|
|
||||||
private:
|
|
||||||
Session(const SessionConfig& cfg, const SessionCallbacks& cbs);
|
|
||||||
struct Impl;
|
|
||||||
Impl* impl_;
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace fgl::ayla
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Тесты ядра. Структура зеркалит src: tests/ayla — протоколная часть,
|
|
||||||
# tests/aircon — конверсии/шаблоны (появятся в M3).
|
|
||||||
#
|
|
||||||
# Тесты собираются с исключениями (фреймворк), сама библиотека — без.
|
|
||||||
|
|
||||||
include(FetchContent)
|
|
||||||
FetchContent_Declare(
|
|
||||||
doctest
|
|
||||||
GIT_REPOSITORY https://github.com/doctest/doctest.git
|
|
||||||
GIT_TAG v2.4.12
|
|
||||||
GIT_SHALLOW TRUE
|
|
||||||
)
|
|
||||||
FetchContent_MakeAvailable(doctest)
|
|
||||||
|
|
||||||
function(fgl_add_test name)
|
|
||||||
add_executable(test_${name} ${ARGN})
|
|
||||||
target_compile_features(test_${name} PRIVATE cxx_std_20)
|
|
||||||
target_link_libraries(test_${name} PRIVATE fgl-aircon doctest_with_main)
|
|
||||||
target_include_directories(test_${name} PRIVATE "${CMAKE_SOURCE_DIR}/src")
|
|
||||||
add_test(NAME ${name} COMMAND test_${name})
|
|
||||||
endfunction()
|
|
||||||
|
|
||||||
# Раннер сессии — не тест, приложение для интеграционных сценариев
|
|
||||||
# (запускается tests/ayla/test_session_mock.py через ctest).
|
|
||||||
add_executable(session_runner ayla/session_runner.cpp)
|
|
||||||
target_compile_features(session_runner PRIVATE cxx_std_20)
|
|
||||||
target_link_libraries(session_runner PRIVATE fgl-aircon)
|
|
||||||
target_include_directories(session_runner PRIVATE "${CMAKE_SOURCE_DIR}/src")
|
|
||||||
|
|
||||||
fgl_add_test(ayla_platform ayla/test_platform.cpp)
|
|
||||||
fgl_add_test(ayla_httpd ayla/test_httpd.cpp)
|
|
||||||
fgl_add_test(ayla_crypto ayla/test_crypto.cpp)
|
|
||||||
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()
|
|
||||||